DEV Community

Aditya Rawas
Aditya Rawas

Posted on Originally published at adityarawas.in

Java 27 Explained: New Features, JVM Changes, and What It Means for Backend Developers

Originally published at adityarawas.in


Java 27 shipped this week, and if you've spent the last few years building on Node.js or Go while watching Java from a distance, this release is worth a second look. The JVM has quietly become one of the fastest-moving runtimes in the industry — six-month release cadence, aggressive feature previews, and a garbage collector that no longer makes anyone nervous about pause times. This isn't your enterprise-Java-from-2015 story anymore.

This deep-dive breaks down what actually changed in Java 27, why it matters if you're running polyglot infrastructure with Node.js or Go services, and how to think about JVM-based backends in a Docker-first, cloud-native world.

What's Actually New in Java 27

Java follows a strict six-month release train (March and September each year), and Java 27 is the September 2026 release. Unlike LTS releases (21, 25), Java 27 is a short-term feature release — but it's where the interesting previews graduate before landing in the next LTS (Java 29, expected 2028).

Structured Concurrency Goes Final

After multiple preview rounds since Java 21, Structured Concurrency (JEP scope, finalized in 27) is no longer a preview feature. This is arguably the biggest deal in the release.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<User> userTask = scope.fork(() -> fetchUser(userId));
    Supplier<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));

    scope.join();
    scope.throwIfFailed();

    return new UserProfile(userTask.get(), ordersTask.get());
}
Enter fullscreen mode Exit fullscreen mode

Compare this to how you'd write the equivalent in Node.js:

const [user, orders] = await Promise.all([
  fetchUser(userId),
  fetchOrders(userId),
]);
Enter fullscreen mode Exit fullscreen mode

Node's Promise.all has always made concurrent I/O trivial. Java's problem was never expressing concurrency — it was managing the lifecycle and error propagation of concurrent tasks cleanly. Structured concurrency finally gives Java a scoped, cancellation-aware model that doesn't leak threads when one branch fails. If you've dealt with orphaned threads in a CompletableFuture chain, you'll appreciate this.

Virtual Threads Are Now the Default for Executors

Virtual threads (Project Loom) became stable in Java 21, but Java 27 makes Executors.newVirtualThreadPerTaskExecutor() the recommended default for most server frameworks, including Spring Boot 4.x and Micronaut. The practical effect: you can write blocking-style code that scales like async code.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> handleRequest(i))
    );
}
Enter fullscreen mode Exit fullscreen mode

Ten thousand virtual threads cost you kilobytes, not megabytes. This directly attacks the reason people reached for Node.js's event loop or Go's goroutines in the first place — thread-per-request without the memory tax.

Pattern Matching for switch Gets Deconstruction Patterns

Record patterns now support nested deconstruction in switch, finalized after being in preview since Java 21/22.

record Point(int x, int y) {}
record Line(Point start, Point end) {}

static String describe(Object shape) {
    return switch (shape) {
        case Line(Point(var x1, var y1), Point(var x2, var y2))
            when x1 == x2 -> "vertical line";
        case Line l -> "diagonal or horizontal line";
        default -> "unknown shape";
    };
}
Enter fullscreen mode Exit fullscreen mode

This is Java catching up to destructuring patterns TypeScript developers take for granted, but with exhaustiveness checking baked into the compiler.

ZGC Becomes Fully Generational and Default for Most Workloads

The Z Garbage Collector's generational mode (introduced in 21) is now the default GC for new applications without explicit configuration. Sub-millisecond pause times regardless of heap size are no longer an opt-in feature — they're the baseline.

# Java 27 — no flags needed for generational ZGC
java -jar app.jar

# Java 21 — required explicit opt-in
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
Enter fullscreen mode Exit fullscreen mode

For teams running Java in Kubernetes with tight memory limits, this matters more than any language feature.

Java 27 vs Node.js vs Go: A Backend Engineer's Comparison

Aspect Java 27 Node.js 22+ Go 1.23+
Concurrency model Virtual threads + structured concurrency Single-threaded event loop + worker threads Goroutines + channels
Startup time Slower (JVM warmup, improving with CDS/AppCDS) Fast Fastest
Memory footprint Higher baseline, improved by ZGC Low-moderate Very low
Type system Static, nominal, pattern matching Dynamic (TS adds static layer) Static, structural
GC pause times Sub-ms with generational ZGC N/A (V8 GC, generally fine) Sub-ms, simple GC
Container image size Larger, mitigated by jlink custom runtimes Small (Alpine-based) Smallest (static binaries)
Ecosystem maturity Extremely mature (Spring, Micronaut, Quarkus) Mature, fragmented Mature, opinionated
Best fit Large enterprise systems, long-lived services I/O-heavy APIs, real-time apps Infra tooling, CLIs, microservices

