---
title: 'Inner and Local Classes'
source: 'https://academia.sh/en/courses/java-object-model/inner-and-local-classes'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:41+00:00'
license: 'CC BY-SA 4.0'
---

# Inner and Local Classes

A single source file produces six class files, and four of those carry hidden fields never written in the source. An inner class binds to the enclosing instance through a field and reads that field live; a local class copies the variable it captures through a constructor parameter, which is why that variable can never be reassigned.

The previous lesson's measurement left a restriction vanishing without ever being written into
a class file: a `final` local variable's restriction stopped only at the compiler. If that word
disappears once compilation ends, what is it for? There is a place in Java where the compiler
wants that very same word **on its own, without it being written** — and there, its
counterpart becomes visible.

That place is where a class is written inside another class. In Java, a class can be defined
in another class's body, in a method's body, or directly inside a `new` expression. In the
source, all of these sit nested, in a single file; a class file, though, cannot be nested.
There are three questions: how many separate files does this nesting open into, how do the
produced files bind to the outside, and which side does that binding form leave the decision
to?

## The Measurement Core

The measurement compiles a source and reads every class file produced. For each file, two
things are counted: its fields — those written in the source together with the **hidden** ones
the compiler adds — and the constructor's signature.

- **CI31** — The oracle is the source itself: we know which field we wrote, so any extra field
  appearing in the list is one the compiler added. A hidden field is told apart not by guessing
  but by its flag in the class file.
- **CI32** — All class files are produced from a **single** source file; the count covers every
  file in that directory.
- **CI33** — In the capture measurement, the object is produced **before** the outer field's
  value is changed, so a later reading of that value is proof of a live read.
- **CI34** — In the compile trials, a single line changes, and every trial compiles in its own
  temporary directory.
- **CI35** — No environment-dependent data is read; what is counted is file count, field count,
  and constructor parameters.

```java
// Gauge.java — the class files produced, the synthetic fields they carry, and constructor signatures
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.reflect.AccessFlag;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    static Path newDir() throws Exception { return Files.createTempDirectory("gauge"); }

    static boolean compile(Path d, String name, String source) throws Exception {
        Path f = d.resolve(name + ".java");
        Files.writeString(f, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        return ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), "-cp", d.toString(), f.toString()) == 0;
    }

    static List<String> classFiles(Path d) throws Exception {
        try (var s = Files.list(d)) {
            return s.map(p -> p.getFileName().toString())
                    .filter(n -> n.endsWith(".class")).sorted().toList();
        }
    }

    static String fields(Path d, String file) throws Exception {
        ClassModel cm = ClassFile.of().parse(d.resolve(file));
        List<String> a = new ArrayList<>();
        for (FieldModel f : cm.fields())
            a.add(f.fieldName().stringValue()
                    + (f.flags().has(AccessFlag.SYNTHETIC) ? " [synthetic]" : ""));
        return a.isEmpty() ? "-" : String.join(", ", a);
    }

    static String constructor(Path d, String file) throws Exception {
        ClassModel cm = ClassFile.of().parse(d.resolve(file));
        for (MethodModel m : cm.methods())
            if (m.methodName().stringValue().equals("<init>")) {
                List<String> p = new ArrayList<>();
                for (var a : m.methodTypeSymbol().parameterList()) p.add(a.displayName());
                return "(" + String.join(", ", p) + ")";
            }
        return "-";
    }
}
```

## One Source File, Six Class Files

The measured source holds a single outer class and places five separate forms inside it: an
inner class, a static nested class, a local class defined in an instance method, a local class
defined in a static method, and an anonymous class.

```java
// Production.java — how many files does nested class writing produce
import java.nio.file.Path;

public class Production {
    static final String SOURCE = """
        class Outer {
            int outerField = 1;
            class Inner { int read() { return outerField; } }
            static class Nest { int read() { return 2; } }
            Runnable instanceLocal() {
                int y = 3;
                class Local implements Runnable { public void run() { use(outerField + y); } }
                return new Local();
            }
            static Runnable staticLocal() {
                int y = 4;
                class SLocal implements Runnable { public void run() { use(y); } }
                return new SLocal();
            }
            Runnable anonymous() {
                int y = 5;
                return new Runnable() { public void run() { use(outerField + y); } };
            }
            static void use(int n) { }
        }
        """;

    public static void main(String[] args) throws Exception {
        Path d = Gauge.newDir();
        Gauge.compile(d, "Outer", SOURCE);
        System.out.printf("%-20s %-40s %s%n", "class file", "fields", "constructor sig");
        for (String f : Gauge.classFiles(d))
            System.out.printf("%-20s %-40s %s%n", f, Gauge.fields(d, f), Gauge.constructor(d, f));
        System.out.println("files produced: " + Gauge.classFiles(d).size());
    }
}
```

