跪拜 Guibai
← All articles
Java · Backend · Architecture

A ConcurrentHashMap Cached Embeddings to Save Pennies — and Nearly Killed an AI Chatbot at Launch

By 掘金_答案 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

In-process caching of AI model outputs looks cheap and fast until it turns the JVM heap into a session database. The resulting GC pauses hit every request, not just the cached ones, and the fix is architectural — externalize state to Redis — not a JVM flag.

Summary

A production stress test with 1000 concurrent users sent P99 latency from 2 seconds to 5 seconds, with the latency graph spiking like an EKG. GC logs revealed Full GC pauses lasting 2.5 seconds and reclaiming only 400 MB out of 3.2 GB. A jmap histogram showed 200 MB tied up in float arrays — the locally cached embedding vectors stored per session inside a ConcurrentHashMap. Each 1024-dimensional float array cost 4 KB, and 1000 concurrent sessions pushed 130 MB straight into the old generation, creating a vicious cycle of heap exhaustion and low-efficiency Full GCs. MAT analysis confirmed the reference chain: SessionManager.sessions → ConversationContext → embeddingCache → float[1024], all GC-root reachable and never reclaimed while sessions lived. The emergency fix capped the cache and doubled the heap to 8 GB, but the real solution moved session state to Redis with 30-minute TTL auto-expiry, eliminating Full GCs entirely and bringing P99 back down to 2.2 seconds.

Takeaways
Embedding vectors cached per session in a ConcurrentHashMap consumed 200 MB of heap from 50,000 float arrays, triggering 2.5-second Full GC pauses.
Full GC reclaimed only 400 MB out of 3.2 GB because session objects were GC-root reachable and could not be collected while sessions were active.
MAT heap analysis traced the leak chain: SessionManager.sessions → ConversationContext → embeddingCache → float[1024].
Emergency stopgap capped the embedding cache at 500 entries per session and doubled the heap from 4 GB to 8 GB, cutting Full GC frequency from 5/hour to 1–2/day.
Migrating session storage from ConcurrentHashMap to Redis with 30-minute TTL eliminated Full GCs, dropped P99 latency from 5 s to 2.2 s, and enabled horizontal scaling.
Production JVM heaps should always set -Xms equal to -Xmx to avoid dynamic heap resizing pauses.
Long-lived session objects favor a smaller young generation (1/3) and larger old generation (2/3) to reduce premature promotion pressure.
GC logs and HeapDumpOnOutOfMemoryError must be enabled in production; Arthas provides live JVM inspection without restarts.
Conclusions

The core mistake was treating the JVM heap as a database for session state — a design shortcut that works at low concurrency and collapses under load.

Embedding caching saved fractions of a cent per API call but cost seconds of latency for every user, a trade-off that only becomes visible during stress testing.

ConcurrentHashMap's thread safety and zero network overhead seduced the developer into ignoring its fundamental limitation: objects it holds are GC roots and won't be collected until explicitly removed.

Redis TTL auto-expiry eliminated an entire class of hand-written cleanup code (scheduled tasks, removeIf loops) that is error-prone and easy to get wrong.

The four-step tuning method — baseline, analyze, tune, verify — treats JVM optimization as a data-driven experiment rather than parameter memorization.

Concepts & terms
Full GC (Allocation Failure)
A stop-the-world garbage collection that compacts the entire heap when the JVM cannot allocate an object in the old generation. Pauses of 2–3 seconds freeze all application threads.
GC Roots
Objects that are always reachable and therefore never garbage collected, such as static fields, thread stacks, and active references in live maps like ConcurrentHashMap.
HeapDump / MAT
A snapshot of the JVM heap exported with jmap and analyzed in Eclipse Memory Analyzer to trace object reference chains and identify memory leaks.
Arthas
An Alibaba open-source JVM diagnostic tool that attaches to a running Java process to inspect classes, trace method calls, and generate CPU flame graphs without restarts.
TTL (Time To Live)
A Redis key expiration policy that automatically deletes a key after a set duration, removing the need for manual session cleanup code.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