Nội dung bài viết
  1. Tổng quan và bản chất của JVM
  2. Kiến trúc tổng thể của JVM
  3. Phân tích JVM Memory Areas
  4. Execution Engine, JIT và Garbage Collection
  5. Luồng hoạt động thực tế của JVM
  6. Debug, Memory Leak và JVM Tuning
  7. Thực hành và câu hỏi kiểm tra kiến thức
  8. Tổng kết tư duy của Java System Architect

Java Virtual Machine (JVM) là một trong những nền tảng quan trọng nhất mà lập trình viên Java cần hiểu nếu muốn tiến từ việc viết code ứng dụng sang thiết kế, vận hành và tối ưu các hệ thống phần mềm lớn.

Khi mới học Java, chúng ta thường quan tâm đến cú pháp, class, interface, collection, exception hay framework. Nhưng khi ứng dụng chạy chậm, bộ nhớ tăng bất thường, CPU đạt 100%, xuất hiện OutOfMemoryError hoặc một hệ thống gặp vấn đề về độ trễ, kiến thức về JVM bắt đầu trở thành yếu tố quyết định.

Mục tiêu của bài viết này không chỉ là ghi nhớ tên các thành phần của JVM. Mục tiêu quan trọng hơn là xây dựng một mô hình tư duy giúp chúng ta trả lời được các câu hỏi:

Mục tiêu học tập: - Java code được chuyển thành mã máy và thực thi như thế nào? - Một object nằm ở đâu trong bộ nhớ, và khi nào nó được thu hồi? - JVM quyết định biên dịch một method bằng JIT như thế nào? - Tại sao một ứng dụng có thể hết Heap dù đã có Garbage Collector? - Làm thế nào để phân tích lỗi bộ nhớ và tối ưu JVM dựa trên dữ liệu thực tế?

Phạm vi của bài học: Bài viết sử dụng HotSpot JVM làm ví dụ triển khai phổ biến, đồng thời phân biệt các khái niệm được quy định bởi JVM Specification với các chi tiết riêng của HotSpot. Các thông số, sơ đồ và hành vi cụ thể có thể khác nhau tùy JDK, phiên bản JVM, hệ điều hành và cấu hình chạy.

1. Tổng quan và bản chất của JVM

1.1 JVM là gì?

Java Virtual Machine (JVM) là máy ảo thực thi Java bytecode. JVM cung cấp một môi trường runtime với những quy tắc về nạp class, mô hình bộ nhớ, thực thi lệnh, xử lý ngoại lệ và tương tác với các dịch vụ cấp thấp của nền tảng.

JVM được gọi là máy ảo vì nó tạo ra một môi trường thực thi theo mô hình được định nghĩa bởi đặc tả JVM. Nó không phải là một máy tính vật lý riêng biệt, cũng không nhất thiết là một hệ điều hành ảo hoàn chỉnh. Trong triển khai phổ biến như HotSpot, JVM chạy như một tiến trình native trên hệ điều hành.

Để hình dung, hãy xem ba lớp sau:

Java Application
        |
        | Java bytecode, Java APIs
        v
+----------------------------------+
|              JVM                 |
|                                  |
| Class Loading                    |
| Runtime Memory                   |
| Interpreter / JIT Compiler       |
| Garbage Collector                |
| Runtime Services                 |
+----------------------------------+
        |
        | Native instructions / OS APIs
        v
Operating System
        |
        v
Physical CPU and Memory

Ứng dụng Java không cần tự triển khai toàn bộ cơ chế cấp phát bộ nhớ, giải mã bytecode, quản lý vòng đời class hay thu hồi object. Phần lớn những nhiệm vụ này được runtime thực hiện theo đặc tả và implementation tương ứng.

Tuy nhiên, cần phân biệt hai khái niệm:

JVM Specification: Đặc tả quy định hành vi mà một JVM tuân thủ phải cung cấp, chẳng hạn định dạng class file, tập lệnh bytecode, runtime data areas và quy tắc thực thi.

JVM Implementation: Chương trình thực tế hiện thực đặc tả đó. HotSpot là một implementation phổ biến. Các implementation khác có thể sử dụng chiến lược biên dịch, quản lý bộ nhớ hoặc tối ưu khác nhau.

Tư duy kiến trúc: Đừng đồng nhất JVM với HotSpot. JVM là mô hình và hợp đồng thực thi; HotSpot là một cách triển khai mô hình đó.

1.2 Triết lý Write Once, Run Anywhere (WORA)

Một ưu điểm nổi tiếng của Java là Write Once, Run Anywhere — viết một lần, chạy trên nhiều nền tảng. Để hiểu chính xác WORA, chúng ta cần nhìn vào tầng bytecode và runtime.

Giả sử có một file Java:

Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println("Hello JVM");
    }
}

Khi biên dịch bằng compiler của JDK, source code được chuyển thành class file chứa bytecode. Bytecode tuân theo định dạng class file và tập lệnh của JVM, thay vì được biên dịch trực tiếp thành mã máy chỉ dành cho một CPU cụ thể.

                 Main.java
                     |
                     | javac
                     v
                 Main.class
                 Bytecode
                     |
          +----------+----------+
          |          |          |
          v          v          v
       Windows      Linux      macOS
          |          |          |
       JVM phù hợp với từng nền tảng
          |          |          |
          v          v          v
       Native      Native     Native
       execution   execution  execution

Cùng một file Main.class có thể chạy trên các hệ điều hành khác nhau nếu những điều kiện tương thích được đáp ứng, bao gồm JVM phù hợp, phiên bản class file được hỗ trợ, thư viện cần thiết và các phụ thuộc của ứng dụng.

WORA không có nghĩa mọi chương trình Java chắc chắn chạy được ở mọi nơi mà không cần thay đổi. Một ứng dụng sử dụng thư viện native chỉ có trên Windows, đường dẫn file cố định theo hệ điều hành hoặc các tính năng phụ thuộc nền tảng vẫn có thể cần điều chỉnh.

Điểm cốt lõi: Java bytecode giúp chuẩn hóa đầu vào cho môi trường thực thi. JVM và các thư viện nền tảng chịu trách nhiệm xử lý sự khác biệt giữa các nền tảng ở phía dưới.

1.3 Phân biệt JDK, JRE và JVM

Ba khái niệm này thường bị nhầm lẫn, đặc biệt khi làm việc với nhiều phiên bản Java hoặc triển khai ứng dụng lên server.

JDK - Java Development Kit
|
+-- Compiler: javac
+-- Diagnostic tools
+-- Development utilities
|
+-- Java Runtime Components
    |
    +-- JVM
    |   +-- Class Loader
    |   +-- Runtime Data Areas
    |   +-- Execution Engine
    |   +-- GC
    |
    +-- Java Platform Libraries

JVM (Java Virtual Machine): Môi trường thực thi bytecode, quản lý runtime và cung cấp các cơ chế như class loading, execution và memory management.

JRE (Java Runtime Environment): Theo mô hình truyền thống, đây là môi trường gồm JVM cùng những thư viện và thành phần cần thiết để chạy ứng dụng Java. Từ Java 9, cách phân phối runtime đã thay đổi; không nên mặc định mọi bản JDK hiện đại đều có một thư mục JRE riêng. Có thể tạo runtime image tùy chỉnh bằng công cụ jlink.

JDK (Java Development Kit): Bộ công cụ phục vụ phát triển Java, bao gồm compiler javac, công cụ chẩn đoán và các thành phần runtime cần thiết. JDK được dùng khi phát triển, build, kiểm tra và thường cả khi vận hành ứng dụng.

Ví dụ thực tế:

Khi phát triển một ứng dụng Java, chúng ta dùng javac để biên dịch. Khi chạy ứng dụng, lệnh java khởi động runtime phù hợp. Khi phân tích sự cố production, các công cụ như jcmd, jstack, jmap hoặc Java Flight Recorder có thể hỗ trợ điều tra, tùy phiên bản JDK và môi trường triển khai.

1.4 Luồng chuyển đổi: Source Code → Bytecode → Native Code

Quá trình thực thi Java thường được mô tả bằng ba tầng. Trên thực tế, tầng thứ ba có thể được thực hiện theo nhiều chiến lược, chứ không phải lúc nào cũng biên dịch toàn bộ chương trình thành mã máy trước khi chạy.

Bước 1 — Source Code (.java): Lập trình viên viết mã Java. Đây là dạng mã nguồn có cú pháp hướng đến con người.

Bước 2 — Compilation (.class): Compiler javac kiểm tra mã nguồn và tạo class file chứa bytecode cùng metadata cần thiết. Một source file có thể tạo ra nhiều class file, ví dụ khi sử dụng nested class.

Compile Java
javac Main.java
javap -c Main

Lệnh javap -c Main giúp xem bytecode của class. Đây là công cụ hữu ích khi cần hiểu compiler đã chuyển biểu thức Java thành những instruction nào.

Bước 3 — Runtime execution: JVM nạp class, kiểm tra và thực thi bytecode. Interpreter có thể thực thi bytecode trực tiếp theo mô hình của JVM. Với những đoạn code đủ nóng, JIT compiler có thể biên dịch chúng thành native machine code được tối ưu cho nền tảng đang chạy.

Java Source Code (.java)
          |
          | javac
          v
Java Bytecode (.class)
          |
          | Class Loading
          v
Verification / Linking
          |
          v
Interpreter execution
          |
          | Profiling identifies hot code
          v
JIT Compilation (when appropriate)
          |
          v
Native Machine Code
          |
          v
