When the previous lesson closed, a poor piece of the project was left pending: the incidents of a Loan are a String[] with loose texts such as "Page 42 underlined". A real incident has three pieces of data — the day it was detected, the reason and its severity — and a string cannot carry them without degenerating into a fragile format like "12|Page underlined|MINOR" that would have to be split every time. You need a type. But a public top-level class, in its own file, for something that only exists inside a loan and that nobody will use on its own?
Java offers a better answer: nested classes. They are classes declared inside another class, and they let an auxiliary type live exactly where it makes sense, with exactly the right visibility. There are four variants with different rules, and choosing badly has real consequences: one of them is the classic cause of memory leaks in production Java applications. This lesson teaches you to tell them apart, to pick the right one by default — which is almost always the same one — and to recognise the pattern when you see it in the standard library, where it is everywhere: Map.Entry, Thread.State, the internal nodes of LinkedList.
Contents
- The four nested classes: overview and comparison table
- Member inner class (non-static)
- The hidden
Outer.thisreference and its cost - Memory leaks: the real case
- Static nested class: the right option almost always
- Local class: a class inside a method
- Why captured variables are required to be effectively final
- Visibility: the
privatenested class as a hidden detail - The generated
.classfiles and what they reveal - Real use cases
- BiblioTech:
Loan.Incident - BiblioTech: a nested catalogue comparator
- Common Mistakes and Tips
- Exercises
- The four nested classes: overview and comparison table
A nested class is a class declared inside the body of another class or of a method. Java offers four variants:
public class Outer {
private int field = 10;
// 1. Member inner class (non-static)
class Inner { }
// 2. Static nested class
static class Nested { }
public void method() {
// 3. Local class
class Local { }
// 4. Anonymous class (04-04)
Runnable r = new Runnable() {
@Override public void run() { }
};
}
}| Feature | Inner (member) | Static nested | Local | Anonymous |
|---|---|---|---|---|
| Where it is declared | Class body | Class body | Inside a method | In an expression |
The static keyword |
No | Yes | Not applicable | Not applicable |
| Has a name | Yes | Yes | Yes (local) | No |
| Reference to the outer instance | Yes (Outer.this) |
No | Yes, if the method is not static | Yes, if the context is not static |
| Accesses instance fields of the outer class | Yes | Only the static ones |
Yes | Yes |
| Accesses local variables | — | — | Yes, if effectively final | Yes, if effectively final |
Can have static members |
Yes (since Java 16) | Yes | Yes (since Java 16) | Constants only |
| Instantiated with | outer.new Inner() |
new Outer.Nested() |
new Local() in the method |
new Type() { ... } |
| Access modifiers | All four | All four | None | None |
| Memory-leak risk | Yes | No | Low | Yes, if stored |
| Real frequency of use | Low | Very high | Low | Medium |
The practical conclusion is in the last two rows: the static nested class is the default option, and the non-static inner class is only justified when you genuinely need access to the outer object's state. Let us see why.
- Member inner class (non-static)
It is a class declared as a member of another, without static. Its defining trait: each of its instances is bound to an instance of the outer class, and can access all its members, even the private ones.
public class Loan {
private final String reference;
private int elapsedDays;
public Loan(String reference, int elapsedDays) {
this.reference = reference;
this.elapsedDays = elapsedDays;
}
/** NON-static inner class: each notice belongs to ONE particular loan. */
public class InnerNotice {
private final String text;
public InnerNotice(String text) {
this.text = text;
}
public String format() {
// Accesses the outer loan's private fields DIRECTLY:
return "[" + reference + " / day " + elapsedDays + "] " + text;
}
}
}Look at format(): it uses reference and elapsedDays with no prefix at all, as if they were its own, and they are private fields of Loan. That is the capability that distinguishes the inner class.
And here comes the syntax that surprises everybody: to create an InnerNotice you first need a Loan.
Loan l = new Loan("LN-0001", 12);
// Correct syntax: outer instance DOT new
Loan.InnerNotice notice = l.new InnerNotice("Delay detected");
System.out.println(notice.format());
// new Loan.InnerNotice("..."); // DOES NOT COMPILE: the outer instance is missingThat l.new InnerNotice(...) is probably the strangest syntax in Java, and its strangeness is informative: it is telling you that the inner class does not exist without its outer object. Inside the outer class itself you do not need to write it, because this is implicit:
public void note(String text) {
InnerNotice notice = new InnerNotice(text); // equivalent to this.new InnerNotice(text)
System.out.println(notice.format());
}
- The hidden
Outer.this reference and its cost
Outer.this reference and its costHow can InnerNotice read Loan's fields? Because the compiler adds a hidden field with a reference to the outer instance. The code that is actually generated is equivalent to this:
// "Desugared" version of what the compiler does:
public class Loan$InnerNotice {
final Loan this$0; // HIDDEN FIELD
private final String text;
Loan$InnerNotice(Loan outer, String text) {
this.this$0 = outer; // stored at construction time
this.text = text;
}
public String format() {
return "[" + this$0.reference + " / day " + this$0.elapsedDays + "] " + text;
}
}That field is called this$0 in the bytecode and you can refer to it with the syntax Outer.this, which becomes indispensable when names clash:
public class Loan {
private String status = "ACTIVE";
public class InnerNotice {
private String status = "PENDING"; // same name
public String describe() {
String status = "LOCAL"; // and a local variable too
return "local=" + status // LOCAL
+ ", notice=" + this.status // PENDING
+ ", loan=" + Loan.this.status; // ACTIVE
}
}
}The resolution rule works from the inside out: local variable → field of the inner class (this) → field of the outer class (Outer.this).
But that hidden field has a real cost, and it is the central point of the lesson:
| Cost | Consequence |
|---|---|
| Memory | Each inner instance weighs one extra reference |
| Construction | An outer instance is always required |
| Serialisation | Serialising the inner one drags the whole outer one along (module 7) |
| Object lifetime | While the inner one lives, the outer one CANNOT be collected |
The last row is the dangerous one.
- Memory leaks: the real case
A memory leak in Java is not memory that gets lost: it is memory the garbage collector cannot free because something is still pointing at it. And a non-static inner class points at its outer object always, even if it uses not a single one of its fields.
Let us look at the concrete scenario. Imagine a HeavyCatalog that takes up a lot of memory and from which we only want to keep a small identifier:
public class HeavyCatalog {
private final Material[] materials = new Material[100_000]; // VERY large
private final String code;
public HeavyCatalog(String code) { this.code = code; }
/** NON-static inner class: watch out. */
public class Label {
private final String text;
public Label(String text) { this.text = text; }
public String getText() { return text; }
// It uses NOTHING from HeavyCatalog... but it retains it anyway
}
public Label createLabel() {
return new Label("Catalogue " + code);
}
}public class LeakDemo {
private static HeavyCatalog.Label savedLabel; // lives for the whole program
public static void main(String[] args) {
HeavyCatalog catalog = new HeavyCatalog("CAT-2026");
savedLabel = catalog.createLabel();
catalog = null; // "I no longer need the catalogue"
System.gc(); // a suggestion to the collector
// The 100,000-slot array IS STILL IN MEMORY:
// savedLabel -> this$0 -> catalog -> materials
System.out.println(savedLabel.getText());
}
}flowchart LR
A["savedLabel (static field, alive)"] --> B["Label"]
B -->|"hidden this$0"| C["HeavyCatalog"]
C --> D["Material[100000]"]
E["variable catalog = null"] -.->|"no longer points"| C
Setting catalog = null achieves nothing: there is still a live path to the giant array, going through the hidden reference. In an application that creates thousands of labels and stores them in a cache, this is a leak that grows until the OutOfMemoryError, and it is one of the hardest to diagnose because Label's code does not mention the catalogue anywhere.
The fix is one word:
/** STATIC nested class: no hidden reference, no leak. */
public static class Label {
private final String text;
public Label(String text) { this.text = text; }
public String getText() { return text; }
}With static, the Label is an independent object. When catalog stops being referenced, the collector takes away the hundred-thousand-slot array and the label is left on its own, weighing what a string weighs. The details of the collector and of how to diagnose these cases are studied in lesson 10-07.
- Static nested class: the right option almost always
A static nested class is a static member of the outer class. Conceptually it is not an inner class: it is a perfectly ordinary top-level class that, for convenience, lives inside another one's namespace.
public class Loan {
/** STATIC nested class: independent of any particular loan. */
public static class Incident {
private final int day;
private final String reason;
public Incident(int day, String reason) {
this.day = day;
this.reason = reason;
}
public int getDay() { return day; }
public String getReason() { return reason; }
}
}It is instantiated with no extra ceremony, qualifying it with the outer class's name:
Loan.Incident i = new Loan.Incident(12, "Page 42 underlined");
// Or, with a static import of the nested type:
// import com.nexussoftware.bibliotech.domain.Loan.Incident;
Incident i2 = new Incident(14, "Cover detached");Its rules:
- It has no reference to any outer instance. Therefore it cannot access instance fields or methods of the outer class.
- It can access the outer class's
staticmembers, including theprivateones. - It causes no leaks, does not complicate serialisation and is constructed without anything beforehand.
So what does it gain over a top-level class in its own file? Three far from minor things:
- It expresses belonging.
Loan.Incidentsays, in its own name, that an incident belongs to the world of loans. - It allows hiding it. If you declare it
private, it is invisible outsideLoan(section 8). A top-level class cannot beprivate. - It reduces package noise. A fifteen-line auxiliary type does not deserve its own file.
The JDK uses it constantly. Map.Entry is an interface nested inside Map; Thread.State is a nested enum; Character.UnicodeBlock is a static nested class. All follow the same logic: the auxiliary type lives where it makes sense.
Practical rule: declare the nested class
staticby default. Remove thestaticonly when you verify that it needs access to the outer instance's state. It is exactly the criterion of modern IDEs, which warn you with "this inner class can be static".
- Local class: a class inside a method
A local class is declared inside the body of a method, a constructor or a block. Its scope is that block: outside it it does not exist, it cannot even be named.
public class ReportGenerator {
/** Groups materials by severity using a type that only lives here. */
public String severityReport(Material[] materials, int elapsedDays) {
// LOCAL class: it is born and dies inside this method
class Counter {
private int minor;
private int severe;
void record(String severity) {
if ("MINOR".equals(severity)) { minor++; }
if ("SEVERE".equals(severity)) { severe++; }
}
String summary() {
return String.format("After %d days: %d minor, %d severe",
elapsedDays, minor, severe);
}
}
Counter counter = new Counter();
for (Material m : materials) {
counter.record(m.classifySeverity(elapsedDays));
}
return counter.summary();
}
}Material[] catalog = {
new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018),
new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
new Dvd("Refactoring Live", "DVD-0007", 95)
};
System.out.println(new ReportGenerator().severityReport(catalog, 20));Its characteristics:
- It takes no access modifiers.
public class Counterinside a method does not compile: its visibility is already fixed by the block. - It can access the outer class's fields if the method is not
static. - It can capture the method's local variables and parameters, under a strict condition that is the subject of the next section. Look at
summary(): it useselapsedDays, which is a parameter of the method.
Local classes are uncommon. If the type fits in a few lines and only implements one interface, the lambda (04-05) is better; if it has substance, a static nested class is more readable. Their niche is this: an auxiliary type with several fields and several methods that genuinely only makes sense inside one particular algorithm.
- Why captured variables are required to be effectively final
Try modifying a captured local variable:
public String report(Material[] materials) {
int total = 0;
class Counter {
void add() {
total++; // COMPILATION ERROR
}
}
// ...
}A variable is effectively final (since Java 8) when no value is assigned to it after its initialisation, even though it does not carry the final keyword. The compiler works it out on its own.
Why this restriction? Because of the life cycle of local variables, which you have known since 03-02:
- A local variable lives on the thread's stack, inside the call frame. When the method finishes, that frame disappears and the variable stops existing.
- An object lives on the heap, and can survive far longer than the method that created it.
If the local class's object outlives the method — because you return it, store it in a field or pass it to another thread — where would it read the value of a local variable that no longer exists?
flowchart TD
A["The method runs: 'total' lives on the stack"] --> B["The local class object is created on the heap"]
B --> C["The compiler COPIES the value of 'total' into a hidden field of the object"]
C --> D["The method finishes: the stack frame disappears"]
D --> E["The object survives with ITS COPY of the value"]
Java's solution is capture by value: the compiler copies the value inside the object. And that is where the restriction comes from, because if modification were allowed there would be two uncoordinated copies:
- Modifying
totalin the method would not change the object's copy. - Modifying
totalinside the object would not change the method's copy.
The same name with two different values: an inexhaustible source of bugs. Java prefers to forbid it at compile time.
Other languages choose the opposite: JavaScript captures the variable by reference (real closures), and that is why there you can modify it — at the cost of its own surprises, like the classic loop whose counter ends up with the same value in every callback. Java opted for safety. It will be relevant again in module 8, where sharing mutable state between threads is precisely the source of the worst errors.
Alternatives when you genuinely need to accumulate:
// Option A (the best): a field of the local class or of the outer one
class Counter {
private int total; // field: lives on the heap, is mutable
void add() { total++; }
int getTotal() { return total; }
}
// Option B: a one-element array (old trick, avoid it if you can)
int[] total = {0}; // 'total' does not change: total[0] does
class C { void add() { total[0]++; } }Option A is the correct one: mutability belongs to an object, not to a captured variable. B works because the variable total (the reference to the array) is never reassigned, but it is a trick of dubious readability. The same rule will apply word for word to lambdas in 04-05.
- Visibility: the
private nested class as a hidden detail
private nested class as a hidden detailA nested class takes the four access modifiers, something impossible for a top-level class (which can only be public or package-private). And the most powerful combination is private static:
public class Catalog {
private final Material[] materials;
private int size;
public Catalog(int capacity) {
this.materials = new Material[Math.max(1, capacity)];
this.size = 0;
}
/**
* COMPLETELY hidden implementation detail: outside Catalog
* this type does not exist. It can be changed or removed without breaking anyone.
*/
private static class Statistics {
int available;
int onLoan;
double accruedFine;
}
private Statistics compute(int elapsedDays) {
Statistics s = new Statistics();
for (int i = 0; i < size; i++) {
if (materials[i].isAvailable()) { s.available++; }
else { s.onLoan++; }
s.accruedFine += materials[i].calculateFine(elapsedDays);
}
return s;
}
/** Public API: it does not leak the internal type. */
public String summary(int elapsedDays) {
Statistics s = compute(elapsedDays);
return String.format("%d available, %d on loan, %.2f EUR accrued",
s.available, s.onLoan, s.accruedFine);
}
}This is the encapsulation of 03-07 taken to the level of the type, not just the field. Statistics appears in no public signature, so:
- Nobody outside can declare a variable of that type.
- Changing its fields, renaming it or replacing it breaks no client.
Catalog's public API stays minimal (03-08).
Note that its fields have no modifier and no getters. That is acceptable precisely because the class is private: its only "public API" is the inside of Catalog, and there the ceremony adds nothing. That relaxation would be unacceptable in a public type.
- The generated
.class files and what they reveal
.class files and what they revealA detail that clears up the mental model a great deal: the JVM does not know about nested classes. It is a language mechanism that the compiler translates into top-level classes with constructed names.
Compile a Loan with a nested Incident and a local class Counter, and look at the output directory:
| File | Corresponds to |
|---|---|
Loan.class |
The outer class |
Loan$Incident.class |
Nested class (static or not); the name is preserved |
Loan$1Counter.class |
Local class: name preceded by a number, because there may be several Counters in different methods |
Loan$1.class |
Anonymous class: just a number, because it has no name (04-04) |
Three practical conclusions:
- Each nested class is one more
.classfile. A hundred anonymous classes are a hundred files, with their loading cost and their contribution to the.jarsize. On Android this count mattered a great deal for years. - You can tell in the bytecode whether an inner class is static: the non-static one has the
this$0field. Withjavap -p Loan\$Incidentyou will see it listed. - Access to
privatemembers between outer and inner is not magic. Until Java 10 the compiler generated synthetic bridge methods (access$000) to allow it; since Java 11 it is resolved with nest-based access control, a mechanism of the JVM itself. That is why an inner class can read aprivatefield of the outer one without violating encapsulation: both belong to the same "nest".
- Real use cases
Before applying it to the project, it is worth pinning down the three scenarios where nested classes are the standard answer in professional code.
Builders. A constructor with eight parameters is unreadable (you saw that in 03-04). The Builder pattern offers a static nested class that accumulates values and constructs the object at the end:
Book book = new Book.Builder()
.title("Effective Java")
.author("Joshua Bloch")
.isbn("978-0000000001")
.year(2018)
.build();The Builder is nested inside Book because it only serves to build books. It is use case number one for static nested classes, and it is formalised in 12-02.
Nodes of a data structure. A linked list needs a node with a value and a link to the next one. That node is pure internal detail:
public class SimpleList {
private Node head;
/** Implementation detail: nobody outside knows it exists. */
private static class Node {
Material value;
Node next;
Node(Material value) { this.value = value; }
}
}That is how java.util.LinkedList is written in the JDK, with a private static class Node. You will see it in lesson 05-04.
Grouping auxiliary data. When a method needs to return two or three related values, a nested class avoids inventing a public type. Since Java 16 there is an even better way for this case — records, and they can be nested — which you will see in 04-07.
- BiblioTech:
Loan.Incident
Loan.IncidentThe time has come to replace the String[] of incidents. An incident has a day, a reason and a severity, and only makes sense inside a loan: static nested class.
package com.nexussoftware.bibliotech.domain;
import java.util.Arrays;
import java.util.Objects;
public class Loan {
// ============================================================
// Nested type: an incident recorded during the loan
// ============================================================
/**
* Incident detected on a material during a loan.
*
* <p>It is STATIC because an incident does not need access to the loan's
* state: everything it needs is passed in at construction time. That way it
* can be created, stored and compared without dragging the whole loan along.</p>
*
* <p>It is IMMUTABLE: once noted, an incident does not change.</p>
*/
public static class Incident {
private final int day; // day (integer) on which it was detected
private final String reason;
private final String severity; // "MINOR" or "SEVERE"; in 04-07 it will be an enum
public Incident(int day, String reason, String severity) {
this.day = Math.max(0, day);
this.reason = (reason == null || reason.isBlank())
? "Unspecified" : reason.trim();
this.severity = "SEVERE".equalsIgnoreCase(severity) ? "SEVERE" : "MINOR";
}
/** Shortcut for ordinary incidents. */
public Incident(int day, String reason) {
this(day, reason, "MINOR");
}
public int getDay() { return day; }
public String getReason() { return reason; }
public String getSeverity() { return severity; }
public boolean isSevere() { return "SEVERE".equals(severity); }
@Override
public String toString() {
return String.format("day %d - %s [%s]", day, reason, severity);
}
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
Incident other = (Incident) o;
return day == other.day && reason.equals(other.reason);
}
@Override
public int hashCode() { return Objects.hash(day, reason); }
}
// ============================================================
// Loan
// ============================================================
private final String reference;
private final Material material;
private final Employee employee;
private Incident[] incidents; // previously: String[]
// ... remaining fields and constructor unchanged; incidents = new Incident[0] ...
/** Records an incident with day, reason and severity. */
public void recordIncident(int day, String reason, String severity) {
Incident newIncident = new Incident(day, reason, severity);
Incident[] enlarged = Arrays.copyOf(incidents, incidents.length + 1);
enlarged[incidents.length] = newIncident;
this.incidents = enlarged; // module 5 will do this much better
}
/** Shortcut: minor incident on the loan's current day. */
public void recordIncident(String reason) {
recordIncident(getElapsedDays(), reason, "MINOR");
}
/** @return defensive copy (03-07): modifying it does not affect the loan. */
public Incident[] getIncidents() {
return Arrays.copyOf(incidents, incidents.length);
}
/** Counts how many severe incidents have been recorded. */
public int countSevereIncidents() {
int severe = 0;
for (Incident i : incidents) {
if (i.isSevere()) { severe++; }
}
return severe;
}
}Usage:
Book effectiveJava = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Employee marta = new Employee("Marta Ruiz", "EMP-001");
Loan l = new Loan(effectiveJava, marta, 100, 18);
l.recordIncident(12, "Page 42 underlined", "MINOR");
l.recordIncident(15, "Cover detached", "SEVERE");
l.recordIncident("Coffee stain on the back cover");
System.out.println("Incidents of " + l.getReference() + ":");
for (Loan.Incident i : l.getIncidents()) {
System.out.println(" " + i);
}
System.out.println("Severe: " + l.countSevereIncidents());Incidents of LN-0001: day 12 - Page 42 underlined [MINOR] day 15 - Cover detached [SEVERE] day 18 - Coffee stain on the back cover [MINOR] Severe: 1
Compare it with the previous version. Before: a String[] with texts you had to read with your eyes and where countSevereIncidents() would have required searching for substrings. Now: a type with three pieces of data, its own behaviour (isSevere()), correct equals/hashCode (03-09), immutability (03-07) and a name — Loan.Incident — that says exactly which world it belongs to.
And the key decision is in the static. If Incident were not static:
- You could not create it without a
Loan(l.new Incident(...)). - Each one would drag a reference to the complete loan, with its material and its employee.
- Keeping a history of incidents in memory would retain all the loans they belong to, even if you no longer use them.
- BiblioTech: a nested catalogue comparator
Second use case: sorting the catalogue. Java offers Arrays.sort(array, comparator), where the comparator is an object implementing Comparator and answering one question: given two elements, which one goes first?
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Material;
import java.util.Arrays;
import java.util.Comparator;
/** BiblioTech catalogue operations. */
public class Catalog {
private final Material[] materials;
public Catalog(Material[] materials) {
this.materials = Arrays.copyOf(materials, materials.length);
}
/**
* Comparator by title. It is a STATIC nested class: it needs no
* data from the catalogue, only the two materials it receives.
*/
public static class ByTitle implements Comparator<Material> {
@Override
public int compare(Material a, Material b) {
return a.getTitle().compareToIgnoreCase(b.getTitle());
}
}
/** Comparator by accrued fine, highest first. */
public static class ByFineDescending implements Comparator<Material> {
private final int elapsedDays;
public ByFineDescending(int elapsedDays) {
this.elapsedDays = elapsedDays;
}
@Override
public int compare(Material a, Material b) {
return Double.compare(b.calculateFine(elapsedDays),
a.calculateFine(elapsedDays));
}
}
/** Returns a copy sorted according to the criterion received. */
public Material[] sort(Comparator<Material> criterion) {
Material[] copy = Arrays.copyOf(materials, materials.length);
Arrays.sort(copy, criterion);
return copy;
}
}About Comparator<Material>: the angle brackets indicate the type the comparator works with; Comparator<Material> is "a comparator of materials", and thanks to that the compare method receives Material directly with no conversions. Here you are only using an existing generic type; writing your own generic types is lesson 10-01.
Usage:
Material[] catalog = {
new Dvd("Refactoring Live", "DVD-0007", 95),
new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018),
new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994)
};
Catalog c = new Catalog(catalog);
System.out.println("--- By title ---");
for (Material m : c.sort(new Catalog.ByTitle())) {
System.out.println(" " + m.describe());
}
System.out.println("--- By fine after 20 days ---");
for (Material m : c.sort(new Catalog.ByFineDescending(20))) {
System.out.printf(" %-24s %.2f EUR%n", m.getTitle(), m.calculateFine(20));
}--- By title --- Book "Design Patterns" (ref. 978-0000000002) - Erich Gamma, 1994 Book "Effective Java" (ref. 978-0000000001) - Joshua Bloch, 2018 Magazine "Java Magazine" (ref. REV-2024-03) - no.42 (Monthly) DVD "Refactoring Live" (ref. DVD-0007) - 95 min --- By fine after 20 days --- Refactoring Live 8.50 EUR Java Magazine 1.30 EUR Effective Java 1.25 EUR Design Patterns 1.25 EUR
Notice the contrast between the two comparators: ByTitle has no state and ByFineDescending does (it stores elapsedDays). Both are static, because neither needs anything from the Catalog; the second receives what it needs through the constructor. That is the correct approach: pass the data, do not capture it through the back door.
In 04-04 you will see that ByTitle can be written without declaring the class, at the very point where it is used. And in 04-05 it will come down to a single line.
Common Mistakes and Tips
Forgetting the static. It is the most common mistake and the most expensive. A nested class without static that does not use the outer state pays for a hidden reference, complicates serialisation and can cause memory leaks. IDEs detect it; heed the warning.
Trying new Outer.Inner() with a non-static inner class. It does not compile: it needs an outer instance, with the syntax outer.new Inner(). If that syntax feels awkward, it is a sign that the class should be static.
Declaring static members in an inner class before Java 16. Until Java 15 a non-static inner class only accepted static final constants. Since Java 16 any static member is allowed. With Java 17 as the course reference it is not a problem, but you will see it in old code.
Confusing this with Outer.this. Inside an inner class, this is the inner instance. To reach the outer one you have to write Outer.this. This misunderstanding reappears amplified in anonymous classes (04-04).
Modifying a captured local variable. It does not compile: it must be final or effectively final. The correct solution is a field, not a one-element array.
Overusing nested classes. If your outer class piles up five hundred lines because of four nested types, move them to their own files. Nesting is for small, auxiliary types; when a type has substance of its own, it deserves its file.
Tip: place nested classes at the beginning or at the end. Choose a convention and keep it across the whole project. Many teams put them at the end, after the methods; others at the beginning, as a declaration of types. What matters is not interleaving them among the methods.
Tip: private static class is the starting point. Start with maximum restriction and relax it only when needed. It is the same criterion you applied to fields in 03-07, now applied to types.
Exercises
Exercise 1: Employee.EmployeeHistory
Add to Employee a static nested class EmployeeHistory with the fields loanCount, returnCount and accruedFine (double), with getters and toString. Add to Employee a field history initialised in the constructor and make registerLoan() and registerReturn() update it. Add registerFine(double) as well. Justify in a comment why the class is static.
Exercise 2: memory leak demonstrated
Write two versions of a class HeavyCache containing an int[] data = new int[5_000_000] and a nested class Summary with only a String. In version A, Summary is a non-static inner class; in B, static. In each case: create the cache, obtain the summary, set the cache to null, call System.gc() and show the used memory with Runtime.getRuntime().totalMemory() - freeMemory(). Explain the difference.
Exercise 3: local class for a grouped report
Write a method String reportByType(Material[] materials, int elapsedDays) that uses a local class Group (with type, units and totalFine) to group the materials by their getType() and return a report with one line per type. Remember: no collections, use arrays.
Solutions
Solution 1
package com.nexussoftware.bibliotech.domain;
public class Employee {
public static final int MAX_CONCURRENT_LOANS = 3;
/**
* Accumulated history of an employee.
*
* It is STATIC because it only stores its own counters: it does not need to read
* the employee's name or identifier. That way a history can be copied,
* compared or stored without retaining the whole employee.
*/
public static class EmployeeHistory {
private int loanCount;
private int returnCount;
private double accruedFine;
public int getLoanCount() { return loanCount; }
public int getReturnCount() { return returnCount; }
public double getAccruedFine() { return accruedFine; }
/** Only Employee may modify it: package-private methods, not public. */
void addLoan() { loanCount++; }
void addReturn() { returnCount++; }
void addFine(double amount) {
if (amount > 0) { accruedFine += amount; }
}
@Override
public String toString() {
return String.format("%d loans, %d returns, %.2f EUR in fines",
loanCount, returnCount, accruedFine);
}
}
private final String name;
private final String identifier;
private int totalLoans;
private final EmployeeHistory history;
public Employee(String name, String identifier) {
this.name = (name == null || name.isBlank())
? "Unnamed employee" : name.trim();
this.identifier = (identifier == null || !identifier.startsWith("EMP-"))
? "EMP-000" : identifier.trim();
this.totalLoans = 0;
this.history = new EmployeeHistory();
}
public String getName() { return name; }
public String getIdentifier() { return identifier; }
public EmployeeHistory getHistory() { return history; }
public boolean canBorrow() {
return totalLoans < MAX_CONCURRENT_LOANS;
}
public boolean registerLoan() {
if (!canBorrow()) {
System.out.printf("WARNING: %s already has %d loans (maximum %d).%n",
name, totalLoans, MAX_CONCURRENT_LOANS);
return false;
}
totalLoans++;
history.addLoan();
return true;
}
public void registerReturn() {
totalLoans = Math.max(0, totalLoans - 1);
history.addReturn();
}
public void registerFine(double amount) {
history.addFine(amount);
}
}Employee diego = new Employee("Diego Alonso", "EMP-002");
diego.registerLoan();
diego.registerLoan();
diego.registerReturn();
diego.registerFine(1.25);
diego.registerFine(8.50);
System.out.println(diego.getName() + ": " + diego.getHistory());Design detail: EmployeeHistory's mutators (addLoan, addFine) are not public but package-private. That way the history is read-only from the outside — getHistory() returns something nobody can falsify from another package — but Employee can update it. It is the encapsulation of 03-07 taking advantage of the package visibility you studied there.
Solution 2
package com.nexussoftware.bibliotech;
public class LeakDemo {
// ---------- Version A: NON-static inner class ----------
static class HeavyCacheA {
private final int[] data = new int[5_000_000]; // about 20 MB
private final String code;
HeavyCacheA(String code) {
this.code = code;
data[0] = 1; // avoids optimisations
}
/** NON-static: it keeps a hidden reference to HeavyCacheA. */
class Summary {
private final String text;
Summary(String text) { this.text = text; }
String getText() { return text; }
}
Summary summarise() { return new Summary("Cache " + code); }
}
// ---------- Version B: STATIC nested class ----------
static class HeavyCacheB {
private final int[] data = new int[5_000_000];
private final String code;
HeavyCacheB(String code) {
this.code = code;
data[0] = 1;
}
/** STATIC: an independent object, with no hidden reference. */
static class Summary {
private final String text;
Summary(String text) { this.text = text; }
String getText() { return text; }
}
Summary summarise() { return new Summary("Cache " + code); }
}
private static long usedMemoryMb() {
Runtime r = Runtime.getRuntime();
return (r.totalMemory() - r.freeMemory()) / (1024 * 1024);
}
private static void settle() {
System.gc();
// Without try/catch (module 6) Thread.sleep is avoided: two gc() calls suffice
System.gc();
}
public static void main(String[] args) {
settle();
System.out.println("Initial memory: " + usedMemoryMb() + " MB");
// --- A ---
HeavyCacheA a = new HeavyCacheA("CAT-A");
HeavyCacheA.Summary ra = a.summarise();
a = null;
settle();
System.out.println("A (non-static inner): " + usedMemoryMb()
+ " MB -> " + ra.getText());
// --- B ---
HeavyCacheB b = new HeavyCacheB("CAT-B");
HeavyCacheB.Summary rb = b.summarise();
b = null;
settle();
System.out.println("B (static nested): " + usedMemoryMb()
+ " MB -> " + rb.getText());
}
}Typical output (the numbers vary with the JVM and the available memory):
Initial memory: 2 MB A (non-static inner): 21 MB -> Cache CAT-A B (static nested): 21 MB -> Cache CAT-B
The correct reading of those numbers: after block A, the ~20 MB are still there even though a is null, because ra keeps cache A alive through the hidden this$0 reference. In block B another ~20 MB are created, but the collector can take cache B away as soon as b = null, so memory does not go up by another 20: what you see is cache A, which was never freed.
To observe it even more clearly, repeat the block in a five-iteration loop storing each summary in an array: with version A memory grows without stopping until the OutOfMemoryError; with B it stays stable. This is, literally, the leak that appears in real applications when a listener or a cache entry is implemented as a non-static inner class. The details of the collector are in 10-07.
Solution 3
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Material;
public class CatalogReport {
/**
* Groups the materials by type. The Group class is LOCAL: it only makes
* sense inside this algorithm and must not appear in the API.
*/
public String reportByType(Material[] materials, int elapsedDays) {
class Group {
private final String type;
private int units;
private double totalFine;
Group(String type) { this.type = type; }
void add(Material m) {
units++;
// 'elapsedDays' is a captured parameter: it is not modified
// anywhere in the method, so it is effectively final.
totalFine += m.calculateFine(elapsedDays);
}
String line() {
return String.format(" %-12s %2d units %6.2f EUR%n",
type, units, totalFine);
}
}
// No collections (module 5): parallel arrays of maximum size
Group[] groups = new Group[materials.length];
int groupCount = 0;
for (Material m : materials) {
Group target = null;
for (int i = 0; i < groupCount; i++) {
if (groups[i].type.equals(m.getType())) {
target = groups[i];
break;
}
}
if (target == null) {
target = new Group(m.getType());
groups[groupCount++] = target;
}
target.add(m);
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("REPORT BY TYPE (after %d days)%n", elapsedDays));
for (int i = 0; i < groupCount; i++) {
sb.append(groups[i].line());
}
return sb.toString();
}
}Material[] catalog = {
new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018),
new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994),
new Book("Refactoring", "Martin Fowler", "978-0000000003", 1999),
new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"),
new Dvd("Refactoring Live", "DVD-0007", 95)
};
System.out.print(new CatalogReport().reportByType(catalog, 20));Three teaching points from the solution:
Groupis local and rightly so. It appears in no signature, does not pollute the package and its getter-less fields are acceptable because its reach is the method. If tomorrow it were needed outside, it would become a static nested class or arecord(04-07).- Capture of
elapsedDays. It is a parameter of the method and is never reassigned, so it is effectively final andaddcan use it. If at some point you wroteelapsedDays++, the method would stop compiling on theGroupline, not on the increment line. - The group search is O(n²). Walking the groups for each material is inefficient and here it is unavoidable, because you do not have
HashMap. In module 5 this whole method will come down to a few lines.
Conclusion
You now know that Java lets you declare classes inside classes and you know the four variants with their rules: the member inner class, bound to an outer instance; the static nested class, independent; the local one, confined to a method; and the anonymous one, arriving in the next lesson. You have the table that tells them apart and, above all, the criterion for choosing: static by default, and remove it only when you verify that you need the outer object's state.
You have seen the inner class mechanism from the inside: the hidden this$0 field the compiler adds, the outer.new Inner() syntax that gives the dependency away, and Outer.this to disambiguate when names clash. And you have seen its real cost: while the inner instance lives, the outer one cannot be collected. That is the cause of genuine memory leaks in production, hard to diagnose precisely because the inner class's code does not mention the outer one anywhere. The fix is one word.
You understand the effectively final restriction on variables and — more importantly — why it exists: local variables live on the stack and die with the method, while objects live on the heap and can survive; Java captures by value, and allowing modification would create two uncoordinated copies of the same name. It is a rule that will reappear identically with lambdas. You know how to use a private static nested class as a completely hidden implementation detail — the encapsulation of 03-07 applied to the whole type, not just the field — and you can read the files Outer$Inner.class, Outer$1Local.class and Outer$1.class knowing what each name reveals.
And you have applied it. BiblioTech's incidents have stopped being loose strings: Loan.Incident is a static nested class, immutable, with day, reason and severity, with correct equals/hashCode and with a name that declares which world it belongs to. And the catalogue can now be sorted with Catalog.ByTitle and Catalog.ByFineDescending, two nested comparators passed as a parameter to Arrays.sort to change the criterion without touching the code that sorts.
That last example leaves a question hanging. ByTitle is a whole class — declaration, @Override, body — for a single line of logic used in only one place. Declaring it, naming it and giving it its own slot in Catalog's namespace is a lot of ceremony for so little. In lesson 04-04, Anonymous Classes, you will learn to declare and instantiate an implementation at the very point where you need it and without giving it a name: you will see its full syntax — including the final semicolon everybody forgets — what it can and cannot do, the trap of this inside an anonymous class, and a decision table that will tell you, for each situation, whether it is time for a named class, a nested one, a local one, an anonymous one or — what comes next — a lambda.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
