Layers of Logic

7.4

Static Nested, Inner, Local, and Anonymous Classes

Choose the right nesting form by asking about scope, the enclosing object, and captured values.

Core29 min read4 exercises
01

Previously on

You know top-level classes, object references, static, inheritance, abstract classes, and interfaces.

This lesson changes where a class declaration appears. The important questions are not visual. Ask what scope the type needs, whether it needs an enclosing object, and which local values it captures.

02

The problem

Some helper types belong to exactly one outer class. A map entry or builder may have no useful meaning outside its owner.

Other implementations are needed in one method or one expression. Giving each one a top-level file expands the visible design without adding a reusable concept.

Java offers four nested forms:

Form Declared where Automatic enclosing object
static nested class member of a class, with static no
inner class non-static member of a class yes
local class inside a block depends on its context and use
anonymous class inside an object-creation expression depends on its context and use

The correct choice follows from lifetime and access, not from which syntax is shortest.

03

The idea

Start with a static nested class

Use a static nested class when the helper belongs conceptually to the outer type but does not need one outer object.

class Registry {
    private static int nextId = 1;

    static class Entry {
        private final int id;
        private final String name;

        Entry(String name) {
            id = nextId++;
            this.name = name;
        }

        String label() {
            return id + ": " + name;
        }
    }
}

Create it using the outer type as a qualifier:

Registry.Entry entry = new Registry.Entry("Atlas");
System.out.println(entry.label());

No Registry object is required. Entry can use the outer class’s static field.

A static nested class may receive an outer object explicitly if one operation needs it. The key is that no enclosing instance arrives automatically.

Use an inner class when every helper belongs to one object

class Registry {
    private final String region;

    Registry(String region) {
        this.region = region;
    }

    class Entry {
        private final String name;

        Entry(String name) {
            this.name = name;
        }

        String label() {
            return region + ": " + name;
        }
    }
}

An Entry needs a particular Registry because label reads that registry’s region.

Registry north = new Registry("north");
Registry south = new Registry("south");

Registry.Entry a = north.new Entry("Atlas");
Registry.Entry b = south.new Entry("Tide");

System.out.println(a.label()); // north: Atlas
System.out.println(b.label()); // south: Tide

The syntax north.new Entry(...) identifies the enclosing object. Inside a Registry instance method, new Entry(...) uses the current registry automatically.

04

Under the hood

Going deeper

Static nested does not mean “cannot access private code”

Both static nested and inner classes are members of the enclosing class. Java allows nested code and its enclosing class to access each other’s private members.

The difference is the receiver.

class Outer {
    private static int shared = 10;
    private int perObject = 20;

    static class Nested {
        int readShared() {
            return shared;
        }

        int readObject(Outer outer) {
            return outer.perObject;
        }
    }
}

Nested accesses shared directly. It needs an explicit Outer reference for perObject because there is no automatic enclosing object.

An inner class can access both directly because its instance has an enclosing Outer.

Name shadowing and Outer.this

An inner method can have several values named name:

class Registry {
    private String name = "outer";

    class Entry {
        private String name = "inner";

        void print(String name) {
            System.out.println(name);               // parameter
            System.out.println(this.name);          // Entry field
            System.out.println(Registry.this.name); // Registry field
        }
    }
}

Registry.this means the enclosing Registry object. this means the current Entry object.

Avoid heavy shadowing in ordinary code. The syntax is valuable when reading libraries and generated code.

Inner classes and static members

Older Java rules restricted many static declarations inside inner classes. Current Java permits static members in inner classes.

That does not turn an inner class into a static nested class. The category depends on whether the class declaration itself has static and whether instances have an enclosing object.

Use the enclosing-instance rule as your definition. Version-specific static-member restrictions are not a reliable mental model.

The enclosing relationship can affect reachability

An inner-class instance is created with an enclosing instance. When its code uses that outer object, the compiled class needs a way to retain and reach it.

long-lived collection -> Entry inner object -> Registry outer object

If the collection still reaches Entry, garbage collection must also treat the referenced Registry as reachable.

This is useful when Entry genuinely depends on registry state. It is wasted retention when the helper never needed that state.

Current javac versions can omit a stored outer reference when an inner class never uses the enclosing instance. That is an optimisation, not a design promise.

