JAVA / NESTED CLASSES AND THE OBJECT CONTRACT
Static nested classes for tightly coupled helpers
Write nested helpers that carry no hidden reference to their enclosing instance, choose their visibility, and know when to promote them to top-level classes.
What you will learn
- Mark a member class static when it never needs an enclosing instance to do its job.
- Instantiate one as new Outer.Nested() and give a nested class its own type parameters.
- Hide implementation helpers as private static nested classes like Node, Entry or Token.
- Spot when a hidden enclosing reference would keep a large outer object alive.
Understanding Static nested classes for tightly coupled helpers
A member class marked static is a full class that merely lives inside another class's name and access boundary. The keyword is easy to misread: it does not mean the class has a single shared instance, nor that its fields are static. It means instances of it carry no reference to an instance of the enclosing class, which is why you can write new Outer.Nested(...) from anywhere, including a static method, while a non-static member class insists on an enclosing object to attach itself to.
The mental model that keeps this straight is a move operation: take a top-level class, drop its source inside another class, change nothing else. Two things change. Its qualified name becomes Outer.Nested, and it now shares a private-access boundary with the enclosing class, so each can read and write the other's private members directly. Before Java 11 the compiler faked that permission by generating synthetic bridge methods, which is why old stack traces sometimes showed frames named access$000; since then the class file records nest membership and the JVM permits the access outright.
Because there is no hidden field pointing outward, a static nested class cannot accidentally keep its enclosing object alive: one list Node parked in a cache does not pin the whole list and everything the list references. The absence of an enclosing instance also means the enclosing class's type parameters are not in scope, so a generic nested helper must declare its own. Reach for a static nested class exactly when the helper is meaningless on its own, such as Node, Entry, Token or Builder. If you can picture another class wanting it, give it its own file instead.
public class Main {
static final class Stack<T> {
// A node needs a value and a link, never the Stack it lives in,
// so it is static: no hidden reference, no enclosing instance.
private static final class Node<E> {
final E value;
final Node<E> next;
Node(E value, Node<E> next) {
this.value = value;
this.next = next;
}
}
private Node<T> head;
private int size;
void push(T value) {
head = new Node<>(value, head);
size++;
}
T pop() {
if (head == null) {
throw new java.util.NoSuchElementException("empty stack");
}
T value = head.value;
head = head.next;
size--;
return value;
}
int size() {
return size;
}
}
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
stack.push("first");
stack.push("second");
System.out.println("size " + stack.size());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println("size " + stack.size());
// Works from a static method with no Stack in sight.
Stack.Node<String> lone = new Stack.Node<>("detached", null);
System.out.println(lone.value + " next=" + lone.next);
}
}static on a member class removes the hidden reference to an enclosing instance while keeping shared private access, turning it into a top-level class that happens to live inside another class.
Worked examples
Builder as a public static nested class
Shows why a builder must be static: it has to exist before the object it builds.
public class Main {
static final class Pizza {
private final int diameterCm;
private final boolean extraCheese;
private Pizza(Builder b) {
this.diameterCm = b.diameterCm;
this.extraCheese = b.extraCheese;
}
static final class Builder {
private int diameterCm = 30;
private boolean extraCheese = false;
Builder diameter(int cm) {
this.diameterCm = cm;
return this;
}
Builder extraCheese() {
this.extraCheese = true;
return this;
}
Pizza build() {
return new Pizza(this);
}
}
@Override
public String toString() {
return "Pizza(" + diameterCm + "cm, extraCheese=" + extraCheese + ")";
}
}
public static void main(String[] args) {
Pizza a = new Pizza.Builder().diameter(24).extraCheese().build();
Pizza b = new Pizza.Builder().build();
System.out.println(a);
System.out.println(b);
}
}Example explained
Line 1new Pizza.Builder() runs with no Pizza in existence; a non-static Builder would demand a Pizza first, which is the very thing being built.
Line 2build() calls the private Pizza constructor and that constructor reads the Builder's private fields, because nest mates share private access in both directions.
Line 3Each Builder instance keeps its own diameterCm; static describes the Builder-to-Pizza link, not the storage of the fields.
Line 4Builder is public API here, so it stays visible rather than private, and callers name it as Pizza.Builder.
A static nested class declares its own type parameters
Demonstrates that the enclosing class's type parameter is not in scope inside a static nested class.
import java.util.ArrayList;
import java.util.List;
public class Main {
static final class Cache<K> {
// The K below is a brand new type parameter that shadows Cache's K.
static final class Entry<K> {
final K key;
final long stamp;
Entry(K key, long stamp) {
this.key = key;
this.stamp = stamp;
}
@Override
public String toString() {
return key + "@" + stamp;
}
}
private final List<Entry<K>> log = new ArrayList<>();
void touch(K key, long stamp) {
log.add(new Entry<>(key, stamp));
}
List<Entry<K>> log() {
return log;
}
}
public static void main(String[] args) {
Cache<String> cache = new Cache<>();
cache.touch("a", 1L);
cache.touch("b", 2L);
System.out.println(cache.log());
// Entry is tied to no Cache instance and to no Cache type argument.
Cache.Entry<Integer> free = new Cache.Entry<>(7, 99L);
System.out.println(free);
}
}Example explained
Line 1Entry has to write <K> itself; using Cache's K there fails with "non-static type variable K cannot be referenced from a static context".
Line 2In the field List<Entry<K>> log the K is Cache's parameter, because that declaration sits in Cache's scope, not in Entry's body.
Line 3new Cache.Entry<>(7, 99L) compiles with no Cache<Integer> anywhere, which is the whole point: Entry is only namespaced inside Cache.
Line 4In production code rename the nested parameter to something like E; the shadowing compiles but misleads every later reader.
Private static nested class behind an interface
Hides the concrete helper type completely while still letting it touch the enclosing class's private state.
public class Main {
interface Ticker {
int next();
}
static final class Tickers {
private static int instancesCreated = 0;
static Ticker stepBy(int step) {
return new StepTicker(step);
}
static int instancesCreated() {
return instancesCreated;
}
private static final class StepTicker implements Ticker {
private final int step;
private int value = 0;
StepTicker(int step) {
this.step = step;
instancesCreated++;
}
@Override
public int next() {
value += step;
return value;
}
}
}
public static void main(String[] args) {
Ticker five = Tickers.stepBy(5);
Ticker three = Tickers.stepBy(3);
System.out.println(five.next());
System.out.println(three.next());
System.out.println(five.next());
System.out.println(Tickers.instancesCreated());
System.out.println(five.getClass().getSimpleName());
}
}Example explained
Line 1stepBy declares Ticker as its return type, so no outside caller can name StepTicker; the concrete class stays an implementation detail of Tickers.
Line 2The constructor writes to the private static field instancesCreated because the nested class sits inside the enclosing class's access boundary.
Line 3five and three each keep their own value field, so the two tickers count independently even though the class is static.
Line 4getSimpleName() reports StepTicker, and that readable name is what shows up in stack traces if next() ever throws.
Important notes
Nested enums, interfaces, records and annotation types are implicitly static; writing static on them is legal but redundant, and it is why a nested record can never capture the enclosing instance.
private does not wall a nested class off from its enclosing class: private means private to the whole top-level class, so the two can read each other's private fields directly instead of via getters nobody outside needs.
Common mistakes
Leaving static off a Node or Entry class: every node then carries a synthetic reference to the container, so one cached node keeps the entire structure and all its elements from being collected, and new Node<>(...) inside a static factory does not even compile.
Reading static as "shared": the fields of a static nested class are per-instance like any other class, so two Builder instances never overwrite each other's values; only the class-to-enclosing-instance link is gone.
Using the enclosing class's type parameter T inside the static nested class, which fails with "non-static type variable T cannot be referenced from a static context"; the fix is a new parameter on the nested class, not deleting static.
Try it yourself
Change, predict, then run
In a browser editor, write a Histogram class with a private static nested Bucket holding a String label and an int count, plus a static factory Histogram.of(String... labels) that creates one Bucket per label and a print() method. Then delete static from Bucket and explain the exact compile error the factory now produces.
Open the Java workspaceCheck your understanding
Given class Cache<K> { static class Entry<K> { ... } }, what is the relationship between the two K parameters?
- They must resolve to the same type; the compiler links a nested class's parameters to the enclosing class's.
- The declaration is a compile error, because a static nested class cannot be generic.
- They are unrelated: Entry declares its own K, which shadows Cache's K inside Entry's body.
- Entry inherits Cache's K, so repeating it is harmless but redundant.
Show answer
A static nested class has no enclosing instance, so the enclosing class's type arguments are not in scope for it; Entry's K is a fresh parameter, which is exactly why Cache.Entry<Integer> can sit next to a Cache<String>. Option 3 is tempting because Entry<K> written inside Cache really does mean Cache's K, but that is a use site in Cache's scope, not the nested class inheriting the parameter.