CPU executes instructions

Native code ở đây là mã máy phù hợp với kiến trúc CPU và nền tảng đang chạy. Trong HotSpot, JIT-generated code được quản lý trong code cache; nó không đơn giản là ghi đè toàn bộ file .class thành một file executable mới.

Điểm cần nhớ: javac biên dịch source code thành bytecode. JIT compiler hoạt động tại runtime và có thể biên dịch các đoạn code nóng thành mã máy. Hai quá trình này có vai trò khác nhau.

2. Kiến trúc tổng thể của JVM

2.1 Bản đồ kiến trúc JVM

Để hiểu cách các thành phần phối hợp với nhau, chúng ta có thể chia kiến trúc JVM thành bốn nhóm lớn: Class Loader Subsystem, Runtime Data Areas, Execution Engine và cơ chế tương tác với native code.

                     JVM
                      |
          +-----------+-----------+
          |           |           |
          v           v           v
     Class Loader  Runtime Data  Execution
      Subsystem      Areas        Engine
          |           |           |
          |       +---+---+       +----------+
          |       |       |       |          |
          |      Heap   Stacks Interpreter   JIT
          |                               Compiler
          |           |           |
          +-----------+-----------+
                      |
                      v
             Runtime Services
                      |
              +-------+-------+
              |               |
              v               v
             JNI        Native Libraries
                              |
                              v
                       Operating System

Mỗi nhóm giải quyết một vấn đề khác nhau:

Class Loader Subsystem: Nạp class vào runtime, phối hợp với quá trình linking và initialization.

Runtime Data Areas: Cung cấp các vùng dữ liệu phục vụ thực thi, từ object trên Heap đến frame của từng method invocation.

Execution Engine: Thực thi bytecode, biên dịch các đoạn code thích hợp và phối hợp với cơ chế quản lý bộ nhớ.

JNI và Native Libraries: Cho phép Java tương tác với native code và những chức năng của hệ điều hành hoặc thư viện native.

Đây là sơ đồ khái niệm. Các implementation JVM có thể tổ chức thành phần nội bộ khác nhau; GC cũng không chỉ là một bộ phận độc lập nằm hoàn toàn trong Execution Engine mà còn phối hợp chặt chẽ với allocator, runtime và cơ chế quản lý object.

2.2 Class Loader Subsystem

Trước khi JVM thực thi một method, nó phải có thông tin về class chứa method đó. Class Loader Subsystem đảm nhiệm việc nạp class và tham gia vào vòng đời của class trong JVM.

Quy trình tổng quát gồm ba giai đoạn:

Class Loading Lifecycle
          |
          v
       Loading
          |
          v
        Linking
          |
          +-- Verification
          |
          +-- Preparation
          |
          +-- Resolution
          |
          v
     Initialization

2.2.1 Loading — Nạp class

Ở giai đoạn Loading, JVM xác định binary representation của class, lấy dữ liệu class file từ nguồn thích hợp và tạo representation tương ứng trong runtime.

Trong các JDK hiện đại, ba built-in class loaders thường được nhắc đến là:

Bootstrap Class Loader: Nạp các class nền tảng cốt lõi của Java runtime. Đây là thành phần đặc biệt, không nhất thiết là một đối tượng Java thông thường có thể quan sát trực tiếp.

Platform Class Loader: Nạp các class thuộc platform modules được giao cho loader này, bên ngoài nhóm do Bootstrap xử lý.

Application Class Loader: Còn được gọi là system class loader. Trong cấu hình thông thường, loader này nạp application classes từ classpath hoặc những nguồn được cấu hình liên quan.

Application Class Loader
          |
          | parent delegation
          v
Platform Class Loader
          |
          | parent delegation
          v
Bootstrap Class Loader

Mô hình ủy quyền lên parent giúp các class nền tảng được ưu tiên nạp bởi loader phía trên, giảm nguy cơ ứng dụng vô tình định nghĩa class trùng tên để thay thế class hệ thống. Đây là mô hình phổ biến, nhưng custom class loader có thể thay đổi hành vi trong phạm vi được phép.

Trong Java 8, tên thường gặp là Extension Class Loader. Từ Java 9, hệ thống module và Platform Class Loader thay thế mô hình extension loader cũ. Vì vậy, khi học JVM cần chú ý phiên bản Java đang sử dụng.

2.2.2 Linking — Liên kết class

Linking bao gồm ba công đoạn: Verification, Preparation và Resolution.

Verification: Kiểm tra class file và bytecode có đáp ứng các ràng buộc cần thiết để JVM có thể xử lý hay không. Quá trình này giúp phát hiện những cấu trúc bytecode không hợp lệ, vi phạm các quy tắc kiểu dữ liệu hoặc các ràng buộc của execution model.

Preparation: Chuẩn bị các cấu trúc dữ liệu cần thiết cho class và cấp phát bộ nhớ cho các biến class (static fields) theo quy tắc JVM. Các biến này được gán giá trị mặc định ban đầu; việc thực thi các phép gán static thông thường thuộc Initialization.

Resolution: Chuyển symbolic references trong runtime constant pool thành các tham chiếu trực tiếp hoặc cơ chế liên kết mà runtime sử dụng được. Tùy implementation và loại tham chiếu, việc resolve có thể được thực hiện sớm hoặc trì hoãn đến khi cần.

2.2.3 Initialization — Khởi tạo class

Initialization là giai đoạn thực thi logic khởi tạo class, bao gồm việc xử lý các phép gán biến static và các static initializer theo thứ tự được Java quy định.

Config.java
public class Config {
    static int timeout = 30;

    static {
        System.out.println("Config initialized");
    }
}

Trong quá trình chuẩn bị, timeout trước tiên nhận giá trị mặc định 0. Khi class được initialize, logic khởi tạo sẽ gán giá trị 30 và thực thi static block.

Compiler thường biểu diễn logic khởi tạo class trong method đặc biệt <clinit> khi cần. Đây là tên ở cấp bytecode, không phải method mà lập trình viên có thể khai báo trực tiếp như một method Java thông thường.

Initialization có thể được kích hoạt khi chương trình thực hiện các hành động chủ động sử dụng class, ví dụ tạo instance, gọi static method, hoặc đọc/ghi một static field không phải compile-time constant trong những trường hợp được Java Language Specification quy định.

Lưu ý: Loading không đồng nghĩa với Initialization. Class có thể đã được load nhưng static initializer chưa chạy. Các bước linking và resolution cũng có thể được thực hiện theo nhu cầu, thay vì luôn xảy ra theo một trình tự thời gian cứng nhắc trong mọi implementation.

2.3 Runtime Data Areas — Các vùng dữ liệu runtime

JVM cần một mô hình bộ nhớ để lưu dữ liệu phục vụ quá trình thực thi. Theo JVM Specification, các Runtime Data Areas bao gồm những vùng được chia sẻ giữa các thread và những vùng có vòng đời gắn với từng thread.

Runtime Data Areas
|
+-- Shared between threads
|   |
|   +-- Heap
|   +-- Method Area
|
+-- Per-thread
    |
    +-- PC Register
    +-- JVM Stack
    +-- Native Method Stack

Heap: Vùng dữ liệu runtime chủ yếu lưu trữ object và array. Đây là vùng được chia sẻ giữa các thread.

Method Area: Vùng logic chứa dữ liệu liên quan đến class, như metadata, method information và runtime constant pool. Cách hiện thực vùng này phụ thuộc JVM implementation.

JVM Stack: Mỗi thread có stack riêng để quản lý các frame phục vụ method invocation.

PC Register: Mỗi thread có PC register theo mô hình JVM, giúp theo dõi vị trí thực thi của thread đối với Java virtual machine instruction.

Native Method Stack: Phục vụ việc thực thi native method theo implementation. JVM Specification cho phép các implementation thiết kế hoặc quản lý vùng này theo cách phù hợp.

Không nên hình dung những vùng trên như các hộp RAM vật lý luôn tách biệt tuyệt đối. Đây là các vùng logic của runtime; cách chúng được hiện thực và cấp phát trong bộ nhớ native phụ thuộc vào JVM cụ thể.

2.4 Execution Engine

Execution Engine chịu trách nhiệm thực thi bytecode. Trong HotSpot, hai cơ chế quan trọng là Interpreter và JIT Compiler.

Interpreter: Thực thi bytecode mà không cần biên dịch trước toàn bộ ứng dụng thành native code. Cơ chế này cho phép chương trình bắt đầu chạy nhanh.

JIT Compiler: Biên dịch các method hoặc đoạn code được chọn thành native machine code trong quá trình chạy. JIT có thể tận dụng thông tin profiling để tối ưu những đường thực thi phổ biến.

Garbage Collector: Quản lý việc thu hồi bộ nhớ của các object không còn reachable theo quy tắc của GC. Đây là thành phần cốt lõi của runtime memory management và phối hợp với quá trình cấp phát object.

Execution Engine không phải lúc nào cũng biên dịch tất cả bytecode. Những method ít được gọi có thể tiếp tục được thực thi bằng Interpreter, trong khi các đoạn code nóng có thể được biên dịch và tối ưu hóa.

2.5 JNI và Native Method Libraries

Java cung cấp Java Native Interface (JNI) để tương tác với code được viết bằng những ngôn ngữ native, phổ biến là C hoặc C++. JNI định nghĩa một giao diện để native code có thể làm việc với JVM và các đối tượng Java theo những quy tắc được hỗ trợ.

Java Application
       |
       v
Java native method declaration
       |
       v
      JNI
       |
       v
