Skip to content

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.

Check yourself

What can a just-in-time compiler do that an ahead-of-time compiler cannot?

runs immediatelypast a thresholdstill hotassumption brokenBytecodeone artefactInterpreterfirst few thousandcallsQuick compileradequate code, fastOptimising compilerthe hot few methodsDeoptimisationa new classinvalidates it
Data storeServiceExternalDashed = asynchronousConsidered, not chosen
Interpreted first, compiled where it matters, undone when assumptions break
Evidence · 1 source
  1. 01
    GraalVM: native image

    That ahead-of-time compilation removes startup cost, and gives up the profile-guided optimisation a long-running JVM accumulates, which is the trade described here.

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.

Check yourself

Why must equals and hashCode be overridden together?

placed on insertlooks in the wrong placeKey objecthashCode = 42Bucket 42entry stored hereField mutatedhashCode is now 87Immutable keyhash cannot moveLookup: bucket 87present, unreachableFound
ClientData storeServiceExternalDashed = asynchronousConsidered, not chosen
The hash chooses the bucket, so changing it strands the entry
Evidence · 1 source
  1. 01
    JEP 395: records

    That records became a standard feature in Java 16 and generate equals, hashCode and toString from their components, which is what removes the hand-written contract bug.

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.

Check yourself

Why is allocating short-lived objects cheap on the JVM?

cheap to reclaimpromotionsize flat over hourssize rising over hoursAllocationyoung regionDies youngthe overwhelmingmajoritySurvivescopied, then promotedOld generationcollected rarelyNever releasedcache with noevictionSteady after GC
ClientData storeExternalServiceDashed = asynchronousConsidered, not chosen
Most objects die young, and the exceptions are the problem
Evidence · 2 sources
  1. 01
    JEP 248: make G1 the default garbage collector

    That G1 became the default collector in Java 9, replacing the parallel collector.

  2. 02
    JEP 377: ZGC, a scalable low-latency garbage collector

    That ZGC does most of its work concurrently and holds pauses low largely independently of heap size, at some cost in throughput.

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.

Check yourself

What is the consequence of an executor with an unbounded queue?

a stack eachmounted while runningfreed while waiting1,000,000 tasksPlatform threadsone each, stacks andswitchesVirtual threadsscheduled by the JVMSmall carrier poolunmounts on blockingOut of memoryBlocking, at scale
ClientServiceEdge / CDNExternalData storeDashed = asynchronousConsidered, not chosen
A blocking call no longer occupies an operating system thread
Evidence · 2 sources
  1. 01
    JEP 444: virtual threads

    That virtual threads became a standard feature in Java 21, are scheduled by the JVM onto a small pool of platform threads, and unmount when they block.

  2. 02
    JEP 491: synchronize virtual threads without pinning

    That blocking inside a synchronized block pinned the carrier thread before Java 24, and that this specific pin was removed in Java 24.

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.

Check yourself

Which of these does erasure make impossible?

erasedthe type is goneunless you carried itList<String>compile timeCompilerverifies, insertscastsListno parameter at runtimeReflectioncannot see StringType tokenpassed explicitly
ClientServiceData storeExternalDashed = asynchronousConsidered, not chosen
The type is checked, then discarded
Evidence · 1 source
  1. 01
    Java tutorials: type erasure and its restrictions

    That generic type parameters are erased after compilation, which is why the type cannot be recovered at run time and why a type token is needed to deserialise into a generic collection.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.