The takeaway isn't "Java wins" or "Go wins" — it's that Java 27 closes the gap on concurrency ergonomics and memory efficiency that used to be Go and Node's biggest advantages.

Running Java 27 in Docker: A Practical Setup

If you're deploying Java 27 services alongside your Node.js and Go microservices, container size and startup time are where Java traditionally loses. Java 27 with jlink custom runtimes closes that gap significantly.

# Stage 1: Build a custom minimal JRE
FROM eclipse-temurin:27-jdk-alpine AS jlink
WORKDIR /app
COPY . .
RUN jlink \
    --add-modules java.base,java.net.http,java.sql \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /custom-jre

# Stage 2: Minimal runtime image
FROM alpine:3.20
COPY --from=jlink /custom-jre /opt/jre
COPY --from=jlink /app/target/app.jar /app/app.jar
ENV PATH="/opt/jre/bin:${PATH}"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Enter fullscreen mode Exit fullscreen mode

This custom-JRE approach can shrink a 300MB+ base image down to 60-80MB, comparable to a typical Node.js Alpine image and much closer to Go's static binary footprint.

Enabling Class Data Sharing for Faster Cold Starts

Cold start time is Java's Achilles heel in serverless and autoscaling environments. AppCDS (Application Class Data Sharing) mitigates this significantly in Java 27:

# Generate the CDS archive during build
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar

# Use it at runtime
java -XX:SharedArchiveFile=app.jsa -jar app.jar
Enter fullscreen mode Exit fullscreen mode

In practice, this can cut startup time by 30-40% — meaningful if you're running Java in Kubernetes with horizontal pod autoscaling and expect pods to come up quickly under load.

Migration Considerations from Java 21/25 LTS

If you're on Java 21 LTS (the current dominant production version), moving to Java 27 isn't mandatory since it's not an LTS release. But there are reasons to track it:

  • Preview feature stabilization — features you might be using with --enable-preview flags in 21 or 25 are now stable in 27, meaning you can drop those flags.
  • Spring Boot and Quarkus alignment — major frameworks typically ship compatibility updates targeting the newest non-LTS release to test forward compatibility before the next LTS.
  • Deprecation cleanup — Java 27 removes several APIs deprecated since Java 9 (notably remnants of the old Security Manager and some legacy java.rmi internals). Run your test suite with --release 27 before committing.
# Check compilation compatibility against Java 27 without switching runtimes
javac --release 27 -Xlint:all Main.java
Enter fullscreen mode Exit fullscreen mode

For teams not ready to move off Java 21 LTS, the pragmatic path is to track Java 27 in a staging environment, validate framework compatibility, and wait for Java 29 LTS in 2028 for production migration.

Why This Matters Beyond the JVM Ecosystem

Even if you never touch Java, this release is a signal worth reading. The industry-wide trend — Go's goroutines, Node's async/await, Java's virtual threads, even Rust's async runtimes — is converging on the same idea: make concurrency cheap and structured, and let developers write straight-line code.

Java 27 proves that a 30-year-old, historically verbose language can modernize its concurrency model without breaking backward compatibility — something JavaScript's ecosystem has struggled with (see: the CommonJS/ESM split) and Go has mostly avoided by being younger and more disciplined from day one.

If you're architecting a polyglot system — Go for infra tooling, Node for edge APIs, Java for core business logic — Java 27's improvements mean you can lean on the JVM for high-throughput, long-lived services without the historical memory and startup-time penalties.

Key Takeaways

  • Java 27 finalizes Structured Concurrency, giving the JVM a scoped, cancellation-safe concurrency model comparable in ergonomics to Promise.all in Node.js.
  • Virtual threads are now the recommended executor default, letting you write blocking-style code that scales to tens of thousands of concurrent tasks with minimal memory overhead.
  • Generational ZGC is the default garbage collector, delivering sub-millisecond pause times without manual JVM tuning.
  • Pattern matching with record deconstruction in switch brings Java closer to TypeScript-style destructuring, with compiler-enforced exhaustiveness.
  • Docker image sizes for Java services can shrink dramatically using jlink custom runtimes — from 300MB+ down to 60-80MB.
  • AppCDS cuts cold-start times by 30-40%, addressing Java's biggest weakness versus Go and Node in autoscaling environments.
  • Java 27 is not LTS — track it in staging, but plan production migrations around Java 29 LTS in 2028.
  • The broader trend across Java, Go, and Node.js is convergence: cheap, structured concurrency primitives that let developers write simple, straight-line code for complex I/O workloads.

Top comments (0)