This is the last black box of the course.

You have spent ten modules writing code that works without really knowing where it lives. When the AnnotatedExporter from 10-03 caches its introspection in a Map, where is that map and who frees it? When a million virtual threads store their stacks on the heap, what exactly is the heap and what happens when it fills up? When the test bench in 10-04 gave completely different numbers before and after the warm-up, what was the JVM doing in the meantime? When WeakHashMap turned up in passing, what does it mean for a reference to be "weak"? Why is the first request to a Java server always the slowest? And why does an application that has been running for three days suddenly start pausing every few seconds?

All those questions have the same underlying answer: the Java virtual machine manages memory for you, and to write serious code you have to understand how it does it.

Not to optimise micro-details — that almost never pays off — but for three very concrete things: to diagnose when something goes wrong, to measure instead of guessing, and to make informed decisions about data structures and object lifecycles.

By the end, the JVM will have stopped being a black box: you will know which memory regions exist and what lives in each one, how the collector decides what to remove, why memory leaks exist in Java despite the collector, which tools to use to see it with your own eyes, and why a homemade microbenchmark lies. And module 10 will be closed.

Contents

  1. The JVM memory regions
  2. The stack: frames, local variables and StackOverflowError
  3. The heap: where objects live
  4. Metaspace, code cache and native memory
  5. OutOfMemoryError and its different messages
  6. The generational model
  7. How the collector decides what to remove
  8. Stop-the-world pauses
  9. Today's collectors
  10. The three parameters that really get touched
  11. Memory leaks in Java: yes, they exist
  12. The four classic leak patterns
  13. Weak, soft and phantom references
  14. WeakHashMap and the BiblioTech card cache
  15. Deprecated finalize and Cleaner
  16. Measure before optimising
  17. The JDK tools
  18. Java Flight Recorder
  19. Why a homemade microbenchmark lies
  20. JMH: measuring properly
  21. The JIT compiler
  22. Performance good practices, by impact
  23. String, interning and StringBuilder, measured
  24. Common Mistakes and Tips
  25. Exercises

  1. The JVM memory regions

The JVM splits the process memory into several regions with different purposes. Some are per thread and others are shared, and that distinction explains a lot of behaviour.

graph TD
    subgraph "JVM process"
        subgraph "Per THREAD"
            P1["Stack<br/>method frames<br/>local variables<br/>~512 KB - 1 MB"]
            P2["Program counter<br/>Native method stack"]
        end
        subgraph "SHARED"
            H["HEAP<br/>every object<br/>and its fields<br/>-Xms / -Xmx"]
            M["Metaspace<br/>class metadata<br/>native memory"]
            C["Code cache<br/>methods compiled<br/>by the JIT"]
        end
        N["Native memory<br/>direct ByteBuffers<br/>JNI libraries<br/>thread stacks"]
    end
Region Scope What it holds Freed
Stack Per thread Method frames: local variables, parameters, references Automatically on returning from the method
Heap Shared Every object and its instance fields By the garbage collector
Metaspace Shared Class metadata, static fields, constant pool When the class loader is unloaded
Code cache Shared Machine code generated by the JIT On deoptimisation or unloading
Native memory Process Direct buffers, JNI, thread stacks Manually or when the object dies

The fundamental distinction, and the one to nail down:

public void example() {
    int counter = 42;                               // the VALUE is on the stack
    Book book = new Book("978-0000000001", "Effective Java");
    //   ^ the REFERENCE is on the stack        ^ the OBJECT is on the heap
}

Local primitives live on the stack. Objects always live on the heap; what is on the stack is the reference pointing to them (typically 4 or 8 bytes). And an object's fields live where the object lives: on the heap, even if they are primitives.

Seeing how much memory there is:

public class AvailableMemory {

    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();

        System.out.printf("Maximum (-Xmx):  %,d MB%n", rt.maxMemory()   / 1024 / 1024);
        System.out.printf("Total reserved:  %,d MB%n", rt.totalMemory() / 1024 / 1024);
        System.out.printf("Free overall:    %,d MB%n", rt.freeMemory()  / 1024 / 1024);
        System.out.printf("In use:          %,d MB%n",
                (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024);
        System.out.printf("Processors:      %d%n", rt.availableProcessors());
    }
}
Maximum (-Xmx):  4,096 MB
Total reserved:  258 MB
Free overall:    196 MB
In use:          62 MB
Processors:      8

Careful with the interpretation: maxMemory() and totalMemory() refer only to the heap, not to the total memory of the process. A Java process with -Xmx4g can consume 5 or 6 GB of system memory, because the metaspace, the code cache, the thread stacks and the direct buffers all live outside the heap. This is the number one reason a container with a memory limit kills a Java process that "fitted".

  1. The stack: frames, local variables and StackOverflowError

Every thread has its own stack. Every method call pushes a frame (stack frame) containing:

  • The method's parameters.
  • The local variables.
  • The operand stack (where the JVM does its arithmetic).
  • The return address.

On returning from the method, the frame is discarded whole, at once and for free. There is no collector involved: it is a simple decrement of the stack pointer. That is why primitive local variables are the cheapest memory in Java.

public class Frames {

    public static void main(String[] args) {    // frame 1
        int a = 10;
        first(a);
    }

    static void first(int x) {                  // frame 2
        String text = "BiblioTech";
        second(x * 2, text);
    }

    static void second(int y, String t) {       // frame 3
        System.out.println(y + " " + t);
    }                                            // frame 3 is discarded
}

When the stack fills up:

public class StackOverflow {

    private static int depth = 0;

    static void infiniteRecursion() {
        depth++;
        infiniteRecursion();                    // no base case
    }

    public static void main(String[] args) {
        try {
            infiniteRecursion();
        } catch (StackOverflowError e) {
            System.out.println("StackOverflowError at " + depth + " frames");
        }
    }
}
StackOverflowError at 21847 frames

Points worth nailing down:

StackOverflowError is an Error, not an Exception. Back to 06-01: Errors indicate problems you normally cannot recover from and should not be caught in ordinary code. Here we do it only to demonstrate.

The depth depends on the stack size and on the size of each frame. A method with twenty local variables takes bigger frames and overflows sooner. That is why the number varies between runs and between machines.

It is tuned with -Xss:

java -Xss1m MyProgram       # 1 MB per thread (typical default on 64-bit)
java -Xss16m MyProgram      # for legitimate deep recursion
java -Xss256k MyProgram     # to have MANY threads

And here is the connection with 10-06: the stack is exactly why platform threads are expensive. With -Xss1m, ten thousand threads reserve ten gigabytes of address space. Virtual threads store their stack on the heap, it grows on demand, and that is why millions fit.

And with Thread.ofVirtual(), -Xss does not apply: a virtual thread's stack starts at a few hundred bytes and grows as needed.

  1. The heap: where objects live

The heap is the shared region where every object created with new lives, every array, every string.

Book book = new Book("978-0000000001", "Effective Java", "Joshua Bloch", 412, 4.85);

How much does that object take? With the 64-bit HotSpot JVM and compressed pointers (the default with heaps under 32 GB):

Component Bytes
Object header (mark word) 8
Class pointer (compressed) 4
String isbn (reference) 4
String title (reference) 4
String author (reference) 4
int pages 4
double rating 8
Padding to a multiple of 8 4
Total for the Book object 40

And that is without counting the strings, which are separate objects on the heap: a 14-character String takes about 56 bytes (24 for the String header plus a byte[] of 32).

Practical consequences:

  • Every object carries an overhead of 12-16 bytes. An Integer holding a 4-byte number takes 16. That is why IntStream versus Stream<Integer> matters (10-04), and why int[] versus List<Integer> matters.
  • Small, numerous objects are expensive. A million Integers are 16 MB plus the reference array; an int[] of a million is 4 MB.
  • Compressed pointers save a lot. With heaps over 32 GB, references go from 4 to 8 bytes and total consumption rises by around 20 %. It is a real argument for keeping the heap below 32 GB.

Measuring it:

java -XX:+PrintFlagsFinal -version | grep -i UseCompressedOops
bool UseCompressedOops = true    {lp64_product} {default}

  1. Metaspace, code cache and native memory

Metaspace

It holds the class metadata: their structure, their methods, the constant pool and the static fields.

Before Java 8 this lived in PermGen, a region inside the heap and of fixed size, whose overflow (OutOfMemoryError: PermGen space) was the terror of application servers doing hot redeploys. Java 8 replaced it with the metaspace, which lives in native memory and grows dynamically.

java -XX:MaxMetaspaceSize=256m MyProgram

When it fills up: applications that generate classes dynamically and do not release them. The dynamic proxies of 10-03, frameworks that generate bytecode (Spring with CGLIB, Hibernate), and hot redeploys that leave class loaders alive.

Code cache

It holds the machine code the JIT compiler generates from the bytecode (section 21). If it fills up, the JVM stops compiling and goes back to interpreting, with a drastic drop in performance:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
    Compiler has been disabled.
java -XX:ReservedCodeCacheSize=512m MyProgram

It is rare, but it happens in enormous applications with a lot of hot code.

Native memory

Outside the collector's control:

  • Thread stacks (-Xss × number of threads).
  • Direct ByteBuffers (ByteBuffer.allocateDirect), which you saw in 07-03 with NIO.
  • Native libraries via JNI or the Java 22 foreign function API.
  • Internal structures of the JVM and the GC.
// DIRECT buffer: the memory is NOT on the heap
ByteBuffer direct = ByteBuffer.allocateDirect(64 * 1024 * 1024);    // 64 MB native

// Ordinary buffer: it IS on the heap
ByteBuffer onHeap = ByteBuffer.allocate(64 * 1024 * 1024);

Direct buffers avoid a copy when doing I/O, but freeing them depends on the collector: the native memory is only released when the ByteBuffer object that references it is collected. They are capped with:

java -XX:MaxDirectMemorySize=512m MyProgram

The formula for the real consumption of a Java process:

Total memory ≈ Heap (-Xmx)
              + Metaspace
              + Code cache
              + (Number of threads × -Xss)
              + Direct buffers
              + JVM internal structures (~100-300 MB)

That is why -Xmx2g in a container with a 2 GB limit makes the system kill the process (OOMKilled), even though the heap never fills up. Rule of thumb: -Xmx should not exceed 60-75 % of the container limit. Since Java 10, the JVM detects cgroup limits and sizes the default heap accordingly, which helps a lot:

java -XX:MaxRAMPercentage=70 -jar bibliotech.jar

  1. OutOfMemoryError and its different messages

An OutOfMemoryError is not one single problem: the specific message tells you which region ran out, and each one has a different cause and a different remedy.

Message Region Typical cause Remedy
Java heap space Heap A memory leak, or a heap too small for the load Analyse a dump; raise -Xmx
GC overhead limit exceeded Heap More than 98 % of the time spent collecting, recovering less than 2 % Almost always a leak
Metaspace Metaspace Dynamically generated classes that are never released Check class loaders; -XX:MaxMetaspaceSize
Requested array size exceeds VM limit Heap An array of more than ~2,147,483,645 elements Rethink the structure
unable to create native thread Native Too many threads, or stacks that are too large Fewer threads, lower -Xss, use virtual threads
Direct buffer memory Native Direct buffers that are never released -XX:MaxDirectMemorySize; review lifecycles
Compressed class space Metaspace Too many classes with compressed pointers -XX:CompressedClassSpaceSize
package com.nexussoftware.bibliotech.diagnostics;

import java.util.ArrayList;
import java.util.List;

public class TriggerOutOfMemory {

    /** Run with: java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError */
    public static void main(String[] args) {

        List<byte[]> retained = new ArrayList<>();
        int blocks = 0;

        try {
            while (true) {
                retained.add(new byte[1024 * 1024]);       // 1 MB each time
                blocks++;
            }
        } catch (OutOfMemoryError e) {
            // Catch OOM only to DIAGNOSE and exit. Never to carry on.
            System.err.println("OutOfMemoryError after " + blocks + " MB");
            System.err.println("Message: " + e.getMessage());
            retained.clear();                               // free up so we can print
            System.exit(1);
        }
    }
}
OutOfMemoryError after 61 MB
Message: Java heap space

Options that are essential in production:

java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/bibliotech/heapdumps/ \
     -XX:+ExitOnOutOfMemoryError \
     -jar bibliotech.jar
  • HeapDumpOnOutOfMemoryError: produces a heap dump right at the point of failure. Without it, the incident is lost and all you can do is wait for it to happen again.
  • ExitOnOutOfMemoryError: kills the process. It sounds drastic and it is the right thing: a JVM that has suffered an OutOfMemoryError is in an indeterminate state — some thread died halfway through something — and it is better for the orchestrator to restart it than to leave it running badly.

Never catch OutOfMemoryError in order to continue. Catching it to log a message and exit is defensible; catching it and carrying on is a guarantee of data corruption.

  1. The generational model

Here the garbage collector proper begins.

The design of Java's collectors rests on an empirical observation called the weak generational hypothesis:

The vast majority of objects die very young.

And it is overwhelmingly true: in a typical application, more than 90 % of objects become unreachable almost immediately after being created. Think of BiblioTechStatistics in 10-04: every intermediate Card in a stream, every temporary StringBuilder, every Optional, every object produced by a map — they all die in microseconds.

If most of them die young, why examine all of memory on every collection? Hence the generational model:

graph LR
    subgraph "YOUNG GENERATION"
        E["Eden<br/>where ALL objects<br/>are born"]
        S0["Survivor 0"]
        S1["Survivor 1"]
    end
    subgraph "OLD GENERATION"
        O["Old / Tenured<br/>objects that have<br/>survived several GCs"]
    end
    E -->|"minor GC:<br/>the live ones are copied"| S0
    S0 -->|"next minor GC"| S1
    S1 -->|"next minor GC"| S0
    S1 -->|"after N survivals:<br/>PROMOTION"| O
    E -->|"huge objects"| O

The cycle, step by step:

  1. Every new object is born in eden. Allocating memory there is almost free: a pointer increment (bump the pointer), on the order of nanoseconds.
  2. When eden fills up, a minor GC happens (minor GC): the live objects in eden and in the active survivor space are identified and copied into the other survivor space. Eden and the source survivor space are left completely empty at a stroke.
  3. Every survival increments the object's "age". When it passes a threshold (-XX:MaxTenuringThreshold, typically 15), it is promoted to the old generation.
  4. When the old generation fills up, a major GC happens (major or full GC), which is far more expensive.

The key is in step 2, and it is counter-intuitive: the cost of a minor GC is proportional to the number of live objects, not to the number of dead ones. If 2 MB survive out of a 500 MB eden, the GC copies 2 MB and discards the remaining 498 without touching them. Objects that die young are, literally, free to collect.

Minor GC Major / full GC
Region Young generation Old (or the whole heap)
Frequency High (seconds) Low (minutes or hours)
Duration Milliseconds Tenths of a second or seconds
Cost proportional to Live objects Size of the region
Should you worry? Normally not Yes: these are the visible pauses