Adding one outer-field read can make the compiler restore the reference. A static nested declaration prevents that dependency from appearing later by accident.

Static nested classes have no automatic outer association. Local classes, anonymous classes, and lambdas can also retain objects they capture.

The general review question is: what objects can this small callback keep alive?

Local classes keep a type inside one block

A local class is declared inside a method, constructor, or another block:

static String normalise(String raw) {
    class Cleaner {
        String clean(String value) {
            return value.trim().toLowerCase();
        }
    }

    Cleaner cleaner = new Cleaner();
    return cleaner.clean(raw);
}

Only code after the declaration and within its scope can name Cleaner. The type does not become a member of the outer class.

This can group several methods and a little state for one algorithm. If the helper becomes large or reusable, move it to a member or top-level type.

Local classes can capture effectively final values

static Runnable greeter(String prefix) {
    int times = 2;

    class GreetingTask implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < times; i++) {
                System.out.println(prefix);
            }
        }
    }

    return new GreetingTask();
}

The returned object can run after greeter has returned. Its code still needs prefix and times.

Both variables are effectively final. They are assigned once and never reassigned. Writing the final keyword would not change their status.

This fails:

int times = 2;
times++;

class GreetingTask implements Runnable {
    public void run() {
        System.out.println(times); // compile-time error
    }
}

Java captures values, not a live local-variable slot in a vanished stack frame. Reassignment would make the two storage locations appear inconsistent.

Capturing a reference copies the reference value. The referenced object may still be mutable:

StringBuilder text = new StringBuilder("A");

class Appender {
    void add() {
        text.append("B"); // object mutation is allowed
    }
}

// text = new StringBuilder(); // reassignment would break effective finality

Anonymous classes combine declaration and creation

An anonymous class has no source-level name. It extends one class or implements one interface in the object-creation expression.

interface Alarm {
    void ring();
}

Alarm alarm = new Alarm() {
    @Override
    public void ring() {
        System.out.println("Wake up");
    }
};

alarm.ring();

Read the expression in two parts:

  1. Declare an unnamed class that implements Alarm.
  2. Create one object of that unnamed class.

The semicolon ends the entire assignment expression after the anonymous class body.

An anonymous class can declare fields, methods, and instance initializer blocks. It cannot declare a normal constructor because there is no class name to use.

It can extend a class and pass constructor arguments:

Thread worker = new Thread("registry-worker") {
    @Override
    public void run() {
        System.out.println(getName());
    }
};

The reference type controls which methods the caller can name. A new method declared only inside the anonymous class is not visible through an Alarm reference.

Anonymous classes follow capture rules similar to local classes. They can also use an enclosing instance when created in an instance context.

For a functional interface with one abstract method, a lambda can supply that method’s behaviour:

Alarm alarm = () -> System.out.println("Wake up");

This replaces the previous anonymous-class use case, but it does not declare an anonymous class in the Java language.

Important differences include:

Anonymous class Lambda
creates a new class body and object identity represents behaviour for a target functional interface
this means the anonymous object this keeps the enclosing meaning
can declare instance fields and extra methods expression or block supplies one function body
may implement an interface with several abstract methods requires a functional interface target

JVMs commonly implement lambdas through invokedynamic and runtime linkage. They need not generate one ordinary anonymous class file for each lambda expression.

Phase XI covers target typing, capture, and method references. The preparation from this lesson is narrower: you know what an anonymous class is, so you can compare the two accurately.

Inspect what the compiler generated

Compile a file containing each nested form, then list its class files. Names often look like these:

Outer.class
Outer$Nested.class
Outer$Inner.class
Outer$1Local.class
Outer$1.class

The exact synthetic names are compiler details. Use javap -p to inspect fields and methods, and javap -c to inspect bytecode.

Generated names and fields help diagnose captures, but source semantics remain the contract. Do not write application logic that depends on a synthetic class name.

05

What it costs

Nesting can express ownership and reduce public surface area. Deeply nested logic is harder to test and scan.

Inner and captured objects can extend the lifetime of larger objects. Static nesting avoids the automatic enclosing reference.

