Skip to content
← back to profile

Java and the JVM

Bytecode and warm-up, collections, garbage collection, virtual threads, and what erasure costs.

01

The JVM: bytecode, JIT and warm-up

beginner

Java compiles to bytecode rather than to machine code, and the JVM executes that bytecode on whatever hardware it finds. That indirection is the whole design: one artefact runs anywhere there is a JVM, and the machine code is produced at run time by a compiler that can see what the program is actually doing.

It starts by interpreting, then compiles the parts that run often. Methods and loops that pass a threshold are handed to the just-in-time compiler, first to a fast compiler that produces adequate code quickly, then to an optimising one for the small number of methods that dominate the profile. That is why a Java service is slow for its first few thousand requests and fast afterwards, and why a benchmark that does not warm up measures the interpreter.

Because the optimiser sees the running program, it can do things an ahead-of-time compiler cannot: inline a virtual call because only one implementation has ever been loaded, unroll a loop whose bounds are known in practice, remove a lock the escape analysis proves is only ever held by one thread. It also has to undo those decisions when a new class arrives and invalidates them, which is called deoptimisation and is why performance can change hours into a run.

Memory splits into the heap, shared and garbage collected, and per-thread stacks holding frames and local variables. An object lives on the heap and a reference to it lives on a stack, which is the source of the usual confusion about whether Java is pass-by-value. It is: the value being passed is the reference, so a method can mutate the object and cannot repoint the caller's variable.

The practical consequences are short. Measure after warm-up or do not measure. Expect startup to cost, which is why serverless Java leans on class data sharing and on ahead-of-time compilation with GraalVM. And treat the JVM's flags as a real interface: heap sizing and collector choice are the two settings that change behaviour most, and both have sensible defaults that are worth understanding before overriding.

why this choice

Almost everything surprising about Java performance comes from the code being compiled while it runs. It explains the warm-up, the benchmarks that lie, the optimisations no static compiler could make, and the moment three hours in when the profile shifts because something deoptimised.

in practice

GraalVM native images compile ahead of time to remove startup cost, and give up the profile-guided optimisation that makes long-running JVM services fast. That trade, fast start against fast steady state, is the clearest illustration of what the JIT is buying.

check yourself

A Java service is slow for its first few thousand requests. Why?

runs immediatelypast a thresholdstill hotassumption brokenBytecodeone artefactInterpreterfirst few thousand ca…Quick compileradequate code, fastOptimising compil…the hot few methodsDeoptimisationa new class invalidat…
Data storeServiceExternaldashed = asynchronousconsidered, not chosen
Interpreted first, compiled where it matters, undone when assumptions break
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
02

Collections, equals and hashCode

intermediate

The collections framework is small enough to hold in your head and the choices matter more than the syntax. ArrayList is a growable array: constant-time access by index, linear removal from the middle. LinkedList is almost never the right answer despite what its name suggests, because pointer chasing loses to cache locality on modern hardware. HashMap is the workhorse. TreeMap keeps keys sorted, which is what you want when the question includes everything between two values.

The contract that catches people is between equals and hashCode. Two objects that are equal must return the same hash code, or a HashMap will look in the wrong bucket and fail to find something it is holding. Override one and you must override the other, which is why records exist: a record generates both from its components and removes the whole category.

Mutable keys break the same machinery from the other direction. Put an object in a HashSet, change a field that participates in its hash, and the object is now in the wrong bucket: still present, unreachable by lookup, and it will not be found by contains even though iteration will show it. Keys should be immutable, which in practice means records or classes with final fields.

HashMap's implementation is worth one sentence of knowledge: buckets hold a linked list and convert to a balanced tree once enough entries collide, which turns a degenerate O(n) bucket into O(log n) and defuses the collision attack that this change was introduced to address.

Two habits save real time. Prefer the interface as the declared type, so List rather than ArrayList, because it keeps the implementation a choice rather than a commitment. And be deliberate about mutability: List.of returns an immutable list, Collections.unmodifiableList wraps a mutable one in a view that still changes underneath you, and confusing the two produces a defensive copy that defends nothing.