Native Library (.dll / .so / .dylib)
       |
       v
Operating System / Native APIs

Ví dụ, một thư viện Java có thể khai báo native method để gọi hàm trong thư viện hệ thống hoặc thư viện C/C++ đã được build cho nền tảng tương ứng.

NativeExample.java
public class NativeExample {
    public native int calculate(int value);

    static {
        System.loadLibrary("nativecalc");
    }
}

Đoạn code trên chỉ minh họa cách khai báo. Để chạy được, cần có thư viện native phù hợp với hệ điều hành, kiến trúc CPU, ABI và quy tắc liên kết của runtime. Chỉ khai báo native không tự tạo ra phần cài đặt cho method.

JNI mang lại khả năng tích hợp mạnh mẽ nhưng cũng có chi phí và rủi ro: lỗi truy cập bộ nhớ trong native code, crash ngoài cơ chế exception thông thường của Java, rò rỉ tài nguyên native và khó khăn trong việc triển khai đa nền tảng.

Góc nhìn kiến trúc: Chỉ sử dụng JNI khi lợi ích về hiệu năng, khả năng tích hợp hoặc quyền truy cập native đủ lớn để bù lại chi phí bảo trì và rủi ro vận hành.

3. Phân tích chuyên sâu JVM Memory Areas

Hiểu bộ nhớ JVM là nền tảng để phân tích hiệu năng, concurrency, object lifecycle và các sự cố production. Điều quan trọng là phải phân biệt nơi lưu object, nơi lưu biến tham chiếu, nơi lưu metadata của class và bộ nhớ native được runtime sử dụng.

3.1 Heap Area — Vùng nhớ dành cho object

Heap là vùng runtime được chia sẻ giữa các thread và là nơi JVM cấp phát phần lớn object, array. Ví dụ:

HeapExample.java
User user = new User();
int[] numbers = new int[100];

Order order = new Order();

Trong ví dụ này, các object User, Order và array thường được cấp phát trên Heap. Các biến cục bộ user, numbers và order nếu là local variables trong method sẽ thuộc frame của thread đang thực thi; chúng lưu giá trị tham chiếu khi kiểu dữ liệu là reference type.

Đây là mô hình logic để học. JIT có thể tối ưu chương trình, chẳng hạn loại bỏ một số allocation hoặc scalar-replace object trong các trường hợp phù hợp. Vì vậy, không nên suy luận rằng mỗi biểu thức new trong source code luôn tương ứng với một object vật lý tồn tại nguyên vẹn trên Heap trong suốt quá trình chạy.

3.1.1 Young Generation và Old Generation

Trong các GC theo thế hệ của HotSpot, Heap thường được mô hình hóa thành Young Generation và Old Generation. Đây là chi tiết triển khai của GC, không phải cấu trúc bắt buộc của mọi JVM collector.

Heap (generational GC model)
|
+-- Young Generation
|   |
|   +-- Eden
|   +-- Survivor S0
|   +-- Survivor S1
|
+-- Old Generation

Eden: Trong các collector có mô hình Young Generation truyền thống, nhiều object mới được cấp phát ở Eden.

Survivor S0 và S1: Hai vùng survivor có thể được dùng luân phiên trong các collector thế hệ truyền thống để chứa object sống sót qua các lần thu gom ở Young Generation. Tên gọi và cơ chế sử dụng cụ thể phụ thuộc collector.

Old Generation: Thường chứa các object sống lâu hoặc được chuyển sang vùng lưu trữ lâu dài hơn theo chính sách của collector.

Ví dụ một object mới được tạo và không còn được tham chiếu sau khi method kết thúc có thể được thu hồi trong một lần Young GC. Một object được giữ lại lâu hơn có thể tiếp tục sống qua nhiều chu kỳ GC và được thăng cấp sang Old Generation, tùy chiến lược của collector.

Object allocation
       |
       v
     Eden
       |
       | Young GC
       v
Survivor space (if it survives)
       |
       | survives additional collections
       v
Old Generation (depending on collector)

Không phải mọi GC hiện đại đều sử dụng cấu trúc Eden/S0/S1/Old theo cùng một cách. G1GC vẫn sử dụng mô hình phân thế hệ nhưng tổ chức Heap bằng các region; ZGC có các cơ chế quản lý bộ nhớ khác và chi tiết phụ thuộc phiên bản. Khi phân tích heap dump hoặc GC log, cần xác định collector trước khi diễn giải cấu trúc bộ nhớ.

3.1.2 String Pool nằm ở đâu?

String Constant Pool là cơ chế canonicalization của chuỗi, giúp các string literal và một số chuỗi được intern dùng chung một instance đại diện khi đáp ứng quy tắc của Java.

StringPoolExample.java
String a = "Java";
String b = "Java";

String c = new String("Java");

System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a.equals(c)); // true

Hai biến a và b tham chiếu đến cùng một string literal được intern. Biểu thức new String("Java") tạo một object String mới trong mô hình thông thường, nên a == c là false, mặc dù nội dung của hai chuỗi bằng nhau.

Trong các HotSpot JVM hiện đại, các String object được intern được lưu trong Heap. Bảng quản lý interned strings là cấu trúc runtime riêng; không nên nhầm bảng đó với toàn bộ String object hoặc với runtime constant pool của class.

Có ba khái niệm cần phân biệt:

String Constant Pool: Cơ chế chia sẻ các string canonicalized.

Runtime Constant Pool: Cấu trúc gắn với từng class hoặc interface trong mô hình JVM, chứa các hằng số và symbolic references cần cho runtime.

Heap: Vùng bộ nhớ lưu trữ các String object trong HotSpot hiện đại.

Kinh nghiệm thực tế: Dùng equals() khi so sánh nội dung String. Chỉ dùng == khi thực sự muốn so sánh identity của hai reference.

3.2 Method Area và Metaspace

Method Area là khái niệm trong JVM Specification, mô tả vùng logic chứa thông tin liên quan đến class. Các thông tin đó có thể bao gồm class metadata, method metadata, runtime constant pool và dữ liệu phục vụ việc thực thi.

Metaspace là cơ chế triển khai của HotSpot để lưu phần lớn class metadata trong native memory. Từ Java 8, Metaspace thay thế PermGen của HotSpot.

JVM Specification
       |
       v
    Method Area
       |
       | HotSpot implementation
       v
    Class Metadata
       |
       v
    Metaspace (native memory)

Metaspace không đồng nghĩa với toàn bộ Method Area ở cấp đặc tả, và cũng không có nghĩa mọi dữ liệu liên quan đến class đều nằm trong Metaspace. Chẳng hạn, interned String object ở HotSpot hiện đại nằm trên Heap.

Một ứng dụng có thể tiêu thụ Metaspace đáng kể khi nạp nhiều class, đặc biệt nếu framework tạo class động, sử dụng nhiều proxy hoặc triển khai nhiều class loader tồn tại lâu dài.

MetaspaceExample.java
ClassLoader loader = MyApplication.class.getClassLoader();

System.out.println(
    MyApplication.class.getName()
);

System.out.println(loader);

Đoạn code này minh họa cách quan sát class và class loader. Nó không trực tiếp đo dung lượng Metaspace; để đo đạc cần sử dụng các công cụ chẩn đoán JVM hoặc các nguồn metric thích hợp.

3.2.1 Vì sao Class Loader có thể ảnh hưởng đến Metaspace?

Class thường được định danh trong JVM bằng tên và defining class loader. Hai class có cùng binary name nhưng được định nghĩa bởi hai class loader khác nhau có thể là hai kiểu khác nhau.

Nếu class loader cùng các class mà nó định nghĩa vẫn còn reachable, JVM có thể không đủ điều kiện unload những class đó. Điều này thường xuất hiện trong các hệ thống triển khai module động, ứng dụng server hoặc môi trường hot reload.

Để class được unload, điều kiện liên quan đến class loader phải được đáp ứng và JVM phải thực hiện việc thu hồi theo chính sách runtime. Vì thế, chỉ việc không còn dùng một class trong source code không đảm bảo metadata của nó được giải phóng ngay lập tức.

3.3 JVM Stack — Vùng nhớ của từng thread

Mỗi thread Java có JVM Stack riêng. Stack được sử dụng để quản lý các frame trong quá trình gọi method. Khi một method được gọi, frame tương ứng được tạo; khi method kết thúc, frame được loại khỏi stack theo quy tắc thực thi.

Thread A                    Thread B
   |                           |
   v                           v
JVM Stack A                 JVM Stack B
   |                           |
   +-- main()                  +-- run()
   +-- service()               +-- process()
   +-- calculate()             +-- validate()

Stack không phải là nơi lưu toàn bộ object của chương trình. Nó lưu dữ liệu cần thiết cho từng lần gọi method, bao gồm local variables, operand stack và các thông tin runtime khác theo mô hình frame của JVM.

3.3.1 Stack Frame gồm những gì?

Mỗi method invocation sử dụng một frame. Ở mức khái niệm, frame có ba nhóm dữ liệu quan trọng:

Local Variables: Chứa các tham số và biến cục bộ theo mô hình JVM. Với reference type, giá trị được lưu là reference; object được tham chiếu không vì thế mà trở thành dữ liệu nằm trong local variable slot.

Operand Stack: Stack thao tác mà bytecode sử dụng để tính toán, truyền đối số và nhận kết quả. Ví dụ, bytecode cộng hai số nguyên có thể lấy hai giá trị từ operand stack, cộng chúng và đẩy kết quả trở lại.