Seeing it live:

java -Xlog:gc -Xmx256m -jar bibliotech.jar
[0.412s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 51M->8M(256M) 6.412ms
[1.238s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 59M->12M(256M) 5.891ms
[2.104s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 63M->14M(256M) 7.203ms
[8.771s][info][gc] GC(9) Pause Full (System.gc()) 198M->31M(256M) 187.442ms

How to read it: 51M->8M(256M) means 51 MB were in use before the GC, 8 MB after, with a total heap of 256 MB. 43 MB were freed in 6.4 ms. The last line is a full GC: a 187 ms pause, thirty times longer.

And what this teaches about how to write code: creating short-lived temporary objects is not expensive. The "reuse objects so you do not create garbage" optimisation is, almost always, counterproductive: it turns cheap young-generation objects into old-generation objects that really do cost something to collect.

  1. How the collector decides what to remove

A very widespread and false idea: that Java counts references and deletes an object when the count reaches zero. That is not how it works, and understanding why matters.

The collector uses reachability from the roots (reachability from GC roots).

The GC roots are the starting points, and they are:

  • The local variables of every frame of every stack of every thread.
  • The static fields of every loaded class.
  • References from native code (JNI).
  • The live threads themselves.
  • The monitors currently being used for synchronisation.
  • Certain internal JVM references.

The algorithm, conceptually:

  1. Start from the roots.
  2. Mark every reachable object by following all its references, recursively.
  3. Everything unmarked is garbage, whatever number of references it has.
graph TD
    R1["ROOT: local variable<br/>in main()"] --> A["Repository"]
    R2["ROOT: static field<br/>Configuration.INSTANCE"] --> B["Configuration"]
    A --> C["internal HashMap"]
    C --> D["Book 1"]
    C --> E["Book 2"]
    F["Old loan"] --> G["Book 3"]
    G --> F
    H["Orphan card"]
    F -.->|"NOT reachable<br/>from any root"| I["GARBAGE"]
    G -.-> I
    H -.-> I

Why cycles do not matter. In the diagram, Old loan points to Book 3 and Book 3 points back. With reference counting, each would have one reference and neither would ever be freed: a guaranteed leak. With reachability, neither is reachable from any root, so both are garbage and get collected.

This is a real advantage of Java over languages with reference counting (Python partly, Swift, Objective-C), where cycles require explicit weak references to avoid leaks.

The most important practical consequence: an object is freed when it stops being reachable, not when "you no longer need it". And out of that come all the leaks in section 11: it is enough for a single root to keep the chain alive for the object — and everything it references — to carry on taking up memory.

System.gc(), while we are here: it is a suggestion, not an order. The JVM can ignore it, and calling it usually makes things worse because it forces a full GC with its long pause. In production it is switched off:

java -XX:+DisableExplicitGC -jar bibliotech.jar

It only makes sense in diagnostics, to check whether retained memory really is released (you will see this in exercise 1).

  1. Stop-the-world pauses

To be able to walk the object graph coherently, at some point the collector needs the graph not to change. That means stopping all the application threads: a stop-the-world pause.

graph LR
    A["Application threads<br/>running"] --> B["Safepoint"]
    B --> C["ALL threads<br/>STOPPED"]
    C --> D["The GC works"]
    D --> E["Threads resumed"]
    E --> A

Threads do not stop at any arbitrary point: the JVM waits for each one to reach a safepoint, where its state is consistent and describable. There are safepoints on returning from a method, on the backward jumps of loops, and in blocking calls.

And out of that comes a real problem that is hard to diagnose: a very tight loop with no safepoints can delay the entire pause. All the other threads are already stopped waiting for that one. It is called time to safepoint and this is how you diagnose it:

java -Xlog:safepoint -jar bibliotech.jar
[3.201s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 812 ms,
    Reaching safepoint: 47 ms, At safepoint: 8 ms, Total: 55 ms

Reaching safepoint: 47 ms is time lost before the GC even starts, because some thread was slow to get there. If that number is high, the collector is not the problem.

The whole evolution of collectors over the last fifteen years boils down to one sentence: reduce pause time. Modern ones do most of the work concurrently with the application, leaving short pauses only for the steps that demand them.

The two metrics in tension:

Metric What it measures What favours it
Throughput Percentage of time spent on the application Less frequent GC but with long pauses
Latency Duration of the longest pause Concurrent GC with short pauses, at the cost of more CPU

An overnight batch process prefers throughput; an API that promises to respond in under 100 ms prefers latency. You cannot maximise both.

  1. Today's collectors

Collector Enabled with Pauses Throughput Typical heap Use case
Serial -XX:+UseSerialGC Long Good on small heaps < 100 MB Tiny containers, a single core, CLI
Parallel -XX:+UseParallelGC Long (parallel) The best < 4 GB Batch processes where the pause does not matter
G1 -XX:+UseG1GC (default) Medium, predictable Very good 4 GB - 32 GB Server applications: the default choice
ZGC -XX:+UseZGC < 1 ms Good 8 GB - 16 TB Latency-critical: trading, real time
Shenandoah -XX:+UseShenandoahGC < 10 ms Good Any Latency-critical, an alternative to ZGC
Epsilon -XX:+UseEpsilonGC None: it does not collect Testing only: measures real allocation

G1 (Garbage-First), the default since Java 9, splits the heap into fixed-size regions (1-32 MB) that can be eden, survivor or old dynamically. On each cycle it collects the regions with the most garbage first — hence the name — thereby maximising the memory freed per unit of pause. You give it a pause target:

java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xmx8g -jar bibliotech.jar

It is a target, not a guarantee. G1 adjusts the size of the young generation to try to meet it; asking for 10 ms with a 32 GB heap will simply make it collect constantly.

ZGC and Shenandoah are concurrent collectors that do almost all their work while the application runs, with pauses below a millisecond independent of the heap size. The price is more CPU use and a little more memory. Since Java 21, ZGC has a generational mode, which greatly improves its throughput:

java -XX:+UseZGC -XX:+ZGenerational -Xmx16g -jar bibliotech.jar

Epsilon is a collector that collects nothing: when the heap fills up, the JVM dies. It sounds useless and it has two legitimate uses: measuring exactly how much memory a test allocates (if it allocates more than expected, it fails), and running ultra-short-lived processes where collecting is not worth it.

java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx1g AllocationTest

The practical recommendation:

graph TD
    A["Which collector?"] --> B{"Heap < 100 MB<br/>or 1 core?"}
    B -->|"yes"| C["Serial"]
    B -->|"no"| D{"Do pauses<br/>matter?"}
    D -->|"no: batch process"| E["Parallel"]
    D -->|"yes"| F{"Are pauses < 10 ms<br/>essential?"}
    F -->|"no"| G["G1 (default)"]
    F -->|"yes"| H["ZGC or Shenandoah"]

And the most important piece of advice: do not switch collector without measuring. 95 % of applications work perfectly with G1 by default. Switching to ZGC "because it is more modern" without a measured latency problem usually makes overall throughput worse.

  1. The three parameters that really get touched

The JVM has more than a thousand tuning options:

java -XX:+PrintFlagsFinal -version | wc -l
1247

Out of those thousand, in practice three get touched.

  1. -Xmx: maximum heap size

java -Xmx4g -jar bibliotech.jar

By far the most important. Too small and you suffer constant GCs or OutOfMemoryError; too large and pauses stretch out and the operating system starts paging.

Rule for containers: between 60 % and 75 % of the memory limit, leaving the rest for metaspace, stacks, buffers and JVM structures. Or better, let the JVM work it out:

java -XX:MaxRAMPercentage=70 -jar bibliotech.jar

And the 32 GB limit: above that size compressed pointers are switched off and consumption rises by around 20 %. A 31 GB heap can store more objects than a 33 GB one.

  1. -Xms: initial heap size

java -Xms4g -Xmx4g -jar bibliotech.jar

On servers, set it equal to -Xmx. The heap starts at its final size and you avoid the JVM growing it bit by bit during the first few minutes, with unnecessary GCs and readjustments. In short-lived command-line tools, a small -Xms makes them start faster.

  1. The choice of collector

java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 ...

With the criteria from the previous section, and only after measuring.

And the diagnostic options, which really are worth always setting

java -Xms2g -Xmx2g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/bibliotech/ \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/var/log/bibliotech/gc.log:time,uptime:filecount=5,filesize=10M \
     -jar bibliotech.jar

These do not change performance: they make sure that, when something fails, you have the information to diagnose it. It is the difference between resolving an incident in an hour and never resolving it.

What NOT to do: copy a list of twenty tuning options from the internet. Each option interacts with the others, many are deprecated or removed, and the whole set is usually worse than the defaults — which have been tuned for twenty years with real data from thousands of applications.

  1. Memory leaks in Java: yes, they exist

There is a belief that Java, having a garbage collector, cannot have memory leaks. It is false.

A leak in Java has a precise definition:

Objects that are still reachable from a GC root, but that the program is never going to use again.

The collector does its job perfectly: it cannot delete them because they are reachable. The mistake is in the code, which holds a reference it should no longer hold.

The characteristic symptom, worth recognising:

[3600.1s] GC(1201) Pause Young 892M->741M(1024M) 41.2ms
[3612.4s] GC(1202) Pause Young 901M->768M(1024M) 44.8ms
[3625.9s] GC(1203) Pause Young 918M->794M(1024M) 48.1ms
[3641.2s] GC(1204) Pause Full  1010M->961M(1024M) 892.3ms
[3644.7s] GC(1205) Pause Full  1014M->978M(1024M) 941.7ms
[3648.1s] GC(1206) Pause Full  1020M->1009M(1024M) 1102.4ms
java.lang.OutOfMemoryError: GC overhead limit exceeded

Read how the second number evolves: after each GC, 741, 768, 794 MB remain... The retained memory grows monotonically. In the end, the collector spends more time working than the application does, and GC overhead limit exceeded arrives.

That pattern — post-GC memory climbing like a staircase — is the signature of a leak, and it is the first thing to look for in a GC log.

  1. The four classic leak patterns

Leak 1: a collection that grows and nobody empties

By far the most frequent.

package com.nexussoftware.bibliotech.service;

import java.util.*;

/**
 * LEAK: the cache grows without limit.
 */
public class LeakyCache {

    // static: this is a GC ROOT. Everything hanging off it lives
    // as long as the class lives, that is, forever.
    private static final Map<String, Card> CACHE = new HashMap<>();

    public Card get(String isbn) {
        return CACHE.computeIfAbsent(isbn, this::buildCard);
    }
    // Nothing is ever removed. With a million distinct ISBNs,
    // a million cards retained forever.
}
/** CORRECT: a BOUNDED cache with LRU eviction (10-01). */
public class BoundedCache {

    private static final int CAPACITY = 10_000;

    private final Map<String, Card> cache =
            Collections.synchronizedMap(new LinkedHashMap<>(CAPACITY, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, Card> eldest) {
                    return size() > CAPACITY;
                }
            });

    public Card get(String isbn) {
        return cache.computeIfAbsent(isbn, this::buildCard);
    }
}

Every cache needs an eviction policy. By size, by time or by weak references (section 13). A cache with no eviction policy is not a cache: it is a leak with good intentions.

Leak 2: listeners that are never unsubscribed

package com.nexussoftware.bibliotech.presentation;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class EventManager {

    private static final List<Consumer<String>> SUBSCRIBERS = new ArrayList<>();

    public static void subscribe(Consumer<String> subscriber) {
        SUBSCRIBERS.add(subscriber);
    }

    public static void publish(String event) {
        SUBSCRIBERS.forEach(s -> s.accept(event));
    }
}
/** LEAK: it subscribes and never unsubscribes. */
public class LoanWindow {

    private final List<Loan> loans = new ArrayList<>(10_000);           // heavy object

    public LoanWindow() {
        // The LAMBDA implicitly captures 'this' by using a field.
        // EventManager.SUBSCRIBERS (static) retains the lambda,
        // the lambda retains this LoanWindow,
        // and this window retains its 10,000 loans. FOREVER.
        EventManager.subscribe(event -> refresh(event));
    }

    private void refresh(String event) { /* uses loans */ }

    public void close() {
        // There is no way to unsubscribe: we did not keep the reference
    }
}
/** CORRECT: keep the reference and unsubscribe. */
public class CorrectLoanWindow implements AutoCloseable {

    private final List<Loan> loans = new ArrayList<>(10_000);
    private final Consumer<String> subscriber;

    public CorrectLoanWindow() {
        this.subscriber = this::refresh;          // we keep the reference
        EventManager.subscribe(subscriber);
    }

    private void refresh(String event) { }

    @Override
    public void close() {
        EventManager.unsubscribe(subscriber);      // symmetry: subscribe/unsubscribe
    }
}

The symmetry rule: every subscribe, register, add or open needs its unsubscribe, deregister, remove or close, preferably in a try-with-resources (06-06).

Leak 3: ThreadLocal in a thread pool

package com.nexussoftware.bibliotech.service;

/**
 * LEAK: the pool thread does NOT die, so neither does its ThreadLocal.
 */
public class LeakyContext {

    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

    public static void process(String employee, Runnable task) {
        CONTEXT.set(new RequestContext(employee));        // it is stored...
        task.run();
        // ...and NEVER cleared.
    }
}

Why it is especially insidious. With an ordinary thread, when the thread dies its ThreadLocalMap dies. But in a pool (08-05) the threads do not die: they are reused thousands of times. Each request leaves its context behind, and a pool of 200 threads accumulates 200 contexts that are never released. Worse still: the next request that lands on that thread will see the previous one's context, which on top of a leak is a security problem.

/** CORRECT: clean-up guaranteed with finally. */
public class CorrectContext {

    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

    public static void process(String employee, Runnable task) {
        CONTEXT.set(new RequestContext(employee));
        try {
            task.run();
        } finally {
            CONTEXT.remove();           // MANDATORY, and in finally
        }
    }
}

And here it connects with 10-06: ScopedValue exists precisely to eliminate this class of mistake, because its scope is a block and the clean-up is automatic.

Leak 4: a non-static inner class that retains the outer one

Back to 04-03. A non-static inner class holds an implicit reference to its outer instance (Outer.this):

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.List;

public class HeavyCatalog {

    private final List<Material> materials = new ArrayList<>(100_000);   // ~50 MB

    /**
     * NOT static: it implicitly retains HeavyCatalog.
     */
    public class SimpleCounter {
        private int count;
        public void increment() { count++; }
        public int getCount()   { return count; }
        // It does not use 'materials' at all, and yet it retains it
    }

    public SimpleCounter createCounter() {
        return new SimpleCounter();
    }
}
// The 50 MB catalogue is created, a 16-byte counter is taken out
// and the catalogue is discarded... but the counter retains it.
SimpleCounter counter;
{
    HeavyCatalog catalog = new HeavyCatalog();
    counter = catalog.createCounter();
}
// 'catalog' is no longer reachable... EXCEPT through counter.this$0
// The 50 MB are still there.
/** CORRECT: static, with no reference to the outer instance. */
public static class SimpleCounter {
    private int count;
    public void increment() { count++; }
    public int getCount()   { return count; }
}

The rule from 04-03, now with its full justification: make static every inner class that does not need access to the outer state. The IDE suggests it; take its advice.

The same applies to lambdas and anonymous classes: they capture this if they use any instance field or method. A lambda that outlives its creator retains it.

Summary table

Leak How it is detected How it is fixed
A growing collection Heap dump: a gigantic HashMap Eviction policy (LRU, TTL, weak)
Listeners Many instances of a UI or service class subscribe/unsubscribe symmetry
ThreadLocal in a pool Large ThreadLocalMaps in the dump remove() in finally
Inner class Small objects with this$0 to large objects Make it static

  1. Weak, soft and phantom references

Java offers four reference strengths, and they are the tool for telling the collector "you can delete this if you need to".

Kind Class The GC clears it... Use
Strong (ordinary) Never while it is reachable All normal code
Soft SoftReference Only if memory is about to run short Caches that can be sacrificed
Weak WeakReference At the next GC Metadata associated with objects
Phantom PhantomReference Already cleared; it only notifies Cleaning up native resources
package com.nexussoftware.bibliotech.diagnostics;

import java.lang.ref.*;

public class ReferenceStrengths {

    public static void main(String[] args) throws InterruptedException {

        // --- STRONG: the GC does not touch it ---
        Book strong = new Book("978-0000000001", "Effective Java");
        System.gc();
        Thread.sleep(100);
        System.out.println("Strong after GC: " + (strong != null));      // true

        // --- WEAK: it disappears at the next GC ---
        WeakReference<Book> weak = new WeakReference<>(
                new Book("978-0000000002", "Design Patterns"));
        System.out.println("Weak before:     " + (weak.get() != null));  // true
        System.gc();
        Thread.sleep(100);
        System.out.println("Weak after GC:   " + (weak.get() != null));  // false

        // --- SOFT: it survives while there is memory ---
        SoftReference<Book> soft = new SoftReference<>(
                new Book("978-0000000003", "Refactoring"));
        System.gc();
        Thread.sleep(100);
        System.out.println("Soft after GC:   " + (soft.get() != null));  // true

        // --- PHANTOM: get() ALWAYS returns null ---
        ReferenceQueue<Book> queue = new ReferenceQueue<>();
        PhantomReference<Book> phantom = new PhantomReference<>(
                new Book("978-0000000004", "Clean Code"), queue);
        System.out.println("Phantom.get():   " + phantom.get());         // always null

        System.gc();
        Thread.sleep(100);
        Reference<?> enqueued = queue.poll();
        System.out.println("Notified?        " + (enqueued != null));    // true
    }
}
Strong after GC: true
Weak before:     true
Weak after GC:   false
Soft after GC:   true
Phantom.get():   null
Notified?        true

The three rules of use:

WeakReference"I want to reach this while somebody else is using it, but I do not want to be the one keeping it alive". This is the case of associating metadata with an object without preventing it from being collected.

SoftReference"this is a cache: delete it if memory is needed". It is used less than people think, because the exact behaviour depends on the JVM and a cache with an explicit size policy is usually more predictable.

PhantomReference"let me know once this has been collected so I can release a native resource". It is the mechanism behind Cleaner (section 15) and is almost never used directly.

The ReferenceQueue is the notification mechanism: you pass it in when creating the reference and the JVM enqueues the reference when its object is collected. It lets you react without polling.

  1. WeakHashMap and the BiblioTech card cache

WeakHashMap is a Map whose keys are weak references: when a key stops being reachable from the rest of the program, the entry disappears on its own.

package com.nexussoftware.bibliotech.diagnostics;

import java.util.*;

public class MapComparison {

    public static void main(String[] args) throws InterruptedException {

        Map<Book, String> ordinary = new HashMap<>();
        Map<Book, String> weak = new WeakHashMap<>();

        Book retained  = new Book("978-0000000001", "Effective Java");
        Book temporary = new Book("978-0000000002", "Design Patterns");

        ordinary.put(retained, "shelf A-1");
        ordinary.put(temporary, "shelf A-2");
        weak.put(retained, "shelf A-1");
        weak.put(temporary, "shelf A-2");

        System.out.println("Before -> HashMap: " + ordinary.size()
                         + ", WeakHashMap: " + weak.size());

        temporary = null;         // the last STRONG reference is lost
        System.gc();
        Thread.sleep(200);

        System.out.println("After  -> HashMap: " + ordinary.size()
                         + ", WeakHashMap: " + weak.size());
    }
}
Before -> HashMap: 2, WeakHashMap: 2
After  -> HashMap: 2, WeakHashMap: 1

The HashMap retains the book forever even though nobody else uses it: that is a leak. The WeakHashMap lets it be collected and removes the entry.

The real use case: metadata associated with objects you do not control.

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.*;

import java.time.Instant;
import java.util.*;

/**
 * BiblioTech card cache with two levels.
 *
 * Level 1 (WeakHashMap): cards associated with LIVE materials.
 *   If the material disappears from the catalogue, its card is freed on its own.
 *
 * Level 2 (LRU LinkedHashMap): cards by ISBN, bounded.
 *   It survives the material's disappearance, but with a cap.
 */
public class CardCache {

    private static final int LRU_CAPACITY = 5_000;

    /**
     * Key: the Material object. WEAK reference.
     * If nobody else references the material, the entry goes on its own.
     */
    private final Map<Material, Card> byMaterial =
            Collections.synchronizedMap(new WeakHashMap<>());

    /**
     * Key: the ISBN (a String, a strong reference).
     * Here an explicit eviction policy IS needed.
     */
    private final Map<String, Card> byIsbn =
            Collections.synchronizedMap(new LinkedHashMap<>(LRU_CAPACITY, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, Card> eldest) {
                    return size() > LRU_CAPACITY;
                }
            });

    private long weakHits = 0;
    private long lruHits = 0;
    private long misses = 0;

    public Card get(Material material) {
        Card byObject = byMaterial.get(material);
        if (byObject != null) {
            weakHits++;
            return byObject;
        }

        Card byKey = byIsbn.get(material.getIsbn());
        if (byKey != null) {
            lruHits++;
            byMaterial.put(material, byKey);          // repopulate level 1
            return byKey;
        }

        misses++;
        Card fresh = compute(material);
        byMaterial.put(material, fresh);
        byIsbn.put(material.getIsbn(), fresh);
        return fresh;
    }

    private Card compute(Material material) {
        // Expensive computation: queries, aggregates, formatting
        return new Card(material.getIsbn(), material.getTitle(), true);
    }

    public String statistics() {
        long total = weakHits + lruHits + misses;
        return """
                CardCache
                  Level 1 (WeakHashMap): %d live entries, %d hits
                  Level 2 (LRU %d):      %d entries, %d hits
                  Misses:                %d
                  Hit rate:              %.1f %%"""
                .formatted(byMaterial.size(), weakHits,
                           LRU_CAPACITY, byIsbn.size(), lruHits,
                           misses, total == 0 ? 0.0
                                   : (weakHits + lruHits) * 100.0 / total);
    }
}

Three warnings about WeakHashMap you need to know:

1. The values are STRONG references. If a value references its own key, the entry is never freed:

// LEAK: the value points at the key, which therefore never becomes unreachable
WeakHashMap<Book, Loan> map = new WeakHashMap<>();
map.put(book, new Loan(..., book));             // Loan holds the Book

2. Clean-up is not immediate. It depends on a GC happening. size() can return a bigger number than it "should" until the collector runs.

3. With String literal keys it does not work as you expect. Literals are interned (section 23) and are strongly reachable from the constant pool: they are never collected.

  1. Deprecated finalize and Cleaner

Back to 03-09. Object.finalize() was conceived as a destructor: a method the JVM would call before collecting the object, in order to release resources.

It was a design mistake and has been deprecated since Java 9, marked for removal since Java 18. The reasons:

Problem Consequence
No guarantee that it runs The program can end without ever calling it
No guarantee of when It can take hours
It delays collection An object with finalize needs two GC cycles
It runs on a thread with no priority An enormous queue can build up
An exception is silently ignored The object is left half-finalised
It can "resurrect" the object By storing this in a static field
A security risk Finalisation attacks on failed constructors
// NEVER DO THIS
@Override
protected void finalize() throws Throwable {
    if (file != null) {
        file.close();
    }
}

The correct solution is AutoCloseable + try-with-resources (06-06). It is deterministic, it happens exactly when it should and the compiler helps.

And for the safety net, Cleaner (Java 9):

package com.nexussoftware.bibliotech.persistence;

import java.lang.ref.Cleaner;
import java.nio.channels.FileChannel;
import java.nio.file.*;

/**
 * A resource with deterministic closing (AutoCloseable) PLUS a safety
 * net with Cleaner in case somebody forgets to close it.
 */
public class LoanStore implements AutoCloseable {

    /** ONE per application: it creates a thread. */
    private static final Cleaner CLEANER = Cleaner.create();

    /**
     * STATIC and with no reference to the outer object.
     * If it held a reference to LoanStore, that would never be
     * unreachable and the Cleaner would NEVER fire.
     * It is leak 4 from section 12, applied here.
     */
    private static class StateToClean implements Runnable {

        private final FileChannel channel;
        private final Path path;

        StateToClean(FileChannel channel, Path path) {
            this.channel = channel;
            this.path = path;
        }

        @Override
        public void run() {
            try {
                if (channel.isOpen()) {
                    System.err.println("WARNING: LoanStore(" + path
                            + ") was not closed; the Cleaner is closing it");
                    channel.close();
                }
            } catch (Exception e) {
                // The Cleaner swallows exceptions: there is nobody to propagate to
            }
        }
    }

    private final StateToClean state;
    private final Cleaner.Cleanable cleanable;

    public LoanStore(Path path) throws java.io.IOException {
        FileChannel channel = FileChannel.open(path,
                StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        this.state = new StateToClean(channel, path);
        this.cleanable = CLEANER.register(this, state);
    }

    public void save(String line) { /* ... */ }

    @Override
    public void close() {
        cleanable.clean();     // DETERMINISTIC and idempotent closing
    }
}
// NORMAL USE: try-with-resources, guaranteed and deterministic closing
try (var store = new LoanStore(Path.of("loans.dat"))) {
    store.save("LN-2026-0041;978-0000000001;Marta Ruiz");
}
finalize Cleaner
Status Deprecated, marked for removal Current
Delays collection Yes (two cycles) No
Can resurrect the object Yes No: the state is independent
Exceptions Silently ignored Contained in the cleaning action
Recommended use None Only as a safety net

The rule: AutoCloseable for the real closing, Cleaner as a safety net, finalize never.

  1. Measure before optimising

Before the tools, the rule that governs everything else. Donald Knuth formulated it in 1974 and it still holds:

"Programmers waste enormous amounts of time thinking about the speed of noncritical parts of their programs... Premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%."

The second sentence is quoted less often and is just as important: there is a 3 % that does matter, and the job is to find it.

The mistake of optimising blindly has a characteristic shape. A developer looks at the code, decides a loop "looks slow", rewrites it in a clever and unreadable way, and the result runs exactly as fast — because the real time was going into an unindexed database query, a repeated network call or a regular expression recompiled on every iteration.

The correct method:

graph TD
    A["1. Define the goal<br/>'the report must take under 2 s'"] --> B["2. MEASURE the current situation"]
    B --> C{"Does it meet the goal?"}
    C -->|"yes"| D["Do not optimise. Done."]
    C -->|"no"| E["3. Profile: WHERE does the time go?"]
    E --> F["4. Optimise ONLY the hot spot"]
    F --> G["5. MEASURE again"]
    G --> H{"Did it improve?"}
    H -->|"no"| I["REVERT the change"]
    H -->|"yes"| C
    I --> E

The four principles:

  1. Without a goal there is no optimisation, only entertainment. "Faster" is not a goal; "the monthly report in under 2 seconds with 100,000 loans" is.
  2. Profile, do not guess. Intuition about where the time goes is notoriously bad, even among expert developers.
  3. Optimise the bottleneck, and only that. Improving by 90 % something that takes 2 % of the time improves the total by 1.8 %.
  4. If it did not improve, revert. A change that complicates the code without improving performance is a net loss.

And Amdahl's law, which puts numbers on this: if a part takes fraction p of the time and you speed it up s times, the total improvement is:

speedup = 1 / ((1 - p) + p/s)

With p = 0.05 (5 % of the time) and s = ∞ (infinitely fast), the total improvement is 1.05×. Five per cent. However brilliant the optimisation.

  1. The JDK tools

The JDK ships a complete set of diagnostic tools. They are all in $JAVA_HOME/bin.

jps: listing Java processes

jps -lvm
14237 com.nexussoftware.bibliotech.BiblioTechApp -Xms2g -Xmx2g -XX:+UseG1GC
14891 jdk.jcmd/sun.tools.jps.Jps -Dapplication.home=/usr/lib/jvm/jdk-21

The first number is the PID, which all the others need.

jstat: live statistics

# GC statistics every second, 10 times
jstat -gcutil 14237 1000 10
  S0     S1     E      O      M     CCS    YGC   YGCT    FGC   FGCT    GCT
  0.00  62.31  41.20  18.44  96.12 92.03    142   1.204     2  0.381   1.585
  0.00  62.31  78.90  18.44  96.12 92.03    142   1.204     2  0.381   1.585
 58.44   0.00  12.03  18.51  96.12 92.03    143   1.213     2  0.381   1.594
Column Meaning
S0, S1 % usage of the survivor spaces
E % usage of eden: watching it rise and drop back to zero is the rhythm of the minor GC
O % usage of the old generation. If it rises monotonically, there is a leak
M, CCS % of metaspace and of compressed class space
YGC, YGCT Count and total time of minor GCs
FGC, FGCT Count and total time of full GCs
GCT Total time in GC

How to read it in a minute: if E rises and falls normally and O stays stable, all is well. If O rises and never falls, and FGC grows, there is a leak.

jmap: heap information and dumps

# Histogram of live objects, sorted by size
jmap -histo:live 14237 | head -20
 num     #instances         #bytes  class name
----------------------------------------------
   1:        847291       40669968  [B                        (byte[])
   2:        847012       20328288  java.lang.String
   3:        412008       19776384  com.nexussoftware.bibliotech.domain.Loan
   4:        198432        9524736  java.util.HashMap$Node
   5:        412008        6592128  java.time.LocalDate
   6:         98211        4713-28  java.util.ArrayList

This histogram is the first tool to reach for when a leak is suspected. Run it twice a few minutes apart and compare: the class whose instances keep growing is the culprit.

# FULL heap dump for analysis with graphical tools
jmap -dump:live,format=b,file=/tmp/bibliotech.hprof 14237

Warning: a dump causes a pause proportional to the heap size (an 8 GB heap can stop the process for several seconds) and produces a file the size of the heap. In production, with care and preferably on an instance taken out of the load balancer.

jcmd: the Swiss army knife

It is the modern tool that subsumes all the others:

jcmd 14237 help
GC.class_histogram
GC.heap_dump
GC.heap_info
GC.run
JFR.start
JFR.dump
Thread.print
VM.flags
VM.native_memory
VM.system_properties
VM.uptime
jcmd 14237 GC.heap_info
jcmd 14237 Thread.print              # thread dump: deadlocks (08-04)
jcmd 14237 VM.flags                  # effective JVM options
jcmd 14237 GC.class_histogram
jcmd 14237 VM.native_memory summary  # requires -XX:NativeMemoryTracking=summary

Thread.print is the first thing to run when something hangs. It shows the stack of every thread and detects deadlocks automatically:

Found one Java-level deadlock:
=============================
"client-42":
  waiting to lock monitor 0x00007f... (object 0x000000071ab2, a java.lang.Object),
  which is held by "client-17"
"client-17":
  waiting to lock monitor 0x00007f... (object 0x000000071ab3, a java.lang.Object),
  which is held by "client-42"

It is exactly the deadlock from 08-04, seen from the outside.

jconsole and VisualVM

Graphical tools for live monitoring: memory, threads, classes, CPU and JMX MBeans. jconsole ships with the JDK; VisualVM is downloaded separately and is more complete, with profiling and heap dump analysis.

jconsole 14237

To analyse heap dumps seriously, Eclipse MAT (Memory Analyzer Tool) is the reference: it computes the retained size of each object (how much memory would be freed if it disappeared) and has an automatic leak-suspects report that gets it right most of the time.

  1. Java Flight Recorder

JFR is the most powerful tool of the set, and the least well known.

It is an event collection engine built into the JVM with an overhead of around 1 %, low enough to leave it always on in production. It records thousands of event types: allocations, GC, locking, I/O, exceptions, JIT compilation, CPU usage.

# At start-up
java -XX:StartFlightRecording=duration=120s,filename=/tmp/bibliotech.jfr \
     -jar bibliotech.jar

# On a running process
jcmd 14237 JFR.start name=diagnostics settings=profile duration=120s \
     filename=/tmp/bibliotech.jfr

jcmd 14237 JFR.check
jcmd 14237 JFR.dump name=diagnostics filename=/tmp/snapshot.jfr
jcmd 14237 JFR.stop name=diagnostics

The predefined profiles are default (~1 % overhead, fit for continuous production use) and profile (~2 %, more detail, for one-off diagnostics).

Analysing the recording from the command line:

jfr summary /tmp/bibliotech.jfr
 Event Type                        Count    Size (bytes)
=========================================================
 jdk.ObjectAllocationSample        18421         589472
 jdk.ExecutionSample                9204         294528
 jdk.GCPhasePause                    412          13184
 jdk.JavaMonitorEnter                287          14924
 jdk.SocketRead                      194           9312
 jdk.ThreadPark                      142           5680
# Specific events
jfr print --events GCPhasePause /tmp/bibliotech.jfr | head -30
jfr print --events ObjectAllocationSample /tmp/bibliotech.jfr | head -40
jfr print --events JavaMonitorEnter /tmp/bibliotech.jfr

And JDK Mission Control (JMC) is the graphical application that analyses .jfr files with automatic reports: CPU hot spots, allocation sites, lock contention, GC pauses, I/O latencies.

Why JFR is better than a traditional profiler:

Instrumenting profiler JFR
Overhead 10 % - 100 % or more ~1 %
Distortion of the results High: it alters what it measures Minimal
Use in production Not advised Designed for it
JVM events Does not see them GC, JIT, safepoints, locks
Continuous recording Difficult Yes, with a circular buffer

The recommended production configuration:

java -XX:StartFlightRecording=disk=true,maxsize=512m,maxage=12h,\
settings=default,filename=/var/log/bibliotech/recording.jfr \
     -XX:FlightRecorderOptions=repository=/var/log/bibliotech/jfr \
     -jar bibliotech.jar

With that, when an incident occurs you will have the last 12 hours of events recorded instead of having to wait for it to happen again.

  1. Why a homemade microbenchmark lies

In 10-04 and 10-06 you wrote homemade test benches and we said, twice, that they were indicative and that only JMH gives reliable results. Time to explain why.

package com.nexussoftware.bibliotech.diagnostics;

public class MisleadingBenchmark {

    public static void main(String[] args) {

        long start = System.nanoTime();

        for (int i = 0; i < 100_000_000; i++) {
            Math.sqrt(i);                          // we "measure" sqrt
        }

        long ns = System.nanoTime() - start;
        System.out.printf("100 million sqrt in %.2f ms%n", ns / 1_000_000.0);
    }
}
100 million sqrt in 3.41 ms

A hundred million square roots in 3.4 milliseconds. That is 34 picoseconds per operation, well below the time a single clock cycle takes. The result is impossible, and the reason is that the JIT compiler removed the entire loop: the result of Math.sqrt(i) is not used, so it is dead code.

The five problems of a homemade microbenchmark:

  1. Warm-up

The JVM starts by interpreting the bytecode. Only after thousands of executions does the JIT compile the method into optimised machine code. Measuring the first executions measures the interpreter, not the real code, and can be 10 or 100 times slower.

  1. Dead-code elimination

If the JIT proves that a computation does not affect the observable result, it removes it. That is exactly what happened above.

  1. Constant folding

If the inputs are constants known at compile time, the JIT computes the result once and substitutes it:

// The JIT can replace this with  int r = 5050;
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;

  1. Collector effects

A GC that happens during the measurement adds its pause to the result. A measurement that "came out slow" may simply be one that coincided with a GC.

  1. System noise

Other processes, CPU frequency scaling, the operating system scheduler, the CPU cache still cold. The variance between identical runs can exceed 20 %.

A decent homemade bench mitigates some of these, and it is what we did in 10-04:

public class LessBadBenchmark {

    /** volatile: stops the JIT removing the computation */
    private static volatile double sink;

    public static void main(String[] args) {
        // 1. WARM-UP
        for (int r = 0; r < 10; r++) {
            measure();
        }
        // 2. Several repetitions, and the MEDIAN (less sensitive to spikes than the mean)
        long[] times = new long[11];
        for (int r = 0; r < times.length; r++) {
            times[r] = measure();
        }
        java.util.Arrays.sort(times);
        System.out.printf("Median: %.2f ms%n", times[times.length / 2] / 1_000_000.0);
    }

    private static long measure() {
        long start = System.nanoTime();
        double accumulated = 0;
        for (int i = 0; i < 100_000_000; i++) {
            accumulated += Math.sqrt(i);
        }
        sink = accumulated;          // consume the result
        return System.nanoTime() - start;
    }
}
Median: 312.47 ms

A hundred times slower than the "impossible" measurement. This number is plausible: about 3 nanoseconds per square root. But it is still not reliable: it does not control the GC, it does not isolate noise, it does not compute confidence intervals and the volatile introduces a cost of its own.

  1. JMH: measuring properly

JMH (Java Microbenchmark Harness) is the official tool, developed by the same team that builds the JVM, and the only serious way to measure Java code.

What it does for you:

  • Runs warm-up iterations until the JIT has stabilised the code.
  • Uses Blackhole to consume results in a way the JIT cannot optimise away.
  • Forks processes (@Fork) so that compiling one test does not contaminate the next.
  • Runs multiple iterations and computes mean, deviation and confidence intervals.
  • Avoids constant folding with @State.
  • Reports memory allocation per operation.
package com.nexussoftware.bibliotech.benchmark;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class BiblioTechBenchmark {

    @Param({"100", "10000", "1000000"})
    private int size;

    private List<Book> catalog;

    @Setup
    public void prepare() {
        catalog = IntStream.range(0, size)
                .mapToObj(i -> new Book(String.format("978-%010d", i),
                                        "Title " + i, "Author " + (i % 100),
                                        200 + (i % 400), 3.0 + (i % 20) / 10.0))
                .collect(Collectors.toCollection(ArrayList::new));
    }

    @Benchmark
    public long withLoop() {
        long total = 0;
        for (Book b : catalog) {
            if (b.getPages() > 400) {
                total += b.getPages();
            }
        }
        return total;
    }

    @Benchmark
    public long withStream() {
        return catalog.stream()
                .filter(b -> b.getPages() > 400)
                .mapToLong(Book::getPages)
                .sum();
    }

    @Benchmark
    public long withParallelStream() {
        return catalog.parallelStream()
                .filter(b -> b.getPages() > 400)
                .mapToLong(Book::getPages)
                .sum();
    }

    /** Blackhole: consumes the value so the JIT cannot remove it. */
    @Benchmark
    public void grouping(Blackhole bh) {
        bh.consume(catalog.stream()
                .collect(Collectors.groupingBy(Book::getAuthor, Collectors.counting())));
    }
}
mvn clean package
java -jar target/benchmarks.jar BiblioTechBenchmark -prof gc
Benchmark                          (size)  Mode  Cnt      Score      Error  Units
BiblioTechBenchmark.withLoop          100  avgt   20      0.082 ±    0.003  us/op
BiblioTechBenchmark.withLoop        10000  avgt   20      8.914 ±    0.142  us/op
BiblioTechBenchmark.withLoop      1000000  avgt   20   1102.412 ±   28.331  us/op
BiblioTechBenchmark.withStream        100  avgt   20      0.341 ±    0.012  us/op
BiblioTechBenchmark.withStream      10000  avgt   20     11.208 ±    0.201  us/op
BiblioTechBenchmark.withStream    1000000  avgt   20   1284.904 ±   31.204  us/op
BiblioTechBenchmark.withParallelStream 100 avgt   20     12.041 ±    1.412  us/op
BiblioTechBenchmark.withParallelStream 10000 avgt 20     28.114 ±    2.031  us/op
BiblioTechBenchmark.withParallelStream 1000000 avgt 20  241.882 ±   18.442  us/op

Benchmark                          (size)  Mode  Cnt      Score  Units
withStream:gc.alloc.rate.norm       10000  avgt   20    128.004  B/op
withLoop:gc.alloc.rate.norm         10000  avgt   20      0.001  B/op

Five conclusions, and all of them useful:

1. With 100 elements, the loop is 4× faster than the stream. Setting up the stream pipeline has a fixed cost of about 250 nanoseconds that dominates when there is little work.

2. With a million, the difference drops to 16 %. The fixed cost is amortised and what remains is the per-element overhead of the lambdas.

3. The parallel version is 50× SLOWER with 100 elements and 4.5× faster with a million. Exactly what you anticipated in 10-04, now with reliable numbers.

4. The stream allocates 128 bytes per operation; the loop, zero. That is the real cost of the pipeline, and it is a figure no homemade bench gives you.

5. The error margin (±) is what makes the measurement credible. 1102.412 ± 28.331 means the difference from 1284.904 ± 31.204 is real and not noise. Without that interval, comparing two bare numbers means nothing.

And the design lesson that comes out of this: the difference between a loop and a stream is 16 % over a million elements. If your code is not in a hot loop, pick whichever reads better. The clarity of stream().filter().sum() is worth far more than 180 microseconds in a report that runs once a day.

  1. The JIT compiler

What remains is to explain why Java code speeds up over time.

Java follows a mixed compilation model:

graph LR
    A["Source code<br/>.java"] -->|"javac"| B["Bytecode<br/>.class"]
    B --> C["Interpreter<br/>slow, instant start-up"]
    C -->|"counts invocations<br/>and jumps"| D{"Hot spot?"}
    D -->|"no"| C
    D -->|"yes, C1 threshold"| E["JIT C1<br/>fast compilation<br/>light optimisation"]
    E -->|"still hot<br/>C2 threshold"| F["JIT C2<br/>slow compilation<br/>AGGRESSIVE optimisation"]
    F -->|"assumption invalidated"| C

The stages:

  1. Interpretation. At start-up, the JVM executes the bytecode instruction by instruction. It is slow but it begins immediately.
  2. C1 (client). After a few thousand invocations, the method is compiled quickly with light optimisations. It is instrumented to gather statistics.
  3. C2 (server). If it is still hot (tens of thousands of executions), C2 recompiles it with aggressive optimisations based on the profile collected.

That transition is called tiered compilation and is on by default. It explains the characteristic behaviour of a Java application: it starts slow and speeds up over the first few minutes.

Seeing it:

java -XX:+PrintCompilation -jar bibliotech.jar 2>&1 | head -20
    112    1       3       java.lang.String::hashCode (49 bytes)
    118    2       3       java.util.HashMap::hash (20 bytes)
    134    5       4       java.lang.String::equals (65 bytes)
    891   142       4       com...LoanManager::lend (218 bytes)
   1204   198       3       com...BiblioTechStatistics::countByType (94 bytes)
   2841   142       4       com...LoanManager::lend (218 bytes)   made not entrant

The columns are: milliseconds since start-up, compilation id, level (1-3 is C1, 4 is C2) and the method. That made not entrant at the end is a deoptimisation.

The main optimisations

Inlining. It replaces a method call with its body, removing the cost of the call and — more importantly — opening the door to other optimisations by seeing the complete code:

// You write
public int totalPages(List<Book> books) {
    int total = 0;
    for (Book b : books) {
        total += b.getPages();       // a call to a getter
    }
    return total;
}

// The JIT inlines getPages() and generates something equivalent to
for (Book b : books) {
    total += b.pages;                // direct field access
}

That is why getters cost nothing in Java, contrary to what many people assume: the JIT always inlines them.

Escape analysis. If the JIT proves that an object does not escape the method, it can remove it entirely, placing its fields in registers:

public double distance(int x1, int y1, int x2, int y2) {
    Point a = new Point(x1, y1);      // does not escape
    Point b = new Point(x2, y2);      // does not escape
    return Math.hypot(a.x() - b.x(), a.y() - b.y());
}
// The JIT may NOT CREATE the two objects: zero heap allocation

This explains why short-lived records (10-06) and the intermediate Optionals of streams (10-04) are usually free: the JIT makes them disappear.

Speculation and deoptimisation. The JIT bets based on what it has observed. If a Material has always been a Book, it compiles a direct, monomorphic call. If a Magazine shows up tomorrow, the assumption is invalidated: the method is marked made not entrant and goes back to being interpreted and recompiled.

This has a surprising practical consequence: code that has seen few distinct types runs faster. A polymorphic method always called with the same implementation is optimised thoroughly; the same method with five implementations alternating cannot be optimised in the same way.

Other optimisations: dead-code elimination, loop unrolling, constant folding, copy propagation, vectorisation with SIMD instructions, and removal of bounds checks when the compiler proves the index is in range.

Practical consequences

Effect Consequence
Slow start-up The first request can be 10-100× slower. It matters in serverless functions and in CLIs
Warm-up required Any measurement must warm up first
Simple code optimises better Small methods get inlined; huge ones do not
Fewer types = faster Excessive polymorphism prevents speculation
The JVM beats you at writing tricks Manual optimisations that get in the JIT's way make the result worse

The slow start-up is mitigated with AppCDS (sharing preloaded classes), with AOT (ahead-of-time compilation), or with GraalVM Native Image, which compiles to a native binary with millisecond start-up and less memory, at the cost of losing the dynamic optimisations and having to declare all reflection (10-03).

  1. Performance good practices, by impact

Ordered from highest to lowest real impact. The first points are worth orders of magnitude; the last ones, percentages.

VERY HIGH impact: the algorithm and the data structure

Back to module 5. This is worth more than everything else put together.

// O(n·m): for every material, walk ALL the loans
for (Material m : materials) {                        // n = 10,000
    for (Loan l : loans) {                            // m = 50,000
        if (l.getIsbn().equals(m.getIsbn())) { ... }
    }
}
// 500,000,000 comparisons

// O(n+m): index first
Map<String, Loan> byIsbn = loans.stream()
        .collect(Collectors.toMap(Loan::getIsbn, l -> l, (a, b) -> a));

for (Material m : materials) {
    Loan l = byIsbn.get(m.getIsbn());                 // O(1)
}
// 60,000 operations: 8,000 times fewer

And the choice of collection:

Operation ArrayList LinkedList HashMap TreeMap
Access by index O(1) O(n)
Lookup by value O(n) O(n) O(1) O(log n)
Insert at the end O(1) amortised O(1) O(1) O(log n)
Insert at the front O(n) O(1)
Full traversal Very fast (contiguous memory) Slow (jumps) Medium Medium

And ArrayList beats LinkedList almost always, even where the theory says otherwise, because of cache locality: contiguous elements are read in a block, whereas LinkedList jumps all over the heap. You saw this in 10-04 with parallelism.

HIGH impact: avoiding unnecessary work

// BAD: it compiles the regular expression on EVERY call
public boolean isValidIsbn(String isbn) {
    return isbn.matches("978-\\d{10}");     // an internal Pattern.compile each time
}

// GOOD: compiled once
private static final Pattern ISBN = Pattern.compile("978-\\d{10}");

public boolean isValidIsbn(String isbn) {
    return ISBN.matcher(isbn).matches();
}
// BAD: a query inside the loop
for (Loan l : loans) {
    Material m = catalog.queryDatabase(l.getIsbn());              // N queries
}

// GOOD: one batch query
Map<String, Material> materials = catalog.queryBatch(
        loans.stream().map(Loan::getIsbn).distinct().toList());

The N+1 problem is, by a wide margin, the most common performance problem in enterprise applications. You will meet it by name in 11-03 with Hibernate.

HIGH impact: reusing expensive objects

public class BiblioTechServices {

    // Immutable and thread-safe: ONE per application (10-05, 09-06)
    private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
    private static final Pattern ISBN = Pattern.compile("978-\\d{10}");
    private static final HttpClient HTTP = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5)).build();
}

Creating an HttpClient per request (09-06) multiplies latency by four and leaks threads. Creating a DateTimeFormatter per CSV line multiplies the export time.

And the exception to the rule: SimpleDateFormat cannot be reused across threads (10-05). But since you no longer use it, the problem does not exist.

MEDIUM impact: StringBuilder in loops

// BAD: quadratic. Each + creates a new String, copying everything
String report = "";
for (Loan l : loans) {
    report += l.getId() + ";" + l.getEmployee() + "\n";
}

// GOOD: linear
StringBuilder sb = new StringBuilder(loans.size() * 48);
for (Loan l : loans) {
    sb.append(l.getId()).append(';').append(l.getEmployee()).append('\n');
}
String report = sb.toString();

// EVEN BETTER if it fits: streams (10-04)
String report = loans.stream()
        .map(l -> l.getId() + ";" + l.getEmployee())
        .collect(Collectors.joining("\n"));

An important nuance: a concatenation outside a loop is not a problem. The compiler turns "a" + b + "c" into an efficient call (with invokedynamic and StringConcatFactory since Java 9). The problem is specifically the loop, where the copying is quadratic.

MEDIUM impact: avoiding autoboxing

// BAD: 10 million Long objects
Long sum = 0L;
for (int i = 0; i < 10_000_000; i++) {
    sum += i;                           // unbox, add, BOX
}

// GOOD: zero objects
long sum = 0L;
for (int i = 0; i < 10_000_000; i++) {
    sum += i;
}

// And in streams
int total = catalog.stream().mapToInt(Book::getPages).sum();       // IntStream

MEDIUM impact: initial size of collections

// Resizes ~14 times on the way to 10,000, copying each time
List<Loan> list = new ArrayList<>();

// A single allocation
List<Loan> list = new ArrayList<>(10_000);

// HashMap: capacity / load factor, so it does not rehash
Map<String, Loan> map = new HashMap<>((int) (10_000 / 0.75f) + 1);

The micro-tricks that do NOT help

"Optimisation" Reality
i++ versus ++i in a loop Identical after compiling
Counting-down loops (for (i = n; i-- > 0;)) No measurable difference
Avoiding getters by accessing the field The JIT inlines them: identical
Marking methods final "so they get optimised" The JIT already works it out
System.gc() to "free memory" It makes things worse: it forces a full GC
Reusing objects so as not to create garbage Counterproductive: it promotes objects to the old generation
x >> 1 instead of x / 2 The compiler already does it, and it reads worse
Concatenating with StringBuilder outside a loop Unnecessary since Java 9

  1. String, interning and StringBuilder, measured

A section of its own because strings are the most used type and the one that consumes the most memory in a typical application — remember the histogram in section 17: byte[] and String in the top two places.

The string pool and interning

public class StringPool {

    public static void main(String[] args) {

        String a = "Effective Java";                         // literal: to the POOL
        String b = "Effective Java";                         // the SAME pooled object
        String c = new String("Effective Java");             // a NEW object on the heap
        String d = c.intern();                               // looks it up in the pool

        System.out.println("a == b: " + (a == b));           // true
        System.out.println("a == c: " + (a == c));           // false
        System.out.println("a == d: " + (a == d));           // true
        System.out.println("a.equals(c): " + a.equals(c));   // true

        // COMPILE-TIME concatenation: it is a literal
        String e = "Effective " + "Java";
        System.out.println("a == e: " + (a == e));           // true

        // RUNTIME concatenation: a new object
        String part = "Effective ";
        String f = part + "Java";
        System.out.println("a == f: " + (a == f));           // false
    }
}
a == b: true
a == c: false
a == d: true
a.equals(c): true
a == e: true
a == f: false

This is why strings are compared with equals and never with == (01-04), and now you know exactly why: == compares references, and two strings with the same content can be different objects.

intern() has a legitimate and narrow use: when you read millions of repeated strings from a file or a database, interning them saves memory because all the repetitions share one object. But the pool lives on the heap and has a lookup cost; do not use it without measuring.

String compaction (Java 9). Internally, String went from char[] (2 bytes per character) to byte[] plus an encoding flag: Latin-1 strings — most of them — take half the space. It is transparent and was one of the biggest memory improvements in the history of the JDK.

Concatenation, measured

package com.nexussoftware.bibliotech.benchmark;

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5) @Measurement(iterations = 10) @Fork(2)
public class StringBenchmark {

    @Param({"10", "100", "1000", "10000"})
    private int n;

    @Benchmark
    public String concatWithPlus() {
        String result = "";
        for (int i = 0; i < n; i++) {
            result += "LN-2026-" + i + ";";           // QUADRATIC
        }
        return result;
    }

    @Benchmark
    public String withStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append("LN-2026-").append(i).append(';');
        }
        return sb.toString();
    }

    @Benchmark
    public String withSizedStringBuilder() {
        StringBuilder sb = new StringBuilder(n * 16);   // estimated capacity
        for (int i = 0; i < n; i++) {
            sb.append("LN-2026-").append(i).append(';');
        }
        return sb.toString();
    }
}
Benchmark                                   (n)  Mode  Cnt        Score      Error  Units
StringBenchmark.concatWithPlus               10  avgt   20        0.412 ±    0.011  us/op
StringBenchmark.concatWithPlus              100  avgt   20        8.204 ±    0.204  us/op
StringBenchmark.concatWithPlus             1000  avgt   20      512.891 ±   14.201  us/op
StringBenchmark.concatWithPlus            10000  avgt   20    48204.112 ± 1204.441  us/op
StringBenchmark.withStringBuilder            10  avgt   20        0.198 ±    0.008  us/op
StringBenchmark.withStringBuilder           100  avgt   20        1.412 ±    0.041  us/op
StringBenchmark.withStringBuilder          1000  avgt   20       14.208 ±    0.312  us/op
StringBenchmark.withStringBuilder         10000  avgt   20      148.904 ±    3.204  us/op
StringBenchmark.withSizedStringBuilder    10000  avgt   20      104.412 ±    2.118  us/op

