The product is the runtime; the language is the front end
Java the language and Java the platform get used as synonyms, and nearly every property practitioners actually care about belongs to the platform. javac is a small and deliberately unambitious compiler. It type-checks, desugars a short list of constructs, and emits class files. It does almost no optimisation, because optimisation is not its job and it does not have the information to do it well. Every performance characteristic people attribute to Java - inlining through a virtual call, making an allocation disappear entirely, compacting a two-hundred-gigabyte heap without a visible pause, parking a blocked thread's stack somewhere other than an OS thread - happens later, in the runtime, against a program that is already executing.
That split is why "what should this service be written in" and "what should this service run on" are two questions rather than one. Kotlin, Scala, Clojure and Groovy are all viable production languages precisely because they emit the same class files and inherit the whole apparatus underneath: the same collectors, the same profilers, the same heap-dump format, the same flight recorder, the same operational playbook. Choosing one of them is a decision about syntax and type system, not about operations.
What follows is the map, not the territory: every section stays deliberately at survey height and points at whichever article carries the detail.
The class file is the actual contract
What comes out of a compile is not machine code and not source. It is a class file: a magic number, a class-file version, a constant pool of symbolic references, a flat list of fields and methods, and for each method a stack-based bytecode using roughly two hundred opcodes over an operand stack and an array of local variable slots. The encoding stays close to the source it came from - which is why decompilers work well on Java and poorly on most compiled languages - and it carries enough metadata alongside the instructions that reflection, annotations and generic signatures survive compilation even where the generic types themselves are erased.
The version number on that file is doing more work than it looks. A JVM refuses to load a class file whose version is newer than it understands, and that is the only refusal it makes on version grounds; anything older loads without complaint. That asymmetry is the entire backward-compatibility story of the ecosystem in one rule. A jar published a decade ago still links against code compiled today, and the reason a JAR from Maven Central is worth depending on at all is that this rule has been honoured almost without exception.
Before any of it executes, the JVM proves the bytecode is well-formed - stack depths consistent at every merge point, types compatible at every use, no jump into the middle of an instruction. Verification is why a hostile class file cannot forge a pointer, and it is the foundation the whole sandboxing and plugin-isolation story rests on. The loading and linking machinery around it is the class loading article.
Write once, run anywhere - what actually holds
The portable part is real and larger than sceptics allow: bytecode semantics, integer and floating-point behaviour, string and collection semantics, the threading and locking model, and the standard library's contracts are the same on every conforming JVM. What is not portable is everything that reaches outside the JVM. Native libraries loaded through JNI or the newer foreign function and memory API are per-platform binaries. The default charset and line separator differ. Path semantics and filesystem case sensitivity differ. The number of processors a container reports differs from the number the host has, and code that sized a thread pool from availableProcessors() has been bitten by that on every container platform. In practice the portability failures are almost never "the class file would not load" - they are an application that quietly assumed a Unix path separator or a UTF-8 default.
How code gets into a running VM
Classes enter the JVM lazily, one at a time, the first time something actually references them - not when the jar appears on the classpath. A service that ships fifty thousand classes may load eight, and that laziness is what makes fat dependency graphs affordable. Loading is performed by a class loader, and the pair of (name, loader) - not the name alone - is the runtime identity of a type, which is the mechanism behind every plugin system, every application server that hosts multiple applications, and every "same class, different version" isolation scheme in the ecosystem. It is also behind the classic redeploy leak, where one stale reference to an undeployed application's loader retains every class it ever defined.
The loader hierarchy, the upward delegation rule, the link phases between loading and initialisation, and the way modules changed all of it belong to the class loading article. The startup cost of doing this work on every boot, and the archive format that lets a JVM map pre-parsed metadata instead of re-parsing class files, is class data sharing.
Execution: interpret first, then compile what matters
Every method starts interpreted. The interpreter is slow per instruction but instant to start, needs no compilation budget, and - crucially - is instrumented. While it runs, it counts invocations and back-edges and records what is actually happening at each call site: which receiver types show up, which branches are taken, which fields never change after startup. When a method crosses a threshold, the runtime hands it to a compiler, and the profile goes with it.
The result is a tiered arrangement in which a fast, lightly-optimising compiler takes the first pass to get code to native speed quickly while continuing to collect profile data, and a slow, aggressive compiler takes the second pass using that data to speculate: inlining through calls that are currently monomorphic, folding fields that are currently constant, eliminating branches that have never been taken. Because those are bets rather than proofs, the runtime installs guards, and when a guard fails it deoptimises - discards the compiled code, reconstructs an interpreter frame mid-method, and starts over. The five tiers, the thresholds, the inlining decisions, the deopt mechanics and the code cache are the JIT article. The optimisation that removes allocations outright is escape analysis. The mechanism that lets the VM stop threads at a known-good point to swap code or move objects is safepoints. The alternative in which everything is compiled ahead of time and the profile is never observed is GraalVM and Native Image.
Memory: three budgets, only one of which is the heap
The single most common operational surprise on the JVM is a container killed for memory while the heap graph is flat. The heap - the part -Xmx bounds and the collector manages - is one of three budgets, and the full accounting (what each budget contains, how native memory tracking categorises it, how to size a container against the total) is the JVM memory article, with heap tuning covering how to pick the heap number itself. Class metadata occupies metaspace - native memory, sitting entirely outside the heap, with its own ceiling - and is reclaimed only once an entire cohort of a loader's classes becomes unreachable together; that is the metaspace article. Everything else is plain native memory: thread stacks (one per platform thread, sized in the hundreds of kilobytes to a megabyte), the JIT's code cache, direct byte buffers, memory-mapped files, the allocator's own fragmentation, and any native library you loaded. Sizing a container as heap-plus-a-little is how you get killed.
Within the heap, allocation is close to free - a pointer bump into a thread-local buffer - and the cost shows up later, when something has to determine what is still reachable and reclaim the rest. Every modern collector is a different answer to the same trilemma between pause time, throughput, and heap overhead: the GC architecture article covers the shared machinery - tri-colour marking, write barriers, generational assumptions, region-based heaps, moving objects while the application runs - with G1, ZGC and Shenandoah going deep on individual collectors.
Object layout is the part the language currently does not let you control. Every object carries a header, every non-primitive field is a reference, and an array of objects is an array of pointers to objects scattered across the heap rather than a packed block of data. That indirection is the single largest structural gap against languages with value types, and closing it is Project Valhalla.
Threads and the memory model
Java shipped with threads in the language and a memory model in the specification, which in the 1990s was unusual and is the reason the JVM became the default host for server workloads. A Java thread was historically a one-to-one wrapper over an OS thread: real, preemptively scheduled, expensive enough that you pool them, and limited to a few thousand per process. That constraint shaped two decades of library design - thread pools, callbacks, futures, and eventually whole reactive frameworks whose only purpose was to avoid blocking a thread. Virtual threads remove the constraint by scheduling many application threads onto a few carrier threads and unmounting a blocked stack onto the heap; the mechanics, the pinning failure modes, and what it means for existing code are the virtual threads article, with structured concurrency and scoped values covering the programming model built on top.
The memory model is the harder half and the part most engineers carry a wrong intuition about. It does not say a read sees the most recent write; it says nothing at all unless a happens-before relationship exists, and without one the compiler, the JIT and the hardware are all free to reorder. Correct concurrent Java is a matter of establishing those relationships deliberately - through locks, volatile fields, final-field guarantees, or the concurrent library's own contracts - not of code that "seems to work". The Java memory model article owns this, and the concurrency overview maps the primitives.
The module system, and what it really changed
The platform gained a module system that adds a layer above packages: a named module declares what it requires and which packages it exports, and the runtime enforces that graph rather than treating it as documentation. Accessibility became a runtime property, not just a compile-time one, and the flat classpath - a list of jars searched in order, with no notion of versions and no notion of who is allowed to see what - stopped being the only option.
The practical effect on application teams was not the part anyone expected. Most codebases never wrote a module-info and still felt the change, because the JDK itself was modularised. Internal packages that had always been reachable became inaccessible; reflective access into runtime internals moved from working-with-a-warning towards failing outright; libraries that poked at unsupported APIs had to find supported replacements. The migration cost was concentrated in frameworks and instrumentation, not in application logic. The lasting benefit for most teams is the ability to link a runtime image containing only the modules an application actually needs instead of shipping an entire JDK. The modules article covers the declaration model, and jlink covers the image-building side.
"Which Java version" is three questions wearing one coat
The platform moved from irregular multi-year releases to a time-boxed cadence, with a subset of releases designated for long-term support. The most useful thing to understand about that designation is that it is a vendor commitment rather than a technical property of the runtime. There is one upstream codebase; what differs between builds is who backports security and correctness fixes to an older release line, for how long, and under what licence. Several vendors ship builds of the same source with materially different support windows, and the honest answer to "which one" is usually determined by procurement and by what your base container images already carry. Because designations and windows shift, check the current position with the vendor rather than with any article, including this one. Which releases carry the designation, how long each support window actually runs, and what an upgrade between two of them costs in practice belong to the release cadence article; who ships which build, under which licence, with which backport policy, belongs to the vendor article.
The version question also conflates three independent choices. There is the class-file version you emit, which sets the oldest JVM that will accept your output. There is the JDK you compile and test with, which sets which APIs and language features you can use. And there is the JVM you deploy on, which determines your collectors, your JIT, your startup cost and your diagnostics. These do not have to match - compiling with a recent JDK while targeting an older release is routine - and separating them is how large codebases upgrade the runtime without a language migration.
Upgrades, when they hurt, almost never hurt because of the language. They hurt because something manipulates bytecode and does not recognise the new class-file version, because a JVM agent's assumptions broke, because reflective access into internals is now refused, or because a tuning flag was removed. The remediation list is a runtime list.
Builds, coordinates, and the flat classpath underneath
Java's dependency story is unusually boring in the good way, and the reason is a single shared namespace. Artifacts are identified by group, artifact and version, and the overwhelming majority of the open-source ecosystem publishes to one repository with immutable coordinates. A build declares direct dependencies; the tool walks each dependency's own metadata to assemble the transitive closure; the result is a classpath. Maven and Gradle both do this and both resolve conflicts, but they do not resolve them the same way - a nearest-declaration rule and a highest-version rule produce different classpaths from identical declarations, which is why "it builds under one tool and not the other" is a real category of bug. The exact conflict-resolution rules, the lifecycle and plugin models, the caching and incrementality behaviour, and what each tool does to a large multi-module build are the Maven article, the Gradle article and the Bazel article respectively.
What matters at this altitude is what the classpath becomes at runtime, because the resolution intelligence stops at the build boundary. The JVM receives an ordered list of jars with no version information whatsoever and loads the first class it finds under a given name. Two libraries needing incompatible versions of a third do not fail at build time; they fail at first use, deep in a request, as a NoSuchMethodError or an AbstractMethodError against a class that loaded perfectly well. The standard escape hatch is to relocate one copy into a private package namespace at build time, which works and is also why some jars are twenty megabytes. Auditing the closure for known vulnerabilities is a separate discipline - dependency checking - and it operates on the same coordinate graph.
Where the language is going - one story, not a feature list
Recent additions look like a grab bag until you group them by the project that produced them, at which point they resolve into three or four coherent efforts, each closing a specific structural gap.
The first is about modelling data honestly. Records give a transparent carrier for a fixed set of values with generated equality and accessors; sealed types let a hierarchy declare its complete list of permitted subtypes; pattern matching lets you test and destructure against those shapes in one expression, with the compiler able to prove exhaustiveness because the hierarchy is closed. Delivered separately, these are three conveniences. Delivered together, they are algebraic data types and a way to write code over them - which is why treating them as unrelated features leads to using records as mere boilerplate reduction and missing the point.
The second is about making concurrency cheap enough to write straightforwardly, which is the virtual threads work described above: the goal is not faster threads but permission to go back to blocking, sequential code that a stack trace can explain. The third is about the memory representation gap - Valhalla's flattened values, so that an array of small objects can be an array of data rather than an array of pointers. The fourth is about the boundary with native code and hardware: foreign memory and function access as a supported replacement for JNI, and the vector API for expressing SIMD portably. Startup and warmup have their own line of work, visible today in archived class metadata, checkpoint-and-restore, and Leyden.
Why it is still the default for large backends - and when it is not
The honest case has little to do with the language and a lot to do with what happens after deployment. The runtime is instrumented from the inside: a flight recorder built into the VM that can run continuously at low overhead, heap dumps you can carry off the host and walk offline months later, profilers that attach to a live process and attribute CPU and allocation to source lines without a redeploy, thread dumps that name every lock holder. JFR and async-profiler cover the tooling. Very few runtimes let an on-call engineer answer "what is this process doing right now" on a production instance without having planned for the question in advance.
Underneath that sits an operational maturity that is unglamorous and hard to replicate: the failure modes have names, the knobs exist and are documented, and the answers to "why did latency spike" are usually findable. There is also a genuine structural advantage for long-running services in how the code gets compiled. A profile-guided JIT optimises against the workload the process is actually serving - the receiver type that dominates this call site in this deployment, the config field that became constant after startup, the branch this tenant never takes - and can revert when reality changes. An ahead-of-time compiler has to be correct for every possible input and cannot specialise on any of them. For a process that lives for days, the warmup cost amortises to nothing and the specialisation is pure profit; for a process that lives for two hundred milliseconds, it is all cost and no profit. That trade, and the tooling that shifts it, is developed properly in the JIT article and the GraalVM article - this overview only claims that the trade is what the choice turns on.
Add compatibility as an economic property. A library published years ago still links, so the cost of a dependency does not compound the way it does in ecosystems that break at every major version, and a large codebase can upgrade its runtime and its libraries on separate schedules. Combined with the depth of the library ecosystem and the size of the hiring pool, that is most of why large organisations keep choosing it.
The counter-case is equally concrete. There is a resident memory floor - heap plus metaspace plus code cache plus a stack per platform thread - and below a certain container size the runtime's own overhead is a meaningful fraction of the budget. Short-lived processes are the wrong shape entirely: a command-line tool or a batch job measured in seconds exits before the JIT reaches steady state, and a cold serverless invocation pays startup where the user can see it. The ecosystem has answers - native compilation, checkpoint-and-restore, archived class metadata - and each buys startup by giving something up; those trades are covered in GraalVM, CRaC and CDS, and the right conclusion is that they are mitigations rather than a reason to pretend the problem does not exist. Finally, it is not a runtime you can operate without someone on the team who can read a GC log and a thread dump. On a small team shipping a small service, that operational tax can outweigh everything above.
The JVM is the product and the language is the front end. Almost every property that makes Java a defensible choice for a long-running backend - the profile-guided compiler, the collectors, the built-in observability, the class-file compatibility rule that keeps a decade-old jar linkable - lives in the runtime, which is also why "which Java version" is really a question about the JVM, and why the cases where Java is the wrong tool are exactly the ones where a process does not live long enough for the runtime to earn its keep.