Backend

99 Ngày Java — Ngày 71: Bộ nhớ JVM — stack, heap, metaspace

SSite Admin
6 tháng 10, 2026 11 phút đọc 7 lượt xem
99 Ngày Java — Ngày 71: Bộ nhớ JVM — stack, heap, metaspace

Mười ngày vừa rồi là concurrency: luồng, khóa, executor, virtual thread. Từ hôm nay ta mở chặng mới: bên trong JVM. Trước khi nói về garbage collector hay profiler, cần một tấm bản đồ: khi chương trình chạy, mỗi thứ nằm ở đâu? Biến local ở đâu, object ở đâu, bản thân class ở đâu, và khi JVM báo OutOfMemoryError thì nó đang nói về vùng nào. Bài này vẽ tấm bản đồ đó cho Java 21.

Bộ nhớ JVM: stack frame, heap theo thế hệ, metaspace và các loại OutOfMemoryError

1. Ba vùng cần nhớ: stack, heap, metaspace

JVM chia bộ nhớ thành nhiều vùng, nhưng ba vùng quyết định phần lớn hành vi bạn quan sát được. Stack: mỗi luồng một cái, chứa các frame của lời gọi phương thức. Heap: dùng chung cho mọi luồng, chứa mọi object và mảng, do garbage collector quản lý. Metaspace: bộ nhớ native nằm ngoài heap, chứa metadata của các class đã nạp.

import java.util.ArrayList;
import java.util.List;

public class MemoryMap {
    // Metadata của lớp MemoryMap (tên, bytecode, bảng hằng) → METASPACE (bộ nhớ native)
    // ArrayList mà CACHE trỏ tới → HEAP; CACHE là static nên là một GC root
    static final List<String> CACHE = new ArrayList<>();

    static int sum(int a, int b) {
        int result = a + b;      // a, b, result: giá trị nguyên thủy nằm ngay trong FRAME của sum()
        return result;           // return: frame bị pop, ba biến biến mất — không cần GC
    }

    public static void main(String[] args) {
        int count = 3;                           // nguyên thủy → frame của main()
        StringBuilder sb = new StringBuilder();  // biến sb (tham chiếu) → frame; đối tượng → HEAP
        for (int i = 0; i < count; i++) {
            sb.append(sum(i, i)).append(' ');    // mỗi lần gọi sum(): push một frame, xong thì pop
        }
        CACHE.add(sb.toString());                // String mới → heap, sống tiếp vì CACHE giữ
        System.out.println(CACHE);               // [0 2 4 ]
    }
}
  • Giá trị nguyên thủy của biến local (count, result) nằm ngay trong frame. Với biến kiểu tham chiếu (sb), frame chỉ giữ tham chiếu; bản thân object nằm trên heap.

  • Field của object, kể cả field nguyên thủy, đi cùng object lên heap. Câu “primitive nằm trên stack” chỉ đúng với biến local và tham số.

  • Metadata của class (cấu trúc, bytecode, bảng hằng) nằm ở metaspace. Giá trị của static field thì được giữ cùng đối tượng Class trên heap, và static field là một GC root.

  • Chuỗi literal nằm trong string pool, và string pool nằm trên heap từ Java 7. Không còn vùng PermGen nào từ Java 8.

2. Stack: mỗi luồng một chồng frame

Mỗi lần gọi phương thức, JVM đẩy một frame lên stack của luồng đang chạy. Frame gồm mảng biến local (tham số và biến khai báo trong hàm), operand stack để bytecode tính toán, và thông tin để quay về nơi gọi. Phương thức kết thúc thì frame bị gỡ, toàn bộ biến local biến mất ngay, không cần garbage collector.

public class StackDepth {
    static int depth = 0;

    static void dive() {
        depth++;
        dive();          // không có điểm dừng: mỗi lần gọi thêm một frame
    }