StringBenchmark.concatWithPlus:gc.alloc.rate.norm     10000  avgt  20  512048912.0  B/op
StringBenchmark.withStringBuilder:gc.alloc.rate.norm  10000  avgt  20     524312.0  B/op

Read the table carefully, because it is the best demonstration in the whole section:

n With + With StringBuilder Factor
10 0.41 µs 0.20 µs
100 8.2 µs 1.4 µs
1,000 513 µs 14.2 µs 36×
10,000 48,204 µs 149 µs 324×

The factor grows with n, and that is the signature of a difference in complexity: concatenating with + is O(n²) because every + copies the whole accumulated string; StringBuilder is amortised O(n).

And the memory allocation is the damning figure: 512 megabytes versus 524 kilobytes for the same resulting string. A thousand times more garbage generated, with all the GC work that implies.

Sizing the StringBuilder saves a further 30 % by avoiding resizes.

And the conclusion to take away: with n = 10 the difference is irrelevant and not worth worrying about. With n = 10,000 it is the difference between 149 microseconds and 48 milliseconds. The same code change is irrelevant or critical depending on the context — and only measurement tells you which.

Common Mistakes and Tips

1. Optimising without measuring. The king of mistakes. Intuition about where the time goes is bad even among experts. Profile first.

2. Believing a homemade microbenchmark says anything. With no warm-up, without consuming the result and without repetitions, the JIT removes the code and you measure nothing. Use JMH.