Frame Data: Các dữ liệu runtime khác cần cho quá trình thực thi, chẳng hạn thông tin hỗ trợ dynamic linking, xử lý exception và trả kết quả. Cách biểu diễn nội bộ cụ thể phụ thuộc implementation.

Stack Frame
|
+-- Local Variables
|   +-- Parameters
|   +-- Local primitives
|   +-- Object references
|
+-- Operand Stack
|   +-- Intermediate values
|   +-- Method arguments
|   +-- Computation results
|
+-- Frame Data
    +-- Runtime linking information
    +-- Return / exception handling data

Ví dụ:

Calculator.java
static int add(int a, int b) {
    int result = a + b;
    return result;
}

public static void main(String[] args) {
    int total = add(10, 20);
    System.out.println(total);
}

Khi gọi add(10, 20), JVM tạo frame cho method add. Các giá trị tham số được đặt trong local variable slots; bytecode thực hiện phép cộng và trả kết quả về caller. Khi add kết thúc, frame của nó được pop và frame của main tiếp tục thực thi.

Before calling add()
+-------------------+
| main()            |
| total: not set    |
+-------------------+

During add()
+-------------------+
| add()             |
| a = 10            |
| b = 20            |
| result = 30       |
+-------------------+
| main()            |
+-------------------+

After add() returns
+-------------------+
| main()            |
| total = 30        |
+-------------------+

Sơ đồ trên minh họa trạng thái logic, không phải ảnh chụp chính xác từng slot vật lý trong HotSpot. JIT có thể tối ưu hoặc loại bỏ một số giá trị và frame khỏi cách thực thi vật lý thông thường.

3.4 PC Register — Program Counter Register

Mỗi thread JVM có PC register riêng theo mô hình runtime. Đối với thread đang thực thi Java method, PC register biểu diễn vị trí của JVM instruction hiện tại hoặc instruction tiếp theo theo quy tắc đặc tả.

PC register giúp mô hình thực thi của JVM theo dõi tiến trình thực hiện bytecode. Đối với một số hoạt động native method, trạng thái PC được mô tả khác với khi thực thi Java virtual machine instruction.

Thread
  |
  +-- JVM Stack
  |
  +-- PC Register
         |
         v
   Current execution position
   within Java instruction stream

Trong thực tế, lập trình viên không đọc PC register như một biến Java thông thường. Khi debug hoặc phân tích thread dump, công cụ thường hiển thị stack trace, trạng thái thread và thông tin chẩn đoán ở cấp cao hơn.

3.5 Native Method Stack

Native Method Stack hỗ trợ việc thực thi native methods theo cơ chế của JVM implementation. Khi Java gọi qua JNI sang C/C++, quá trình thực thi có thể sử dụng stack native của thread cùng những cấu trúc runtime liên quan.

Điều này giải thích tại sao một ứng dụng Java vẫn có thể gặp vấn đề do native memory hoặc native stack, ngay cả khi Heap chưa đầy.

Ví dụ, một thư viện native có thể cấp phát bộ nhớ ngoài Heap Java mà không giải phóng đúng cách. Khi đó, dung lượng resident memory của tiến trình có thể tăng nhưng biểu đồ Heap vẫn không thể hiện đầy đủ mức tăng này.

Phân biệt quan trọng: Heap usage, Metaspace usage, native memory và tổng bộ nhớ của tiến trình là những chỉ số khác nhau. Không thể dùng riêng Heap usage để kết luận toàn bộ JVM đang sử dụng bao nhiêu RAM.

4. Execution Engine, JIT Compiler và Garbage Collection

4.1 Interpreter — Thực thi bytecode trực tiếp

Interpreter là cơ chế thực thi bytecode mà không cần chờ biên dịch trước toàn bộ chương trình thành native code.

Ưu điểm của Interpreter là giảm thời gian chuẩn bị để bắt đầu chạy. Đây là điều hữu ích với những method ít được gọi hoặc trong giai đoạn khởi động ứng dụng.

Nhược điểm là việc diễn giải bytecode lặp đi lặp lại thường không đạt hiệu năng tốt như native code đã được tối ưu cho một đoạn thực thi nóng.

4.2 JIT Compiler — Biên dịch tại runtime

JIT là viết tắt của Just-In-Time Compiler. Trong HotSpot, JIT compiler có thể biên dịch bytecode thành native machine code khi runtime xác định rằng việc biên dịch đem lại lợi ích.

Thay vì biên dịch tất cả method ngay khi ứng dụng khởi động, JVM có thể thu thập dữ liệu trong quá trình chạy, xác định method nóng và tối ưu chúng.

Application starts
        |
        v
Interpreter executes code
        |
        v
Runtime profiling
        |
        v
Hot method / hot loop detected
        |
        v
JIT compilation
        |
        v
Optimized native code
        |
        v
Faster repeated execution

JIT có thể thực hiện nhiều kỹ thuật tối ưu, chẳng hạn:

Method Inlining: Chèn phần thân của một method nhỏ vào vị trí gọi khi điều kiện phù hợp. Điều này có thể giảm chi phí gọi method và mở ra thêm cơ hội tối ưu hóa.

Escape Analysis: Phân tích xem object có thoát khỏi phạm vi sử dụng cục bộ hay không. Kết quả có thể cho phép tối ưu allocation, scalar replacement hoặc loại bỏ một số thao tác đồng bộ trong những trường hợp phù hợp.

Loop Optimization: Tối ưu các vòng lặp dựa trên cấu trúc code và thông tin runtime.

Devirtualization: Khi profiling cung cấp đủ thông tin về các kiểu thực tế tại điểm gọi, JIT có thể tối ưu một số virtual call và sau đó có thể inline method phù hợp.

Các tối ưu này phụ thuộc vào code, profile, implementation và điều kiện runtime. Không nên giả định rằng JIT sẽ luôn thực hiện tất cả các tối ưu cho mọi method.

4.3 Tiered Compilation — C1, C2 và Profiling

Trong HotSpot, Tiered Compilation kết hợp các mức thực thi và biên dịch để cân bằng thời gian khởi động, chi phí biên dịch và hiệu năng khi chạy lâu dài.

C1 Compiler: Thường được liên hệ với các mức biên dịch nhanh hơn, có thể thu thập profiling và cung cấp mức tối ưu trung gian.

C2 Compiler: Trong cấu hình HotSpot truyền thống, C2 tập trung vào tối ưu hóa sâu hơn cho các đoạn code nóng, thường với chi phí phân tích và biên dịch cao hơn.

Ở chế độ Tiered Compilation, JVM có thể bắt đầu bằng Interpreter, sau đó biên dịch ở mức phù hợp khi method trở nên nóng. Dữ liệu profiling có thể giúp compiler quyết định chiến lược tối ưu hóa.

Method execution
       |
       v
Interpreter + profiling
       |
       v
C1 compilation / profiling
       |
       v
Hot code detected
       |
       v
C2 optimization (when applicable)
       |
       v
Optimized machine code

Sơ đồ này là mô hình giản lược. Những mức biên dịch cụ thể, các transition và compiler được sử dụng có thể thay đổi theo phiên bản JDK, kiến trúc và flags. Một số build hoặc cấu hình không sử dụng đầy đủ cùng một pipeline.

Vì sao profiling quan trọng? Giả sử một method xử lý phần lớn request với một kiểu dữ liệu phổ biến. JIT có thể tận dụng profile để tối ưu cho đường thực thi thường gặp. Nếu sau đó profile thay đổi, giả định tối ưu có thể không còn đúng và JVM có thể thực hiện deoptimization để quay về một cách thực thi an toàn hơn.

Góc nhìn hiệu năng: Code chạy nhanh trong một microbenchmark ngắn chưa chắc phản ánh hiệu năng ổn định sau khi JVM đã warm-up. Khi benchmark, hãy phân biệt cold start, warm-up và steady state.

4.4 Garbage Collector — Quản lý vòng đời object

Garbage Collector (GC) tự động thu hồi bộ nhớ của các object không còn reachable từ những GC roots theo mô hình reachability của JVM.

GC không chỉ đơn giản tìm những object mà lập trình viên không còn muốn sử dụng. Nó không thể biết ý định của lập trình viên. Nếu một object vẫn được tham chiếu từ static field, collection sống lâu hoặc một thread đang hoạt động, object đó có thể tiếp tục reachable và không đủ điều kiện thu hồi, dù về mặt nghiệp vụ không còn cần thiết.

4.4.1 Reachability và GC Roots

GC thường bắt đầu từ các root references mà runtime xem là điểm xuất phát để xác định các object còn sống. Những root này có thể liên quan đến stack đang hoạt động, static references, JNI references và các cấu trúc runtime khác.

GC Roots
   |
   +------> Object A ------> Object B
   |
   +------> Object C

Object D ------> Object E

A, B, C: reachable from roots
D, E: unreachable if no other root reaches them

Nếu Object D và Object E không thể được đi tới từ bất kỳ GC root nào, chúng có thể đủ điều kiện để GC thu hồi. Thời điểm thu hồi thực tế không được đảm bảo ngay sau khi reference cuối cùng biến mất.

4.4.2 Mark-and-Sweep — Cơ chế nền tảng

Mark-and-Sweep là một mô hình GC kinh điển gồm hai giai đoạn khái niệm.

Mark: Xác định các object reachable và đánh dấu chúng còn sống.

Sweep: Thu hồi vùng nhớ thuộc các object không còn sống theo kết quả đánh dấu.

Heap before collection
+--------------------------------+
| A | B | C | D | E | F | G     |
+--------------------------------+
        |
        v
