LLD Dojo

Java Bridge · J3

References, null and garbage collection — nothing owns anything, and one new leak

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 objectCopy 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 reboundNo equivalent. Every reference can be null and can be rebound
const Copy& r — a read-only viewNo equivalent. final binds the variable, never the object
unique_ptr / shared_ptr in a signature announces ownershipA signature announces nothing about lifetime
Stack or heap is your callOnly 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 objectvoid f(Copy c) copies the reference
void f(Copy& c) — callee can reassign the caller's variableImpossible. There is no reference-to-reference
void f(const Copy& c) — callee cannot mutateImpossible. 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:

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 outNo non-nullable reference type exists
Dereferencing a null pointer is undefined behaviourNullPointerException, at a defined point, with a message
nullptr into an int — compile errorSame. 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 surfacesThe stack trace's top frame is the dereference

The Java replacements for Copy&, in the order to reach for them:

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++ leakJava equivalent
new with no deleteCannot happen
shared_ptr cycleCannot happen
Forgot to erase from a cache or registryIdentical, and now the only kind
Valgrind finds itNothing 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:

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 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.

← J2 · Packages, imports, classpath — and compiling by hand J4 · Construction — final fields, no initialiser lists, and static factories →

← all lessons