TL;DR
JVM của bạn đã hết bộ nhớ off-heap (direct memory). Ba cách khắc phục nhanh:
- Tăng giới hạn direct memory:
-XX:MaxDirectMemorySize=512m - Với Netty, thêm
-Dio.netty.maxDirectMemory=0để Netty dùng giới hạn của JVM thay vì giới hạn riêng của nó. - Kiểm tra rò rỉ
ByteBuffer— direct buffer không tự giải phóng cho đến khiCleanerkích hoạt, và quá trình này có thể chậm hàng phút so với thời điểm cấp phát khi tải cao.
Chuyện gì đang xảy ra bên dưới
Direct buffer tồn tại ngoài Java heap. Chúng được cấp phát qua ByteBuffer.allocateDirect() hoặc sun.misc.Unsafe.allocateMemory() và được theo dõi theo một giới hạn riêng: -XX:MaxDirectMemorySize.
Giá trị mặc định thường bằng với -Xmx, nhưng trên một số phiên bản JVM có thể thấp tới 64 MB. Vượt quá giới hạn này bạn sẽ gặp lỗi sau:
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.base/java.nio.Bits.reserveMemory(Bits.java:175)
at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:118)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:318)
Bốn tình huống hay gây ra lỗi này:
PooledByteBufAllocatorcủa Netty cấp phát trước các direct arena khi khởi động — trên máy 16 nhân, điều này có thể ngốn 300–500 MB trước khi ứng dụng xử lý bất kỳ request nào.- Code cấp phát direct buffer trong vòng lặp chặt và phụ thuộc vào GC để giải phóng. GC không chạy đủ thường xuyên khi tải liên tục.
- gRPC, Kafka client, RxNetty — tất cả đều dùng Netty bên trong và âm thầm tiêu thụ direct memory mà bạn không tính đến.
- Container với giới hạn bộ nhớ 2 GB trong khi JVM mặc định
MaxDirectMemorySizebằng với-Xmx1 GB. Cộng thêm metaspace và thread stack là đã vượt giới hạn.
Cách 1 — Tăng giới hạn direct memory
Đây là cách nhanh nhất. Điều chỉnh giá trị dựa trên workload thực tế của bạn:
-XX:MaxDirectMemorySize=1g
Với Spring Boot fat JAR:
java -XX:MaxDirectMemorySize=1g -jar app.jar
Trên Kubernetes, đặt qua JAVA_TOOL_OPTIONS để áp dụng bất kể JVM được khởi chạy như thế nào:
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxDirectMemorySize=512m -Xmx1g"
Một nguyên tắc cần nhớ: heap + direct memory + metaspace + thread stack phải nằm trong giới hạn container. Vượt quá và Kubernetes sẽ OOMKill pod — không có lỗi JVM, chỉ là tự động khởi động lại lặng lẽ.
Cách 2 — Tinh chỉnh dành riêng cho Netty
PooledByteBufAllocator của Netty tạo một direct arena cho mỗi nhân CPU theo mặc định. Trên máy cấu hình mạnh, chỉ riêng lúc khởi động đã có thể cấp phát trước 400 MB trước khi ứng dụng xử lý bất kỳ request nào.
Tùy chọn A — Xóa giới hạn direct memory riêng của Netty và để JVM flag kiểm soát toàn bộ:
-Dio.netty.maxDirectMemory=0
Tùy chọn B — Chuyển sang heap buffer. Thông lượng thấp hơn một chút, nhưng mô hình bộ nhớ đơn giản hơn nhiều:
// Trong Netty server bootstrap của bạn
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); // false = heap
Tùy chọn C — Giữ direct buffer nhưng giảm số lượng arena để thu nhỏ footprint cấp phát trước:
-Dio.netty.allocator.numDirectArenas=1
Tùy chọn C thường là sự đánh đổi tốt nhất cho các service nặng về I/O: bạn giữ được lợi thế hiệu năng của direct memory trong khi giảm việc cấp phát lúc khởi động từ 400 MB xuống còn ~25 MB.
Cách 3 — Tìm và khắc phục rò rỉ buffer
Tăng giới hạn chỉ mua thêm thời gian. Nếu mức sử dụng tăng đều đặn và crash chỉ xảy ra muộn hơn, bạn đang có rò rỉ bộ nhớ.
Direct buffer được giải phóng bởi đối tượng Cleaner gắn với garbage collection. Nếu code giữ references hoặc cấp phát nhanh hơn GC có thể dọn dẹp, bộ nhớ sẽ tăng không giới hạn. Bắt đầu bằng cách kiểm tra những gì đang thực sự được cấp phát ngay lúc này — mà không cần khởi động lại:
# Yêu cầu -XX:NativeMemoryTracking=summary khi khởi động
jcmd <pid> VM.native_memory summary scale=MB
# Cách dự phòng cũ hơn qua jmap
jmap -histo <pid> | grep Direct
Để tắt buffer caching trong tầng NIO (Java 9+, hữu ích khi profiling):
-Djdk.nio.maxCachedBufferSize=0
Với NIO thuần, ép giải phóng ngay thay vì chờ GC. Lưu ý: cách này dùng internal JDK API có thể thay đổi trong các phiên bản tương lai:
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);
try {
// dùng buf
} finally {
if (buf instanceof sun.nio.ch.DirectBuffer) {
((sun.nio.ch.DirectBuffer) buf).cleaner().clean();
}
}
Với Netty, mọi ByteBuf đều phải được release. Bỏ sót một lần gọi release() là buffer đó rò rỉ suốt vòng đời của tiến trình:
ByteBuf buf = ctx.alloc().directBuffer(1024);
try {
// ghi vào buf, truyền qua pipeline
} finally {
buf.release(); // giảm ref count; giải phóng khi refCnt về 0
}
Bật leak detector của Netty trong môi trường staging. Nó tốn kém trong production, nhưng vô cùng hữu ích để tìm chính xác nơi rò rỉ được cấp phát:
-Dio.netty.leakDetection.level=PARANOID
Rò rỉ sẽ xuất hiện dưới dạng LEAK: ByteBuf.release() was not called before it's garbage-collected trong log, kèm stack trace đầy đủ trỏ đến vị trí cấp phát.
Cách 4 — Ép GC chạy thường xuyên hơn (chỉ là giải pháp tạm thời)
Chưa thể thay đổi code ngay? Bạn có thể bảo JVM thu gom rác tích cực hơn, qua đó kích hoạt callback Cleaner trên các direct buffer không còn được tham chiếu sớm hơn:
-XX:+ExplicitGCInvokesConcurrent -XX:MaxGCPauseMillis=50
Cách khác, gọi System.gc() từ một background thread. Cách này không đẹp và không giải quyết nguyên nhân gốc rễ — nhưng đã cứu không ít kỹ sư trực on-call lúc 3 giờ sáng:
ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();
cleaner.scheduleAtFixedRate(System::gc, 0, 30, TimeUnit.SECONDS);
Hãy xem đây là bản vá tạm thời. Hãy ship bản fix thực sự — giải phóng tường minh hoặc tăng ngân sách bộ nhớ — trong sprint tới.
Xác nhận bản fix
Sau khi áp dụng thay đổi, theo dõi direct memory dưới tải thực tế. Mức sử dụng phải ổn định, không tăng liên tục:
# Yêu cầu -XX:NativeMemoryTracking=summary khi khởi động
watch -n 2 'jcmd $(pgrep -f app.jar) VM.native_memory summary scale=MB | grep -A5 "Internal"'
# Qua JMX — hoạt động mà không cần NativeMemoryTracking
# MBean: java.nio:type=BufferPool,name=direct
# Attributes: Count, MemoryUsed, TotalCapacity
Với Prometheus + JMX exporter, vẽ đồ thị metric này:
java_nio_buffer_pool_memory_used_bytes{pool="direct"}
Ứng dụng hoạt động tốt sẽ cho thấy đường thẳng ngang sau khi warmup. Rò rỉ trông như sườn núi trượt tuyết — tăng đều đặn cho đến lần crash tiếp theo.
Nếu bạn chạy với chế độ leak detection PARANOID, một lần chạy sạch sẽ không có dòng LEAK: ByteBuf.release() was not called nào. Dù chỉ một dòng cũng cần điều tra.
Tham chiếu nhanh
- Giảm áp ngay lập tức:
-XX:MaxDirectMemorySize=512m(hoặc cao hơn) - Netty cấp phát quá nhiều lúc khởi động:
-Dio.netty.allocator.numDirectArenas=1 - Tìm rò rỉ:
-Dio.netty.leakDetection.level=PARANOID+ luôn gọibuf.release() - Giám sát: JMX MBean
java.nio:type=BufferPool,name=directhoặc Prometheusjava_nio_buffer_pool_memory_used_bytes{pool="direct"}

