Java and the JVM
Bytecode and warm-up, collections, garbage collection, virtual threads, and what erasure costs.
The JVM: bytecode, JIT and warm-up
beginnerJava 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.
What can a just-in-time compiler do that an ahead-of-time compiler cannot?
Evidence · 1 source
- 01GraalVM: 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.
Answers are generated and can be wrong. The topic above is the reviewed version.
Collections, equals and hashCode
intermediateThe 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 must equals and hashCode be overridden together?
Evidence · 1 source
- 01JEP 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.
Answers are generated and can be wrong. The topic above is the reviewed version.
Garbage collection and pauses
intermediateGarbage 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 is allocating short-lived objects cheap on the JVM?
Evidence · 2 sources
- 01JEP 248: make G1 the default garbage collector
That G1 became the default collector in Java 9, replacing the parallel collector.
- 02JEP 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.
Answers are generated and can be wrong. The topic above is the reviewed version.
Threads, executors and virtual threads
advancedJava 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.
What is the consequence of an executor with an unbounded queue?
Evidence · 2 sources
- 01JEP 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.
- 02JEP 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.
Answers are generated and can be wrong. The topic above is the reviewed version.
Generics, erasure and what survives to run time
advancedJava 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.
Which of these does erasure make impossible?
Evidence · 1 source
- 01Java 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.
Answers are generated and can be wrong. The topic above is the reviewed version.