Huolala's JDK 21 Migration: Framework First, AI-Assisted Rewrites, and 12 Silent Breakages
JDK 21 at Scale: Practice on the Ground
Author: Huolala Technology Tags: Backend
JDK 8 carried Huolala through an entire generation of microservices; more than a thousand online services still run on it. Moving to JDK 21 means the runtime, Spring, and a batch of middleware all have to change with it. Changing java.version is one line. The hard part is that after compilation succeeds, the behavior of many dependencies has quietly changed. This time, the framework absorbed the compatibility cost first, AI handled the repetitive code changes in bulk, and business projects switched parent and finished up against a checklist. Base moves first; business moves later.
1. Background
Upgrade benefits
| Benefit | Description |
|---|---|
| Lifecycle | Back to mainstream LTS, with long-term security patches and root certificate updates. Spring Boot 2.x stopped open-source maintenance long ago; staying on the old stack means patching vulnerabilities yourself. |
| Ecosystem | Open-source frameworks, cloud vendor JDKs, community solutions, and AI programming tools already treat JDK 21 and the new Spring generation as the default baseline. New capabilities, examples, and subsequent LTS versions are released along this line; external experience can land directly in the project without maintaining a separate branch for the old stack. |
| Resource efficiency | The JVM correctly recognizes the container memory and CPU quota instead of estimating from the host. At the same business volume, production measurements show CPU down 10% and memory down 50%. |
Upgrade challenges
Moving to JDK 21 almost inevitably brings a full-stack replacement: Spring Boot 3, Jakarta EE, new-generation connectors and serialization libraries, plus a batch of internal second-party packages. If only the JDK is upgraded and the transitive dependencies and runtime semantics do not move along with it, compilation often still passes; classes, annotations, and serialization only mismatch at startup or at the first request.
More hidden problems appear during release and gray release. Auto-configuration moved from spring.factories to AutoConfiguration.imports; a custom starter that only writes the former silently fails. Connectors, RPC, and monitoring components also have different default behavior from 2.x; latency, metrics, or traces drift slowly as traffic ramps. The thread model and container CPU/memory visibility behave the same way; copying old parameters only exposes problems after traffic has switched.
Upgrade approach
| Upgrade method | Each project upgrades itself | Framework moves first |
|---|---|---|
| Version management | Each manages versions in its POM | BOM centralizes; business does not write versions |
| Jakarta / middleware / RPC | Each project changes them separately | Done in the framework first |
| Code changes | Sample code hand-modified, compatibility issues remembered manually | AI rewrites in bulk, scans against known compatibility issue list |
| What business does | Find dependencies one by one | Switch parent, align baseline by checklist |
All Java services at the company are built on JAF, so most compatibility work can be done by the framework before business projects touch it. Overall approach:
Framework consolidation: versions centralized into BOM; Jakarta migration, middleware and RPC adaptation done in the framework first. See Chapter 2.
AI assistance: repetitive changes like javax to jakarta are bulk rewritten by AI, while scanning against the confirmed compatibility issue list; humans handle acceptance.
Releasable and rollbackable: progress STG to PRE to production; core services gray-release with traffic first, one click back to the old version if problems appear.
After switching parent, business projects still need to align javax, custom starters, internal components, and Lombok to the same baseline according to the checklist at the end of Chapter 2; otherwise issues the framework already solved reappear in business projects.
2. Framework Replacement: Base First, Business Later
If more than a thousand services each upgrade themselves, the effort is hard to close out. So this replacement is mainly carried by the framework layer. Framework versions are divided by major version: 2.x corresponds to JDK 8 and Spring Boot 2; 3.x corresponds to JDK 21, Spring Boot 3.5, and Jakarta. JAF, Hermes, and Monitor, the three core repositories, upgrade together and align versions; business projects only declare dependencies without writing version numbers.
| Component | Role | What this replacement does |
|---|---|---|
| lalaframework (JAF) | Distributed application framework: config, cache, ORM, message, SOA, governance, and other Starter systems | Compile baseline to JDK 21, javax fully moved to jakarta, dependencies centralized through a BOM |
| hll-hermes | In-house SOA/RPC framework: JSON-RPC / gRPC, Consul discovery, routing and governance | RPC aligned with Spring 6, version-bound with JAF |
| lala-jaf-monitor | Monitoring: Prometheus metrics and SkyWalking traces, agent and non-agent modes | Metrics and traces moved to new engines, observability uninterrupted during gray release |
Hermes and JAF are strongly version-bound (hermes.version = lala.release.version), so cross-generation mixing does not happen. The 2.x and 3.x lines are maintained in parallel: new projects use the 3.x scaffold directly, existing services migrate at their own pace without a one-time cutover; capabilities verified on 2.x, such as Kafka and exemplar support, are also backported to 3.x.
Dependencies Raised as a Group
The first step is to raise the whole dependency group, locked by BOM versions, with a lot of dependency compatibility adaptation in between.
Two categories need focused attention:
Whether Spring-managed component versions fit internal components
Example: Redis. Does the internal Datamesh/redis version fit, including protocol and commands? Others such as MySQL and Elasticsearch all need to fit the company's internal server versions.
Whether a component itself has bugs
Example: httpcore5, managed by Spring 6.2.7. Version 5.4 has a bug where the default tcpNoDelay:false does not apply, adding about +40ms latency to large RPC payloads; only upgrading to 5.4.2 fixes it.
The main dependency version comparison is below. Business projects should not override these versions in their POMs:
| Dependency | 2.x | 3.x |
|---|---|---|
| JDK | 8 | 21 |
| Spring Boot | 2.2.6 | 3.5.0 |
| Spring Framework | 5.2.5 | 6.2.7 |
| Servlet API | javax 4.0 | jakarta 6.0 |
| Micrometer | 1.5.x | 1.15.0 |
| Fastjson | 1.2.x | 2.0.x |
| MyBatis / MyBatis-Plus | 3.5.4 / 3.3.0 | 3.5.19 / 3.5.12 |
| MySQL Connector | 5.1.48 | 9.2.0 |
| gRPC | 1.41.1 | 1.73.0 |
| Jackson | 2.10.3 | 2.19.0 |
| Httpclient5 | 4.x | 5.x |
Internal companion components must be the same generation as the framework; mixing a 2.x framework with 3.x components is not supported. During upgrade review, first check the dependency tree for conflicts, then look at business code diffs.
JAF: Middleware Wrappers on a New Base
Jakarta migration: javax.* fully moved to jakarta.*, compile baseline raised to JDK 21, version management changed to revision and flatten-maven-plugin.
Auto-configuration registration switch: META-INF/spring.factories replaced by AutoConfiguration.imports, about 42 files. A custom starter that only writes spring.factories simply does not take effect under Boot 3.
MyBatis pagination refactor: JSqlParser major upgrade, self-built pagehelper parser fully rewritten, about 1100 lines of old implementation deleted.
Startup related: health checks off by default to avoid probes dragging startup; warmup system enhanced with per-component timeout and retry.
3.x continuous evolution: 3.1 dynamic thread pool supports virtual threads and context wrapping; 3.2 introduces JetCache; 3.3+ lands OceanBase / OBKV and LLM KMS encryption.
Hermes: Spring 6 Adaptation for the RPC Base
Remove external dependency: removed alibaba spring-context-support; built in-house BeanRegistrar and context event listener; annotation handling moved back inside the framework.
Spring 6 API adaptation: HandlerInterceptorAdapter is gone; implement HandlerInterceptor directly. InstantiationAwareBeanPostProcessorAdapter replaced by direct interface implementation; postProcessPropertyValues replaced by postProcessProperties.
Dependency upgrades: gRPC 1.41 to 1.73, Jackson 2.10 to 2.19, Resilience4j 1.7 to 2.3, jjwt 0.11 to 0.12 (parserBuilder replaced by parser).
Old package name compatibility: hermes-compatible keeps the old package cn.lalaframework.soa, so existing business SOA APIs can migrate to 3.x without rewriting.
Pitfalls found after upgrade: after the HttpClient5 upgrade, large RPC latency rose about +40ms, caused by Nagle and Delayed ACK interacting. Explicitly enabling tcpNoDelay and upgrading httpcore5 to 5.4.2 fixed it completely; the connection pool default policy was also aligned back to the pre-upgrade behavior.
Monitor: Metrics and Traces on a New Engine
Micrometer 1.5 to 1.15: Prometheus package migration as a whole: io.micrometer.prometheus moved to io.micrometer.prometheusmetrics; CollectorRegistry replaced by PrometheusRegistry; the expire metric system was rewritten.
ByteBuddy 1.10 to 1.15: the agent Transformer adapts to the new ProtectionDomain signature and clears calls to JDK internals like sun.security.
Plugin replacement: old SOA v1.4 plugin removed; new plugins added for Spring MVC 6.x, RestTemplate 6.x, WebClient, and Tomcat 10.x; trace capability aligned with the new stack.
Capability backport: Kafka 3.x support and exemplar capability backported from 2.x, so observability does not break during gray release.
Business Upgrade Checklist
After the framework finishes the common parts, what each business project must change converges to the following five items. For what to test after changes, see the regression checklist at the end of Chapter 3.
Switch parent to lala-boot-parent 3.x and delete all hand-written version numbers from the POM.
Replace javax with jakarta in full, with AI-assisted changes and manual acceptance.
Confirm the custom Starter now uses AutoConfiguration.imports.
Confirm internal components (hll-meta / config-client / prometheus / whitzard / hermes) are the same generation as the framework.
Switch the compile baseline to JDK 21 and align compile-time components such as Lombok.
3. Pitfall Log: Compilation Is Green, Semantics Have Changed
In large-scale upgrades, the most time-consuming problems are where compilation is fully green but semantics have changed. The 12 pitfalls below were all really hit in this upgrade. They are split into dependency/contract issues and release/resource-model issues. Each one is written as symptom, cause, and action. At the end of the chapter, there is a regression checklist for direct use.
Dependency and Contract: Interface Unchanged, Behavior Changed
Incidents 01 to 03 are dependency and build issues: old versions and old wrappers fail directly on the new stack. Incidents 04 to 08 are contract and semantic issues that the compiler cannot catch at all; they can only be covered by regression testing.
Release and Resource Model: Pitfalls That Only Explode in the Gray-Release Window
Incidents 09 to 12 are related to release mode and machine specifications. Some appear only in the gray-release window; others can only be reproduced on production-spec machines and are hard to find in non-production environments.
Regression Checklist
Go through each item during regression. The corresponding pitfall number is in parentheses.
Dependency tree has no javax and jakarta coexistence; business POM does not override BOM versions; Lombok is 1.18.30 or above (01, 02).
Pagination uses the official interceptor; scheduled tasks confirm the node still exists and can trigger normally after upgrade (03).
External contracts explicitly declare field names; validation happens at the server boundary; failure paths have contract tests (04, 06, 08).
Time field regression covers mixed data, meaning data written by 5.x and read by 9.x; deserialization replays real online messages (05, 06).
Caller paths do not contain //; if compatibility is required, explicitly relax with UrlPathHelper (07).
Kafka consumer group switches as a whole, or accept a short window where only old instances consume; production disables remote debugging by default (09, 10).
Pinning tracking is enabled and the hot path has no Pinning; at least one regression run happens on a machine with 3 or more cores (11, 12).
4. Feature Application: Virtual Threads and Language Features
Virtual Threads: Choose the Scenario First
Platform threads map one-to-one to OS threads, and both creation and switching are expensive. Virtual threads are scheduled by the JVM; when blocked on HTTP, JDBC, Redis, or RPC, they unmount from the carrier thread, which can switch to other virtual threads. So they suit synchronous code with lots of waiting and little computation. IO concurrency can be limited with Semaphore or a connection pool; there is no need to configure a tiny platform thread pool just for rate limiting.
These scenarios continue to use platform threads: CPU-intensive encryption and decryption, and large JSON serialization; virtual threads do not make computation faster. Scheduled tasks, XXL-JOB, and Kafka consumer threads are managed by their respective frameworks, and their concurrency is already limited by task count or partition count, so switching them to virtual threads brings little benefit.
With Tomcat, enabling spring.threads.virtual.enabled switches the whole request onto a virtual thread. Before enabling it, confirm that Filters and interceptors do not do blocking IO inside synchronized. The request itself does not break Trace; only when you create new virtual threads inside a request do you need the Helper.
Two JAF Integration Modes
VirtualThreadContextHelper
When the parent thread hands a task to a new virtual thread, ThreadLocal does not propagate automatically. Inside the same virtual thread, blocking IO and carrier switching do not require another wrapper. The Helper does two things before submission:
RunnableWrapper.of captures the SkyWalking Trace.
ContextAwareRunnable.of carries the business ContextHeaders.
runVirtual corresponds to Thread.startVirtualThread.
newVirtualThreadExecutor corresponds to an unbounded executor that creates one new VT per task, like ThreadPerTaskExecutor.
// Native (usable for standalone scripts without Trace requirement)
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> doWork());
}
// Framework recommended (business traces always use this)
VirtualThreadContextHelper.runVirtual(() -> doWork());
CompletableFuture<String> f = VirtualThreadContextHelper.supplyVirtual(() -> loadData());
ExecutorService ex = VirtualThreadContextHelper.newVirtualThreadExecutor("biz-virtual-");
Dynamic Thread Pool
After enabling virtualThread=true, the dynamic thread pool only changes the way threads are created. The executor is still a ThreadPoolExecutor; core size, max size, queue, and rejection policy all remain effective, and the created Worker threads are all virtual threads. The scheduling pool DtpScheduledExecutor ignores this switch.
@Bean
public ThreadPoolExecutor virtualExecutor() {
return ThreadPoolBuilder.newBuilder()
.threadPoolName("virtualExecutor")
.corePoolSize(20)
.virtualThread(true)
.maximumPoolSize(100)
.queueCapacity(2048)
.buildDynamic();
}
It differs from newVirtualThreadPerTaskExecutor(): that one creates a new VT per submitted task and has no queue and no core/max. DTP core/max limits the Worker virtual thread count, not the carriers. Carriers are provided by the JVM on demand based on CPU core count, with a default upper limit around max(cores, 256), shared with Tomcat and the Helper. In IO scenarios, raising DTP max increases concurrency.
Language Features
Features in JDK 21 that can be used immediately are the finalized pattern matching, Record deconstruction, sequenced collections, and text blocks. They do not affect performance; the benefit is fewer casts and less boilerplate. With sealed interfaces, they can also catch missing branches at compile time.
Can go into production: pattern matching, Record deconstruction, sequenced collections, text blocks.
Disabled in production: String Templates, Structured Concurrency, and other features that are not finalized. Do not add --enable-preview.
Pattern Matching: One Less Cast
The instanceof type check can bind a variable directly; this has been available since JDK 16. Switch type patterns were finalized in JDK 21. Both remove casts and repeated null checks.
if (factory instanceof NamedThreadFactory named) {
return named.getNamePrefix();
}
if (dataSource instanceof ItemDataSource itemDataSource) {
return itemDataSource.getRealDataSource();
}
switch suits one entry point dispatching to multiple types. When a sealed interface is switched on without a default branch, a missing branch is a direct compile error. Two easy pitfalls:
null needs its own case null; otherwise a null value causes an NPE.
A when condition must be written before a broader case; otherwise it is covered by the earlier case and compilation fails.
static String describe(Object value) {
return switch (value) {
case null -> "empty";
case String s when s.isBlank() -> "blank";
case String s -> "text:" + s.length();
case Integer i -> "int:" + i;
default -> value.getClass().getSimpleName();
};
}
Record: Data Carrier Only, Not a Bean
Suitable for: read-only data objects, cache keys, route keys assembled from a few fields.
Keep using class for: MyBatis entities, config with setters, Spring Beans.
When used with switch, record fields can be deconstructed directly:
public record RouteKey(String tenantId, String table) {}
static String shard(Object key) {
return switch (key) {
case RouteKey(String tenant, String table) -> tenant + ":" + table;
case String s -> s;
default -> "default";
};
}
Sequenced Collections: Official getFirst / getLast
JDK 21 adds unified getFirst() and getLast() for ordered collections such as List and LinkedHashSet, so get(0) and get(size - 1) are no longer needed. LinkedHashMap has firstEntry() and lastEntry(). Calling getFirst() on an empty List throws NoSuchElementException, so check emptiness first. reversed() returns a reverse view, not a copy; use new ArrayList<>(list.reversed()) when you need a snapshot.
List<String> steps = new ArrayList<>(List.of("auth", "route", "invoke"));
steps.addFirst("warmup");
String last = steps.getLast();
List<String> reversed = steps.reversed(); // view, not copy
LinkedHashMap<String, Integer> order = new LinkedHashMap<>();
order.putLast("tail", 1);
Map.Entry<String, Integer> first = order.firstEntry();
Text Blocks: Test Samples and Fixtures, Not Runtime SQL
Text blocks (""") suit JSON fixtures in unit tests and error message templates. Runtime SQL assembled with user input still has injection risk; that has not changed.
String payload = """
{"orderId": "%s", "status": "CREATED"}
""".formatted(orderId);
5. Runtime Governance: Let the JVM See the Real Container
The runtime benefits of the upgrade reach production through three things: choosing GC by latency target, making the JVM read the container's real memory and CPU, and troubleshooting with aggregate metrics first instead of only dumps. Conclusion first: use G1 by default; evaluate generational ZGC when interfaces are latency-sensitive and the heap is large. Set heap size by percentage; do not hardcode -Xmx.
How to Choose GC
JDK 21 defaults to G1, and most business should start there. Generational ZGC has shorter pauses but uses more memory and CPU. Only one collector can remain in the startup parameters; when -XX:+UseG1GC and -XX:+UseZGC appear together, the JVM fails to start. When switching, delete the old parameter.
| Dimension | G1 (default) | Generational ZGC |
|---|---|---|
| Suitable heap | 2G to 16G most common; larger can also run | More worthwhile above 8G; small heaps get little benefit |
| Pause target | Usually 50 to 200ms; target is not a hard constraint | Usually within a few milliseconds; more stable |
| Throughput | Better, lower CPU overhead | Slightly worse; pays for latency stability |
| Additional memory overhead | Relatively small | Larger; container needs extra headroom |
| When to use | JAF / middleware default; start here | Evaluate when slow requests cannot be solved and heap keeps growing |
G1 pause target: do not set unreachable values like 20ms. When the target cannot be met, collection becomes more frequent and throughput gets worse, but pause time still does not reach 20ms. For online services with 4G to 8G heaps, starting from 200ms is more realistic.
IHOP: when the old generation grows fast and reclamation cannot keep up, lower InitiatingHeapOccupancyPercent from the default 45 to 35 to 40. When reclamation is too idle, raise it slightly.
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+ParallelRefProcEnabled \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*,gc+heap=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=50M \
-jar app.jar
Generational ZGC does not need many tuning items; giving it enough heap and CPU matters more. With only 1 core in the container, collection drags for a long time, which looks like no full-heap collection but interface timeouts. After go-live, compare three things: whether slow requests dropped, whether CPU rose obviously, and whether container memory is closer to the limit. The switch parameters are -XX:+UseZGC and -XX:+ZGenerational. ZGC itself uses more memory than G1; start with a heap ratio from 65%, and adjust to around 70% when GC pressure is high.
GC logs should use -Xlog, not PrintGCDetails. Keep at least several days so a release or traffic spike can be matched.
System.gc() on G1 triggers full-heap collection. In production use -XX:+ExplicitGCInvokesConcurrent to make it concurrent. Do not use -XX:+DisableExplicitGC to turn it off: when off-heap memory fills up, NIO relies on System.gc() to reclaim DirectByteBuffer, and disabling it makes OOM more likely.
Netty/NIO off-heap memory does not appear in heap memory charts. When it fills up, it looks like the heap is empty yet OOM happens. Set MaxDirectMemorySize separately.
Container Memory and CPU
JDK 21 reads the container memory limit and CPU quota instead of estimating from the host. Previously, with a 64-core 128G host and a 2-core 4G container, the JVM calculated default heap and GC threads from the host, easily exceeding the container quota. After switching to a JDK 21 image, most such problems disappear automatically; what remains to adjust manually is the heap percentage and off-heap reservation.
| Memory slice | Who uses it | How to reserve it in a container |
|---|---|---|
| Java heap | Objects, G1/ZGC regions | MaxRAMPercentage: G1 70 to 75, ZGC 65 to 70 |
| Metaspace / CodeCache | Classes, JIT | Spring Boot 3 services often use 256M to 512M; do not ignore |
| DirectMemory / Netty | NIO, RPC, gateway | Explicitly set -XX:MaxDirectMemorySize; the default can approach heap size |
| Thread stacks | Carrier platform threads plus virtual threads | Virtual thread stacks are small, but carrier threads still count as platform threads |
| GC itself | Collector | ZGC is clearly higher than G1, so ZGC heap ratio should not be opened above 85% |
To start the heap faster, you can add InitialRAMPercentage at about half the upper limit. Do not set it equal to the upper limit. When the two are equal, the heap applies for the limit at startup, memory usage quickly approaches the limit, and oversold nodes kill these instances first.
In very small containers, such as 500m, the JVM may see only 1 core and collection is slow. If needed, write -XX:ActiveProcessorCount=2, but do not exceed the container CPU limit.
Use the company's unified JDK 21 base image. Do not compile with 21 and run with 17. Below is a startup command for a 4G container with G1 that can be copied directly into the deployment template. During the initial virtual thread rollout, keep pinned thread tracking on, then turn it off after the hot path is confirmed clean:
java -XX:+UseG1GC \
-XX:MaxRAMPercentage=70.0 \
-XX:InitialRAMPercentage=35.0 \
-XX:MaxDirectMemorySize=512m \
-XX:+ExplicitGCInvokesConcurrent \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump.hprof \
-Djdk.tracePinnedThreads=short \
-Xlog:gc*,gc+heap=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=50M \
-jar app.jar
Observability: Aggregate First, Then Drill Down
Previously, one thread dump could show who was stuck. With virtual threads, the count can reach hundreds of thousands; a full dump consumes disk and cannot be read manually, and most threads are only waiting on IO. The troubleshooting order has to change:
| Tool | What to look at | How to use it |
|---|---|---|
| GC logs | Pauses, old-generation occupancy, allocation speed | Compare 30 minutes after release; with ZGC focus on allocation being stuck, not pause time |
| JFR | GC, locks, pinned virtual threads, IO | jcmd JFR.start name=onboarding settings=profile dumponexit=true |
| Pinning tracking | Virtual thread doing IO inside synchronized | -Djdk.tracePinnedThreads=short |
| Thread snapshot | Carrier threads and a small number of business stacks | jcmd Thread.dump_to_file -format=json /tmp/threads.json; do not paste hundreds of thousands of lines into a group |
| Dynamic thread pool metrics | Pool size, rejections, virtual thread count | Metric prefix hllci_dtp.* |
| Monitor | Trace across virtual threads | Carrier switching on the same virtual thread does not break the chain on 8.x; cross-task work must use the Helper |
After go-live, first run a comparison. It is not enough that the process is alive:
When only switching to JDK 21 without virtual threads: are GC pauses and container memory more stable than on JDK 8?
After enabling virtual threads: Trace is not broken, pinning count is near zero, and slow requests caused by blocking IO do not regress.
Record JFR for 1 to 2 minutes for comparison; it is better than repeated dumps for lock and IO hotspots.
6. Future Outlook
After the upgrade, these areas continue to evolve:
| Direction | Now | Next |
|---|---|---|
| Concurrency model | Virtual threads enabled by scenario; cross-task Trace and context through the Helper | IO-heavy chains default to virtual threads; the framework catches Trace and ThreadLocal at task submission |
| Locks and pinning | Hot paths replace synchronized with ReentrantLock; early rollout uses pinning tracking as a gate | From JDK 24, synchronized no longer pins carrier threads (JEP 491); after the next LTS, JDK 25, new code no longer needs to avoid synchronized deliberately |
| GC | G1 by default; large-heap services evaluate generational ZGC separately | Generational ZGC covers more large-heap, low-latency services; G1 remains the default for most business |
| Runtime baseline | Heap percentage, JFR, and thread pool metrics are configured by each service using this document as reference | Container-aware heap percentage is written into the image baseline; JFR and thread pool metrics are written into release gates |
Every pitfall hit and every parameter tuned in this upgrade eventually sinks into the framework and the base image. At the next runtime upgrade, the best business should still only need to change one line of parent.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
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.