跪拜 Guibai
← All articles
Backend

Huolala's JDK 21 Migration: Framework First, AI-Assisted Rewrites, and 12 Silent Breakages

By 货拉拉技术 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

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.

Summary

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.

Takeaways
— At the same business volume, the JDK 21 plus Spring Boot 3.5 stack measured about 10 percent less CPU and 50 percent less memory than the JDK 8 stack.
— JAF 3.x pins all dependency versions in a BOM; business POMs declare dependencies without writing versions.
— Hermes RPC and JAF are version-bound (hermes.version = lala.release.version) to prevent cross-generation mixing.
— JAF migrated roughly 42 files to AutoConfiguration.imports and deleted about 1100 lines of the self-built pagehelper parser.
— Custom starters registered only in spring.factories are silently ignored under Spring Boot 3; they must use AutoConfiguration.imports.
— httpcore5 5.4 shipped a broken tcpNoDelay default that added about 40ms to large RPC calls; 5.4.2 plus explicit tcpNoDelay and restored connection pool defaults fixed it.
— After framework work, each business project reduces to five checklist items: parent switch, javax to jakarta, starter registration, internal component alignment, and a JDK 21 compile baseline.
— Virtual threads suit IO-heavy synchronous code; CPU-bound crypto and serialization, scheduled tasks, and Kafka consumers stay on platform threads.
— JDK 21 defaults to G1; generational ZGC pays off around 8G-plus heaps, and its heap ratio should start near 65 percent.
— JDK 21 reads container memory and CPU limits, so heap should be set with MaxRAMPercentage, 70 to 75 for G1, rather than a fixed Xmx.
— Production troubleshooting for virtual threads starts with GC logs, JFR, and pooled metrics; a full thread dump at hundreds of thousands of threads is neither readable nor useful.
Conclusions

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.

Concepts & terms
BOM (Bill of Materials)
A Maven POM that centralizes dependency versions. Business modules declare dependencies without version numbers, and the BOM locks versions for the whole platform so component generations cannot drift apart.
Jakarta namespace
The package namespace javax.* was moved to jakarta.* when Java EE was handed over to Eclipse. Spring Boot 3 requires jakarta.*, so imports, annotations, and Servlet API types must all be migrated.
AutoConfiguration.imports
Spring Boot 3's replacement for spring.factories as the auto-configuration registration mechanism. A custom starter that only lists its configuration in spring.factories silently no longer loads under Boot 3.
Virtual thread pinning
A virtual thread that enters a synchronized block or native method during blocking IO pins its carrier thread, blocking other virtual threads from using that carrier. JDK 21 can report this with -Djdk.tracePinnedThreads=short; JEP 491 in JDK 24 removes the limitation.
Generational ZGC
A low-pause garbage collector variant in JDK 21 that separates young and old generations. Pause times are a few milliseconds, but memory and CPU overhead are higher than G1, so it targets large heaps of about 8G and above.
From the discussion
Featured comments
AI向善

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.

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