    public static void main(String[] args) throws InterruptedException {
        long stackSize = Long.parseLong(args.length > 0 ? args[0] : "256") * 1024;
        Thread t = new Thread(null, () -> {
            try {
                dive();
            } catch (StackOverflowError e) {     // Error, không phải Exception
                System.out.println("StackOverflowError ở độ sâu " + depth);
            }
        }, "diver", stackSize);                  // kích thước stack gợi ý cho riêng luồng này
        t.start();
        t.join();
    }
}
// java StackDepth.java 256    → StackOverflowError ở độ sâu 3370      (stack 256 KB)
// java StackDepth.java 4096   → StackOverflowError ở độ sâu 101076    (stack 4 MB)
// Con số cụ thể khác nhau theo máy và JIT; tỉ lệ theo kích thước stack mới là điều cần thấy.
  • Stack có kích thước cố định cho mỗi luồng, đặt bằng -Xss (khoảng 1 MB mặc định trên Linux và macOS 64-bit). Hết chỗ thì JVM ném StackOverflowError. Tăng -Xmx không giúp gì: heap và stack là hai vùng khác nhau.

  • Nguyên nhân gần như luôn là đệ quy không có điểm dừng hoặc quá sâu: duyệt cây lệch, toString() của hai object gọi vòng nhau, hai entity JPA quan hệ hai chiều được serialize sang JSON.

  • Vì stack là riêng của từng luồng, biến local không bao giờ bị luồng khác nhìn thấy. Thứ bị chia sẻ là object trên heap mà nhiều luồng cùng giữ tham chiếu, đúng bài toán của chặng concurrency vừa xong.

  • Một nghìn platform thread là khoảng một nghìn stack được giữ chỗ. Virtual thread (Ngày 68) khác ở đây: frame của nó được lưu thành các khối trên heap và lớn dần theo nhu cầu, nên tạo hàng trăm nghìn luồng ảo vẫn ổn.

3. Heap: young và old

Mọi new đều xin bộ nhớ từ heap. Các collector thông dụng chia heap theo tuổi của object: young generation gồm eden (nơi object ra đời) và hai survivor space; old generation chứa những object đã sống qua nhiều lần thu gom. Với G1, collector mặc định, heap được cắt thành nhiều region bằng nhau và mỗi region được gán vai trò eden, survivor hoặc old.

import java.util.ArrayList;
import java.util.List;

public class HeapFill {
    public static void main(String[] args) {
        List<byte[]> keep = new ArrayList<>();
        try {
            while (true) {
                keep.add(new byte[64 * 1024]);   // 64 KB mỗi vòng, không bao giờ thả
            }
        } catch (OutOfMemoryError e) {
            int held = keep.size() / 16;
            keep = null;                         // thả ra để còn chỗ in thông báo
            System.out.println("giữ được " + held + " MB: " + e.getMessage());
        }
    }
}
// java -Xmx64m HeapFill.java   → giữ được 59 MB: Java heap space
  • -Xms là kích thước heap ban đầu, -Xmx là trần. Không đặt -Xmx thì JVM lấy mặc định một phần tư RAM mà nó nhìn thấy.

  • Trong container, JVM đọc giới hạn bộ nhớ của cgroup thay vì RAM của máy chủ. Dùng -XX:MaxRAMPercentage để nói “heap được dùng bao nhiêu phần trăm của container” thay cho một con số cứng.

  • Cấp phát trên heap rất rẻ: mỗi luồng có một vùng đệm riêng trong eden (TLAB) nên phần lớn lệnh new chỉ là dịch một con trỏ. Cái đắt là giữ object sống lâu, không phải tạo ra nó.

  • Chia theo thế hệ là để thu gom rẻ hơn: đa số object chết rất trẻ. Vì sao điều đó quan trọng và GC tận dụng nó thế nào là nội dung của Ngày 72.

4. Metaspace và phần bộ nhớ ngoài heap

Khi một class được nạp, JVM lưu metadata của nó vào metaspace. Đây là bộ nhớ native xin thẳng từ hệ điều hành, không tính vào -Xmx. Metadata chỉ được giải phóng khi class loader nạp ra nó không còn ai tham chiếu. Ứng dụng thông thường nạp một số class cố định rồi dừng; metaspace tăng mãi là dấu hiệu có nơi đang sinh class hoặc class loader liên tục.