3. Confusing the heap with the process memory. -Xmx2g does not mean "2 GB in total": the metaspace, code cache, thread stacks and direct buffers are missing. It is the number one cause of OOMKilled in containers.

4. Calling System.gc(). It is a suggestion, it forces a full GC with a long pause and it almost never helps. Disable it in production.

5. Catching OutOfMemoryError in order to continue. The JVM is left in an indeterminate state. Log it and exit.

6. Believing the collector counts references. It uses reachability from roots, which is why cycles are not a problem and why a single reference from a static field keeps an entire graph alive.

7. Caches with no eviction policy. A cache that only grows is not a cache: it is leak number one.

8. ThreadLocal without remove() in a pool. Pool threads do not die, so neither does the value. A leak, plus data leaking between requests.

9. Non-static inner classes and lambdas that capture this. They retain the whole outer instance. Make them static when they do not need the outer state.

10. Using finalize. Deprecated, not guaranteed and it delays collection. AutoCloseable for closing, Cleaner as a safety net.

11. Copying JVM tuning options from the internet. Many are deprecated or removed, they interact with each other and the whole set is usually worse than the defaults. Touch -Xmx, -Xms and the collector, and only with data.

12. Reusing objects "so as not to create garbage". Short-lived objects are almost free in the young generation. Reusing them promotes them to the old generation, where they really do cost.

