Java Bridge · J3
References, null and garbage collection — nothing owns anything, and one new leak
- Java Bridge
- 8 min read
- after J1
The idea
Every class-typed variable is a reference, and the dot hides it
Copy sameBook = onTheShelf; reads like a copy. It duplicates the handle, so sameBook.markDamaged() prints Copy[978-0132350884#1, damaged] when you print onTheShelf. Same for a parameter: mutating through it reaches the caller's object, while reassigning it reaches nothing.
Your next question is the C++ one. Two places now hold this book, the shelf's list and the loan card in its pocket. Which of them owns it, and does the card get a shared_ptr or a raw pointer? Java has no answer because it has no question. There is no delete, no destructor, no dangling reference, and the cycle between a Copy and its LoanCard is collected like anything else. Sharing costs you nothing.
Two things it does not buy you. null is the value every reference type accepts in silence: Copy copy = null; compiles under -Xlint:all with zero warnings, and the failure waits for the first .. And a reference in a long-lived collection keeps its object alive, so the leak you can still write is the card left in the pocket after the book came back.
What replaces ownership is a question worth more marks: who is allowed to mutate this. Hand out a defensive copy when the object is mutable, reachable from outside, and load-bearing for one of your invariants. If any of the three is false, don't. Making the type immutable removes the decision, which is what the corpus does with Title and Loan.
Coming from C++
From C++ — five pointer shapes collapse into one, and one leak survives
In C++, writing the loan card's field is a design decision. Copy, Copy&, Copy*, std::unique_ptr<Copy>, std::shared_ptr<Copy> — five spellings, and picking one commits you to a lifetime story. Java offers one spelling. That is a smaller decision to make. The trap is that Java writes it with the syntax of the one C++ option it does not have.
The declaration
C++ — the shape is yours to choose
class LoanCard {
Copy copy_; // a whole book, copied in
Copy& copy_; // borrowed, must outlive the card
Copy* copy_; // borrowed, may dangle, may be null
std::unique_ptr<Copy> copy_; // the card owns it, shelf must not
std::shared_ptr<Copy> copy_; // shared, refcounted, cycles leak
};
Java — one shape
public final class LoanCard {
private final Copy copy;
}
| C++ | Java |
|---|---|
Copy c is a value; sizeof is the object | Copy c is a reference; the object is always on the heap |
Copy* p and p->f() | Copy c and c.f(). Same indirection, different punctuation |
Copy& r — cannot be null, cannot be rebound | No equivalent. Every reference can be null and can be rebound |
const Copy& r — a read-only view | No equivalent. final binds the variable, never the object |
unique_ptr / shared_ptr in a signature announces ownership | A signature announces nothing about lifetime |
| Stack or heap is your call | Only primitives (int, long, boolean, char, double) are values |
final is the row worth re-reading. private final Copy copy says the field is assigned once. It says nothing about the book. copy.markDamaged() through a final field compiles and mutates, which is the opposite of what const Copy& gives you. There is no qualifier that buys back the missing half.
Assignment and passing
C++ — the reader can tell from the declaration
Copy onTheShelf{"978-0132350884#1", "978-0132350884"};
Copy sameBook = onTheShelf; // a second book. Two objects.
Copy& alias = onTheShelf; // one book, two names, and the & says so
Java — one line, and the declaration does not say
Copy onTheShelf = new Copy("978-0132350884#1", "978-0132350884");
Copy sameBook = onTheShelf; // one book, two names
sameBook.markDamaged();
Real output from worked/:
onTheShelf.damaged() : true
onTheShelf == sameBook : true
onTheShelf : Copy[978-0132350884#1, damaged]
The C++ line that behaves this way needs an & in it. The Java line does not, and there is no spelling of the assignment that would give you the copying version. sameBook = new Copy(...) builds a different book; nothing in the language duplicates the one you have unless you write a method that does it.
Parameters follow the same rule, and the two halves of it come apart in a way worth seeing:
private static void inspect(Copy copy) {
copy.markDamaged(); // the caller sees this
copy = new Copy("978-0132350884#99", CLEAN_CODE); // the caller sees nothing
copy.markDamaged();
}
before : Copy[978-0132350884#2, intact]
after inspect() : Copy[978-0132350884#2, damaged]
barcode unchanged : 978-0132350884#2
| C++ | Java |
|---|---|
void f(Copy c) copies the object | void f(Copy c) copies the reference |
void f(Copy& c) — callee can reassign the caller's variable | Impossible. There is no reference-to-reference |
void f(const Copy& c) — callee cannot mutate | Impossible. Any non-private method is available to the callee |
Out-parameters via & or * | Return a value, or a record of several |
What genuinely disappears
C++ — the shelf and the card both refer to one book, so somebody has to decide
class Shelf {
std::vector<std::shared_ptr<Copy>> copies_;
std::unordered_map<std::string, std::shared_ptr<LoanCard>> out_;
};
class LoanCard {
std::shared_ptr<Copy> copy_; // and Copy holds a weak_ptr back, or this cycle leaks
};
Java — no decision, and the cycle is fine
Copy copy = new Copy("978-0198611868#1", DICTIONARY);
LoanCard card = new LoanCard(copy, "M-3", DUE);
copy.lentOn(card); // copy -> card -> copy
worked/ drops both locals and asks the collector:
--- 6. a reference cycle, unreachable
collected after gc : true
Four things that were part of the design in C++ are not design decisions here:
- Ownership. Nobody owns the book. Two collections hold references and neither is special.
unique_ptrversusshared_ptrin an interface. A Java signature cannot express it, so reviewers never argue about it.- Dangling references. A reference you can still read refers to a live object. Always.
- Cycles. Reachability is computed from the roots, not by counting.
weak_ptrhas no routine Java counterpart, andWeakReferenceexists for caches rather than for cycles.
Two things do not disappear. Files, sockets and locks still need release, and that is try-with-resources, which is J9. And reachability is now your problem, which is the last section.
null against nullptr
The consequence differs, not the concept. nullptr is one legal value of a pointer, and C++ gives you Copy& when you want to rule it out. Java has no such type, so null is a legal value of every reference type you will ever declare.
C++ — the type system can refuse
Copy& find(const std::string& barcode); // cannot return null. Throws instead
Copy* find(const std::string& barcode); // may return null, and the * warns the reader
int copiesHeld = nullptr; // error: cannot initialize 'int' with 'nullptr_t'
Java — the declaration cannot refuse
public Copy copyWithBarcode(String barcode) { // may return null, and nothing says so
...
}
Copy copy = null; // legal for every reference type
int copiesHeld = null; // the one place it is rejected
Only the primitive is caught, and this is the whole of the compiler's help:
Prim.java:3: error: incompatible types: <null> cannot be converted to int
int copiesHeld = null;
^
1 error
Now the part that matters. This file compiles with javac -Xlint:all, zero warnings, and javac exits 0:
public class NullDesk {
public static void main(String[] args) {
Copy copy = null;
System.out.println(copy.barcode());
}
}
The failure is at the first ., at run time. Java 21 names the expression that was null, and reading the message carefully saves you the debugger:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Copy.barcode()" because the return value of "Shelf.copyWithBarcode(String)" is null
at Desk.main(Desk.java:8)
Two shapes of that message, both from worked/:
through a local : Cannot invoke "Copy.barcode()" because "<local1>" is null
through a call : Cannot invoke "Copy.barcode()" because the return value of "Shelf.copyWithBarcode(String)" is null
<local1> is a slot number rather than a name, because the class file carries no local variable table by default. Recompile with javac -g and the same line reads because "missing" is null. Worth knowing on day one: if a message names a <localN> you cannot place, -g turns it into the identifier you wrote.
| C++ | Java |
|---|---|
Copy& cannot be null; the type rules it out | No non-nullable reference type exists |
| Dereferencing a null pointer is undefined behaviour | NullPointerException, at a defined point, with a message |
nullptr into an int — compile error | Same. The only compile-time null check you get |
A null check is a habit you can drop when the type is & | A null check is a habit or a documented contract |
| Crash location is wherever the corruption surfaces | The stack trace's top frame is the dereference |
The Java replacements for Copy&, in the order to reach for them:
Objects.requireNonNull(x, "x")at the boundary. Fails at the constructor or the method entry, naming the parameter, instead of at some later.. The corpus does this in every constructor — seeShelf,Member,Library.- Throw a named exception for an absent thing.
Shelf.require(isbn)in the corpus throwsUnknownIsbnException, so no caller has a null to forget about. Optional<Copy>on a query that can legitimately find nothing.Catalogue.findreturnsOptional<Title>, which makes the empty case unignorable at the call site. J11 covers it.
The corpus reference solution returns null from no method at all. That is not a coincidence and it is worth copying.
The leak that garbage collection does not prevent
This is the part a C++ developer under-weights, because "no memory management" gets heard as "no memory bugs". A reachable object is never collected, and a reference in a long-lived collection is reachability.
Shelf.takeBack removes two references. Shelf.takeBackLeavingTheCard removes one, leaves the card in the pocket, and reports the same numbers at the desk:
public void takeBack(String barcode) {
LoanCard card = requireOut(barcode);
out.remove(barcode);
card.copy().backOnShelf(); // the line the second version omits
}
--- 7. the leak garbage collection cannot prevent
collected while out : false
collected after takeBack : true
copies out, both shelves : 0 and 0
collected after takeBackLeavingTheCard : false
Both shelves report zero copies out. One of them still holds a finished loan, reachable through Copy.onLoan for the life of the shelf, and it will hold one per return forever.
| C++ leak | Java equivalent |
|---|---|
new with no delete | Cannot happen |
shared_ptr cycle | Cannot happen |
Forgot to erase from a cache or registry | Identical, and now the only kind |
| Valgrind finds it | Nothing finds it; a heap dump shows it as growth, not as an error |
An interviewer notices this one, and it is graded as a design bug rather than a memory bug. The question to ask about any collection you add is what removes from it. A Map with a put and no remove on any code path is the tell, and listeners and observer registrations are where it shows up most.
Ownership becomes a question about mutation
Once sharing is free, "who owns this" stops being interesting and "who may change this" starts being the question you are graded on. Two mechanisms, and the second one has a catch.
A defensive copy on the way out. Shelf.copies() returns List.copyOf(copies), so no caller can add a book to the shelf. Real output, and read both lines:
handedOut.add(...) : UnsupportedOperationException
shelf's second copy : Copy[978-0132350884#2, damaged]
List.copyOf is shallow. The list is frozen and the books in it are not, so handedOut.get(1).markDamaged() reached the shelf's own copy. Returning an unmodifiable collection of mutable objects protects the collection only, and that half-measure reads worse than no protection because it looks like protection.
Immutability, which removes the decision. The corpus makes Title and Loan records and Member a final class with no setters. Handing out a Title cannot let anyone edit the catalogue, so InMemoryCatalogue.titles() needs no thought beyond the list wrapper. That is why the reference solution has no defensive-copy problem to solve.
The threshold. Copy at the boundary when all three hold: the type is mutable, a caller outside the class can reach the object, and one of your invariants depends on its state. Two of the three is not enough. Miss the first and you are allocating for nothing; miss the third and you are copying data nobody's correctness depends on, which an interviewer reads as ceremony.
When all three do hold, prefer making the type immutable over copying it. A copy is a rule every future caller has to remember; an immutable type is a rule the compiler remembers. Reach for the copy when the type is not yours to change.
Worked walkthrough
NOTES — one book, two holders, and the seven things that follow
Run it first
Four files, from the directory holding the sources:
.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main
-Xlint:all prints nothing. Note that now, because block 5 is a null dereference the compiler had every chance to see.
Real output, verbatim:
--- 1. two names, one Copy
onTheShelf.damaged() : true
onTheShelf == sameBook : true
onTheShelf : Copy[978-0132350884#1, damaged]
--- 2. passing a Copy to a method
before : Copy[978-0132350884#2, intact]
after inspect() : Copy[978-0132350884#2, damaged]
barcode unchanged : 978-0132350884#2
--- 3. one Copy, referenced from the shelf and from the card
card : LoanCard[978-0132350884#1 -> M-1, due 2026-01-26T09:00:00Z]
shelf's object == card's object : true
shelf's view now : Copy[978-0132350884#1, damaged]
card's view now : Copy[978-0132350884#1, damaged]
--- 4. List.copyOf protects the list, not the copies
handedOut.add(...) : UnsupportedOperationException
shelf's second copy : Copy[978-0132350884#2, damaged]
--- 5. null, and the two shapes of the message
copyWithBarcode(unheld): null
through a local : Cannot invoke "Copy.barcode()" because "<local1>" is null
through a call : Cannot invoke "Copy.barcode()" because the return value of "Shelf.copyWithBarcode(String)" is null
--- 6. a reference cycle, unreachable
collected after gc : true
--- 7. the leak garbage collection cannot prevent
collected while out : false
collected after takeBack : true
copies out, both shelves : 0 and 0
collected after takeBackLeavingTheCard : false
Every line is deterministic across runs, including the three booleans in block 7. The one thing that varies is <local1> in block 5, which becomes "missing" under javac -g.
The two lines to read together are the last two of block 7. Both shelves say zero copies out, so both look correct from the desk. One of them is holding a finished loan it will never let go of.
Copy.java — the mutable object the whole lesson turns on
public final class Copy {
private final String barcode;
private final String isbn;
private boolean damaged;
damaged being mutable is the deliberate choice, and it is what makes the rest of the file interesting. The corpus's own value types are not like this: Title and Loan are records, and Member is a final class whose owing() returns a new instance. With an immutable type, everything below stops being a question, because a reference you cannot change through is as good as a copy. This lesson needs a mutable object because the traps only exist around one.
final on the two String fields buys less than const would. It stops the field being reassigned. It says nothing about what the referenced object can do. String happens to be immutable, so here the two coincide; write private final List<String> notes and any holder of the reference can still call add. That is the trap J1 flagged and this file is where it bites.
public void markDamaged() {
this.damaged = true;
}
One public mutator is all it takes to make the sharing in block 3 a design question. Delete this method and Copy becomes immutable, Shelf.copies() needs no defensive thought, and the loan card can hand its copy to anyone. The count of mutators is the size of the problem.
private LoanCard onLoan;
The back-reference that completes the cycle, and the one that leaks in block 7. Copy points at LoanCard, LoanCard points at Copy. In C++ with shared_ptr on both sides that pair is never freed, and the fix is a weak_ptr on one side. Here it costs nothing while both are unreachable, and costs everything if a live shelf is at the other end of it.
void lentOn(LoanCard card) {
this.onLoan = Objects.requireNonNull(card, "card");
}
Package-private, so the lifecycle has one driver. Shelf is the only thing that lends and takes back, so it is the only thing that may set this. If lentOn were public, any holder of a Copy could point it at a card the shelf has never heard of, and takeBack would then clear a reference the shelf did not create. Narrow visibility is how you say "this transition is mine" in a language with no friend.
requireNonNull with the parameter name, not a bare assignment. Without it, a null card is stored happily and the failure arrives later, at whichever . reads it. The exception message would then name a field or a <localN>, several frames away from the caller who passed null. With it, the stack trace's top frame is the mistake.
LoanCard.java — the field that would have been five decisions
public final class LoanCard {
private final Copy copy;
In C++ this declaration is where you commit to a lifetime story. Copy, Copy&, Copy*, unique_ptr<Copy>, shared_ptr<Copy> all compile and all mean different things about who frees the book and who may outlive whom. Java has one spelling, so this line carries no lifetime claim at all. Nothing here says the shelf also holds this book, and nothing needs to.
What this line does still claim: the card cannot be re-pointed at a different book, because of final. It does not claim the book cannot change. copy.markDamaged() from inside this class would compile.
public Copy copy() {
return copy;
}
This accessor hands out the shelf's object. That is the aliasing in block 3, and it is a decision rather than an accident. Three alternatives, in order of cost. Return the barcode instead of the book. Return an immutable snapshot. Or keep this line and accept that the card's holder may damage a book on the shelf.
The corpus took the first one. corpus/library/contract/Loan.java records a String copyId, not a Copy, and its own comment says a loan is "a snapshot of a loan as it stood when it was handed to you". A loan holding an id has no aliasing question to answer, and it also cannot go stale. That is the design payoff of this lesson, and it is why the reference solution never needs a defensive copy.
What breaks if you keep the reference anyway: nothing, until a second holder mutates. Then you have two correct-looking classes and a bug that belongs to neither.
Shelf.java — where reachability becomes your problem
private final List<Copy> copies = new ArrayList<>();
private final Map<String, LoanCard> out = new HashMap<>();
These two fields are the lifetime of every book in the branch. A Shelf that lives for the process holds its Copy objects for the process, which is correct. The out map is the one that must shrink, because a loan is finished at some point and the shelf is not.
Both fields are private, and that is load-bearing rather than habit. They are the reason copies() can decide what escapes. Make either one public and every rule below is unenforceable from inside this class.
public void restock(List<Copy> arriving) {
Objects.requireNonNull(arriving, "arriving");
copies.addAll(arriving);
}
addAll copies the list, not the books. The shelf's list and the caller's list are now two lists holding the same Copy objects. Clearing the caller's list changes nothing here; damaging a book through the caller's list is visible here immediately.
The alternative that looks equivalent and is not. this.copies = arriving would make the caller's list be the shelf's list, so a later arriving.clear() empties the shelf from outside. It also discards everything restocked earlier. Both failures are silent.
requireNonNull before addAll, deliberately. copies.addAll(null) throws anyway, but the message comes from inside ArrayList and names an expression you did not write. Rejecting at the boundary produces NullPointerException: arriving, which is the parameter and the caller's mistake.
public List<Copy> copies() {
return List.copyOf(copies);
}
This stops the shelf growing from outside, and nothing else. List.copyOf returns an unmodifiable list of the same references, so add throws and get(1).markDamaged() reaches the shelf's own book. Block 4 shows both halves in two lines of output.
Returning copies directly is the bug this line prevents: any caller could add a book the shelf never accessioned, or clear the branch. Collections.unmodifiableList(copies) is the other option and is not the same thing. It is a live view, so the caller sees later restocks; a caller iterating it while the shelf restocks gets ConcurrentModificationException.
The corpus uses this same call in InMemoryCatalogue.titles() and InMemoryLoanRepository.outstanding(). There it is a complete defence, because the elements are records. Here it is half of one.
public Copy copyWithBarcode(String barcode) {
for (Copy copy : copies) {
if (copy.barcode().equals(barcode)) {
return copy;
}
}
return null;
}
return null is the line that creates block 5. The method's signature promises a Copy and delivers a value that fails at the first .. No caller is forced to check, no compiler flags the omission, and -Xlint:all says nothing.
Three ways to remove the trap, and the corpus uses two of them:
Optional<Copy>as the return type, as inCatalogue.find. The call site cannot ignore the empty case without writing something that reads wrong.- Throw for the absent case, as in
Shelf.require(isbn)withUnknownIsbnException. Right when absence is a caller error rather than an expected answer. - Return null and document it. Acceptable for a private helper whose two callers are on the same screen, which is what this one is used as by
lend.
copy.barcode().equals(barcode), not ==. That is J6's trap, and it is here because copies() returning List<Copy> means every lookup goes through a comparison somebody has to get right.
public LoanCard lend(String barcode, String memberId, Instant dueAt) {
Copy copy = copyWithBarcode(barcode);
if (copy == null) {
throw new IllegalArgumentException("no copy with barcode " + barcode);
}
The null check converts a later NullPointerException into a message with the barcode in it. Without it, new LoanCard(copy, ...) throws NullPointerException: copy from requireNonNull, which tells you a null arrived and not which barcode was asked for. The rule that generalises: check for absence where you still know what was being looked for.
LoanCard card = new LoanCard(copy, memberId, dueAt);
out.put(barcode, card);
copy.lentOn(card);
return card;
}
Three references to one card are created here, and takeBack has to drop two of them. The map holds one, the copy holds one, the caller holds the returned one. The caller's goes away on its own when the caller's frame does. The other two are the shelf's job.
public void takeBack(String barcode) {
LoanCard card = requireOut(barcode);
out.remove(barcode);
card.copy().backOnShelf();
}
out.remove alone is not enough, and that is the whole point of block 7. Removing the map entry makes copiesOut() correct. The card is still reachable through Copy.onLoan, and the copy is reachable from copies for the life of the shelf, so the finished loan is retained. backOnShelf() is the line that ends it.
What this costs if omitted: one LoanCard and one Instant retained per return, forever, with every number at the desk still correct. Nothing throws. A heap dump shows growth in LoanCard, and the only way to find the cause is to ask which reference chain still reaches it.
takeBackLeavingTheCard exists so the difference is one call rather than one paragraph. It is the same method with backOnShelf() deleted. Compare the two output lines it produces.
Main.java — two details that make the measurements honest
private static WeakReference<LoanCard> lendAndDropTheCard(Shelf shelf) {
return new WeakReference<>(shelf.lend(DICTIONARY + "#1", "M-2", DUE));
}
No local variable holds the card, on purpose. Write LoanCard card = shelf.lend(...) in main and that local keeps the card reachable for the rest of main. Block 7 would then report false for reasons that have nothing to do with the shelf. A separate frame that returns only the weak reference removes that confound.
private static boolean collected(WeakReference<?> ref) {
for (int attempt = 0; attempt < 20 && ref.get() != null; attempt++) {
System.gc();
System.gc() is a request the JVM may decline, so one call proves nothing. A WeakReference is cleared by any collection that finds its referent unreachable, so the loop asks repeatedly and stops as soon as the answer arrives. The false results in block 7 are the interesting ones, and a false after twenty full collections means reachable rather than unlucky.
This is a teaching instrument, not a pattern to use. Production code calling System.gc() in a loop is a red flag; WeakReference in production is for caches and for listener registries.
Two experiments, run for real
1 · The null dereference -Xlint:all will not mention
public class NullDesk {
public static void main(String[] args) {
Copy copy = null;
System.out.println(copy.barcode());
}
}
.toolchain\jdk-21\bin\javac.exe -Xlint:all -d . *.java
Zero output, exit code 0. The value is a compile-time constant null, the dereference is unconditional, and javac still has nothing to say. There is no flag that changes this, because nullability is not in the type system.
Then at run time, uncaught:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Copy.barcode()" because the return value of "Shelf.copyWithBarcode(String)" is null
at Desk.main(Desk.java:8)
Read the message backwards. The last clause names the expression that was null, the first names the call that failed. Java 14 added this and Java 21 has it on by default, so the message usually tells you which of several dots on one line was the problem. That is a habit worth building now, because the alternative is a debugger.
The one place the compiler does reject null:
Prim.java:3: error: incompatible types: <null> cannot be converted to int
int copiesHeld = null;
^
1 error
Primitives, and only primitives. Which is the same rule seen from the other side: int, long, boolean, char and double are values, and every other type you declare is a reference with null in its range.
2 · The <localN> in the message, and how to get a name
Block 5 dereferences a local:
Copy missing = shelf.copyWithBarcode(DESIGN_PATTERNS + "#1");
System.out.println(missing.barcode());
Compiled plainly:
Cannot invoke "Copy.barcode()" because "<local1>" is null
Compiled with javac -g:
Cannot invoke "Copy.barcode()" because "missing" is null
The class file carries no local variable table unless you ask for it. Both messages come from the same source line. If a NullPointerException names a <localN> you cannot place, recompile that file with -g and run it again rather than reading bytecode. Build tools usually pass -g already, so this is mostly a hand-compilation surprise.
Which design to write
The corpus reference solution returns null from no method, holds no mutable object in two places, and has no defensive copy to get right. It reaches that position by making the shared things immutable. Title and Loan are records, Member is final with no setters, and a copy travels as a String copyId rather than as an object.
Write Copy as it is here when the thing genuinely has changing state that several parts of the design need to see, and then decide who may change it. Write it as the corpus does when a snapshot is enough. The second is the answer more often than a C++ background expects, because the reason C++ code passes objects around by reference is a cost Java has already paid.
Worked source
The 4 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/Copy.java58 linesworked/src/LoanCard.java44 linesworked/src/Shelf.java89 linesworked/src/Main.java189 lines
worked/src/Copy.java58 lines
// Copy.java
//
// One physical book: the barcode inside the front cover, the ISBN of the title it is a copy
// of, and whether it came back damaged.
//
// Mutable, on purpose. The corpus's own Title and Loan are immutable records, and this lesson
// is about what changes once something in the design is not. See NOTES.md.
import java.util.Objects;
public final class Copy {
private final String barcode;
private final String isbn;
private boolean damaged;
/** The card currently in the pocket, or null when this copy is on the shelf. */
private LoanCard onLoan;
public Copy(String barcode, String isbn) {
this.barcode = Objects.requireNonNull(barcode, "barcode");
this.isbn = Objects.requireNonNull(isbn, "isbn");
}
public String barcode() {
return barcode;
}
public String isbn() {
return isbn;
}
public boolean damaged() {
return damaged;
}
/** Irreversible. Whoever holds a reference to this copy can call it. */
public void markDamaged() {
this.damaged = true;
}
public LoanCard onLoan() {
return onLoan;
}
// Package-private: the Shelf drives the lifecycle, callers outside the package cannot.
void lentOn(LoanCard card) {
this.onLoan = Objects.requireNonNull(card, "card");
}
void backOnShelf() {
this.onLoan = null;
}
@Override
public String toString() {
return "Copy[" + barcode + (damaged ? ", damaged" : ", intact") + "]";
}
}
worked/src/LoanCard.java44 lines
// LoanCard.java
//
// The card that goes in the book's pocket: which physical copy left the building, who has it,
// and when it is due back.
//
// The field of interest is `copy`. It is a reference to the same object the shelf holds. In
// C++ this line forces a decision — Copy, Copy&, Copy*, unique_ptr, shared_ptr — and the
// decision is about lifetime. Here there is one shape available and no lifetime question.
import java.time.Instant;
import java.util.Objects;
public final class LoanCard {
private final Copy copy;
private final String memberId;
private final Instant dueAt;
public LoanCard(Copy copy, String memberId, Instant dueAt) {
this.copy = Objects.requireNonNull(copy, "copy");
this.memberId = Objects.requireNonNull(memberId, "memberId");
this.dueAt = Objects.requireNonNull(dueAt, "dueAt");
}
/**
* The copy that is out. Handing this back hands out the shelf's object, not a picture of it.
* Whether that is acceptable is the design question in NOTES.md, not a language question.
*/
public Copy copy() {
return copy;
}
public String memberId() {
return memberId;
}
public Instant dueAt() {
return dueAt;
}
@Override
public String toString() {
return "LoanCard[" + copy.barcode() + " -> " + memberId + ", due " + dueAt + "]";
}
}
worked/src/Shelf.java89 lines
// Shelf.java
//
// The copies this branch holds and the cards for the ones that are out. A cut-down version of
// corpus/library/reference/src/Shelf.java: same job, but holding Copy objects instead of
// deriving availability from the loan drawer, because objects held in two places is the point.
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class Shelf {
private final List<Copy> copies = new ArrayList<>();
private final Map<String, LoanCard> out = new HashMap<>();
/**
* Take a delivery. The arriving copies are added to this shelf's own list, so the caller's
* list and the shelf's list are two lists over the same books from here on.
*/
public void restock(List<Copy> arriving) {
Objects.requireNonNull(arriving, "arriving");
copies.addAll(arriving);
}
/** Every copy held, in accession order. The list cannot be modified; the copies can. */
public List<Copy> copies() {
return List.copyOf(copies);
}
/** The copy with this barcode, or null when the shelf has never held it. */
public Copy copyWithBarcode(String barcode) {
for (Copy copy : copies) {
if (copy.barcode().equals(barcode)) {
return copy;
}
}
return null;
}
public int copiesOut() {
return out.size();
}
/**
* Lend one copy.
*
* @throws IllegalArgumentException if no copy with that barcode is held
* @throws IllegalStateException if that copy is already out
*/
public LoanCard lend(String barcode, String memberId, Instant dueAt) {
Copy copy = copyWithBarcode(barcode);
if (copy == null) {
throw new IllegalArgumentException("no copy with barcode " + barcode);
}
if (out.containsKey(barcode)) {
throw new IllegalStateException(barcode + " is already out");
}
LoanCard card = new LoanCard(copy, memberId, dueAt);
out.put(barcode, card);
copy.lentOn(card);
return card;
}
/** Take a copy back, and drop both references to the finished loan. */
public void takeBack(String barcode) {
LoanCard card = requireOut(barcode);
out.remove(barcode);
card.copy().backOnShelf();
}
/**
* Take a copy back and leave the card in the pocket. The desk works, the numbers are right,
* and the finished loan is now reachable for the life of the shelf.
*/
public void takeBackLeavingTheCard(String barcode) {
requireOut(barcode);
out.remove(barcode);
}
private LoanCard requireOut(String barcode) {
LoanCard card = out.get(barcode);
if (card == null) {
throw new IllegalStateException(barcode + " is not out");
}
return card;
}
}
worked/src/Main.java189 lines
// Main.java
//
// Seven demonstrations, in the order the ideas depend on each other. Every line of output in
// NOTES.md came from running this file.
import java.lang.ref.WeakReference;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class Main {
private static final String CLEAN_CODE = "978-0132350884";
private static final String DICTIONARY = "978-0198611868";
private static final String DESIGN_PATTERNS = "978-0201633610";
private static final Instant DUE = Instant.parse("2026-01-26T09:00:00Z");
public static void main(String[] args) {
twoNamesOneCopy();
passingCopiesTheReference();
Shelf shelf = sharedBetweenShelfAndCard();
copyOfProtectsTheListOnly(shelf);
nullFailsAtUseNotAtAssignment(shelf);
cyclesAreCollected();
theLeakGarbageCollectionCannotPrevent();
}
/** 1. Assignment copies the reference. There is one Copy on the heap, and two names for it. */
private static void twoNamesOneCopy() {
System.out.println("--- 1. two names, one Copy");
Copy onTheShelf = new Copy(CLEAN_CODE + "#1", CLEAN_CODE);
Copy sameBook = onTheShelf;
sameBook.markDamaged();
System.out.println("onTheShelf.damaged() : " + onTheShelf.damaged());
System.out.println("onTheShelf == sameBook : " + (onTheShelf == sameBook));
System.out.println("onTheShelf : " + onTheShelf);
System.out.println();
}
/**
* 2. A parameter is a fresh name for the same object. Calling a mutator through it reaches
* the caller's object; reassigning it reaches nothing.
*/
private static void passingCopiesTheReference() {
System.out.println("--- 2. passing a Copy to a method");
Copy delivered = new Copy(CLEAN_CODE + "#2", CLEAN_CODE);
System.out.println("before : " + delivered);
inspect(delivered);
System.out.println("after inspect() : " + delivered);
System.out.println("barcode unchanged : " + delivered.barcode());
System.out.println();
}
/** Two statements, two different reaches. Only the first one is visible to the caller. */
private static void inspect(Copy copy) {
copy.markDamaged();
copy = new Copy(CLEAN_CODE + "#99", CLEAN_CODE);
copy.markDamaged();
}
/** 3. The shelf and the loan card hold the same object, and neither of them owns it. */
private static Shelf sharedBetweenShelfAndCard() {
System.out.println("--- 3. one Copy, referenced from the shelf and from the card");
Shelf shelf = new Shelf();
shelf.restock(new ArrayList<>(List.of(
new Copy(CLEAN_CODE + "#1", CLEAN_CODE),
new Copy(CLEAN_CODE + "#2", CLEAN_CODE),
new Copy(DICTIONARY + "#1", DICTIONARY))));
LoanCard card = shelf.lend(CLEAN_CODE + "#1", "M-1", DUE);
System.out.println("card : " + card);
System.out.println("shelf's object == card's object : "
+ (shelf.copyWithBarcode(CLEAN_CODE + "#1") == card.copy()));
card.copy().markDamaged();
System.out.println("shelf's view now : " + shelf.copyWithBarcode(CLEAN_CODE + "#1"));
System.out.println("card's view now : " + card.copy());
System.out.println();
return shelf;
}
/** 4. List.copyOf freezes the list. It does not freeze the books in it. */
private static void copyOfProtectsTheListOnly(Shelf shelf) {
System.out.println("--- 4. List.copyOf protects the list, not the copies");
List<Copy> handedOut = shelf.copies();
try {
handedOut.add(new Copy(DESIGN_PATTERNS + "#1", DESIGN_PATTERNS));
System.out.println("add succeeded, so the shelf grew from outside");
} catch (UnsupportedOperationException e) {
System.out.println("handedOut.add(...) : UnsupportedOperationException");
}
handedOut.get(1).markDamaged();
System.out.println("shelf's second copy : " + shelf.copyWithBarcode(CLEAN_CODE + "#2"));
System.out.println();
}
/** 5. null is accepted at assignment and rejected at use. Twice, two different messages. */
private static void nullFailsAtUseNotAtAssignment(Shelf shelf) {
System.out.println("--- 5. null, and the two shapes of the message");
Copy missing = shelf.copyWithBarcode(DESIGN_PATTERNS + "#1");
System.out.println("copyWithBarcode(unheld): " + missing);
try {
System.out.println(missing.barcode());
} catch (NullPointerException e) {
System.out.println("through a local : " + e.getMessage());
}
try {
System.out.println(shelf.copyWithBarcode(DESIGN_PATTERNS + "#1").barcode());
} catch (NullPointerException e) {
System.out.println("through a call : " + e.getMessage());
}
System.out.println();
}
/** 6. Copy points at LoanCard, LoanCard points at Copy, and the pair is still collected. */
private static void cyclesAreCollected() {
System.out.println("--- 6. a reference cycle, unreachable");
WeakReference<Copy> watch = makeACycleAndDropIt();
System.out.println("collected after gc : " + collected(watch));
System.out.println();
}
private static WeakReference<Copy> makeACycleAndDropIt() {
Copy copy = new Copy(DICTIONARY + "#1", DICTIONARY);
LoanCard card = new LoanCard(copy, "M-3", DUE);
copy.lentOn(card);
return new WeakReference<>(copy);
}
/**
* 7. The leak that survives garbage collection: a finished loan still reachable, because one
* line that removes a reference was left out.
*/
private static void theLeakGarbageCollectionCannotPrevent() {
System.out.println("--- 7. the leak garbage collection cannot prevent");
Shelf tidy = new Shelf();
tidy.restock(new ArrayList<>(List.of(new Copy(DICTIONARY + "#1", DICTIONARY))));
WeakReference<LoanCard> whileOut = lendAndDropTheCard(tidy);
System.out.println("collected while out : " + collected(whileOut));
tidy.takeBack(DICTIONARY + "#1");
System.out.println("collected after takeBack : " + collected(whileOut));
Shelf forgetful = new Shelf();
forgetful.restock(new ArrayList<>(List.of(new Copy(DICTIONARY + "#1", DICTIONARY))));
WeakReference<LoanCard> leaked = lendAndDropTheCard(forgetful);
forgetful.takeBackLeavingTheCard(DICTIONARY + "#1");
System.out.println("copies out, both shelves : " + tidy.copiesOut() + " and "
+ forgetful.copiesOut());
System.out.println("collected after takeBackLeavingTheCard : " + collected(leaked));
}
/** No local survives this frame, so the only reference left is the shelf's. */
private static WeakReference<LoanCard> lendAndDropTheCard(Shelf shelf) {
return new WeakReference<>(shelf.lend(DICTIONARY + "#1", "M-2", DUE));
}
/**
* System.gc() is a request, not a command, so ask a few times before believing the answer.
* A WeakReference is cleared by any collection that finds its referent unreachable.
*/
private static boolean collected(WeakReference<?> ref) {
for (int attempt = 0; attempt < 20 && ref.get() != null; attempt++) {
System.gc();
try {
Thread.sleep(5);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return ref.get() == null;
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.