Mark reachable objects
+--------------------------------+
| A*| B*| C | D*| E | F*| G     |
+--------------------------------+
        |
        v
Sweep unreachable objects
+--------------------------------+
| A | B | free | D | free | F   |
+--------------------------------+

Dấu * chỉ object được đánh dấu trong sơ đồ minh họa. Các GC hiện đại thường sử dụng biến thể của marking, copying, compaction, region management hoặc nhiều chiến lược kết hợp; không phải tất cả đều thực hiện đúng một chu trình Mark-and-Sweep đơn giản.

4.4.3 Stop-The-World (STW)

Stop-The-World là trạng thái mà các thread ứng dụng cần tạm dừng để JVM thực hiện một công đoạn GC hoặc runtime operation đòi hỏi trạng thái nhất quán.

STW không có nghĩa toàn bộ GC luôn chạy bằng cách dừng ứng dụng. Nhiều collector hiện đại thực hiện phần lớn công việc đồng thời với application threads, nhưng vẫn có thể cần một số pha STW.

Đối với hệ thống yêu cầu độ trễ thấp, thời lượng và tần suất các pause rất quan trọng. Một pause kéo dài có thể làm request chậm, khiến hàng đợi tăng hoặc gây timeout ở các hệ thống phụ thuộc lẫn nhau.

Khi điều tra, không nên chỉ nhìn vào tổng thời gian GC. Cần xem thêm pause duration, tần suất GC, allocation rate, mức sống sót của object, mức sử dụng Heap sau GC và độ trễ thực tế của ứng dụng.

4.4.4 G1GC — Garbage-First Collector

G1GC là một collector phổ biến của HotSpot, được thiết kế để cân bằng thông lượng và độ trễ với Heap có kích thước đáng kể.

Thay vì xem Heap như một vùng liên tục chỉ chia theo Young và Old Generation, G1 chia Heap thành nhiều region. Các region có thể đóng vai trò Eden, Survivor hoặc Old tùy trạng thái và cách sử dụng tại thời điểm runtime.

G1 lựa chọn các vùng để thu gom dựa trên những tiêu chí như lượng bộ nhớ có thể thu hồi và chi phí dự kiến. Tên gọi Garbage-First phản ánh ý tưởng ưu tiên các vùng có tiềm năng thu hồi bộ nhớ tốt.

G1 Heap (conceptual regions)
+------+------+------+------+------+
| Eden | Eden | Surv.| Old  | Old  |
+------+------+------+------+------+
| Old  | Eden | Free | Old  | Surv.|
+------+------+------+------+------+
| Eden | Old  | Eden | Old  | Free |
+------+------+------+------+------+

Region roles can change over time.

G1 có thể thực hiện các chu kỳ thu gom Young, Mixed và những pha concurrent marking theo điều kiện runtime. Mixed collection có thể thu gom các region thuộc Young Generation cùng một số region thuộc Old Generation.

G1 là lựa chọn mặc định trên nhiều cấu hình HotSpot hiện đại, nhưng vẫn cần kiểm tra phiên bản JDK và flags thực tế thay vì giả định mọi môi trường đều dùng cùng collector.

4.4.5 ZGC — Garbage Collector hướng đến độ trễ thấp

ZGC là collector của HotSpot được thiết kế để giảm thời gian dừng ứng dụng, đặc biệt hữu ích với những ứng dụng nhạy cảm với latency và có Heap lớn.

ZGC sử dụng nhiều cơ chế đồng thời để giảm phần công việc bắt buộc phải dừng application threads. Tuy nhiên, “low latency” không đồng nghĩa với “không có pause”, và ZGC không tự động làm mọi ứng dụng nhanh hơn trong mọi tình huống.

Việc lựa chọn ZGC cần dựa trên yêu cầu latency, thông lượng, chi phí CPU, cấu hình bộ nhớ, phiên bản JDK và kết quả benchmark đại diện cho workload thật.

Các phiên bản JDK khác nhau có thể có thay đổi đáng kể về kiến trúc và khả năng của ZGC, bao gồm những thay đổi trong cách xử lý thế hệ. Khi triển khai, cần đọc tài liệu tương ứng với JDK đang sử dụng.

4.4.6 So sánh nhanh các chiến lược GC

Serial GC: Chiến lược đơn giản, thường phù hợp với những ứng dụng nhỏ hoặc môi trường hạn chế tài nguyên. Có thể có thời gian dừng đáng kể khi Heap lớn.

Parallel GC: Ưu tiên thông lượng bằng cách sử dụng nhiều worker threads cho các công đoạn GC phù hợp. Thích hợp với workload ưu tiên throughput hơn pause latency.

G1GC: Collector theo region, cân bằng giữa throughput và latency với mục tiêu pause có thể cấu hình ở mức định hướng.

ZGC: Tập trung vào việc giảm pause latency, đặc biệt trong các ứng dụng nhạy cảm với thời gian phản hồi.

Không có GC tốt nhất cho mọi hệ thống. Một hệ thống batch có thể ưu tiên thông lượng; một API giao dịch có thể nhạy cảm với p99 latency; một service chạy trong container nhỏ lại cần quan tâm đến tổng bộ nhớ và chi phí CPU. Hãy lựa chọn dựa trên workload và số liệu.

5. Luồng hoạt động thực tế: Từ java Main đến khi kết thúc

Để kết nối những khái niệm vừa học, chúng ta sẽ theo dõi một chương trình nhỏ từ lúc biên dịch, khởi động JVM, nạp class, tạo object, gọi method cho đến khi chương trình kết thúc.

5.1 Chương trình minh họa

Main.java
public class Main {

    static class User {
        private final String name;

        User(String name) {
            this.name = name;
        }

        String getName() {
            return name;
        }
    }

    public static void main(String[] args) {
        int number = 10;
        User user = new User("Qui");

        String message = user.getName() + " - " + number;
        System.out.println(message);
    }
}

Chương trình có một local primitive variable number, một biến tham chiếu user, một object User và một biến tham chiếu message trỏ đến String object được tạo trong quá trình nối chuỗi.

5.2 Bước 1 — Biên dịch chương trình

Terminal
javac Main.java

Compiler kiểm tra source code và tạo class file. Với nested class như Main.User, compiler thường tạo thêm class file tương ứng, chẳng hạn Main$User.class.

Ở bước này, chưa cần khởi chạy một JVM để thực thi ứng dụng. Compiler có thể tự sử dụng các thành phần và thư viện của môi trường JDK, nhưng việc biên dịch source code thành class file không đồng nghĩa với việc ứng dụng Java đã chạy.

5.3 Bước 2 — Khởi động JVM

Terminal
java Main

Lệnh java Main khởi chạy Java launcher, thiết lập runtime và yêu cầu JVM khởi động ứng dụng với class Main. JVM và launcher cần chuẩn bị những cấu trúc runtime, thread, heap, class-loading infrastructure và các thành phần cần thiết khác.

Không nên hiểu rằng toàn bộ Heap, mọi class của ứng dụng hoặc mọi object đều được tạo ngay tại thời điểm này. Một phần dữ liệu chỉ xuất hiện khi chương trình thực sự cần đến.

5.4 Bước 3 — Nạp và khởi tạo class

Class Loader tìm class Main theo cơ chế class loading của runtime. JVM tiến hành các bước cần thiết của Loading, Linking và Initialization trước khi thực thi điểm vào của chương trình.

Trong quá trình này, JVM cũng có thể nạp những class phụ thuộc, bao gồm các class thuộc Java platform. Các class được nạp theo nhu cầu; việc một class file tồn tại trên đĩa không có nghĩa class đó phải được load ngay từ đầu.

5.5 Bước 4 — Tạo frame cho main()

Khi main() bắt đầu thực thi, thread thực thi có một JVM Stack riêng. Một frame tương ứng với lời gọi main() được sử dụng để lưu dữ liệu phục vụ method.

Thread executing main()
|
+-- JVM Stack
    |
    +-- main() frame
        |
        +-- args reference
        +-- number (local int)
        +-- user (reference, initially unset)
        +-- message (reference, initially unset)

Sơ đồ trên là mô hình logic. Các local variable slots thực tế phụ thuộc bytecode và có thể được JIT tối ưu hóa. Ở thời điểm trước khi những câu lệnh tương ứng được thực thi, các biến cục bộ chưa được gán giá trị không thể được sử dụng hợp lệ trong Java source code.

5.6 Bước 5 — Gán number = 10

Câu lệnh int number = 10; đặt giá trị nguyên 10 vào local variable của frame main().

main() frame
+--------------------------+
| number = 10              |
| user = not assigned yet  |
| message = not assigned   |
+--------------------------+

Heap
+--------------------------+
| No User object yet       |
+--------------------------+

Ở thời điểm này, biến number là kiểu primitive int. Không có một object Integer bắt buộc phải được tạo ra chỉ để lưu giá trị này.

5.7 Bước 6 — Tạo User object

Khi thực thi new User("Qui"), JVM cần tạo một object User theo ngữ nghĩa của Java. Trong mô hình thông thường, object được cấp phát trên Heap và constructor được gọi để khởi tạo trạng thái của object.

JVM Stack - main() frame
+----------------------------+
| number = 10                |
| user ---------------------+----------+
+----------------------------+          |
                                        |
                                        v
Heap
+--------------------------------------+
| User object                          |
| name reference ------------------+   |
+----------------------------------|---+
                                   |
                                   v
                            String "Qui"