why this choice

The equals and hashCode contract is the one piece of Java that will silently lose data in a collection rather than throwing, and mutable keys are the version of it that survives code review. Both are removed by making key types immutable, which is what records were added for.

in practice

Records, standard since Java 16, generate equals, hashCode and toString from their components, which is why they are the default choice for a key or a value object. The hand-written versions are where the contract gets broken, usually by adding a field to one and not the other.

check yourself

Why is LinkedList rarely the right choice despite its complexity table?

placed on insertlooks in the wrong placeKey objecthashCode = 42Bucket 42entry stored hereField mutatedhashCode is now 87Immutable keyhash cannot moveLookup checks buc…present, unreachableFound
ClientData storeServiceExternaldashed = asynchronousconsidered, not chosen
The hash chooses the bucket, so changing it strands the entry
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
03

Garbage collection and pauses

intermediate

Garbage collection removes a category of bug rather than a category of work. Nothing is freed by hand, so use-after-free and double-free do not exist, and in exchange the runtime decides when to reclaim memory, which means it sometimes decides during a request you cared about.

The observation the collectors are built on is that most objects die young. Allocation happens in a small young region, collection there is cheap because it copies the few survivors rather than visiting the many dead, and anything that survives repeatedly is promoted to an older region collected far less often. That generational split is why allocating short-lived objects in Java is much cheaper than intuition suggests.

The collector you get by default is G1, which divides the heap into regions and collects those with the most garbage first, aiming at a pause time goal you can set rather than a fixed schedule. For workloads that cannot tolerate pauses at all, ZGC and Shenandoah do most of their work concurrently with the application and hold pauses to a small number of milliseconds regardless of heap size, at the cost of some throughput.

What actually causes trouble is rarely the collector's algorithm. It is a memory leak wearing a different name: a cache with no eviction, a listener never unregistered, a thread-local on a pooled thread, a static map that only grows. The heap fills, the collector runs more and more often to reclaim less and less, and the service spends its time collecting rather than working before it finally fails.

So the useful skill is reading the evidence rather than tuning flags. Allocation rate, pause time distribution, and the size of the old generation after each full collection: if that last number is rising over hours, you have a leak and no collector setting will help. A heap dump names the objects and what is holding them, which is the answer rather than the symptom.

why this choice

Tuning collector flags is where people start and it is almost never the fix. The two things worth knowing are that short-lived allocation is cheap by design, and that a rising post-collection heap size is a leak, which is a code problem that no amount of configuration will collect around.

in practice

G1 has been the default since Java 9, and ZGC exists for the case where pause time matters more than throughput, holding pauses low even on very large heaps. Choosing between them is a workload question; reaching for either before reading an allocation profile is a guess.

check yourself

What do ZGC and Shenandoah trade for their short pauses?

cheap to reclaimpromotionsize flat over hourssize rising over hoursAllocationyoung regionDies youngthe overwhelming majo…Survivescopied, then promotedOld generationcollected rarelyNever releasedcache with no evictionSteady after coll…
ClientData storeExternalServicedashed = asynchronousconsidered, not chosen
Most objects die young, and the exceptions are the problem
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
04

Threads, executors and virtual threads

advanced

Java threads are operating system threads, so each carries a stack measured in hundreds of kilobytes or more and a context switch costs the kernel real time. That is why a thread per request stopped scaling, why thread pools exist, and why a generation of frameworks went asynchronous and reactive to avoid blocking a thread on IO.

An executor separates the work from the thread running it, which is the right abstraction and comes with a decision people leave at its default: the queue. An unbounded queue turns overload into an out-of-memory failure rather than a rejection, which is the backpressure argument in its most concrete form. A bounded queue with a sensible rejection policy converts the same overload into an error the caller can act on.

Virtual threads, standard since Java 21, change the arithmetic underneath all of this. They are scheduled by the JVM onto a small pool of platform threads and unmount when they block, so a blocking call no longer occupies an operating system thread and a million concurrent tasks becomes reasonable. The point is that ordinary blocking code becomes the scalable style again, which removes most of the reason to write reactive pipelines that were hard to read and harder to debug.