13. Worrying about + on strings outside a loop. Since Java 9 the compiler handles it efficiently. The problem is the loop.

Tip 1: set the diagnostic options from day one. -XX:+HeapDumpOnOutOfMemoryError, a rotated GC log and continuous JFR cost no performance and are the difference between resolving an incident in an hour or never.

Tip 2: learn to read a GC log. Five minutes with jstat -gcutil or a GC log answer "is there a leak?" better than a day of guesswork. Look for the pattern of retained memory climbing like a staircase.

Tip 3: the right data structure is worth more than all the micro-optimisations put together. Swapping O(n²) for O(n) improves things a thousandfold; swapping i++ for ++i improves nothing.

Tip 4: write clear code first. The JIT optimises simple code better, and clear code is the code you can optimise later when measurement asks for it. Unreadable "hand-optimised" code is usually slower and is always harder to maintain.

Tip 5: if you cannot explain why a change improves things, do not make it. And if it improves things but you do not know why, measure it in another context before generalising.

Exercises

Exercise 1: leak detector and diagnostics

Build a leak laboratory for BiblioTech:

  1. A LeakLab class that reproduces the four patterns from section 12, each one in an isolated method.
  2. A MemoryMonitor that, on a separate thread, records every second the memory used after a GC (using Runtime and System.gc(), justifying why it is allowed here) and automatically detects the monotonic-growth pattern.
  3. For each leak, print the retained memory at the start and at the end, plus the detector's verdict.
  4. Implement the corrected version of each leak and show that the detector no longer flags it.
  5. Add instructions for producing a heap dump with jcmd and what to look for in it.

Exercise 2: a card cache with three strategies, measured

Implement and compare three cache strategies for BiblioTech's cards:

  1. UnboundedCache (a plain HashMap, the leak).
  2. LruCache (a bounded LinkedHashMap).
  3. WeakCache (a WeakHashMap).

For each one measure, with 100,000 accesses following a realistic distribution (80 % of the accesses over 20 % of the ISBNs):

  • Hit rate.
  • Memory retained after a GC.
  • Total time.
  • Surviving entries after memory pressure.

Produce a comparison table and a reasoned recommendation. Document why the measurement is homemade and what it would take to do it with JMH.

Exercise 3: a BiblioTech performance report

Write PerformanceReport, a self-diagnosis tool that BiblioTech can run in production:

  1. Memory state by region, using ManagementFactory (MemoryMXBean, MemoryPoolMXBean).
  2. GC statistics per collector (GarbageCollectorMXBean): number of collections, total time and percentage of uptime.
  3. Thread state (ThreadMXBean): totals, daemons, peak, and deadlock detection.
  4. Classes loaded and unloaded (ClassLoadingMXBean).
  5. JIT compilation time (CompilationMXBean).
  6. A health indicator (green/amber/red) with justified rules.
  7. A method that starts a JFR recording on demand with jcmd.

All of it in a sealed record per section, with exhaustive switch expressions and text blocks for the report (10-06).

Solutions

Solution 1

package com.nexussoftware.bibliotech.diagnostics;

import java.util.*;
import java.util.function.Consumer;

/**
 * Reproduces the four classic leak patterns and their fixes.
 * This is NOT production code: it is a teaching laboratory.
 */
public class LeakLab {

    // ==================================================================
    // LEAK 1: a collection that grows without limit
    // ==================================================================

    static class LeakyCache {
        // static + never cleared = a GC root that retains everything
        private static final Map<String, byte[]> CACHE = new HashMap<>();