Trường name là một reference field bên trong object User. Nó tham chiếu đến một String object. Biến local user trong frame lại tham chiếu đến object User.

Đây là điểm rất quan trọng: biến reference không phải bản thân object. Khi gán user = null, chúng ta chỉ thay đổi giá trị reference trong biến; object chỉ trở thành unreachable nếu không còn đường tham chiếu nào khác từ GC roots đến nó.

5.8 Bước 7 — Gọi getName() và nối chuỗi

Khi gọi user.getName(), một method invocation xảy ra. Theo mô hình JVM, frame phục vụ method được tạo và thực thi, sau đó trả kết quả về caller.

Tiếp theo, biểu thức nối chuỗi tạo ra kết quả tương đương nội dung "Qui - 10". Cách compiler triển khai phép nối chuỗi phụ thuộc phiên bản Java và ngữ cảnh, nhưng kết quả ngữ nghĩa của chương trình là một String có nội dung đó.

main() frame
    |
    +-- invokes getName()
            |
            v
       getName() frame
            |
            v
       returns name reference
            |
            v
       caller continues
            |
            v
       builds message String

Sau khi biểu thức hoàn tất, biến message nhận reference đến chuỗi kết quả. Tùy cách thực thi và tối ưu hóa, không nên suy luận quá chi tiết về số object tạm thời được tạo chỉ từ source code.

5.9 Bước 8 — In kết quả

Câu lệnh System.out.println(message) gọi API của Java platform để in nội dung. Runtime có thể sử dụng các đối tượng và thư viện liên quan đến output stream cùng những thành phần hệ thống tương ứng.

Output
Qui - 10

Trong quá trình này, Interpreter hoặc mã máy đã được JIT biên dịch có thể thực thi các đoạn code liên quan. Chương trình không nhất thiết phải được biên dịch toàn bộ thành native code trước khi dòng output xuất hiện.

5.10 Bước 9 — Method kết thúc và GC có thể thu hồi object

Khi main() kết thúc, frame của nó được loại khỏi JVM Stack. Nếu không còn reference nào từ GC roots đến các object mà frame từng tham chiếu, những object đó có thể trở thành eligible for garbage collection.

Tuy nhiên, JVM không đảm bảo GC sẽ chạy ngay sau khi method kết thúc. Khi một ứng dụng đơn giản kết thúc, hệ điều hành và runtime sẽ dọn dẹp tài nguyên của tiến trình theo vòng đời thực tế của chúng. Không cần gọi System.gc() để kết thúc một chương trình thông thường.

main() returns
      |
      v
main() frame removed
      |
      v
Local references no longer exist
      |
      v
Objects may become unreachable
      |
      v
GC may reclaim their memory
      |
      v
Process exits
Tổng kết luồng thực thi: 1. javac biên dịch source code thành class file. 2. java launcher khởi động runtime và JVM. 3. Class Loader nạp class và JVM thực hiện linking/initialization cần thiết. 4. Method invocation sử dụng stack frame; object thường được cấp phát trên Heap. 5. Interpreter và JIT phối hợp thực thi code. 6. Khi reference không còn reachable, object có thể được GC thu hồi.

6. Debug, Memory Leak và JVM Tuning

Kiến thức JVM có giá trị nhất khi chúng ta sử dụng nó để giải quyết vấn đề thực tế. Một Java service có thể gặp nhiều loại sự cố khác nhau: Heap đầy, Metaspace tăng liên tục, thread stack cạn, native memory tăng hoặc thời gian pause GC vượt ngưỡng.

Điều đầu tiên cần làm là xác định loại tài nguyên bị cạn kiệt. Không nên mặc định mọi sự cố bộ nhớ đều do Heap hoặc cứ tăng -Xmx là sẽ giải quyết được.

6.1 OutOfMemoryError: Java heap space

Lỗi java.lang.OutOfMemoryError: Java heap space xuất hiện khi JVM không thể cấp phát object trên Heap trong điều kiện hiện tại, sau khi runtime thực hiện những nỗ lực xử lý bộ nhớ thích hợp.

Các nguyên nhân phổ biến:

Giữ quá nhiều object sống: Collection, cache hoặc cấu trúc dữ liệu tăng liên tục và giữ reference đến những object không còn cần thiết.

Allocation rate quá cao: Ứng dụng tạo object liên tục khiến GC phải hoạt động thường xuyên. Dù đây không phải lúc nào cũng dẫn đến OOM, áp lực cấp phát có thể làm hiệu năng suy giảm.

Heap quá nhỏ: Mức sử dụng bộ nhớ hợp lệ của workload vượt quá dung lượng Heap được cấu hình.

Object hoặc collection quá lớn: Một request, tác vụ import hoặc truy vấn tải dữ liệu lớn vào bộ nhớ cùng lúc.

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

public class MemoryLeakExample {
    private static final List CACHE =
            new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            CACHE.add(new byte[1024 * 1024]);
        }
    }
}

Ví dụ trên cố ý giữ các mảng byte trong static collection. Các object vẫn reachable qua CACHE, vì vậy GC không thể thu hồi chúng chỉ vì ứng dụng không đọc lại từng mảng. Khi Heap không đủ chỗ, ứng dụng có thể gặp OutOfMemoryError.

6.1.1 Cách điều tra Heap OOM

Ưu tiên thu thập bằng chứng trước khi thay đổi cấu hình:

Bước 1: Kiểm tra log ứng dụng, GC log và thời điểm OOM.

Bước 2: Thu thập heap dump nếu môi trường và chính sách vận hành cho phép.

Bước 3: Dùng công cụ phân tích heap dump để xem dominator tree, retained heap và các đường reference đang giữ object.

Bước 4: Xác định object tăng bất thường, nguồn giữ reference và tốc độ tăng bộ nhớ.

Bước 5: Sửa vòng đời object, giới hạn cache, phân trang dữ liệu hoặc điều chỉnh cấu hình dựa trên bằng chứng.

Có thể cấu hình JVM tạo heap dump khi xảy ra OOM bằng tùy chọn thích hợp. Ví dụ trong HotSpot:

JVM Options
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/java/heapdump.hprof

Đường dẫn phải tồn tại hoặc có thể được tạo theo điều kiện triển khai, tiến trình phải có quyền ghi và dung lượng đĩa phải đủ. Heap dump có thể chứa dữ liệu nhạy cảm, vì vậy cần bảo vệ file theo chính sách bảo mật.

6.1.2 OutOfMemoryError: Metaspace

Lỗi java.lang.OutOfMemoryError: Metaspace liên quan đến việc JVM không thể cấp phát thêm vùng bộ nhớ cần thiết cho class metadata trong Metaspace của HotSpot.

Các nguyên nhân thường gặp bao gồm nạp số lượng class quá lớn, tạo class động liên tục, giữ lại class loader hoặc triển khai ứng dụng lặp lại mà class loader cũ không được giải phóng.

Không nên kết luận ngay rằng chỉ cần tăng -XX:MaxMetaspaceSize. Nếu nguyên nhân là class loader leak, tăng giới hạn có thể chỉ trì hoãn sự cố.

Cần quan sát class loading, class unloading, Metaspace usage và vòng đời class loader. Các công cụ chẩn đoán như jcmd, logging của JVM hoặc Java Flight Recorder có thể giúp thu thập bằng chứng, tùy phiên bản.

6.1.3 StackOverflowError

java.lang.StackOverflowError thường xảy ra khi một thread sử dụng quá nhiều stack frames hoặc tiêu thụ quá nhiều native stack space cho các lần gọi method lồng nhau.

RecursiveExample.java
public class RecursiveExample {
    static void recurse() {
        recurse();
    }

    public static void main(String[] args) {
        recurse();
    }
}

Method recurse() gọi lại chính nó mà không có điều kiện dừng. Mỗi lời gọi cần thêm một frame theo mô hình thực thi, khiến stack cuối cùng không đủ chỗ.

Các cách xử lý:

Thêm điều kiện dừng: Thiết kế base case rõ ràng cho đệ quy.

Giảm độ sâu đệ quy: Chuyển sang vòng lặp hoặc thuật toán dùng cấu trúc dữ liệu tường minh nếu phù hợp.

Điều tra chuỗi gọi: Dùng stack trace để xác định method và đường gọi dẫn đến độ sâu bất thường.

Điều chỉnh -Xss khi có bằng chứng: Tăng stack size có thể hỗ trợ một số workload có call depth hợp lệ lớn, nhưng làm tăng mức tiêu thụ bộ nhớ tiềm năng trên mỗi thread và không sửa được đệ quy vô hạn.

6.1.4 Native memory và lỗi không thuộc Java Heap

Một tiến trình Java có thể sử dụng nhiều bộ nhớ ngoài Heap: Metaspace, code cache, thread stacks, direct buffers, thư viện native và các cấu trúc runtime khác.

Vì vậy, tình huống container bị kill do vượt giới hạn memory không nhất thiết tạo ra OutOfMemoryError: Java heap space. Tiến trình có thể bị hệ điều hành hoặc container runtime chấm dứt trước khi JVM có cơ hội phát ra lỗi Java tương ứng.

Khi Heap còn thấp nhưng RSS của tiến trình tăng cao, hãy mở rộng phạm vi điều tra sang native memory, số lượng thread, direct buffer, thư viện native và giới hạn bộ nhớ của container.

6.2 Memory Leak trong Java

Java có Garbage Collector nhưng vẫn có thể xảy ra memory leak. Nguyên nhân là GC chỉ thu hồi object không còn reachable theo các quy tắc của runtime, chứ không thể tự biết object nào không còn giá trị nghiệp vụ.