import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

public class MetaspaceFill {
    static class Payload {}

    // Mỗi loader định nghĩa lại cùng một lớp → mỗi lần là một bản metadata mới trong metaspace
    static class IsolatedLoader extends ClassLoader {
        Class<?> define(byte[] bytes) {
            return defineClass("MetaspaceFill$Payload", bytes, 0, bytes.length);
        }
    }

    public static void main(String[] args) throws Exception {
        byte[] bytes;
        try (InputStream in = MetaspaceFill.class.getResourceAsStream("MetaspaceFill$Payload.class")) {
            bytes = in.readAllBytes();
        }
        List<Class<?>> keep = new ArrayList<>();   // giữ Class → giữ loader → metadata không được gỡ
        while (true) {
            keep.add(new IsolatedLoader().define(bytes));
        }
    }
}
// javac MetaspaceFill.java
// java -XX:MaxMetaspaceSize=24m MetaspaceFill
// → Exception in thread "main" java.lang.OutOfMemoryError: Metaspace   (heap vẫn còn trống)
# Heap: kích thước ban đầu và tối đa
java -Xms256m -Xmx512m App
# Trong container: tính theo phần trăm RAM của container (mặc định 25%)
java -XX:MaxRAMPercentage=75 App

# Stack của mỗi platform thread (mặc định khoảng 1 MB trên Linux/macOS 64-bit)
java -Xss512k App

# Metaspace: mặc định KHÔNG giới hạn — đặt trần để lỗi lộ ra sớm
java -XX:MaxMetaspaceSize=256m App

# Bộ nhớ native của JVM đi đâu: bật Native Memory Tracking rồi hỏi bằng jcmd
java -XX:NativeMemoryTracking=summary App
jcmd <pid> VM.native_memory summary

# Luôn đặt sẵn: chụp heap khi OOM (Ngày 75 sẽ đọc file này)
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps App
Total: reserved=1510MB, committed=168MB
-                 Java Heap (reserved=64MB, committed=64MB)      ← -Xmx
-                     Class (reserved=1024MB, committed=1MB)     ← compressed class space
-                 Metaspace (reserved=64MB, committed=1MB)       ← metadata của class
-                    Thread (reserved=16MB, committed=1MB)       ← stack của các luồng
-                      Code (reserved=242MB, committed=7MB)      ← code cache của JIT
-                        GC (reserved=81MB, committed=81MB)      ← cấu trúc riêng của collector
// Số liệu minh họa, làm tròn từ một lần chạy với -Xmx64m. reserved = địa chỉ đã giữ chỗ,
// committed = bộ nhớ thật sự đã xin hệ điều hành. Tổng committed luôn lớn hơn -Xmx.
  • Mặc định metaspace không có trần: nó có thể lớn tới khi hệ điều hành hết bộ nhớ. Đặt -XX:MaxMetaspaceSize để lỗi lộ ra sớm với thông điệp rõ ràng.

  • Ngoài heap và metaspace, tiến trình Java còn dùng bộ nhớ cho stack của các luồng, code cache của JIT, cấu trúc của collector và direct buffer (ByteBuffer.allocateDirect, giới hạn bằng -XX:MaxDirectMemorySize).

  • Vì vậy bộ nhớ tiến trình luôn lớn hơn -Xmx. Đặt -Xmx bằng đúng giới hạn của container thì container sẽ bị hệ điều hành kill (OOMKilled, exit code 137) mà không có exception Java nào được ghi lại.

  • Native Memory Tracking cho biết từng phần chiếm bao nhiêu. Nó tốn vài phần trăm hiệu năng, nên bật khi cần điều tra chứ không để thường trực.

5. Đọc đúng thông điệp OutOfMemoryError

