Skip to main content
    Dinix Software logo
    Back to Blog
    JavaMigrationEnterprise

    Java 21 Migration Guide: What Enterprise Teams Need to Know

    How we moved Olympus Mobility from Java 17 to 21: virtual threads, the pinning trap, pattern matching, and an incremental migration plan that held up.

    February 15, 20266 min read
    D

    Dimitar Gochev

    CEO & R&D Team Lead

    Why Migrate to Java 21?

    Java 21 is a Long-Term Support (LTS) release, and it brings transformative features that enterprise teams can't afford to ignore. From virtual threads that dramatically simplify concurrent programming to pattern matching that makes code more expressive, Java 21 represents the biggest leap forward since Java 8.

    At Dinix Software, we recently completed a full migration of the Olympus Mobility platform - a system serving 500,000+ users - from Java 17 to Java 21. Here's what we learned.

    Virtual Threads: The Game Changer

    Virtual threads (Project Loom, JEP 444) are arguably the most impactful feature in Java 21. They allow you to create millions of lightweight threads without the overhead of traditional platform threads. For I/O-bound applications - which describes most enterprise backends - this means dramatically better throughput with simpler code.

    The point is that the programming model does not change. You keep writing straightforward blocking code, and the runtime unmounts the virtual thread from its carrier while it waits:

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (var providerId : providerIds) {
            executor.submit(() -> client.fetchAvailability(providerId));
        }
    }

    On Spring Boot 3.2 and later you can hand the entire web layer over with a single property:

    spring.threads.virtual.enabled=true

    In our Olympus Mobility migration, switching to virtual threads for our REST API handlers resulted in a 3x improvement in concurrent request handling without any architectural changes.

    The Pinning Problem Nobody Warns You About

    Virtual threads are cheap to create, but they are not free to block. When a virtual thread blocks inside a synchronized block, it pins its carrier platform thread, and the scheduler cannot reuse that carrier while it waits. Pin enough carriers and throughput collapses back to platform-thread levels.

    The fix is mechanical: on any path that performs I/O, replace synchronized with a ReentrantLock.

    private final ReentrantLock lock = new ReentrantLock();
    
    void recordTrip(Trip trip) {
        lock.lock();
        try {
            repository.save(trip);   // blocking I/O, safe to unmount
        } finally {
            lock.unlock();
        }
    }

    Run your load tests with -Djdk.tracePinnedThreads=full to find the offenders before production does. Later JDK releases reduced this problem considerably, but on 21 it is real and you have to design around it.

    Pattern Matching, Record Patterns and Sealed Classes

    Pattern matching for switch (JEP 441) and record patterns (JEP 440) together enable a more functional programming style in Java. We found these particularly useful in our domain logic, where complex business rules became significantly more readable.

    sealed interface FareEvent permits TripStarted, TripEnded, TripCancelled {}
    
    String describe(FareEvent event) {
        return switch (event) {
            case TripStarted(var id, var at)      -> "trip %s started at %s".formatted(id, at);
            case TripEnded(var id, var cost)      -> "trip %s cost %s".formatted(id, cost);
            case TripCancelled c when c.refunded() -> "refunded cancellation";
            case TripCancelled c                   -> "cancellation, no refund";
        };
    }

    The compiler enforces exhaustiveness. Add a new permitted subtype and every switch that does not handle it stops compiling, which converts an entire class of runtime bug into a build failure.

    Sequenced Collections

    A smaller change with outsized day-to-day value: SequencedCollection (JEP 431) finally gives ordered collections a common vocabulary for their ends.

    var first = trips.getFirst();
    var last  = trips.getLast();
    var newestFirst = trips.reversed();

    No more list.get(list.size() - 1), and no more remembering that LinkedHashSet had no way to ask for its last element at all.

    Generational ZGC

    Generational ZGC (JEP 439) collects young objects separately, which is exactly the shape of a typical request-scoped web workload. If you are running large heaps and care about tail latency, it is worth benchmarking with:

    -XX:+UseZGC -XX:+ZGenerational

    Measure before and after on your own traffic. Garbage collector choice is workload-specific, and the default G1 remains a perfectly good answer for many services.

    Migration Strategy: The Incremental Approach

    We recommend an incremental migration strategy:

    1. Start by updating your build toolchain (Maven/Gradle) to support Java 21
    2. Run your existing test suite - most code will work without changes
    3. Address any deprecated API usage flagged by the compiler
    4. Gradually adopt new features in new code
    5. Refactor existing code to use new features where it adds clarity

    Treat steps 1-3 as one pull request that changes behaviour as little as possible. Shipping the runtime upgrade separately from the language-feature adoption means that if something regresses in production, you know which of the two caused it.

    Common Pitfalls

    Watch out for these common issues during migration:

    • Some third-party libraries may not yet support Java 21 - check compatibility early
    • Virtual threads interact differently with synchronized blocks - prefer ReentrantLock for I/O operations
    • If you use reflection heavily, review the new module access restrictions
    • Anything that reflects over JDK internals - older Lombok, Mockito, bytecode agents, some APM collectors - tends to break first, so upgrade those before you change the runtime
    • Thread pools sized for platform threads become actively harmful once the work is virtual: a fixed pool of 200 is now a cap of 200, not a safeguard

    What About Java 25?

    Java 25 is the newer LTS, and for a greenfield service it is the obvious target. For an existing estate the calculus is different: 21 has years of library support behind it, every major framework baselines against it, and the migration path from 17 is well travelled. Moving 17 to 21 now and 21 to 25 later is usually less risky than one long jump, because each step keeps your dependency graph on versions that other people have already run in production.

    Conclusion

    Java 21 migration is worth the investment. The performance gains from virtual threads alone justify the effort, and the improved developer experience with pattern matching and sealed classes makes your codebase more maintainable for years to come.

    If you are weighing a JDK upgrade across a larger estate, our software development and IT consulting teams do exactly this kind of migration work.