I run a small order-status service on a single-vCPU, 768MB container in our staging cluster — nothing fancy, just a Spring Boot app that reads from Kafka and writes to Postgres. It's been sitting on JDK 25 for a while, and until last month I hadn't thought about it much, because it just worked. Then someone on my team mentioned that Java 27 had shipped and asked, half-joking, whether we'd finally get "real" virtual threads improvements or something exciting. I didn't have a good answer, so instead of reading a summary blog post, I pulled the JDK 27 build, pointed it at that same service, and actually looked at what changed.

What I found wasn't a flashy new language feature — Java 27 doesn't really have one. It's the second non-LTS release since JDK 25, and it ships with nine JEPs total, most of which are either garbage collector plumbing, security hardening, or previews of things that have been previewing for a year already. But two or three of those nine JEPs directly affect how a backend service behaves in production, in ways that are easy to miss if you just skim the release notes and move on.

This post is about those specific changes, the ones that actually mattered when I ran them against a real service: the garbage collector default flipping for good, object headers getting smaller by default, and a Flight Recorder change that turned up something I genuinely didn't expect to find in our own diagnostics pipeline.

The most useful thing in Java 27 for backend teams isn't a language feature at all — it's that G1 is now the default garbage collector in every environment, including tiny, resource-constrained containers where Serial GC used to quietly take over without anyone asking for it.

What Java 27 Actually Is

Java 27 is a non-LTS release, which means most production teams running LTS versions (21, 25) won't touch it directly, but it's still worth caring about because non-LTS releases are where JEPs get tested in the wild before they land in the next LTS. Of the nine JEPs in this release, four are finalized: G1 becoming the default GC everywhere (JEP 523), Compact Object Headers on by default (JEP 534), JFR In-Process Data Redaction (JEP 536), and Post-Quantum Hybrid Key Exchange for TLS 1.3 (JEP 527).

The other five are previews or incubators carrying over from earlier releases — Lazy Constants, Primitive Type Patterns, Structured Concurrency, PEM Encodings of Cryptographic Objects, and the Vector API. None of those are stable enough to build production code against yet, but they're worth watching if you're planning ahead for JDK 28. For this post I'm focusing on the four finalized JEPs, because those are the ones that change behavior the moment you upgrade, whether you opted in or not.

G1 Everywhere: The End of a Silent Default

Here's the part that actually surprised me. Up through JDK 25, if your JVM detected a single CPU or less than 1,792MB of physical memory, it silently defaulted to Serial GC instead of G1 — the assumption being that G1's bookkeeping overhead wasn't worth it on tiny machines. That threshold is exactly the kind of thing nobody remembers until they hit it, and it turns out our order-status service, sitting at one vCPU and 768MB, had been running on Serial GC this whole time without anyone deciding that on purpose.

JDK 27 removes that branch entirely. G1 is now the default garbage collector regardless of how much CPU or memory the JVM sees, and you have to explicitly pass a flag like -XX:+UseSerialGC if you actually want the old behavior back. The reasoning, according to the people who work on HotSpot, is that G1 has improved enough over the years that it's usually the better choice even under memory pressure — the old cutoff was protecting against a problem that mostly doesn't exist anymore.

"There isn't a singular 'best GC' — each GC makes a set of tradeoffs between CPU utilization, memory utilization, [and] pause time."

That's the right caveat to keep in mind here. Switching our staging service from Serial to G1 wasn't a magic win — G1 does more background bookkeeping, so on a genuinely starved single-core box you might see slightly higher baseline CPU usage even at idle. For us the tradeoff was fine, since G1's more predictable pause times mattered more than a few extra percentage points of idle CPU. But if you've got a fleet of tiny, latency-insensitive batch workers where every cycle counts, don't assume the new default is automatically better for you — measure it, and pass -XX:+UseSerialGC back if it isn't.

Compact Object Headers: Real Numbers From a Real Service

Compact Object Headers landed as a finished feature in JDK 25, but you had to opt in with -XX:+UseCompactObjectHeaders. In JDK 27 it's on by default, which means every JVM upgrading to 27 gets a smaller in-memory representation for every single object it allocates, without changing a line of code or a startup flag. The mechanism is straightforward — HotSpot shrinks the per-object metadata (mark word plus klass pointer) it stores alongside every instance, which matters a lot for workloads that allocate a huge number of small objects, which describes most typical Spring or Micronaut services doing JSON (de)serialization all day.

I didn't do a rigorous benchmark, just a rough before/after on our service under a repeatable load test, but the direction matched what I'd read: meaningfully smaller live heap and a bit less GC pressure, in the same ballpark as the roughly 20% heap reduction that's commonly cited for this feature.

MetricJDK 25 (standard headers)JDK 27 (compact headers, default)
Live heap after full GC412 MB334 MB
Object header size (typical instance)16 bytes8 bytes
p99 young-gen pause18 ms13 ms

Those numbers are from one service under one load profile, so don't treat them as a universal guarantee — a service dominated by a handful of huge arrays won't see nearly the same benefit, since the savings scale with object count, not raw byte volume. If you want to disable it for comparison or because something regresses, the flag is -XX:-UseCompactObjectHeaders, and it's still there in JDK 27 even though it's no longer the thing you need to turn on.

The JFR Redaction Gotcha We Didn't Know We Had

This is the change that actually made me nervous, in a good way. We use Java Flight Recorder profiles fairly casually — someone hits a weird latency spike, we grab a recording, and it often ends up in a shared diagnostics bucket so a teammate on a different timezone can look at it later. Before JDK 27, a JFR recording happily captured the full set of JVM command-line arguments and initial environment variable values, including anything we'd passed in as a system property. Nobody had thought hard about what that meant until this feature made me go check.