        static void use(String key) {
            CACHE.computeIfAbsent(key, k -> new byte[10 * 1024]);      // 10 KB
        }
        static int size()  { return CACHE.size(); }
        static void clear() { CACHE.clear(); }
    }

    static class BoundedCache {
        private static final int CAPACITY = 500;

        private static final Map<String, byte[]> CACHE =
                new LinkedHashMap<>(CAPACITY, 0.75f, true) {
                    @Override
                    protected boolean removeEldestEntry(Map.Entry<String, byte[]> e) {
                        return size() > CAPACITY;
                    }
                };

        static void use(String key) {
            CACHE.computeIfAbsent(key, k -> new byte[10 * 1024]);
        }
        static int size()  { return CACHE.size(); }
        static void clear() { CACHE.clear(); }
    }

    // ==================================================================
    // LEAK 2: listeners that are never unsubscribed
    // ==================================================================

    static class EventManager {
        private static final List<Consumer<String>> SUBSCRIBERS = new ArrayList<>();

        static void subscribe(Consumer<String> s)   { SUBSCRIBERS.add(s); }
        static void unsubscribe(Consumer<String> s) { SUBSCRIBERS.remove(s); }
        static int howMany()                        { return SUBSCRIBERS.size(); }
        static void clear()                         { SUBSCRIBERS.clear(); }
    }

    /** LEAK: it subscribes with a lambda capturing this and never unsubscribes. */
    static class LeakyWindow {
        private final byte[] heavyData = new byte[100 * 1024];      // 100 KB

        LeakyWindow() {
            EventManager.subscribe(event -> handle(event));         // captures this
        }
        private void handle(String e) { /* uses heavyData */ }
    }

    /** CORRECT: it keeps the reference and offers close(). */
    static class CorrectWindow implements AutoCloseable {
        private final byte[] heavyData = new byte[100 * 1024];
        private final Consumer<String> subscriber;

        CorrectWindow() {
            this.subscriber = this::handle;
            EventManager.subscribe(subscriber);
        }
        private void handle(String e) { }

        @Override public void close() { EventManager.unsubscribe(subscriber); }
    }

    // ==================================================================
    // LEAK 3: ThreadLocal in a pool
    // ==================================================================

    static class LeakyContext {
        private static final ThreadLocal<byte[]> CONTEXT = new ThreadLocal<>();

        static void process() {
            CONTEXT.set(new byte[500 * 1024]);       // 500 KB
            // no remove(): the pool thread retains it forever
        }
    }

    static class CorrectContext {
        private static final ThreadLocal<byte[]> CONTEXT = new ThreadLocal<>();

        static void process() {
            CONTEXT.set(new byte[500 * 1024]);
            try {
                // real work
            } finally {
                CONTEXT.remove();                     // MANDATORY
            }
        }
    }

    // ==================================================================
    // LEAK 4: a non-static inner class
    // ==================================================================

    static class HeavyCatalog {
        private final byte[] materials = new byte[2 * 1024 * 1024];    // 2 MB

        /** NOT static: it retains HeavyCatalog.this */
        class LeakyCounter {
            private int count;
            void increment() { count++; }
        }

        /** static: it retains nothing from the outer class */
        static class CorrectCounter {
            private int count;
            void increment() { count++; }
        }

        LeakyCounter createLeaky()             { return new LeakyCounter(); }
        static CorrectCounter createCorrect()  { return new CorrectCounter(); }
    }

    // ==================================================================
    // MONITOR
    // ==================================================================

    /**
     * Measures RETAINED memory (after a GC), not used memory.
     *
     * Here System.gc() IS justified: this is a diagnostic tool,
     * not production code, and we need to know how much memory
     * survives a collection.
     */
    static class MemoryMonitor {

        private final List<Long> samples = new ArrayList<>();
        private final String name;

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

        long sample() {
            System.gc();
            try { Thread.sleep(120); } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            Runtime rt = Runtime.getRuntime();
            long used = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
            samples.add(used);
            return used;
        }

        /** Monotonic growth in at least 80 % of the intervals = a leak. */
        boolean detectsLeak() {
            if (samples.size() < 4) {
                return false;
            }
            int growing = 0;
            for (int i = 1; i < samples.size(); i++) {
                if (samples.get(i) > samples.get(i - 1)) {
                    growing++;
                }
            }
            double ratio = (double) growing / (samples.size() - 1);
            long delta = samples.get(samples.size() - 1) - samples.get(0);
            return ratio >= 0.8 && delta > 5;          // a 5 MB threshold
        }

        String verdict() {
            long first = samples.get(0);
            long last = samples.get(samples.size() - 1);
            return String.format("%-26s %4d MB -> %4d MB  (Δ %+4d MB)  %s",
                    name, first, last, last - first,
                    detectsLeak() ? "*** LEAK DETECTED ***" : "stable");
        }
    }

    // ==================================================================

    public static void main(String[] args) throws Exception {

        System.out.println("Maximum heap: "
                + Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB");
        System.out.println("Run with: java -Xmx512m LeakLab");
        System.out.println("=".repeat(74));

        testLeak1();
        testLeak2();
        testLeak3();
        testLeak4();

        System.out.println("=".repeat(74));
        System.out.println("""

                DIAGNOSIS WITH REAL TOOLS
                ─────────────────────────
                1. Locate the process:
                     jps -lvm

                2. Check whether the old generation grows without falling (column O):
                     jstat -gcutil <PID> 1000 30

                3. Histogram of live objects, twice a few minutes apart:
                     jcmd <PID> GC.class_histogram | head -20
                   The class whose count keeps growing is the suspect.

                4. Full dump for analysis:
                     jcmd <PID> GC.heap_dump /tmp/bibliotech.hprof

                5. Open the .hprof with Eclipse MAT:
                     - "Leak Suspects Report" names the culprit in 80 % of cases
                     - Sort by "Retained Heap": how much each object would free
                     - "Path to GC Roots" on the suspect: WHO is retaining it
                       (excluding weak and soft references)
                """);
    }

    private static void testLeak1() {
        System.out.println("\n--- LEAK 1: an unbounded collection ---");

        var monitor = new MemoryMonitor("Unbounded HashMap");
        monitor.sample();
        for (int round = 0; round < 5; round++) {
            for (int i = 0; i < 2_000; i++) {
                LeakyCache.use("isbn-" + round + "-" + i);
            }
            monitor.sample();
        }
        System.out.println(monitor.verdict());
        System.out.println("  Retained entries: " + LeakyCache.size());
        LeakyCache.clear();

        var monitor2 = new MemoryMonitor("Bounded LinkedHashMap");
        monitor2.sample();
        for (int round = 0; round < 5; round++) {
            for (int i = 0; i < 2_000; i++) {
                BoundedCache.use("isbn-" + round + "-" + i);
            }
            monitor2.sample();
        }
        System.out.println(monitor2.verdict());
        System.out.println("  Retained entries: " + BoundedCache.size() + " (cap 500)");
        BoundedCache.clear();
    }

    private static void testLeak2() {
        System.out.println("\n--- LEAK 2: listeners ---");

        var monitor = new MemoryMonitor("Without unsubscribing");
        monitor.sample();
        for (int round = 0; round < 5; round++) {
            for (int i = 0; i < 500; i++) {
                new LeakyWindow();             // created, discarded... and retained
            }
            monitor.sample();
        }
        System.out.println(monitor.verdict());
        System.out.println("  Live subscribers: " + EventManager.howMany());
        EventManager.clear();

        var monitor2 = new MemoryMonitor("With try-with-resources");
        monitor2.sample();
        for (int round = 0; round < 5; round++) {
            for (int i = 0; i < 500; i++) {
                try (var w = new CorrectWindow()) {
                    // use it
                }
            }
            monitor2.sample();
        }
        System.out.println(monitor2.verdict());
        System.out.println("  Live subscribers: " + EventManager.howMany());
    }

    private static void testLeak3() throws Exception {
        System.out.println("\n--- LEAK 3: ThreadLocal in a pool ---");

        var pool = java.util.concurrent.Executors.newFixedThreadPool(50);

        var monitor = new MemoryMonitor("ThreadLocal without remove");
        monitor.sample();
        for (int round = 0; round < 4; round++) {
            var latch = new java.util.concurrent.CountDownLatch(200);
            for (int i = 0; i < 200; i++) {
                pool.submit(() -> { LeakyContext.process(); latch.countDown(); });
            }
            latch.await();
            monitor.sample();
        }
        System.out.println(monitor.verdict());
        System.out.println("  50 threads × 500 KB retained ≈ 25 MB that never get freed");

        var monitor2 = new MemoryMonitor("ThreadLocal with remove");
        monitor2.sample();
        for (int round = 0; round < 4; round++) {
            var latch = new java.util.concurrent.CountDownLatch(200);
            for (int i = 0; i < 200; i++) {
                pool.submit(() -> { CorrectContext.process(); latch.countDown(); });
            }
            latch.await();
            monitor2.sample();
        }
        System.out.println(monitor2.verdict());

        pool.shutdown();
    }

    private static void testLeak4() {
        System.out.println("\n--- LEAK 4: a non-static inner class ---");

        List<Object> counters = new ArrayList<>();

        var monitor = new MemoryMonitor("NON-static inner class");
        monitor.sample();
        for (int round = 0; round < 4; round++) {
            for (int i = 0; i < 20; i++) {
                HeavyCatalog catalog = new HeavyCatalog();          // 2 MB
                counters.add(catalog.createLeaky());                // retains the 2 MB
            }
            monitor.sample();
        }
        System.out.println(monitor.verdict());
        System.out.println("  " + counters.size()
                + " 16-byte counters retaining " + (counters.size() * 2) + " MB");
        counters.clear();

        var monitor2 = new MemoryMonitor("Static inner class");
        monitor2.sample();
        for (int round = 0; round < 4; round++) {
            for (int i = 0; i < 20; i++) {
                HeavyCatalog catalog = new HeavyCatalog();
                counters.add(HeavyCatalog.createCorrect());         // retains nothing
            }
            monitor2.sample();
        }
        System.out.println(monitor2.verdict());
        System.out.println("  " + counters.size()
                + " counters retaining no catalogue at all");
    }
}
Maximum heap: 512 MB
Run with: java -Xmx512m LeakLab
==========================================================================

--- LEAK 1: an unbounded collection ---
Unbounded HashMap            12 MB ->  116 MB  (Δ +104 MB)  *** LEAK DETECTED ***
  Retained entries: 10000
Bounded LinkedHashMap        14 MB ->   19 MB  (Δ   +5 MB)  stable
  Retained entries: 500 (cap 500)

--- LEAK 2: listeners ---
Without unsubscribing        14 MB ->  272 MB  (Δ +258 MB)  *** LEAK DETECTED ***
  Live subscribers: 2500
With try-with-resources      14 MB ->   15 MB  (Δ   +1 MB)  stable
  Live subscribers: 0

--- LEAK 3: ThreadLocal in a pool ---
ThreadLocal without remove   15 MB ->   40 MB  (Δ  +25 MB)  *** LEAK DETECTED ***
  50 threads × 500 KB retained ≈ 25 MB that never get freed
ThreadLocal with remove      15 MB ->   16 MB  (Δ   +1 MB)  stable

--- LEAK 4: a non-static inner class ---
NON-static inner class       16 MB ->  176 MB  (Δ +160 MB)  *** LEAK DETECTED ***
  80 16-byte counters retaining 160 MB
Static inner class           17 MB ->   18 MB  (Δ   +1 MB)  stable
  80 counters retaining no catalogue at all

Comments. Four observations.

The detector looks for the right signature: monotonic growth of RETAINED memory. Measuring used memory would be useless, because it rises and falls with the rhythm of the GC. Measuring after forcing a GC leaves only what survives, and that is the only thing that matters for diagnosing a leak.

Leak 4 is the most disproportionate and the hardest to spot. Eighty sixteen-byte objects retain 160 MB. In a heap dump, the suspect shows up as a LeakyCounter with an enormous retained size, and the path to the root shows the synthetic this$0 field. That is exactly the information Eclipse MAT gives and no other tool does.

Leak 3 does not grow indefinitely: it stabilises at 25 MB. That is 50 threads × 500 KB, and there it stays. That makes it harder to detect (it does not trigger OutOfMemoryError) and more dangerous for another reason: the next request landing on that thread would see the previous one's context, with the corresponding security implication.

The jcmd and MAT instructions are the transferable part. The laboratory is didactic; in production you diagnose with the compared histogram and MAT's "Leak Suspects Report".

Solution 2

package com.nexussoftware.bibliotech.diagnostics;

import com.nexussoftware.bibliotech.domain.Card;

import java.util.*;
import java.util.function.Function;

/**
 * Comparison of three cache strategies.
 *
 * WARNING: a HOMEMADE measurement. The timings are indicative.
 * See the closing note about JMH.
 */
public class CacheComparison {

    /** Common contract. */
    interface Cache {
        Card get(String isbn, Function<String, Card> computation);
        int size();
        long hits();
        long misses();
        String name();

        default double hitRate() {
            long total = hits() + misses();
            return total == 0 ? 0 : hits() * 100.0 / total;
        }
    }

    /** STRATEGY 1: unbounded. This is the leak from section 12. */
    static class UnboundedCache implements Cache {
        private final Map<String, Card> map = new HashMap<>();
        private long hits, misses;

        public Card get(String isbn, Function<String, Card> computation) {
            Card c = map.get(isbn);
            if (c != null) { hits++; return c; }
            misses++;
            c = computation.apply(isbn);
            map.put(isbn, c);
            return c;
        }
        public int size()     { return map.size(); }
        public long hits()    { return hits; }
        public long misses()  { return misses; }
        public String name()  { return "Unbounded HashMap"; }
    }

    /** STRATEGY 2: bounded LRU. */
    static class LruCache implements Cache {
        private final int capacity;
        private final LinkedHashMap<String, Card> map;
        private long hits, misses;

        LruCache(int capacity) {
            this.capacity = capacity;
            this.map = new LinkedHashMap<>(capacity, 0.75f, true) {
                @Override protected boolean removeEldestEntry(Map.Entry<String, Card> e) {
                    return size() > LruCache.this.capacity;
                }
            };
        }
        public Card get(String isbn, Function<String, Card> computation) {
            Card c = map.get(isbn);
            if (c != null) { hits++; return c; }
            misses++;
            c = computation.apply(isbn);
            map.put(isbn, c);
            return c;
        }
        public int size()     { return map.size(); }
        public long hits()    { return hits; }
        public long misses()  { return misses; }
        public String name()  { return "LRU (" + capacity + ")"; }
    }

    /**
     * STRATEGY 3: weak keys.
     *
     * CAREFUL: the keys are Strings. If they came from the interned
     * pool they would NEVER be collected. Here they are built
     * dynamically, so they are indeed eligible.
     */
    static class WeakCache implements Cache {
        private final Map<String, Card> map = new WeakHashMap<>();
        private long hits, misses;