OutOfMemoryError không phải một lỗi mà là một họ lỗi. Phần chữ đi sau dấu hai chấm cho biết vùng nào cạn, và mỗi vùng có cách xử lý khác hẳn nhau.

  • Java heap space: heap đầy và GC không thu hồi đủ. Hoặc heap thật sự nhỏ so với dữ liệu, hoặc có rò rỉ (object còn bị giữ ngoài ý muốn). Lấy heap dump trước khi tăng -Xmx.

  • GC overhead limit exceeded: JVM dành gần hết thời gian cho GC mà thu hồi được rất ít. Thông điệp này đến từ Parallel GC; với G1 mặc định, cùng tình trạng đó thường kết thúc bằng Java heap space. Bản chất giống nhau: heap gần như toàn object còn sống.

  • Metaspace: quá nhiều class, hoặc class loader bị rò. Thường gặp khi redeploy nhiều lần trên application server, hoặc thư viện sinh class động không có điểm dừng. Tăng heap không có tác dụng.

  • unable to create native thread: hệ điều hành từ chối tạo thêm luồng, do hết bộ nhớ native cho stack hoặc chạm giới hạn số luồng và tiến trình (ulimit -u, giới hạn pid của container). Tăng -Xmx còn làm tệ hơn vì bớt phần bộ nhớ dành cho stack.

  • Direct buffer memory: vùng direct buffer đã đầy, thường do thư viện mạng giữ buffer mà không trả.

  • StackOverflowError không thuộc họ này: nó là lỗi độ sâu của một luồng, sửa bằng cách sửa đệ quy.

6. Tự kiểm tra

  • Trong MemoryMap, liệt kê mọi thứ đang nằm trên heap tại dòng System.out.println và chỉ ra cái nào còn reachable sau khi main kết thúc vòng lặp.

  • Chạy StackDepth với 256, 1024 và 4096: độ sâu có tăng tuyến tính không? Thêm ba tham số long cho dive() rồi đo lại và giải thích.

  • Chạy HeapFill với -Xmx64m rồi -Xmx128m. Thêm -XX:+HeapDumpOnOutOfMemoryError và xem file nào được tạo ra.

  • Chạy MetaspaceFill với và không có -XX:MaxMetaspaceSize, theo dõi bằng jcmd <pid> VM.native_memory summary. Sau đó bỏ danh sách keep: vì sao chương trình không còn lỗi?

Tóm lại: stack giữ frame và biến local của từng luồng, hết thì StackOverflowError; heap giữ mọi object, chia young và old, giới hạn bởi -Xmx; metaspace giữ metadata của class ngoài heap; và mỗi thông điệp OutOfMemoryError chỉ vào một vùng khác nhau. Có bản đồ rồi, Ngày 72 đi vào cỗ máy dọn dẹp heap: garbage collection, từ reachability tới cách đọc log GC.

Tài liệu đối chiếu

JVM Specification, Run-Time Data Areas (Java SE 21): https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.5 ; HotSpot GC Tuning Guide: https://docs.oracle.com/en/java/javase/21/gctuning/ ; Native Memory Tracking: https://docs.oracle.com/en/java/javase/21/vm/native-memory-tracking.html ; Troubleshooting Guide, Understand the OutOfMemoryError Exception: https://docs.oracle.com/en/java/javase/21/troubleshoot/troubleshooting-memory-leaks.html

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 71: Microservices tổng quan

Monolith và microservices khác nhau ở đâu, được gì và trả giá gì, khi nào không nên tách, và vì sao nên bắt đầu bằng modular monolith với Spring Modulith.

6 thg 10, 20269 phút5
99 Ngày Spring — Ngày 70: Observability: metrics, tracing và log

Dùng Actuator, Micrometer và tracing để nhìn thấy độ trễ, lỗi và hành trình request.

5 thg 10, 20262 phút7
99 Ngày Java — Ngày 70: Tổng kết concurrency: chọn đúng công cụ

Checklist race condition, deadlock, hủy task, backpressure và cách chọn lock, queue, future, virtual thread.

5 thg 10, 20262 phút5