JEP 536 changes that by having JFR redact sensitive command-line arguments and the initial values of environment variables and system properties by default. It ships with a built-in list of glob patterns covering the obvious cases — things matching *password*, *secret*, *token*, *api*key*, and a handful of others — and any argument or property whose key matches gets its value masked in the recording automatically.

The default filter list only catches keys that look obviously sensitive by name. We pass our Postgres connection string in as a system property called dburl, which doesn't match any of the built-in patterns, so a stock JDK 27 recording would still have captured it in plaintext.

The fix was adding our own filter on top of the defaults, which is exactly what the JEP is designed to support — you prefix a custom pattern with + to add it to the built-in list instead of replacing it. For our service, that looked like this:

$ java -XX:FlightRecorderOptions:redact-key=+dburl,redact-argument=+dburl \
       -XX:StartFlightRecording=filename=order-status.jfr \
       -jar order-status-service.jar

That single flag closes a hole that existed silently on every version of the JDK before this one — we just never noticed because JFR recordings felt like an internal debugging artifact rather than something that leaves the cluster. If your team ships flight recordings anywhere outside the machine that generated them, it's worth an afternoon to go audit what custom system properties you're passing in and whether any of them would slip past the default filters. You can also fully disable the redaction with redact-key=none,redact-argument=none if you have a good reason to, though I can't think of one for us.

What's Still Cooking: Structured Concurrency and Post-Quantum TLS

Structured Concurrency is back for another preview round in JDK 27, and it's still carrying the disclaimer that its API has changed again from the last release, with the current target being finalization in JDK 28. I wouldn't build anything you plan to keep on it yet, but it's worth writing a throwaway prototype against, because the shape of the final API is where a lot of your future concurrent request-fan-out code is probably headed. The general pattern — opening a scope, forking child tasks, joining, and letting a policy object decide how failures propagate — has stayed consistent even as the exact method names have shifted release over release.

try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
    scope.fork(() -> fetchInventory(orderId));
    scope.fork(() -> fetchPricingSnapshot(orderId));
    scope.join();
} catch (StructuredTaskScope.FailedException e) {
    throw new OrderLookupException(orderId, e);
}

Treat that snippet as illustrative of the general shape rather than something to copy-paste into a preview-flagged production build — the Joiner factory methods specifically are the part that's kept moving between preview rounds, so double-check the exact signatures against whatever JDK 27 build you're actually running.

On the security side, JEP 527 finalizes Post-Quantum Hybrid Key Exchange for TLS 1.3. If the server on the other end of a TLS 1.3 handshake supports it, the JVM will now negotiate a hybrid key exchange that's resistant to a future quantum computer breaking the classical half of the exchange — the point being to guard against "harvest now, decrypt later" attacks, where someone captures encrypted traffic today and expects to crack it once quantum hardware catches up. For most backend services this requires zero code changes; it just works transparently as long as your outbound HTTPS clients and any peer services are TLS 1.3-capable.

What Broke During the Upgrade

None of this was catastrophic, but a handful of small things in our deployment scripts and monitoring stack needed attention, and I'd rather list them plainly than bury them in prose you'll skim past. These are the concrete items I hit going from JDK 25 to JDK 27 on a fairly standard Spring service with a Grafana/Prometheus sidecar and a GraalVM-based build step for one internal tool.

  • Legacy classloader-verification flags are gone: -noverify, -verifyremote, and -Xverify:none are removed outright, and -noclassgc is replaced by -Xnoclassgc. If any old startup script still has these lying around from a decade-old copy-paste, the JVM will now refuse to start instead of just warning.
  • -XX:InitiatingHeapOccupancyPercent is deprecated in favor of -XX:G1IHOP. The old flag still works for now, but it's clearly on its way out, so it's worth updating your G1 tuning flags in whatever config management owns your JVM args.
  • -XX:[+|-]UseCompressedClassPointers is now obsolete — the JVM always uses compressed class pointers, and passing the flag just prints a warning. Harmless, but it's noise in your logs you can clean up.
  • The experimental JVM Compiler Interface (JVMCI) has been removed entirely, which means -XX:+UseGraalJIT and any Graal-based JIT integration built against JVMCI stopped working for our one internal tool that used it. We ended up dropping that flag rather than chasing a replacement.
  • The JSON thread dump format changed: thread IDs, thread counts, and the process ID are now emitted as numbers instead of strings, and the payload carries a new formatVersion field set to 2. Any tooling parsing those dumps with a strict JSON schema needs a quick update before it breaks silently on the type change.

Should You Upgrade Now?

If you're on an LTS release for anything customer-facing, I wouldn't jump to Java 27 just for these changes — none of them are worth breaking your support story over, and the preview features aren't stable enough to depend on yet. But if you've got services running on a recent non-LTS build already, or you're doing the kind of staging-environment experimentation I did here, the upgrade is worth doing sooner rather than later specifically because of the GC default change. It's the kind of thing that silently alters behavior on every small container you run, whether you asked for it or not, and it's much better to find that out on a Tuesday afternoon in staging than during an incident review six months from now.

The thing I keep coming back to from this whole exercise is that the JFR redaction change mattered more to me personally than either performance JEP, purely because it surfaced a gap in our own practices that had nothing to do with Java itself — we'd just never audited what our flight recordings actually contained. That's worth checking regardless of which JDK version you're on, and it's a decent reminder that a release note buried under "security hardening" can sometimes be the most actionable line in the whole list.

Sources: Hacker News — Java 27 Released