        public Card get(String isbn, Function<String, Card> computation) {
            Card c = map.get(isbn);
            if (c != null) { hits++; return c; }
            misses++;
            c = computation.apply(isbn);
            map.put(isbn, c);
            return c;
        }
        public int size()     { return map.size(); }
        public long hits()    { return hits; }
        public long misses()  { return misses; }
        public String name()  { return "WeakHashMap"; }
    }

    // ------------------------------------------------------------------

    private static final int ACCESSES = 100_000;
    private static final int DISTINCT_ISBNS = 20_000;
    private static final int HOT = DISTINCT_ISBNS / 5;          // the 20 %

    /** Simulated expensive computation. */
    private static Card compute(String isbn) {
        double accumulated = 0;
        for (int i = 1; i <= 2_000; i++) {
            accumulated += Math.sqrt(i);
        }
        return new Card(isbn, "Card " + isbn + " (" + (long) accumulated + ")", true);
    }

    /** An 80/20 distribution: 80 % of the accesses over 20 % of the keys. */
    private static List<String> generateAccesses(Random random) {
        List<String> accesses = new ArrayList<>(ACCESSES);
        for (int i = 0; i < ACCESSES; i++) {
            int index = random.nextInt(100) < 80
                    ? random.nextInt(HOT)
                    : HOT + random.nextInt(DISTINCT_ISBNS - HOT);
            accesses.add(String.format("978-%010d", index));
        }
        return accesses;
    }

    record Result(String name, double hitRate, long memoryMb,
                  long ms, int entries, int survivors) { }

    private static Result measure(Cache cache, List<String> accesses) throws Exception {

        long memoryBefore = retainedMemory();
        long start = System.nanoTime();

        for (String isbn : accesses) {
            cache.get(isbn, CacheComparison::compute);
        }

        long ms = (System.nanoTime() - start) / 1_000_000;
        long memoryAfter = retainedMemory();
        int entries = cache.size();

        // Memory pressure: allocate and release to force aggressive collections
        try {
            List<byte[]> pressure = new ArrayList<>();
            for (int i = 0; i < 200; i++) {
                pressure.add(new byte[1024 * 1024]);
            }
            pressure.clear();
        } catch (OutOfMemoryError e) {
            // expected: it is what we are after
        }
        retainedMemory();
        int survivors = cache.size();

        return new Result(cache.name(), cache.hitRate(),
                Math.max(0, memoryAfter - memoryBefore), ms, entries, survivors);
    }

    private static long retainedMemory() throws InterruptedException {
        System.gc();
        Thread.sleep(150);
        Runtime rt = Runtime.getRuntime();
        return (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
    }

    public static void main(String[] args) throws Exception {

        Random random = new Random(42);               // fixed seed: reproducible
        List<String> accesses = generateAccesses(random);

        System.out.printf("""
                Accesses: %,d over %,d distinct ISBNs (80/20 distribution)
                Maximum heap: %d MB
                """, ACCESSES, DISTINCT_ISBNS,
                Runtime.getRuntime().maxMemory() / 1024 / 1024);

        List<Result> results = new ArrayList<>();
        results.add(measure(new UnboundedCache(), accesses));
        results.add(measure(new LruCache(HOT), accesses));
        results.add(measure(new LruCache(1_000), accesses));
        results.add(measure(new WeakCache(), accesses));

        System.out.printf("%n%-22s %9s %9s %8s %10s %14s%n",
                "STRATEGY", "HITS", "MEMORY", "TIME", "ENTRIES", "AFTER PRESSURE");
        System.out.println("-".repeat(78));

        results.forEach(r -> System.out.printf("%-22s %8.1f%% %7d MB %6d ms %10d %14d%n",
                r.name(), r.hitRate(), r.memoryMb(), r.ms(),
                r.entries(), r.survivors()));

        System.out.println("""

                RECOMMENDATION
                ──────────────
                • Unbounded HashMap: the highest hit rate and the worst decision.
                  It retains EVERY entry indefinitely: it is leak 1 from
                  section 12. Only acceptable if the number of keys is bounded
                  by design (an enum, a fixed configuration list).

                • An LRU sized to the hot set: the best trade-off.
                  With an 80/20 distribution, an LRU the size of the hot 20 %
                  captures almost all the hits with a fraction of the memory.
                  IT IS THE DEFAULT CHOICE.

                • An undersized LRU: the hit rate collapses because the hot
                  entries evict one another (thrashing). Sizing a cache below
                  the working set is worse than not having one at all.

                • WeakHashMap: self-regulating memory, but UNPREDICTABLE
                  behaviour: entries vanish whenever the GC decides.
                  Its real use case is not "a bounded cache" but "metadata
                  associated with live objects I do not control" (section 14).

                ABOUT THIS MEASUREMENT
                ──────────────────────
                It is HOMEMADE and its numbers are indicative:
                  - There is not enough warm-up: the first strategies measured
                    pay for the JIT compilation of compute().
                  - System.gc() is a suggestion: the "retained" memory is
                    approximate.
                  - There are no repetitions and no confidence intervals.
                  - The order of execution matters (the first cache leaves the
                    heap in a different state for the next one).

                To measure properly: JMH with @State(Scope.Benchmark),
                @Setup(Level.Iteration) recreating the cache, @Fork(3) to isolate
                processes and -prof gc for allocation normalised per operation.
                """);
    }
}
Accesses: 100,000 over 20,000 distinct ISBNs (80/20 distribution)
Maximum heap: 512 MB

STRATEGY                    HITS    MEMORY     TIME    ENTRIES AFTER PRESSURE
------------------------------------------------------------------------------
Unbounded HashMap          80.0%      12 MB    412 ms      20000          20000
LRU (4000)                 79.4%       3 MB    438 ms       4000           4000
LRU (1000)                 61.2%       1 MB    891 ms       1000           1000
WeakHashMap                79.8%       9 MB    467 ms      19884              0

RECOMMENDATION
──────────────
• Unbounded HashMap: the highest hit rate and the worst decision.
  ...

Comments. Four points.

The LRU sized to the hot set achieves a 79.4 % hit rate with a quarter of the memory. With an 80/20 distribution, that is the sweet spot: capture the real working set and let the rest go.

The 1,000-entry LRU collapses to 61 % and takes twice as long. The hot set is 4,000 keys; with capacity for 1,000, the hot entries continuously evict one another (thrashing), and every miss costs a full computation. An undersized cache is worse than no cache, because it pays the management cost without delivering the benefit.

The WeakHashMap loses ALL its entries under memory pressure. That column is the key: it goes from 19,884 to 0. It is exactly the promised behaviour and exactly what makes it unsuitable as a performance cache: you cannot guarantee any hit rate.

And the "about this measurement" section is the most honest part of the exercise. Acknowledging the limitations of a homemade measurement is what separates a data point from an opinion with numbers on it. The hit percentages are reliable (they are deterministic); the timings are indicative.

Solution 3

package com.nexussoftware.bibliotech.diagnostics;

import java.lang.management.*;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;

/**
 * BiblioTech performance self-diagnosis.
 * It uses only the JDK management API: no external dependencies.
 */
public class PerformanceReport {

    // --- Report sections as a sealed type (10-06) ---

    public sealed interface Section {

        record Memory(long heapUsedMb, long heapMaxMb, long heapCommittedMb,
                      long nonHeapUsedMb, List<Pool> pools) implements Section { }

        record Pool(String name, String type, long usedMb, long maxMb) { }

        record Collection(List<GcStat> collectors, long totalTimeMs,
                          long uptimeMs) implements Section { }

        record GcStat(String name, long collections, long timeMs) { }

        record Threads(int live, int daemons, int peak, long totalStarted,
                       List<String> deadlocks) implements Section { }

        record Classes(long loaded, long totalLoaded, long unloaded) implements Section { }

        record Compilation(String compiler, long timeMs) implements Section { }
    }

    public enum Health { GREEN, AMBER, RED }

    // ------------------------------------------------------------------

    public Section.Memory collectMemory() {
        MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
        MemoryUsage heap = memory.getHeapMemoryUsage();
        MemoryUsage nonHeap = memory.getNonHeapMemoryUsage();

        List<Section.Pool> pools = ManagementFactory.getMemoryPoolMXBeans().stream()
                .map(p -> new Section.Pool(
                        p.getName(),
                        p.getType().toString(),
                        p.getUsage().getUsed() / 1024 / 1024,
                        p.getUsage().getMax() < 0 ? -1 : p.getUsage().getMax() / 1024 / 1024))
                .toList();

        return new Section.Memory(
                heap.getUsed() / 1024 / 1024,
                heap.getMax() / 1024 / 1024,
                heap.getCommitted() / 1024 / 1024,
                nonHeap.getUsed() / 1024 / 1024,
                pools);
    }

    public Section.Collection collectGc() {
        List<Section.GcStat> collectors =
                ManagementFactory.getGarbageCollectorMXBeans().stream()
                        .map(gc -> new Section.GcStat(
                                gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()))
                        .toList();

        long total = collectors.stream()
                .mapToLong(Section.GcStat::timeMs).sum();

        return new Section.Collection(collectors, total,
                ManagementFactory.getRuntimeMXBean().getUptime());
    }

    public Section.Threads collectThreads() {
        ThreadMXBean threads = ManagementFactory.getThreadMXBean();

        List<String> deadlocks = new ArrayList<>();
        long[] blocked = threads.findDeadlockedThreads();
        if (blocked != null) {
            for (ThreadInfo info : threads.getThreadInfo(blocked, true, true)) {
                deadlocks.add(String.format("%s (id %d) blocked on %s, held by %s",
                        info.getThreadName(), info.getThreadId(),
                        info.getLockName(), info.getLockOwnerName()));
            }
        }

        return new Section.Threads(
                threads.getThreadCount(), threads.getDaemonThreadCount(),
                threads.getPeakThreadCount(), threads.getTotalStartedThreadCount(),
                deadlocks);
    }

    public Section.Classes collectClasses() {
        ClassLoadingMXBean classes = ManagementFactory.getClassLoadingMXBean();
        return new Section.Classes(classes.getLoadedClassCount(),
                classes.getTotalLoadedClassCount(), classes.getUnloadedClassCount());
    }

    public Section.Compilation collectCompilation() {
        CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
        if (jit == null) {
            return new Section.Compilation("(not available)", 0);
        }
        return new Section.Compilation(jit.getName(),
                jit.isCompilationTimeMonitoringSupported() ? jit.getTotalCompilationTime() : -1);
    }

    // ------------------------------------------------------------------

    /** Health indicator with justified rules, via an exhaustive switch. */
    public Health assess(Section section) {
        return switch (section) {

            // Rule: above 90 % of the heap after a GC, a real risk of OOM
            case Section.Memory(var used, var max, var committed, var nonHeap, var pools) -> {
                double usage = max <= 0 ? 0 : used * 100.0 / max;
                yield usage > 90 ? Health.RED : usage > 75 ? Health.AMBER : Health.GREEN;
            }

            // Rule: more than 10 % of uptime in GC indicates a serious problem
            case Section.Collection(var collectors, var totalMs, var uptimeMs) -> {
                double percentage = uptimeMs == 0 ? 0 : totalMs * 100.0 / uptimeMs;
                yield percentage > 10 ? Health.RED : percentage > 5 ? Health.AMBER : Health.GREEN;
            }

            // Rule: a deadlock is ALWAYS red
            case Section.Threads(var live, var daemons, var peak, var started, var locks) -> {
                if (!locks.isEmpty()) yield Health.RED;
                yield live > 1_000 ? Health.AMBER : Health.GREEN;
            }

            // Rule: many classes loaded and none unloaded suggests a metaspace leak
            case Section.Classes(var loaded, var total, var unloaded) ->
                    loaded > 50_000 ? Health.AMBER : Health.GREEN;

            // A high JIT time is only informational
            case Section.Compilation c -> Health.GREEN;
        };
    }

    private String icon(Health h) {
        return switch (h) {
            case GREEN -> "[ OK ]";
            case AMBER -> "[WARN]";
            case RED   -> "[FAIL]";
        };
    }

    // ------------------------------------------------------------------

    public String generate() {
        var memory = collectMemory();
        var gc = collectGc();
        var threads = collectThreads();
        var classes = collectClasses();
        var jit = collectCompilation();

        RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
        OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean();

        StringBuilder sb = new StringBuilder();

        sb.append("""
                ══════════════════════════════════════════════════════════
                  PERFORMANCE REPORT -- BiblioTech
                ══════════════════════════════════════════════════════════
                  JVM:        %s %s
                  Vendor:     %s
                  System:     %s %s (%d processors)
                  Uptime:     %s
                  PID:        %d
                """.formatted(
                        runtime.getVmName(), runtime.getVmVersion(), runtime.getVmVendor(),
                        os.getName(), os.getArch(), os.getAvailableProcessors(),
                        formatDuration(runtime.getUptime()),
                        ProcessHandle.current().pid()));

        // --- MEMORY ---
        sb.append("\n%s MEMORY%n".formatted(icon(assess(memory))));
        sb.append("  Heap:     %,d MB used / %,d MB committed / %,d MB maximum (%.1f %%)%n"
                .formatted(memory.heapUsedMb(), memory.heapCommittedMb(),
                           memory.heapMaxMb(),
                           memory.heapMaxMb() <= 0 ? 0
                                   : memory.heapUsedMb() * 100.0 / memory.heapMaxMb()));
        sb.append("  Non-heap: %,d MB (metaspace, code cache)%n"
                .formatted(memory.nonHeapUsedMb()));
        sb.append("  Regions:%n");
        memory.pools().forEach(p -> sb.append("    %-28s %6d MB%s%n".formatted(
                p.name(), p.usedMb(), p.maxMb() < 0 ? "" : " / " + p.maxMb() + " MB")));

        // --- GC ---
        double gcPercentage = gc.uptimeMs() == 0 ? 0
                : gc.totalTimeMs() * 100.0 / gc.uptimeMs();
        sb.append("\n%s GARBAGE COLLECTION%n".formatted(icon(assess(gc))));
        gc.collectors().forEach(c -> sb.append(
                "  %-28s %,8d collections  %,8d ms  (mean %.1f ms)%n".formatted(
                        c.name(), c.collections(), c.timeMs(),
                        c.collections() == 0 ? 0.0 : (double) c.timeMs() / c.collections())));
        sb.append("  Total in GC: %,d ms out of %,d ms of uptime (%.2f %%)%n"
                .formatted(gc.totalTimeMs(), gc.uptimeMs(), gcPercentage));

        // --- THREADS ---
        sb.append("\n%s THREADS%n".formatted(icon(assess(threads))));
        sb.append("  Live: %d (%d daemons)   Peak: %d   Total started: %,d%n"
                .formatted(threads.live(), threads.daemons(), threads.peak(), threads.totalStarted()));
        if (threads.deadlocks().isEmpty()) {
            sb.append("  No deadlocks detected%n".formatted());
        } else {
            sb.append("  *** DEADLOCK DETECTED ***%n".formatted());
            threads.deadlocks().forEach(d -> sb.append("    ").append(d).append('\n'));
        }

        // --- CLASSES AND JIT ---
        sb.append("\n%s CLASSES%n".formatted(icon(assess(classes))));
        sb.append("  Loaded now: %,d   Historical total: %,d   Unloaded: %,d%n"
                .formatted(classes.loaded(), classes.totalLoaded(), classes.unloaded()));

        sb.append("\n%s JIT COMPILATION%n".formatted(icon(assess(jit))));
        sb.append("  Compiler: %s   Total time: %,d ms%n"
                .formatted(jit.compiler(), jit.timeMs()));

        // --- OPTIONS ---
        sb.append("\n  JVM OPTIONS%n".formatted());
        runtime.getInputArguments().forEach(a -> sb.append("    ").append(a).append('\n'));

        sb.append("""

                ──────────────────────────────────────────────────────────
                  NEXT STEP IF ANYTHING IS ON WARN OR FAIL
                    jstat -gcutil %d 1000 30       (is column O growing?)
                    jcmd %d GC.class_histogram     (which class is growing?)
                    jcmd %d Thread.print           (deadlocks and stacks)
                    jcmd %d JFR.start settings=profile duration=120s \\
                         filename=/tmp/bibliotech.jfr
                ══════════════════════════════════════════════════════════
                """.formatted(ProcessHandle.current().pid(), ProcessHandle.current().pid(),
                              ProcessHandle.current().pid(), ProcessHandle.current().pid()));

        return sb.toString();
    }

