Huolala's JDK 21 Migration: Framework First, AI-Assisted Rewrites, and 12 Silent Breakages
Teams still on JDK 8 and Spring Boot 2 face the same wall: a green compile with changed runtime semantics, silent starter failures, and RPC latency regressions. Huolala's ordering, framework first and business later, plus its concrete parameter values and checklists, compresses that wall into a repeatable migration plan.
Huolala's in-house Java stack now has two generations: JAF 2.x on JDK 8 plus Spring Boot 2, and JAF 3.x on JDK 21 plus Spring Boot 3.5 plus Jakarta. A single BOM pins every dependency version, so business services no longer write version numbers in their POMs. The framework repos JAF, Hermes RPC, and Monitor were upgraded together and version-locked; Hermes always matches the JAF release. Business projects are left with five checklist items: switching parent, replacing javax with jakarta, verifying custom starters use AutoConfiguration.imports, aligning internal components, and moving the compile baseline to JDK 21.
Most of the work is invisible to business code. Twelve real incidents are documented across dependency contracts and release/resource modeling. Spring 6.2.7's managed httpcore5 5.4 had a tcpNoDelay default that did not apply and added about 40ms to large RPC payloads; custom starters registered only in spring.factories fail silently under Boot 3; timestamp fields need mixed-version regression; and Nagle/Delayed ACK effects show up only in production-sized traffic. Virtual threads are enabled by scenario, with a helper class for Trace and context propagation across new virtual threads. The recommended runtime is G1 by default, heap sized as a percentage of container memory, pinned-thread tracking during rollout, and aggregate metrics before thread dumps.
A green compile is a weak guarantee on this upgrade path: the 12 documented pitfalls are mostly semantic changes that only appear at startup, in gray-release traffic, or on production-spec machines.
Putting version management and compatibility work in the framework is what makes a thousand-service migration finish; business projects are reduced to one parent change plus five checklist items.
The virtual thread guidance is deliberately conservative: enable by scenario, keep blocking IO in filters and interceptors out of synchronized, and use the Helper only when a new virtual thread crosses task boundaries.
The plan already treats JDK 24's JEP 491 as the end of the pinning problem, so today's synchronized-avoidance work is a bridge, not a permanent style rule.
For JDK, I first added a local cache layer, which cut the number of requests by about half; compared with the approach in the article, each has its own trade-offs.