There is a caveat worth carrying. Before Java 24 a virtual thread blocking inside a synchronized block pinned its carrier, so a small number of pinned threads could stall everything; JEP 491 removed that specific pin. Native calls and a few other cases can still pin, and the JVM emits an event when it happens, so the modern advice is to measure rather than to avoid synchronized on principle.

The memory model is the part no thread abstraction removes. Without synchronisation there is no guarantee that one thread sees another's write, at all, ever: the compiler and the processor may reorder, and a field read in a loop may be hoisted out of it. volatile, synchronized and the concurrent utilities all establish the happens-before relationships that make visibility defined, and none of them is optional because a test happened to pass.

why this choice

Virtual threads make the simple style the fast style again, which is a rare direction of travel. What they do not change is the memory model: shared mutable state still needs synchronisation for the change to be visible at all, and that is a correctness question rather than a performance one.

in practice

Virtual threads arrived as a standard feature in Java 21, and JEP 491 in Java 24 removed the synchronized pinning that was the most common way to lose their benefit. Both are worth knowing by version, because the advice about avoiding synchronized in virtual threads predates the fix.

check yourself

Why is the Java memory model a correctness concern rather than a performance one?

a stack eachmounted while runningfreed while waiting1,000,000 tasksPlatform threadsone each, stacks and …Virtual threadsscheduled by the JVMSmall carrier poolunmounts on blockingOut of memoryBlocking code, at…
ClientServiceEdge / CDNExternalData storedashed = asynchronousconsidered, not chosen
A blocking call no longer occupies an operating system thread
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.
05

Generics, erasure and what survives to run time

advanced

Java generics are checked at compile time and erased afterwards. A List of String and a List of Integer are the same class at run time, and the compiler inserts the casts that make it safe. That decision was made for compatibility, so that generic code could interoperate with the collections written before generics existed, and everything odd about them follows from it.

The visible consequences are a short list worth memorising. You cannot ask whether something is a List of String at run time, because the answer no longer exists. You cannot create an array of a generic type. You cannot overload two methods that differ only in their type parameter, because after erasure they have the same signature. And a cast to a generic type is unchecked, which is the compiler telling you it has stopped being able to help.

Wildcards are where the syntax earns its reputation and the rule behind them is simple: a producer you read from is declared with extends, a consumer you write to with super. A List of some subtype of Number can be read as Number and cannot be written to, because the compiler does not know which subtype it holds. Once that clicks, the signatures in the standard library stop looking arbitrary.

The pattern for recovering the erased type is to pass it explicitly, as a Class object or through the trick of subclassing a generic type so the parameter is recorded in the class file. Serialisation libraries do this constantly, which is why deserialising into a generic collection needs a type token rather than a class literal.

It is worth contrasting this with the alternative, because it explains a real performance gap. C# reifies generics, so a list of integers holds integers rather than boxed objects, while Java boxes every primitive stored in a collection: a list of a million ints is a million objects with headers, pointers and cache misses. Project Valhalla is the long-running effort to close that gap, and until it lands, primitive-heavy code uses arrays or a specialised library rather than the collections framework.

why this choice

Erasure buys compatibility with pre-generic code and charges for it at run time, in reflection that cannot see the type and in boxing that costs memory and locality. Knowing that the type is gone explains every awkward generic signature and every library that asks you to pass a type token.

in practice

Jackson and similar libraries take a TypeReference rather than a Class when deserialising into a generic collection, for exactly this reason: the parameter is erased, so it has to be recovered from a subclass that recorded it. That API shape is erasure showing through.

check yourself

Which of these does erasure make impossible?

erasedthe type is goneunless you carried itList<String>compile timeCompilerverifies, inserts cas…Listno parameter at run t…Reflectioncannot see StringType tokenpassed explicitly
ClientServiceData storeExternaldashed = asynchronousconsidered, not chosen
The type is checked, then discarded
ask about this
Answers are generated and can be wrong. The topic above is the reviewed version.