```
class file           fields                                   constructor sig
Outer$1.class        val$y [synthetic], this$0 [synthetic]    (Outer, int)
Outer$1Local.class   val$y [synthetic], this$0 [synthetic]    (Outer, int)
Outer$1SLocal.class  val$y [synthetic]                        (int)
Outer$Inner.class    this$0 [synthetic]                       (Outer)
Outer$Nest.class     -                                        ()
Outer.class          outerField                               ()
files produced: 6
```

A single source file gave off **six** class files. Nesting does not exist at the class-file
level; every form gets its own file, and the names are derived from the outer class's name. The
anonymous class had no name at all in the source, and it still has a file.

The second column carries the real measurement. The only field written in the source is
`outerField`, and it appears only in `Outer`'s file. The four fields showing up in the other
files are **not written in the source**: `this$0` and `val$y`. Four of the five forms carry at
least one hidden field; the only form that does not is the static nested class.

The third column shows how the hidden fields get filled. `Outer$Inner`'s constructor takes an
`Outer` parameter; no constructor was ever written for this class in the source, and whoever
writes `new` supplies no parameter either. `Outer$1Local` and `Outer$1` — the local class
defined in an instance method, and the anonymous class — take two parameters: the enclosing
instance and the captured number. `Outer$1SLocal` takes only the number, because there is no
enclosing instance in a static method. The compiler has added both the field and the parameter
filling it; writing something nested is not a matter of appearance, it is **a binding
mechanism**.

The table splits the five forms into three groups by this mechanism. Those carrying both the
enclosing instance and the captured value: the local class defined in an instance method, and
the anonymous class. Those carrying only one: the inner class carrying the enclosing instance,
and the local class in a static method carrying only the captured value. Those carrying
neither: the static nested class. In the source, these five forms are written in a very
similar way — all are a class declaration, and the difference between them is a word or a
position — but they turn into three separate structures in the class file. Choosing a form is
not choosing a spelling convenience.

## Shared Field, Copied Local

The hidden fields carry two separate things, and the two do not behave the same way. `this$0`
is a **reference**; `val$y` is a **copy of a value**. To see the difference, both are read
through the same object.

```java
// Capture.java — the outer field is shared, the captured local is copied
public class Capture {
    int field = 1;

    Runnable produce() {
        int local = 1;
        class Trace implements Runnable {
            @Override public void run() { System.out.println("  field=" + field + " local=" + local); }
        }
        Runnable r = new Trace();
        field = 2;
        return r;
    }

    static final String BODY = """
        class Trial {
            int field = 1;
            Runnable f() {
                int local = 1;
                class Trace implements Runnable { public void run() { use(field + local); } }
                Runnable r = new Trace();
                %s
                return r;
            }
            static void use(int n) { }
        }
        """;

    public static void main(String[] args) throws Exception {
        Capture y = new Capture();
        Runnable r = y.produce();
        System.out.println("after the object was produced, field set to 2, local gone");
        r.run();
        y.field = 3;
        System.out.println("field written to 3 from outside");
        r.run();
        System.out.println();
        for (String[] s : new String[][] {{"field = 2;", "reassigning the outer field"},
                                          {"local = 2;", "reassigning the captured local"}})
            System.out.printf("%-32s -> does it compile: %s%n", s[1],
                    Gauge.compile(Gauge.newDir(), "Trial", BODY.formatted(s[0])));
    }
}
```

```
after the object was produced, field set to 2, local gone
  field=2 local=1
field written to 3 from outside
  field=3 local=1

reassigning the outer field      -> does it compile: true
reassigning the captured local   -> does it compile: false
```

At the moment the `Trace` object was produced, `field` was 1 and `local` was 1. **After** the
object was produced, `field` changed twice, and both changes showed up in the value read: first
2, then 3. `local`, in turn, stayed **1** on both reads. Both names are written in the source
the same way, with no qualifier at all — and yet one tracks the outside value, the other reads
a copy frozen at the moment of production.

The difference comes from what the hidden fields carry. `this$0` points to the outer object,
and the field is fetched from that object on every read; so a change from outside is visible.
`val$y` holds the number itself, filled in once, in the constructor; no link to the outside
variable remains at all. A method's local variable disappears once the method ends anyway — the
object, though, keeps living, so there is nothing left to track.

The last two lines show how the compiler protects this copy. The version reassigning the outer
field compiles; the version reassigning the captured local **does not compile**. The word
`final` is not written anywhere in the source; the compiler places the restriction on its own,
because the only case where the copy could disagree with the original is the original
changing. The previous lesson's measurement — "`final` on a local is never written to the class
file" — finds its counterpart here: that restriction leaves no flag behind, but it protects a
**copy**.

## Which Outer Instance — A Third Source

The decision's side splits in two in this section. What the captured value will be is settled
**the moment the constructor is called**, and never asked again. Which outer object gets read,
though, is asked **again on every read** — and the answer comes from neither the declared type
nor the runtime type.