Local and anonymous classes work well for short, local implementations. Promote them to named types when behaviour needs reuse, documentation, or independent tests.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. What separates a static nested class from a non-static inner class?

    Show the answer

    An inner-class instance is associated with an enclosing object and can use that object's instance members directly. A static nested class has no automatic enclosing-object reference. It can use instance state only through an explicit object reference.

  2. Why may a local or anonymous class capture only final or effectively final local variables?

    Show the answer

    The created object may outlive the method call and its stack frame. Java captures the local variable's value for later use. Preventing reassignment keeps the method variable and captured value from appearing to be one changing variable when they are separate storage.

  3. Is a lambda an anonymous class with shorter syntax?

    Show the answer

    No. Both can supply behaviour for an interface, so a lambda replaces many anonymous-class use cases. Their semantics differ: a lambda does not introduce a new meaning of this, cannot declare instance state, and is commonly implemented without generating one anonymous class per expression.

  4. How can an inner class contribute to a memory leak?

    Show the answer

    An inner class that uses enclosing-instance state needs a reference to that object. If long-lived code stores the inner object, the enclosing object also remains reachable. A compiler may omit the stored reference when the inner class never uses it, but making the class static removes the dependency by design.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises100 pointsabout 110 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Static Nested or Inner

Real work·25 min·20 points

checkedex-7-4-a

Two nested classes, one word apart, with completely different capabilities.

Start by getting both working. The inner class creation syntax is unusual, and working it out yourself is more useful than being shown, because the strangeness is the language telling you something real.

Then uncomment the line in Entry and explain the error properly. Not “static things cannot see instance things”, but why that particular question has no answer.

Finish with javap. With the course JDK, the generated field is named this$0. That name is a compiler detail. The important fact is that Auditor needs a reference to its enclosing object because it reads orgName.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Nested {
    Entry(String name): String show()
    Auditor: String report()
    static String makeAuditorReport()
    static boolean entryCanReadOuterInstanceField()
    static String hiddenFieldName()
    static boolean auditorHasHiddenField()
    static boolean entryHasHiddenField()
    static String defaultChoice()
}

Entry stays static nested and Auditor stays inner, because the difference between them is the exercise. show returns the name, a space, v and the version. report includes both orgName and the version. makeAuditorReport has to build an outer object before it can build an Auditor. hiddenFieldName is the field name exactly as the course JDK's javac prints it.

What your program must do

  • Create both kinds of nested object and get them working
  • Uncomment the outer field access in Entry and explain the error
  • Run javap on both nested classes and find the field the compiler added
  • State which of the two you should reach for by default
Nested.java
public class Nested {
    private String orgName = "Layers of Logic";
    private static String version = "1.0";

    static class Entry {
        String name;
        Entry(String name) { this.name = name; }

        String show() {
            return name + " v" + version;
            // return name + " " + orgName;   // uncomment: does this compile? why not?
        }
    }

    class Auditor {
        String report() {
            return "auditing " + orgName + " v" + version;
        }
    }

    // TODO: build a Nested, then an Auditor from it, and return the report.
    // The syntax for creating an inner object is unusual. Work it out.
    static String makeAuditorReport() {
        return "TODO";
    }

    // ---- what you worked out ----

    // TODO: can Entry read orgName?
    static boolean entryCanReadOuterInstanceField() { return true; }

    // TODO: compile, then run  javap -p 'Nested$Auditor'  and read the field list.
    // What is the field called that you never wrote?
    static String hiddenFieldName() { return "?"; }

    // TODO
    static boolean auditorHasHiddenField() { return false; }

    // TODO: now run javap -p 'Nested$Entry' and compare
    static boolean entryHasHiddenField() { return true; }

    // TODO: one word. Which of the two should you reach for by default?
    static String defaultChoice() { return "?"; }

    public static void main(String[] args) {
        System.out.println(new Entry("Atlas").show());
        System.out.println(makeAuditorReport());
    }
}
Hint 1
An inner class needs an outer object first: Nested n = new Nested(); Nested.Auditor a = n.new Auditor();. That syntax looks strange and it is telling you something true.
Hint 2
Entry is static, so it has no outer object. orgName belongs to a particular Nested, and there is no particular Nested here, so the question has no answer and the compiler rejects it.
Hint 3almost the answer
Run javap -p 'Nested$Auditor' and look at the field list. The compiler added one, and it is not a name you could have typed. Then run the same command on 'Nested$Entry' and see that it has nothing. That difference is the whole section.
What this is really testing

Whether you can inspect the enclosing reference generated when an inner class uses outer state, while keeping compiler-generated fields separate from Java language guarantees.

B

The Leak That Looks Tiny

Hard·30 min·30 points

checkedex-7-4-b

Five small listener objects can keep much larger holders alive.

Run the inner version first and watch the memory climb. Nothing in the loop keeps a Holder. The only thing stored is a listener with no fields at all.

Then run the static version and watch the memory stay flat.

Explain the difference precisely. The listener that reads an outer field carries a route back to the holder. The static listener has no automatic enclosing-object dependency.

This is a genuine production bug shape. Event listeners, callbacks and adapters stored in long lived collections are exactly where it happens, and the fix is one keyword that nobody notices is missing.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Pin {
    Holder(int megabytes, String label): String getLabel(), int bufferBytes()
    Holder.UsingListener, Holder.BlindListener, Holder.StaticListener: String describe()
    static String hiddenFieldName()
    static boolean usingListenerPinsHolder(), blindListenerPinsHolder(), staticListenerPinsHolder()
    static long recordedUsingMB(), static long recordedStaticMB()
    static String oneKeywordFix()
    static boolean addingOneLineCanCreateTheLeak()
}

The three listener classes are given and must keep the shape they have, because the difference between them is the whole exercise. UsingListener reads an outer field, BlindListener is inner and reads nothing outer, StaticListener is static nested. The tests drop the Holder and ask the garbage collector whether it could take it, once for each listener. The recorded numbers are your own, from five 12 MB holders.

What your program must do

  • Run all three loops and record the memory each one leaves behind
  • Run javap on all three listener classes and compare their field lists
  • Explain the difference in terms of what each listener holds
  • Say what one keyword would remove the possibility of this bug
Pin.java
import java.util.*;

public class Pin {

    static class Holder {
        private final byte[] hugeBuffer;
        private final String label;

        Holder(int megabytes, String label) {
            this.hugeBuffer = new byte[megabytes * 1_000_000];
            this.label = label;
        }

        String getLabel()  { return label; }
        int bufferBytes()  { return hugeBuffer.length; }

        // Inner, and it reads an outer field.
        class UsingListener {
            String describe() { return "listener for " + label; }
        }

        // Inner, and it touches nothing outer. Predict what the compiler does with this one.
        class BlindListener {
            String describe() { return "listener for nobody"; }
        }

        // Static nested.
        static class StaticListener {
            String describe() { return "listener for nobody"; }
        }

        UsingListener makeUsing() { return new UsingListener(); }
        BlindListener makeBlind() { return new BlindListener(); }
        static StaticListener makeStatic() { return new StaticListener(); }
    }

    static long usedMB() {
        Runtime rt = Runtime.getRuntime();
        System.gc();
        try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
    }

    // ---- what you found ----

    // TODO: the field name from the previous exercise
    static String hiddenFieldName() { return "?"; }

    // TODO: run the three loops, then run javap -p on each listener class
    static boolean usingListenerPinsHolder()  { return false; }

    // TODO: this one is the surprise. Check it before you answer.
    static boolean blindListenerPinsHolder()  { return true; }

    // TODO
    static boolean staticListenerPinsHolder() { return true; }

    // TODO: the megabytes you actually saw, in the using run and the static run
    static long recordedUsingMB()  { return 0; }
    static long recordedStaticMB() { return 0; }

    // TODO: one word, on the nested class
    static String oneKeywordFix() { return "?"; }

    // TODO: could a change that says nothing about memory create this leak?
    static boolean addingOneLineCanCreateTheLeak() { return false; }

    public static void main(String[] args) {
        System.out.println("start        : " + usedMB() + " MB");

        List<Object> kept = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            kept.add(new Holder(12, "unit-" + i).makeUsing());   // keep only the tiny listener
        }
        System.out.println("5 using inner: " + usedMB() + " MB");

        // TODO: repeat with makeStatic, and then with makeBlind, and compare all three
    }
}
Hint 1
In every loop the Holder goes out of scope immediately and only the listener is kept. So the question is always the same: what else is the listener holding on to?
Hint 2
Two of the three leave memory flat with the course JDK, and one is an inner class. Run javap -p on all three. Current javac can omit the stored outer reference when the class never uses it. Treat that as observed compiler behaviour, not a language guarantee.
Hint 3almost the answer
That makes the bug worse, not better. The class is safe until somebody adds one line that reads an outer field, and then the compiler puts the reference back and nothing in the diff mentions memory. The keyword static takes the possibility away for good.
What this is really testing