6.2.1 Static references giữ object quá lâu

Static collection hoặc cache có thể giữ object sống trong toàn bộ vòng đời class loader, thậm chí gần bằng vòng đời ứng dụng.

StaticCache.java
public class StaticCache {
    private static final java.util.Map CACHE =
            new java.util.HashMap<>();

    public static void put(String key, Object value) {
        CACHE.put(key, value);
    }
}

Đoạn code trên không tự thân là lỗi. Nó trở thành vấn đề khi cache không có giới hạn, không có chính sách eviction hoặc giữ những object không còn cần thiết. Các biện pháp phổ biến là đặt giới hạn kích thước, TTL, chính sách loại bỏ và metric theo dõi.

6.2.2 ThreadLocal không được dọn dẹp đúng cách

ThreadLocal hữu ích khi mỗi thread cần một giá trị riêng. Tuy nhiên, trong thread pool, worker thread có thể tồn tại lâu hơn request. Nếu giá trị ThreadLocal giữ object lớn và không được dọn dẹp, object có thể tồn tại lâu hơn mong muốn.

ThreadLocalExample.java
private static final ThreadLocal CONTEXT =
        new ThreadLocal<>();

public void processRequest() {
    try {
        CONTEXT.set("request-context");
        // Process request
    } finally {
        CONTEXT.remove();
    }
}

Gọi remove() trong finally là một mẫu xử lý thường hữu ích khi giá trị chỉ có ý nghĩa trong phạm vi một request. Tuy nhiên, cần thiết kế theo đúng vòng đời của dữ liệu và framework đang sử dụng.

6.2.3 Tài nguyên không được đóng

File descriptor, socket, database connection và các tài nguyên native không nhất thiết được GC giải phóng đúng thời điểm mà ứng dụng cần. Việc chỉ bỏ reference đến resource không thay thế cho quy trình đóng resource một cách tường minh.

ResourceExample.java
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ResourceExample {
    public static void readFile(Path path) throws IOException {
        try (BufferedReader reader =
                     Files.newBufferedReader(path)) {
            System.out.println(reader.readLine());
        }
    }
}

Try-with-resources giúp đóng tài nguyên triển khai AutoCloseable khi rời khỏi block, kể cả trong những trường hợp exception thích hợp. Với database connection, HTTP response body hoặc tài nguyên do thư viện cung cấp, cần tuân thủ quy tắc quản lý vòng đời của API tương ứng.

6.2.4 Listener, callback và cache không được giải phóng

Ứng dụng có thể giữ object ngoài ý muốn thông qua event listener chưa được unregister, callback được đăng ký vào singleton, queue không giới hạn hoặc cache không có eviction.

Ví dụ, một object màn hình hoặc session đã kết thúc nhưng vẫn được một listener toàn cục tham chiếu. Khi đó object và toàn bộ object graph mà nó giữ có thể tiếp tục sống.

6.2.5 Class Loader Leak

Trong application server hoặc môi trường triển khai module động, class loader cũ có thể không được thu hồi vì còn reference từ thread, static field, ThreadLocal hoặc các cấu trúc bên ngoài. Khi class loader vẫn sống, các class do nó định nghĩa có thể tiếp tục giữ class metadata trong Metaspace.

Đây là một nguyên nhân quan trọng khiến Metaspace tăng sau nhiều lần reload ứng dụng, ngay cả khi số lượng request không tăng đáng kể.

6.2.6 Phân biệt Memory Leak và allocation pressure

Memory leak và allocation pressure có thể tạo ra biểu hiện tương tự nhưng cần cách xử lý khác nhau.

Memory leak: Bộ nhớ bị giữ lại ngày càng nhiều do các object không còn cần thiết vẫn reachable.

Allocation pressure: Ứng dụng tạo nhiều object trong thời gian ngắn, khiến GC phải làm việc liên tục, nhưng phần lớn object có thể được thu hồi nhanh.

Heap dump, retained heap, allocation profiling, GC logs và Java Flight Recorder giúp phân biệt hai tình huống này. Chỉ nhìn một biểu đồ Heap tăng trong vài phút thường chưa đủ để kết luận.

6.3 Các tham số JVM Tuning cơ bản

JVM có nhiều tham số cấu hình bộ nhớ và GC. Bốn tham số dưới đây thường được gặp trong các môi trường phát triển và production.

6.3.1 -Xms — Initial Heap Size

-Xms chỉ định kích thước Heap ban đầu mà JVM yêu cầu. Ví dụ:

JVM Options
-Xms512m

Thiết lập này thường được dùng để định hướng kích thước Heap ban đầu. Việc JVM thực sự commit hoặc sử dụng bộ nhớ vật lý có thể phụ thuộc implementation, hệ điều hành và các cơ chế quản lý bộ nhớ.

6.3.2 -Xmx — Maximum Heap Size

-Xmx giới hạn kích thước Heap tối đa mà JVM được phép sử dụng. Ví dụ:

JVM Options
-Xmx2g

Tăng -Xmx có thể giúp ứng dụng chứa nhiều object sống hơn, nhưng cũng có thể làm tăng nhu cầu bộ nhớ của tiến trình và ảnh hưởng đến hành vi GC. Trong container, cần tính đến giới hạn memory tổng thể thay vì chỉ nhìn vào Heap.

6.3.3 -Xss — Thread Stack Size

-Xss cấu hình kích thước stack cho mỗi thread Java trong các HotSpot configuration hỗ trợ tùy chọn này. Ví dụ:

JVM Options
-Xss512k

Stack lớn hơn có thể cho phép độ sâu gọi method lớn hơn, nhưng làm tăng nhu cầu bộ nhớ tiềm năng khi có nhiều thread. Stack nhỏ hơn có thể giảm mức tiêu thụ tiềm năng nhưng tăng nguy cơ StackOverflowError. Kích thước hiệu dụng và cách sử dụng bộ nhớ phụ thuộc nền tảng.

6.3.4 -XX:MetaspaceSize và -XX:MaxMetaspaceSize

-XX:MetaspaceSize là ngưỡng ban đầu liên quan đến việc JVM kích hoạt GC để xử lý Metaspace trong HotSpot; nó không đơn giản là một giới hạn cứng về tổng dung lượng Metaspace.

-XX:MaxMetaspaceSize mới là tham số đặt giới hạn tối đa cho Metaspace trong HotSpot. Ví dụ:

JVM Options
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m

Các giá trị trên chỉ là ví dụ minh họa, không phải khuyến nghị mặc định cho mọi ứng dụng. Việc đặt giới hạn quá thấp có thể gây lỗi khi ứng dụng cần nạp thêm class; không đặt giới hạn cũng không có nghĩa Metaspace có thể tăng vô hạn mà không chịu giới hạn từ bộ nhớ native hoặc môi trường vận hành.

6.3.5 Ví dụ cấu hình Heap và GC

Example command
java \
  -Xms512m \
  -Xmx2g \
  -Xss512k \
  -XX:MetaspaceSize=128m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -jar application.jar

Đây là ví dụ để minh họa cách truyền JVM options trên shell kiểu Unix. Cú pháp xuống dòng cần điều chỉnh nếu chạy trên Windows Command Prompt hoặc môi trường khác. Các giá trị cần được lựa chọn dựa trên dữ liệu đo đạc và giới hạn triển khai.

Trong container, đừng quên rằng tổng bộ nhớ tiến trình còn bao gồm nhiều vùng ngoài Heap. Một cấu hình -Xmx2g không có nghĩa tiến trình chỉ tiêu thụ tối đa 2 GB RAM.

6.3.6 Khi nào nên thay đổi tham số nào?

Heap OOM với nhiều object sống hợp lệ: Phân tích retained heap và nhu cầu dữ liệu trước; sau đó đánh giá có cần tăng -Xmx hay không.

GC chạy quá thường xuyên: Đo allocation rate, object lifetime và thời gian GC. Không tăng Heap một cách mù quáng khi nguyên nhân thực sự là tạo object không cần thiết.

StackOverflowError: Xem stack trace và độ sâu lời gọi trước. Ưu tiên sửa thuật toán hoặc đệ quy không có điều kiện dừng.

Metaspace OOM: Kiểm tra số lượng class, class loader và class unloading trước khi tăng giới hạn.

Container bị OOM kill: Kiểm tra RSS, giới hạn cgroup, native memory, số thread, direct buffer và Heap; không chỉ kiểm tra -Xmx.

6.4 Quy trình điều tra sự cố JVM trong production

Một Java System Architect cần xây dựng quy trình điều tra dựa trên bằng chứng, có khả năng lặp lại và không làm tăng rủi ro cho hệ thống đang hoạt động.

Bước 1 — Xác định triệu chứng: Request latency tăng, throughput giảm, CPU cao, RSS tăng, GC pause dài hay ứng dụng bị OOM?

Bước 2 — Xác định phạm vi tài nguyên: Phân biệt Heap, Metaspace, thread stacks, direct memory, native memory và giới hạn của container.

Bước 3 — Thu thập dữ liệu: Kiểm tra GC logs, thread dump, heap dump khi phù hợp, metrics và Java Flight Recorder.

Bước 4 — Đặt giả thuyết: Ví dụ cache không giới hạn, allocation rate quá cao, thread pool tăng bất thường hoặc class loader không được giải phóng.

Bước 5 — Kiểm chứng: Dùng dữ liệu để xác nhận hoặc loại bỏ giả thuyết. Tránh thay đổi đồng thời nhiều tham số khiến không thể xác định yếu tố gây tác động.