```java
// Binding.java — which outer instance it is bound to is decided at runtime
public class Binding {
    static class Outer {
        String name;
        Outer(String name) { this.name = name; }
        class Inner { String read() { return name; } }
    }

    public static void main(String[] args) throws Exception {
        Outer a = new Outer("first");
        Outer b = new Outer("second");
        Outer.Inner x = a.new Inner();
        Outer.Inner y = b.new Inner();
        System.out.println("are x and y the same class: " + (x.getClass() == y.getClass()));
        System.out.println("x.read()              : " + x.read());
        System.out.println("y.read()              : " + y.read());
        System.out.println();
        for (String[] s : new String[][] {
                {"I make() { return new D().new I(); }", "with an outer instance"},
                {"static I make() { return new I(); }",  "without an outer instance"}})
            System.out.printf("%-26s -> does it compile: %s%n", s[1],
                    Gauge.compile(Gauge.newDir(), "D",
                            "class D { class I { } " + s[0] + " }"));
    }
}
```

```
are x and y the same class: true
x.read()              : first
y.read()              : second

with an outer instance     -> does it compile: true
without an outer instance  -> does it compile: false
```

`x` and `y` are the same class — the output's first line confirms this — and their declared
types are the same too. Yet the same method call gives two separate results. Neither of the two
sources this course has measured so far can answer this: the two objects' runtime type is one,
and their declared type is one. What decides the result is **the hidden field's value** — which
outer object got written into `this$0`.

This does not add a third answer to the course's question; it sharpens it. Where an overridden
method has the object's class give the answer, here it is **a field of the object** that gives
it; both belong to runtime, but one is bound to type and the other to data. The practical
consequence of this difference is: in a program with no subclass ever added, the first source
never changes, while the second is settled fresh on every single `new`.

The bottom two lines show this field's necessity. An inner class can only be produced through
an outer instance; a `new` written from a context with no outer instance stops compilation. In a
place where the hidden field cannot be filled, the object cannot even be constructed.

## A Bounding Measurement: Where the Hidden Field Is Absent

The claim's boundary sits in two rows of the first table. `Outer$Nest` carries no field at all,
and its constructor takes no parameter; `Outer$1SLocal`, in turn, carries no `this$0`, only
`val$y`.

A static nested class, despite being written in the outer class's body, forms no bond with an
outer **instance** at all. The nesting in the source is only for namespace and visibility; the
class produced is an independent class sitting at the same level as the outer class. This is
why the question "which outer object" has no runtime answer here — the question is never asked
at all. The same reasoning applies to a local class defined in a static method: since there is
no outer instance while that method is running, there is no reference to carry either, but the
captured local variable still gets copied there too.

Two consequences follow from this. First, what decides whether a hidden field exists is not
where the class is written, it is **what it accesses from outside**: a form that never touches
the enclosing instance carries no reference. Second, the measurement can become unobservable.
When an inner class is written that never reads the outer field, `this$0` still gets added, but
no trace of it shows in behavior; the difference between the two forms only surfaces once the
outer object keeps living and can no longer be released for exactly this reason. A programmer
unaware that a link was formed only notices, indirectly, that the object they thought was
discarded is still being held.

## Summary

- A single source file produces **six** class files; nesting does not exist at the class-file
  level, even the anonymous class has its own file, and four of the five forms carry a hidden
  field never written in the source: `this$0` holding the enclosing instance, `val$y` holding
  the captured value.
- Two inner-class instances of the same class and the same declared type give two separate
  answers to the same call; what gives the answer is not the type, it is the enclosing instance
  written into the `this$0` field.
- What fills the hidden fields is the constructor's hidden parameters: the inner class's
  constructor carries the signature `(Outer)`, the local class in an instance method
  `(Outer, int)`, the one in a static method `(int)`.
- The outer field is **shared** and read live — after the object was produced, the values 2 and
  3 were both read; the captured local is **copied** and stayed 1 on both reads.
- Even with no `final` written in the source, reassigning the captured variable stops
  compilation; the restriction keeps the copy from disagreeing with the original.
- A static nested class carries no hidden field, and a local class in a static method carries
  no `this$0`: what decides whether a hidden field exists is not where it is written, it is
  what it accesses from outside.

## Next Step

In this lesson, the compiler produced **classes** never written in the source, and gave them
fields and constructor parameters never written in the source either. What everything produced
shared was taking on a binding job a programmer would otherwise have had to write by hand.
There is a far more visible use of that same production ability: starting from a one-line
declaration, letting the compiler produce a data carrier's entire surface — its field readers,
its equality, its hash value, and its string conversion. The next lesson counts these members
and asks which side each one's decision is left to; it then measures how far the produced
equality protects, meaning how a mutable part inside a data carrier can be changed from
outside.