Whether you can connect a captured enclosing reference to object reachability, and distinguish a Java design rule from a current javac optimisation.

C

Anonymous Class, Then Lambda

Real work·30 min·25 points

checkedex-7-4-c

Write the same behaviour three ways: a named class, an anonymous class, and a lambda.

Then compile it and look at the folder. The anonymous class produced a real file with a real name, one the compiler invented. Seeing Anonymous$1.class on disk is what turns “a class with no name” from a phrase into a fact.

Do the same for a sort, first with an anonymous Comparator and then with a lambda, and confirm the results match exactly.

The deliverable is the comparison. List what target typing lets the lambda omit, then state how this and generated class identity differ between the two forms.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Anonymous {
    static Greeter namedGreeter(), anonymousGreeter(), lambdaGreeter()
    static Comparator<String> anonymousLengthComparator(), lambdaLengthComparator()
    static boolean anonymousClassProducesAClassFile(), lambdaProducesAClassFile()
    static boolean lambdaInterfaceNameComesFromTheTarget(), lambdaMethodNameComesFromTheInterface(), lambdaParameterTypeIsInferred()
    static int abstractMethodsALambdaTargetCanHave()
}

All three greeters return "hello " and the name. The comparators sort shortest first. The form matters as much as the behaviour here, so the tests ask the runtime what each object actually is: the anonymous ones must report themselves as anonymous classes, and the lambdas must not.

What your program must do

  • Write the same behaviour three ways, and call all three
  • Compile it and list the class files produced
  • Sort the same list with both comparators and confirm identical results
  • Write down which source details target typing lets the lambda omit
  • Explain two semantic differences between a lambda and an anonymous class
Anonymous.java
import java.util.*;

interface Greeter { String greet(String name); }

// TODO 1. a named class implementing Greeter
class NamedGreeter implements Greeter {
    @Override public String greet(String name) { return "TODO"; }
}

public class Anonymous {

    static Greeter namedGreeter() { return new NamedGreeter(); }

    // TODO 2. the same behaviour using an anonymous class
    static Greeter anonymousGreeter() {
        return null;
    }

    // TODO 3. the same behaviour using a lambda
    static Greeter lambdaGreeter() {
        return null;
    }

    // TODO 4. sort by length, as an anonymous Comparator
    static Comparator<String> anonymousLengthComparator() {
        return null;
    }

    // TODO 5. the same comparison behaviour, as a lambda
    static Comparator<String> lambdaLengthComparator() {
        return null;
    }

    // ---- what the folder showed ----
    // Compile, then list the class files in the folder.

    // TODO
    static boolean anonymousClassProducesAClassFile() { return false; }

    // TODO
    static boolean lambdaProducesAClassFile() { return true; }

    // ---- what the lambda removed, and how the compiler already knew it ----

    // TODO: the interface name is gone. Where did the compiler get it?
    static boolean lambdaInterfaceNameComesFromTheTarget() { return false; }

    // TODO: the method name is gone too
    static boolean lambdaMethodNameComesFromTheInterface() { return false; }

    // TODO: and the parameter type
    static boolean lambdaParameterTypeIsInferred() { return false; }

    // TODO: how many abstract methods can an interface have and still accept a lambda?
    static int abstractMethodsALambdaTargetCanHave() { return 0; }

    public static void main(String[] args) {
        // TODO: call all three greeters and print the class each one really is

        List<String> units = new ArrayList<>(List.of("Cipher", "Atlas", "Drift", "Beacon"));

        // TODO: sort a copy with each comparator and confirm the results match
        if (units.isEmpty()) System.out.println();
    }
}
Hint 1
The anonymous form is new Greeter() { public String greet(String name) { ... } }. When you assign it, note the semicolon after the closing brace: it is an expression, not a declaration.
Hint 2
After compiling, list the class files. You will see Anonymous$1.class and probably $2. Those are your anonymous classes. The lambda adds no ordinary class file for the expression because the JVM links its implementation at run time.
Hint 3almost the answer
The lambda can omit the interface name (known from the target type), the method name (the interface has only one), the parameter type (it is in the interface), public, the braces and the return for this expression body. That shorter source does not make it an anonymous class.
What this is really testing

Whether you can produce the same behaviour with a named class, an anonymous class, and a lambda, then explain why the lambda is not an anonymous class.

D

Why Effectively Final

Hard·25 min·25 points

checkedex-7-4-d

A task that outlives the method that created it, still printing that method’s local variables.

Run it first and notice the strange part: by the time run() executes, the stack frame holding counter and label is long gone. Something must have been copied.

Then uncomment the increment and read the error. Note that it is reported on the line using the variable, not the line changing it, which is a hint about what Java is protecting.

Explain the rule yourself, in terms of stack frames and the heap. Do not quote the error message back. If your explanation mentions where each thing lives and when it disappears, you have it.

Finish by making a genuinely changing counter work. There is more than one way, and all of them involve moving the counter somewhere that outlives the frame.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Effectively {
    static Task makeTask()
    static Task makeCountingTask()
    static boolean errorIsReportedOnTheUsingLine()
    static String whereLocalsLive()
    static String whereTheObjectLives()
    static boolean valuesAreCopiedIntoTheObject()
    static boolean aChangingLocalWouldMakeTwoAnswers()
}

Task returns a String rather than printing, so the tests can read it. makeTask keeps the locals it has and returns "task 0". makeCountingTask returns a task whose first call gives "1", second "2", and so on, and two tasks made separately must count separately, so a static counter will fail. The two one word answers are stack or heap, case and spaces ignored.

What your program must do

  • Run it and confirm the task works after makeTask has returned
  • Uncomment the increment and record which line the error is reported on
  • Explain the rule in terms of stack frames and the heap
  • Make a genuinely changing counter that the task can see, and keep two of them independent
Effectively.java
interface Task { String run(); }

public class Effectively {

    static Task makeTask() {
        int counter = 0;
        String label = "task";

        Task t = new Task() {
            @Override public String run() {
                return label + " " + counter;
            }
        };

        // counter++;      // uncomment: what happens, and on which line is it reported?
        return t;
    }

    // TODO: a task that counts. Every call returns the next number, starting at 1,
    // as a String. Two tasks made separately must count separately.
    static Task makeCountingTask() {
        int counter = 0;
        return () -> String.valueOf(counter);
    }

    // ---- what you worked out ----

    // TODO: was the error on the line that READS counter, or the line that CHANGES it?
    static boolean errorIsReportedOnTheUsingLine() { return false; }

    // TODO: one word. "stack" or "heap"
    static String whereLocalsLive() { return "?"; }

    // TODO: one word. Where does the task object live?
    static String whereTheObjectLives() { return "?"; }

    // TODO: how did run() still have the values after makeTask returned?
    static boolean valuesAreCopiedIntoTheObject() { return false; }

    // TODO: why is copying only safe when the original cannot change?
    static boolean aChangingLocalWouldMakeTwoAnswers() { return false; }

    public static void main(String[] args) {
        Task t = makeTask();
        System.out.println(t.run());        // makeTask has already returned by now

        // TODO: run the counting task three times and check the number moves
    }
}
Hint 1
Note when t.run() happens. makeTask has already returned, so its frame is gone, and the task can still print label and counter.
Hint 2
The values were copied into the object when it was created. That is the only way it could work. A copy is only safe while the original cannot change, or the two would disagree with no way to say which is right.
Hint 3almost the answer
For a counter that really moves, put it somewhere that outlives the frame: a one element array, or an AtomicInteger. The reference never changes, so it is effectively final, and what it holds can change as much as it likes. Make it inside the method, not as a static field, or two tasks would share it.
What this is really testing

Whether you can explain a compiler rule from how memory works rather than by quoting it. This rule appears again for lambdas, and understanding it once covers both.

08

After the credits

Choose a nested form with three questions:

  1. Does the helper belong to one outer type?
  2. Does each helper object need one enclosing object?
  3. Is the type needed outside this block or expression?

Those answers select static nested, inner, local, or anonymous without memorising four unrelated syntaxes.

Threads you opened in this section

Inner class will return in Phase X. The Collections Framework