Bước 6 — Thử nghiệm có kiểm soát: Benchmark trên workload đại diện, đo throughput, p95/p99 latency, GC pause và tổng memory usage.

Bước 7 — Theo dõi sau triển khai: Kiểm tra xem sự cố có tái diễn hay không, đồng thời thiết lập cảnh báo để phát hiện sớm.

6.4.1 Các công cụ chẩn đoán hữu ích

jcmd: Gửi diagnostic commands đến một JVM đang chạy; có thể dùng để xem thông tin runtime và hỗ trợ thu thập dữ liệu.

jstack: Hữu ích để thu thập thread dump và phân tích chuỗi gọi, trạng thái thread.

jmap: Hỗ trợ các thao tác liên quan đến heap và heap dump trong những môi trường phù hợp.

Java Flight Recorder (JFR): Thu thập dữ liệu sự kiện runtime như allocation, GC, thread activity và một số thông tin hiệu năng khác, tùy cấu hình.

JDK Mission Control (JMC): Công cụ phân tích dữ liệu JFR và hỗ trợ điều tra vấn đề runtime.

VisualVM: Có thể hỗ trợ quan sát tiến trình, Heap, thread và profiling tùy phiên bản và cấu hình.

Các công cụ trên có thể khác nhau về khả năng, quyền truy cập và tùy chọn giữa các phiên bản JDK. Một số thao tác như heap dump hoặc profiling có thể gây thêm tải, sử dụng nhiều dung lượng đĩa hoặc ảnh hưởng độ trễ; cần cân nhắc khi chạy trên production.

7. Thực hành và câu hỏi kiểm tra kiến thức

Để thực sự nắm vững JVM, hãy chuyển kiến thức thành giả thuyết có thể kiểm chứng bằng code và công cụ. Phần này gồm một số bài thực hành nhỏ cùng câu hỏi giúp đánh giá mức độ hiểu biết.

7.1 Thực hành 1 — Quan sát bytecode

Tạo file Main.java:

Main.java
public class Main {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int result = a + b;

        System.out.println(result);
    }
}

Chạy các lệnh:

Terminal
javac Main.java
javap -c Main
java Main

Hãy tìm các instruction liên quan đến việc nạp hằng số, lưu local variables, thực hiện phép cộng và gọi println. Tên và index constant pool trong output có thể khác giữa các phiên bản compiler.

7.2 Thực hành 2 — Quan sát Stack và Heap

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

public class MemoryDemo {
    static class Item {
        private final byte[] data = new byte[1024];

        int size() {
            return data.length;
        }
    }

    public static void main(String[] args) {
        List<Item> items = new ArrayList<>();

        for (int i = 0; i < 1000; i++) {
            items.add(new Item());
        }

        System.out.println(items.size());
        System.out.println(items.get(0).size());
    }
}

Hãy xác định những biến nào là local variables, những object nào được tạo trong Heap theo mô hình thông thường và vì sao các object vẫn reachable khi danh sách items còn tham chiếu đến chúng.

Thử chạy với giới hạn Heap nhỏ trong một môi trường thử nghiệm, quan sát GC logs và so sánh mức sử dụng bộ nhớ trước/sau khi các object không còn được giữ lại. Kết quả cụ thể phụ thuộc phiên bản JVM và cấu hình GC.

7.3 Thực hành 3 — Quan sát GC

Với JDK hỗ trợ các tùy chọn dưới đây, chạy ứng dụng trong môi trường test:

JVM Options
-Xms128m
-Xmx128m
-Xlog:gc*

Cú pháp -Xlog:gc* thuộc hệ thống unified logging của JDK 9 trở lên. Với JDK 8, cách cấu hình GC logging khác.

Hãy quan sát:

- Thời điểm GC bắt đầu và kết thúc.

- Dung lượng Heap trước và sau GC.

- Loại collection và thời gian pause nếu có.

- Liệu mức Heap sau GC có tăng dần qua nhiều chu kỳ hay không.

Không nên kết luận có memory leak chỉ từ một chu kỳ GC. Hãy quan sát xu hướng trong một khoảng thời gian đủ dài và kết hợp với dữ liệu về object allocation, retained heap cùng workload.

7.4 Thực hành 4 — Phân tích class loader

ClassLoaderDemo.java
public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println(
            "Main loader: " +
            ClassLoaderDemo.class.getClassLoader()
        );

        System.out.println(
            "String loader: " +
            String.class.getClassLoader()
        );

        System.out.println(
            "Platform loader: " +
            ClassLoader.getPlatformClassLoader()
        );
    }
}

Chạy chương trình và quan sát kết quả. Trong HotSpot và các JDK phổ biến, class String thường được load bởi Bootstrap Class Loader, được biểu diễn bằng null khi gọi getClassLoader().

Kết quả cụ thể đối với các loader khác phụ thuộc JDK và môi trường. Mục tiêu là hiểu sự khác biệt giữa defining loader, parent delegation và việc nạp class từ application classpath.

7.5 Câu hỏi kiểm tra kiến thức

Câu 1: JVM khác JDK như thế nào? Vì sao nói JDK không chỉ là JVM?

Câu 2: Tại sao cùng một file .class có thể chạy trên nhiều hệ điều hành? Những điều kiện nào có thể khiến WORA không còn đúng trong thực tế?

Câu 3: Phân biệt Loading, Linking và Initialization. Tại sao class đã được load chưa chắc static block đã chạy?

Câu 4: Heap và JVM Stack khác nhau như thế nào? Trong biểu thức User user = new User();, biến reference và object được mô hình hóa ở đâu?

Câu 5: String Pool khác Runtime Constant Pool như thế nào? Vì sao không nên dùng == để so sánh nội dung String?

Câu 6: Interpreter và JIT Compiler khác nhau ra sao? Profiling đóng vai trò gì trong Tiered Compilation?

Câu 7: Vì sao Java có Garbage Collector mà vẫn xảy ra Memory Leak?

Câu 8: G1GC và ZGC có mục tiêu thiết kế khác nhau như thế nào? Vì sao không thể khẳng định một collector luôn tốt hơn mọi collector khác?

Câu 9: Phân biệt -Xms, -Xmx, -Xss và -XX:MetaspaceSize. Vì sao tăng Heap có thể không giải quyết được lỗi do native memory?

Câu 10: Khi RSS của một Java process tăng liên tục nhưng Heap usage ổn định, bạn sẽ kiểm tra những thành phần nào tiếp theo?

Tiêu chí tự đánh giá: Bạn thực sự hiểu JVM khi có thể giải thích hiện tượng bằng mô hình runtime, xác định được dữ liệu cần thu thập để kiểm chứng và đưa ra phương án tối ưu có thể đo lường, thay vì chỉ nhớ định nghĩa hoặc thử thay đổi tham số theo cảm tính.

8. Tổng kết tư duy của Java System Architect

JVM không chỉ là một lớp trung gian giúp Java chạy trên nhiều hệ điều hành. Nó là một runtime phức tạp, chịu trách nhiệm nạp class, thực thi bytecode, quản lý các vùng dữ liệu, tối ưu code tại runtime và phối hợp với hệ điều hành.

Để xây dựng nền tảng vững chắc, hãy ghi nhớ những mối quan hệ quan trọng sau:

Source Code → Bytecode → Runtime Execution: javac tạo class file; JVM nạp và thực thi bytecode bằng những cơ chế phù hợp.

Class Loader → Linking → Initialization: Class cần được đưa vào runtime và chuẩn bị theo quy tắc JVM trước khi được sử dụng.

Heap → Object Lifecycle → Garbage Collection: Object được quản lý dựa trên reachability. Object không còn cần về mặt nghiệp vụ vẫn có thể tồn tại nếu còn reference.

Stack → Method Invocation: Mỗi thread có stack riêng; các frame hỗ trợ việc thực thi method và quản lý dữ liệu cục bộ.

Interpreter → Profiling → JIT: JVM có thể bắt đầu thực thi nhanh rồi tối ưu những đoạn code nóng khi có đủ thông tin runtime.

GC → Pause → Application Latency: Chiến lược GC ảnh hưởng đến sự cân bằng giữa throughput, pause time, CPU và nhu cầu bộ nhớ.

Heap → Metaspace → Native Memory: Tổng mức tiêu thụ bộ nhớ của JVM rộng hơn Heap. Điều tra production cần nhìn toàn bộ tiến trình và giới hạn của môi trường triển khai.

Ở góc nhìn của một Java System Architect, hiểu JVM không có nghĩa là phải thuộc lòng mọi implementation detail. Điều quan trọng là biết phần nào thuộc đặc tả, phần nào phụ thuộc implementation, khi nào cần quan sát runtime và làm thế nào để chuyển một giả thuyết kỹ thuật thành kết luận dựa trên dữ liệu.

Khi đã nắm được nền tảng này, các chủ đề như Garbage Collection tuning, Java Memory Model, concurrency, thread dump analysis, JIT optimization, class loading trong framework và hiệu năng ứng dụng Spring sẽ trở nên dễ tiếp cận hơn rất nhiều.

Thông điệp cuối cùng:
Đừng chỉ hỏi “Java code này chạy như thế nào?”. Hãy tiếp tục hỏi “JVM đang làm gì khi code chạy, dữ liệu nằm ở đâu, chi phí thực thi đến từ đâu và mình có thể chứng minh điều đó bằng công cụ nào?”. Đó là bước chuyển từ người chỉ viết Java code sang một kỹ sư hiểu và làm chủ Java runtime.