    private String formatDuration(long ms) {
        Duration d = Duration.ofMillis(ms);
        return "%dd %02dh %02dm %02ds".formatted(
                d.toDays(), d.toHoursPart(), d.toMinutesPart(), d.toSecondsPart());
    }

    /** Starts a JFR recording on this very process. */
    public void recordJfr(String file, int seconds) throws Exception {
        long pid = ProcessHandle.current().pid();
        var process = new ProcessBuilder("jcmd", String.valueOf(pid),
                "JFR.start", "name=bibliotech", "settings=profile",
                "duration=" + seconds + "s", "filename=" + file)
                .inheritIO().start();
        process.waitFor();
        System.out.println("JFR recording started: " + file);
    }

    public static void main(String[] args) throws Exception {
        PerformanceReport report = new PerformanceReport();

        // Generate some load so the figures are not all zero
        List<byte[]> garbage = new ArrayList<>();
        for (int i = 0; i < 500; i++) {
            garbage.add(new byte[512 * 1024]);
            if (i % 50 == 0) garbage.clear();
        }
        garbage.clear();

        System.out.println(report.generate());
    }
}
══════════════════════════════════════════════════════════
  PERFORMANCE REPORT -- BiblioTech
══════════════════════════════════════════════════════════
  JVM:        OpenJDK 64-Bit Server VM 21.0.1+12
  Vendor:     Eclipse Adoptium
  System:     Linux amd64 (8 processors)
  Uptime:     0d 00h 00m 02s
  PID:        18492

[ OK ] MEMORY
  Heap:     41 MB used / 258 MB committed / 4,096 MB maximum (1.0 %)
  Non-heap: 22 MB (metaspace, code cache)
  Regions:
    CodeHeap 'non-nmethods'             1 MB / 5 MB
    Metaspace                          14 MB
    CodeHeap 'profiled nmethods'        3 MB / 117 MB
    Compressed Class Space              1 MB / 1024 MB
    G1 Eden Space                      28 MB
    G1 Old Gen                         12 MB / 4096 MB
    G1 Survivor Space                   1 MB
    CodeHeap 'non-profiled nmethods'    1 MB / 117 MB

[ OK ] GARBAGE COLLECTION
  G1 Young Generation                   12 collections        41 ms  (mean 3.4 ms)
  G1 Old Generation                      0 collections         0 ms  (mean 0.0 ms)
  Total in GC: 41 ms out of 2,104 ms of uptime (1.95 %)

[ OK ] THREADS
  Live: 11 (9 daemons)   Peak: 11   Total started: 12
  No deadlocks detected

[ OK ] CLASSES
  Loaded now: 1,284   Historical total: 1,284   Unloaded: 0

[ OK ] JIT COMPILATION
  Compiler: HotSpot 64-Bit Tiered Compilers   Total time: 412 ms

  JVM OPTIONS
    -Xms256m
    -Xmx4g
    -XX:+UseG1GC

──────────────────────────────────────────────────────────
  NEXT STEP IF ANYTHING IS ON WARN OR FAIL
    jstat -gcutil 18492 1000 30       (is column O growing?)
    jcmd 18492 GC.class_histogram     (which class is growing?)
    jcmd 18492 Thread.print           (deadlocks and stacks)
    jcmd 18492 JFR.start settings=profile duration=120s \
         filename=/tmp/bibliotech.jfr
══════════════════════════════════════════════════════════

Comments. Four observations.

ManagementFactory gives access to everything with no external dependencies. It is the same information jconsole consumes, available from inside the process itself. An HTTP endpoint returning this report makes any application observable, and it is essentially what Spring Boot Actuator does (you will see it in 12-07).

The health rules are justified, not invented. More than 90 % of the heap after a GC means a real risk of OutOfMemoryError; more than 10 % of uptime in GC means the application spends more time cleaning than working; a deadlock is always red because there are threads that will never recover. A health indicator with arbitrary thresholds is worse than none: it generates alerts that get ignored.

Deadlock detection with findDeadlockedThreads() is the hidden gem of the API. It automatically detects the wait cycle from 08-04 and tells you which thread is waiting on which lock and who holds it. In a production incident with the application hung, those three lines give the complete diagnosis.

And the memory regions in the report are exactly the diagram from section 1, with their real names: G1 Eden Space, G1 Survivor Space, G1 Old Gen, Metaspace, Compressed Class Space and the three CodeHeaps of the JIT compiler. The black box now has names and numbers.

Conclusion

The JVM has stopped being a black box.

You know the memory regions and what lives in each one: the per-thread stack with its frames, its local variables and its StackOverflowError — which is what makes platform threads expensive and what the virtual threads of 10-06 solve by storing the stack on the heap; the shared heap where every object lives with its 12-to-16-byte overhead and its padding to multiples of 8; the metaspace in native memory that replaced PermGen; the JIT's code cache; and the native memory of direct buffers. And you know the formula that avoids the most expensive mistake in containers: -Xmx is not the process memory, and that is why -Xmx2g in a 2 GB container ends in OOMKilled. You tell the different OutOfMemoryErrors apart by their message — Java heap space, GC overhead limit exceeded, Metaspace, unable to create native thread, Direct buffer memory — because each one points to a different region and a different remedy.

You understand the generational model and the hypothesis it rests on: most objects die young. Hence eden, where allocating is a pointer increment, the survivor spaces, promotion after several survivals, and the key property: the cost of a minor GC is proportional to the live objects, not the dead ones. That is why creating temporary objects is almost free and why "reusing objects so as not to generate garbage" is usually counterproductive.

You know how the collector decides what to remove: reachability from the GC roots — the stacks of every thread, static fields, JNI references, live threads, monitors — not reference counting, and that is why cycles are not a problem in Java as they are in other languages. You know about stop-the-world pauses, safepoints and the time to safepoint that is sometimes the real culprit. And you can compare the collectors — Serial, Parallel, G1 by default with its regions and its pause target, ZGC and Shenandoah with sub-millisecond pauses, and Epsilon which collects nothing — knowing that 95 % of applications work fine with G1 and that out of more than a thousand options only three get touched: -Xmx, -Xms and the collector. Plus the diagnostic ones, which cost no performance and are the difference between resolving an incident and not resolving it.

And you know that memory leaks in Java exist, with a precise definition — reachable objects that will never be used again — and a recognisable signature: the memory retained after each GC climbs like a staircase until GC overhead limit exceeded. You recognise the four classic patterns, you have reproduced them and you have fixed them: the collection that grows and nobody empties (every cache needs an eviction policy), the listeners that are never unsubscribed (subscribe/unsubscribe symmetry), the ThreadLocal in a pool whose threads never die — a leak and a data leak between requests at the same time — and the non-static inner class where eighty sixteen-byte objects retain 160 MB through their this$0.

You know the four reference strengths and when to use each one, WeakHashMap with its three traps — the values are strong, clean-up is not immediate, interned literals are never collected — and its real use case, which is not "a bounded cache" but "metadata associated with live objects". And you know that finalize is deprecated for seven distinct reasons, that the real solution is AutoCloseable with try-with-resources, and that Cleaner is only the safety net — with its state class necessarily static, because if it retained the outer object it would never fire.

You have the method: measure before optimising. With a defined goal, profiling instead of intuition, optimisation of the bottleneck and reverting if it did not improve. With Amdahl's law as a reminder that infinitely speeding up something that takes 5 % of the time improves the total by 5 %.

And you have the tools: jps for the PID, jstat -gcutil whose growing O column betrays a leak in a minute, jmap -histo compared at two moments to point at the guilty class, jcmd with Thread.print which detects deadlocks automatically, heap dumps and Eclipse MAT with its retained size and its path to the roots, and above all JFR, with 1 % overhead, fit to leave recording permanently in production, so that when the incident happens you already have the last twelve hours logged.

You know why a homemade microbenchmark lies — you demonstrated it by measuring a hundred million square roots in 3.4 milliseconds, a physically impossible result because the JIT removed the whole loop — and you know the five reasons: warm-up, dead-code elimination, constant folding, GC and system noise. And you know the answer is JMH, with its warm-up, its Blackhole, its forked processes, its confidence intervals and its -prof gc — which told you that a stream allocates 128 bytes per operation where a loop allocates zero, and that the difference between the two over a million elements is 16 %: if you are not in a hot loop, pick whichever reads better.

You understand the JIT compiler and why your code speeds up on its own: interpretation, C1, C2, tiered compilation, and the optimisations that explain things you took for granted — inlining makes getters cost nothing, escape analysis makes objects that do not escape disappear (which is why temporary records and intermediate Optionals are usually free), and speculation with deoptimisation makes code that has seen few types run faster. And you know that this is where Java's slow start-up comes from, and that AppCDS, AOT and GraalVM Native Image exist to mitigate it.

And you have the good practices ordered by real impact: first the algorithm and the data structure — swapping O(n²) for O(n) improved things 8,000-fold in the example — then avoiding unnecessary work (the precompiled Pattern, the N+1 problem), reusing expensive objects (HttpClient, DateTimeFormatter, Pattern), StringBuilder in loops — measured: 324× faster and a thousand times less garbage with 10,000 elements, and irrelevant with 10 — avoiding autoboxing, and sizing collections. As against the micro-tricks that do not help: ++i, counting-down loops, avoiding getters, decorative final, System.gc() and object reuse.


BiblioTech, as module 10 closes, is a different project.

Its repositories are generic: a single Repository<T extends Identifiable> replaced the two hundred and fifty duplicated lines of ConcurrentCatalog and SafeLoanRegistry, with PECS applied and a Result<T> whose return type documents the contract.

Its entities carry their own annotations@CsvField, @Auditable, @Validate, @Retryable — and it has its own reflection engine to read them: an AnnotatedExporter that generates CSV for any entity without knowing it, an AnnotatedValidator, a dependency injection container with singletons and cycle detection, and two dynamic proxies that add auditing and retries without the business logic containing a single line of either.

Its reports are Streams: what in module 5 were seventy-four lines of nested loops with manual maps and null checks are now twelve lines of groupingBy that read like their own statement. And the null that meant "not found" has disappeared, replaced by Optional in every lookup and empty lists in every query.

Its dates are real: LocalDate in the loans, Instant in the audit trail, fines with ChronoUnit.DAYS.between, notices that land on a working day with TemporalAdjusters, CSV in ISO-8601 that any system in the world understands, and an injected Clock that makes tests deterministic.

Its domain is a sealed hierarchy where the compiler verifies you have covered every material, with loan statuses modelled as records where impossible combinations cannot be written, exhaustive switch expressions with no default, text blocks for the SQL and the JSON, and a CatalogServer with virtual threads that serves tens of thousands of connections with the same sequential code that used to serve fifty.

And now, on top of that, it can measure itself: it knows its own consumption per region, its time in GC, its threads, its classes, and it can detect its own deadlocks.


And yet, all of that is hand-made.

You wrote a hundred-and-fifty-line dependency injection container so you would not have to call new, and it works — but it has no scopes, no lifecycle, no per-environment configuration, no transaction management, none of what a real application needs. Spring has been solving that for twenty years.

Your persistence is still CSV files. With atomic writes, with an explicit charset, with a universal annotation-driven exporter — and it is still CSV. There are no queries, no indexes, no transactions, no referential integrity, and two processes writing at once break it. Relational databases have existed since 1970 and Hibernate maps your objects onto them.

You wrote an AnnotatedExporter to generate CSV and, in 09-06, a "teaching hack" with indexOf to read JSON that breaks on the first escape or the first nesting level. Jackson does that properly in one line. And your entities still carry fifty lines of getters, equals, hashCode and toString that Lombok generates with one annotation, using exactly the annotation processor you studied in 10-02.

Your logging is java.util.logging, chosen in 06-07 so as not to add dependencies, with the limitation already flagged there: the whole ecosystem uses SLF4J and a facade that lets you change implementation without touching the code.

Compiling and packaging BiblioTech is still javac by hand, with a hand-written classpath, no dependency management, no versions, no phases, no reproducibility. Maven solves that, and it is the reason the JMH benchmarks in this lesson turned up with an mvn package you could not run.

And the most serious thing of all: there is not a single automated test. Every check in this module has been a main that prints and a human who looks. Every refactoring — generics, streams, java.time, sealed classes, virtual threads — has been done with no safety net at all. That injectable Clock from 10-05, which we built precisely so the code could be tested, still does not test anything.

In module 11, Frameworks and Libraries, hand-writing everything comes to an end. You will see what a framework is and why inversion of control changes who calls whom. You will see Spring, and your hundred-and-fifty-line container will become the ApplicationContext with everything it was missing — and @Transactional will no longer look like magic, because you know it is a dynamic proxy. You will see Hibernate, and BiblioTech's CSV will become a real database with @Entity and @Id read by the same reflection you studied. You will see JUnit, and at last there will be tests: the fixed Clock, the edge cases of the fines, the exhaustiveness of the switch expressions, all verified automatically. You will see Maven, and compiling, managing dependencies, running tests and packaging will be a single command. You will see Mockito for testing what depends on the network and the database. And you will see the essential libraries of the ecosystem: Jackson, which will finally fix the JSON hack from 09-06; Lombok, whose annotation processor you already understand; and SLF4J, the logging facade that was missing.

Everything you have learned in this module is what makes the next one not magic. The frameworks of module 11 are built on generics, annotations, reflection, dynamic proxies, streams and a JVM whose behaviour you now know. You have written miniature versions of almost all of them with your own hands.

Now you are going to use the real ones.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved