Nested and Inner Classes
Choose between static nested, inner, local and anonymous classes, and know why static nested is the safe default for builders and helpers.
class Outer {
static class Nested { } // no outer instance
class Inner { } // outer.new Inner()
}
Outer.Nested n = new Outer.Nested();A class can be declared inside another class. This keeps a helper next to the only code that uses it and lets it share private access with its enclosing class. There are four kinds, and the differences matter.
| Kind | Declared | Has an outer instance? |
|---|---|---|
| Static nested | static class B inside a class | No |
| Inner | class B inside a class, no static | Yes |
| Local | Inside a method body | Yes, in an instance method |
| Anonymous | new Interface() { ... } | Yes, in an instance method |
Static nested: the default
A static nested class is an ordinary class that lives in another class's namespace, as in HttpRequest.Builder or Map.Entry. It needs no outer object. Use it for builders, small result types and helpers. Nested records, enums and interfaces are always static, even without the keyword.
Inner classes carry a hidden reference
An inner class instance holds a reference to the outer object that created it, which is how it can read the outer fields directly. Since JDK 18, javac drops that field when the inner class never uses the outer object, but the class still needs an outer instance to be created. You create one with outer.new Inner(). That hidden reference has costs. A long-lived inner object that uses outer state, such as a task submitted to an executor or a listener registered with a framework, keeps the whole outer object reachable, so a large service or cache cannot be garbage collected. JSON libraries also cannot create a non-static inner class from a request body, because they have no outer instance to give it. If the nested class does not read outer fields, mark it static.
Local and anonymous classes
A local class is declared inside a method and is visible only there. Local records are handy for an intermediate shape in a calculation. Local and anonymous classes can use local variables of the method only if those are final or effectively final.
Anonymous classes were the way to pass behaviour before Java 8. For an interface with one abstract method, a lambda is shorter and does not create a new scope for this. An anonymous class is still useful when you need state or more than one method, such as a small adapter.
A practical rule: start with a static nested class or a separate top-level class; use an inner class only when it genuinely works on the outer object's state.
Example
When to use it
- Give a request or client configuration type a static nested Builder so callers set only the options they need.
- Declare a local record inside a report method to hold parsed CSV rows for one calculation.
- Replace an inner listener class with a static nested one so a registered callback no longer keeps a large service object alive.
More examples
An inner class reading outer state
Each Connection is tied to the Server that created it with api.new Connection(), and reads and updates that server's fields. As long as any connection object is reachable, its server is reachable too.
public class Main {
static class Server {
private final String name;
private int opened;
Server(String name) {
this.name = name;
}
class Connection {
private final int number;
Connection() {
opened++; // updates the outer object
this.number = opened;
}
String describe() {
return name + " connection #" + number;
}
}
}
public static void main(String[] args) {
Server api = new Server("api");
Server.Connection first = api.new Connection();
Server.Connection second = api.new Connection();
IO.println(first.describe());
IO.println(second.describe());
IO.println("opened: " + api.opened);
}
}
// Output:
// api connection #1
// api connection #2
// opened: 2Anonymous class versus lambda
The anonymous class and the lambda both implement Comparator; the lambda is the modern form for any single-method interface. Library factories such as Comparator.comparingInt(String::length) remove the need for either.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> routes = new ArrayList<>(List.of("/orders", "/a", "/users/me"));
routes.sort(new Comparator<String>() {
@Override
public int compare(String x, String y) {
return Integer.compare(x.length(), y.length());
}
});
IO.println(routes);
routes.sort((x, y) -> Integer.compare(y.length(), x.length()));
IO.println(routes);
routes.sort(Comparator.comparingInt(String::length));
IO.println(routes);
}
}
// Output:
// [/a, /orders, /users/me]
// [/users/me, /orders, /a]
// [/a, /orders, /users/me]A local record inside a method
The Line record exists only inside cheapest(), so it does not clutter the rest of the code base. Local records are implicitly static and cannot capture the method's local variables, which keeps them simple.
import java.util.List;
public class Main {
static String cheapest(List<String> lines) {
record Line(String sku, int cents) { }
Line best = null;
for (String raw : lines) {
String[] parts = raw.split(",");
Line line = new Line(parts[0], Integer.parseInt(parts[1]));
if (best == null || line.cents() < best.cents()) {
best = line;
}
}
return best == null ? "none" : best.sku() + " at " + best.cents();
}
public static void main(String[] args) {
IO.println(cheapest(List.of("mug,899", "pen,250", "cap,1200")));
IO.println(cheapest(List.of()));
}
}
// Output:
// pen at 250
// none
Discussion