Java Internals & Concurrency/Escape analysis và scalar replacement — khi JIT xoá object
64/75
Bài 64 / 75~13 phútJVM InternalsMiễn phí lượt xem

Escape analysis và scalar replacement — khi JIT xoá object

Escape analysis: object không thoát method → JIT scalar-replace + elide lock, giảm GC pressure về 0. Cơ chế, pitfall, và cách verify bằng JMH.

TL;DR: Escape analysis là phân tích của C2 JIT compiler (bật mặc định từ Java 6u23) xác định object có thoát khỏi method tạo ra nó không. Object NoEscape mở ra hai tối ưu mạnh: scalar replacement — JIT tách object thành các field primitive nằm trên stack hoặc register nên không alloc heap và không tạo GC pressure; và lock elision — xoá synchronized vì không thể có contention. Nhờ đó object tạo trong vòng lặp hot gần như miễn phí. Nhưng escape analysis chỉ chạy sau khi method được C2 compile (~5.000 lần gọi), chỉ phân tích trong một method sau inline, và dễ bị đánh bại khi virtual call không inline được hoặc có exception path.

Bài về object header trong module này cho thấy mỗi object gánh 12–16 byte header. Câu hỏi tự nhiên: nếu object chỉ dùng cục bộ trong method rồi bị GC ngay, tại sao phải alloc heap tốn kém? Escape analysis là câu trả lời của JIT — phân tích tại compile time, loại bỏ allocation hoàn toàn khi chứng minh được an toàn.

1. Scenario — benchmark Integer sum chạy nhanh đáng ngờ

Đoạn code sau sum 1 triệu Integer trong vòng lặp:

public long sumBoxed(int n) {
    long total = 0;
    for (int i = 0; i < n; i++) {
        Integer boxed = Integer.valueOf(i);  // alloc heap?
        total += boxed;                      // unbox
    }
    return total;
}

Nếu hiểu theo mặt chữ: 1 triệu lần tạo Integer object trên heap (16 byte mỗi object), 1 triệu lần GC pressure, Eden đầy sau vài giây.

Thử đoán

Sau khi JIT đã warm up, method này cấp phát bao nhiêu byte trên heap cho mỗi lần chạy? Viết ra con số bạn tin chắc trước khi đọc tiếp.

Thực tế khi profile với JFR allocation profiling: không có allocation nào từ method này sau khi JIT warm up. Vì sao? JIT thấy boxed không bao giờ thoát ra ngoài method sumBoxed — không pass vào method khác, không return, không store vào field. JIT scalar-replace: biến Integer boxed thành int boxed_value = i trên stack, đọc boxed_value khi unbox. Object không bao giờ được tạo, GC pressure = 0. Đây là escape analysis + scalar replacement.

2. Escape analysis — ba mức escape

Escape analysis (bật mặc định từ Java 6u23, cờ -XX:+DoEscapeAnalysis) là phân tích JIT-time trả lời câu hỏi: "Object O tạo trong method M có reference thoát ra ngoài M không?". Phân tích chạy trong C2 compiler (tier 4), sau khi đã inline các callee. Ba mức:

NoEscape — reference không bao giờ thoát khỏi thread hoặc method tạo ra nó: không pass làm argument (sau inline), không return, không assign vào static/heap field của object khác, không lưu vào array.

ArgEscape — reference thoát qua argument khi gọi method khác, nhưng callee chỉ đọc, không lưu lại. Ví dụ: System.out.println(obj)obj thoát qua argument nhưng println không giữ reference.

GlobalEscape — reference thoát hoàn toàn: assign vào static field, return ra ngoài caller, hoặc store vào object khác đã escape.

Chỉ NoEscape mở ra tối ưu mạnh (scalar replacement, lock elision). Phân tích là conservative — không chắc chắn thì assume GlobalEscape, không optimize.

flowchart TD
    A[New Object created in method M] --> B{Does reference escape M?}
    B -- No --> C[NoEscape]
    B -- Via argument only --> D[ArgEscape]
    B -- Via return/static/field --> E[GlobalEscape]

    C --> F[Scalar Replacement]
    C --> G[Lock Elision]
    D --> I[Limited optimization]
    E --> J[Normal heap alloc]

    style C fill:#6EE7B7
    style D fill:#FCD34D
    style E fill:#FCA5A5

3. Scalar replacement — decompose object thành primitives

Scalar replacement là tối ưu quan trọng nhất từ escape analysis. JIT decompose object NoEscape thành các scalar (giá trị đơn lẻ: int, long, reference tới object khác) và lưu chúng trên stack hoặc trong register thay vì alloc heap.

class Point {
    final int x;
    final int y;
    Point(int x, int y) { this.x = x; this.y = y; }
    int distSquared() { return x * x + y * y; }
}

public int computeDistance(int px, int py) {
    Point p = new Point(px, py);  // NoEscape
    return p.distSquared();
}

Sau scalar replacement (IR pseudo-code):

// JIT transform: Point biet mat, chi con 2 int tren stack
public int computeDistance(int px, int py) {
    int p_x = px;   // scalar: Point.x -> local int
    int p_y = py;   // scalar: Point.y -> local int
    return p_x * p_x + p_y * p_y;  // inline distSquared()
}

Không có new Point(...), không heap allocation, không GC. Object p chưa bao giờ tồn tại trên heap.

Vì sao mạnh hơn "stack allocation": nhiều tài liệu nói object NoEscape được "stack-allocate". Thực tế HotSpot không đẩy object header + fields lên stack — nó decompose object thành từng field riêng lẻ và xử lý như local variable (có thể lên register). Hiệu quả hơn stack allocation vì không cần header (tiết kiệm 12–16 byte), và JIT có thể constant-fold / dead-code-eliminate từng field như primitive thuần.

Trong vòng lặp hot — nơi object sinh ra rồi chết trong cùng iteration — hệ quả rõ nhất: vòng lặp 1 triệu iteration không tạo 1 triệu object trên heap. GC pressure từ method = 0, minor GC ít hơn, throughput tăng, latency p99 giảm.

4. Vòng lặp hot — object helper cũng miễn phí

Pattern hay gặp: object helper tạo và chết trong một iteration.

public double[] normalize(double[] input) {
    double[] output = new double[input.length];
    for (int i = 0; i < input.length; i++) {
        // Vec2 chi dung trong 1 iteration -> NoEscape
        Vec2 v = new Vec2(input[i], 0.0);
        double len = Math.sqrt(v.x * v.x + v.y * v.y);
        output[i] = v.x / len;
    }
    return output;
}

Vec2 v không thoát khỏi iteration — chỉ đọc v.x, v.y rồi ghi kết quả (một double) vào output. JIT scalar-replace v thành hai double trên register: vòng lặp không cấp phát object nào. Lưu ý ngược lại — nếu bạn ghi output[i] = v (lưu chính object vào array) thì v GlobalEscape, không còn scalar-replace.

5. Lock elision — vì sao synchronized cục bộ gần như miễn phí?

Khi object là NoEscape, JVM biết chắc không thread nào khác giữ reference đến nó. Do đó mọi synchronized trên object đó không thể có contention — JIT xoá luôn lock (lock elision, còn gọi là lock elimination).

Ví dụ kinh điển: StringBuffer (append() là synchronized method) so với StringBuilder (không synchronized).

// StringBuffer: append() la synchronized method
public String build(String[] parts) {
    StringBuffer sb = new StringBuffer();   // NoEscape neu khong return sb
    for (String part : parts) {
        sb.append(part);                    // synchronized -- JIT elide
    }
    return sb.toString();                   // tra String, khong phai sb
}

sb không thoát khỏi method (toString() trả String, không trả sb), sau khi C2 compile method thì lock được elide hoàn toàn — build chạy nhanh tương đương phiên bản StringBuilder. Cùng cơ chế áp cho Vector cục bộ: mọi synchronized trong add() / iterator() bị elide, nên code legacy dùng Vector không thoát method có thể nhanh ngang ArrayList.

Lock elision là lý do synchronized không đắt như nhiều người nghĩ

Trong method scope (NoEscape), JIT elide lock của StringBuffer / Vector hoàn toàn — không còn overhead. Nhưng vẫn nên chọn StringBuilder / ArrayList: intent thread-unsafe rõ ràng, không phụ thuộc JIT warm-up mới nhanh, và tránh cho người đọc lầm tưởng thread-safety.

6. Pitfall 1 — escape analysis chỉ chạy sau khi C2 compile

Escape analysis là phân tích của C2 compiler, không phải interpreter và cũng không phải C1. Khi method mới chạy, JVM interpret bytecode; sau đó C1 compile nhưng C1 không làm escape analysis. Chỉ khi method đủ hot để C2 compile (mặc định ~5.000 lần gọi cho tier 4 — Tier4InvocationThreshold; con số 10.000 là CompileThreshold của chế độ non-tiered cũ) thì scalar replacement mới xảy ra.

Lan 1 - ~200:    interpreter     -> new Object() alloc heap
Lan ~200 - 5000: C1 JIT (tier 3) -> compiled, nhung KHONG co escape analysis
Lan ~5000+:      C2 JIT (tier 4) -> escape analysis + scalar replacement

Hệ quả:

  • Micro-benchmark không warm-up: đo allocation rate khi chưa đạt C2 → kết quả sai lệch. Dùng JMH (tự warm-up).
  • Method ít gọi: method chạy vài nghìn lần/giờ không bao giờ đạt C2 threshold → không scalar replacement.
  • Startup phase: app mới khởi động chạy interpreter/C1 → GC pressure cao hơn sau warm-up.
// SAI: benchmark khong warm up -> do interpreter/C1, khong phan anh C2
for (int i = 0; i < 100; i++) hotMethod();   // qua it lap de dat C2

// DUNG: JMH tu warm-up truoc khi do
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@BenchmarkMode(Mode.AverageTime)
public void benchmarkHotMethod() { hotMethod(); }

7. Pitfall 2 — virtual call (và exception path) đánh bại escape analysis

Escape analysis trong HotSpot là intra-procedural sau inline: JIT chỉ phân tích object trong một method sau khi đã inline các callee. Nếu callee không inline được, JIT buộc phải assume object escape qua lời gọi đó.

interface Processor {
    void process(MyObject obj);
}

public void doWork(Processor p, MyObject obj) {
    // JIT khong biet p.process lam gi voi obj -> GlobalEscape assumed
    p.process(obj);
    // -> Khong scalar replacement cho obj
}

Nếu p.process có nhiều implementation (polymorphic call site), JIT không inline được → không chứng minh được obj NoEscape. Stream/lambda thường dính: pipeline dùng nhiều virtual call (lambda interface, intermediate operation), JIT khó inline toàn bộ chain → object trong pipeline thường không được scalar-replace. Khi cần optimize hot path, thay stream bằng loop explicit dùng concrete type (final class + constructor đơn giản) để JIT inline được.

Một yếu tố nữa: exception path. Khi try-catch bao quanh allocation, JIT đôi khi phải giữ object trên heap để handler / JFR inspect được trong stack trace — nên method hot có allocation không được optimize đôi khi do đúng đường exception này. Không cần tránh try-catch trong code thường; chỉ để ý khi profile cho thấy hot method có unexpected allocation.

Monomorphic vs polymorphic call site

JIT giữ inline cache per call site. Nếu call site luôn gọi cùng một concrete type (monomorphic), JIT speculative-inline và escape analysis hoạt động. Hai type (bimorphic) vẫn inline cả hai với branch. Từ ba type trở lên (megamorphic), JIT không inline → escape analysis fail. Profile bằng JFR hoặc -XX:+PrintInlining để phát hiện megamorphic hot site.

8. Verification — xác nhận scalar replacement đang hoạt động

Ba cách theo độ invasive tăng dần:

Cách 1 — JFR Allocation Profiling (production-safe). Thu recording rồi mở trong JDK Mission Control; nếu method hot không xuất hiện event jdk.ObjectAllocationInNewTLAB sau warm-up → scalar replacement đang hoạt động.

java -XX:StartFlightRecording=duration=60s,filename=r.jfr MyApp
# JDK Mission Control > Object Allocation in New TLAB

Cách 2 — JMH GC profiler. Chạy benchmark với -prof gc, đọc gc.alloc.rate.norm (bytes cấp phát mỗi operation). Bằng 0 hoặc rất thấp = scalar replacement hoạt động. Đây là cách đo định lượng, không cần debug build.

java -jar benchmarks.jar -prof gc MyBenchmark.hotMethod

Cách 3 — Diagnostic flags (debug/development). Chỉ chạy trên debug JVM hoặc kèm -XX:+UnlockDiagnosticVMOptions; production OpenJDK thường không support.

java -XX:+UnlockDiagnosticVMOptions \
     -XX:+PrintEscapeAnalysis -XX:+PrintEliminateAllocations MyApp
# Output: [EA] Scalar replacement: Point [id=123] in method foo()

Nguyên tắc: đo trước bằng JFR/JMH, chỉ đào sâu khi profile cho thấy allocation là bottleneck thực sự.

9. Giới hạn của escape analysis trong HotSpot

HotSpot escape analysis là intra-procedural — chỉ phân tích trong một method sau inline, không theo dõi cross-method khi callee không inline được.

// Factory method -- JIT co the inline create() neu no du nho
Point p = PointFactory.create(x, y);  // inline: p co the scalar-replace
// Neu create() qua lon hoac chua duoc C2 -> p GlobalEscape

GraalVM JIT làm mạnh hơn với partial escape analysis (PEA): object escape ở một nhánh code nhưng không ở nhánh khác thì chỉ alloc trên nhánh escape — HotSpot không có.

Kinh nghiệm thực tế:

  • Object nhỏ (1–5 field) constructor đơn giản trong loop → rất có thể được scalar-replace.
  • Object có final field → JIT tin tưởng hơn.
  • Object truyền qua nhiều layer method → ít khả năng scalar-replace.
  • Hot code sau JMH warm-up: dùng JFR confirm allocation rate trước khi optimize thủ công.

10. 📚 Deep Dive Oracle

Deep Dive — spec và reference

11. Tự kiểm tra

Tự kiểm tra
Q1
Escape analysis là gì và ba mức escape trong HotSpot khác nhau thế nào?

Escape analysis là phân tích của C2 compiler (tier 4 JIT, sau khi inline callee) xác định reference đến object có thoát khỏi method tạo ra nó không. Ba mức: NoEscape (không thoát — mở ra scalar replacement + lock elision), ArgEscape (thoát qua argument nhưng callee không lưu reference — vài tối ưu nhỏ), GlobalEscape (assign static / return / store vào object đã escape — alloc heap bình thường).

Phân tích là conservative: không chắc chắn thì assume GlobalEscape. Đó là lý do virtual call không inline được thường kéo object về GlobalEscape.

Q2
Scalar replacement khác stack allocation thế nào, và vì sao mạnh hơn?

Stack allocation (lý thuyết) đẩy nguyên object — header + fields — lên stack; object vẫn có layout đầy đủ. Scalar replacement (cái HotSpot thực sự làm) decompose object thành từng field rời, mỗi field thành local variable có thể nằm luôn trong register.

Nó mạnh hơn vì bỏ được object header (12–16 byte) và cho phép JIT constant-fold / dead-code-eliminate từng field như primitive thuần. Kết quả: object chưa bao giờ tồn tại trên heap.

Q3
Lock elision là gì? Vì sao Vector cục bộ đôi khi nhanh ngang ArrayList?

Lock elision là tối ưu C2 xoá synchronized trên object NoEscape: nếu không thread nào khác thấy object, lock không thể có contention nên thành no-op. Vì thế Vector hay StringBuffer dùng cục bộ trong method (không return ra ngoài) bị elide toàn bộ lock sau warm-up, chạy nhanh ngang ArrayList / StringBuilder.

Điều kiện: object phải NoEscape, method đã được C2 compile, và callee (add()…) inline được để JIT thấy lock target.

Q4
Làm sao verify scalar replacement đang hoạt động cho một method cụ thể?

Ba cách theo độ invasive tăng dần: JFR allocation profiling (production-safe — method hot không xuất hiện event jdk.ObjectAllocationInNewTLAB nghĩa là đã scalar-replace); JMH với -prof gc đọc gc.alloc.rate.norm (bằng 0 là dấu hiệu định lượng); và diagnostic flag -XX:+PrintEliminateAllocations (chỉ chạy trên debug JVM hoặc kèm -XX:+UnlockDiagnosticVMOptions).

Nguyên tắc: đo trước bằng JFR/JMH, chỉ đào sâu khi allocation thật sự là bottleneck.

Q5
Đoán xem p trong đoạn sau có được scalar-replace không, và giải thích:
public String format(int x, int y) { Point p = new Point(x, y); return "(" + p.x + "," + p.y + ")"; }

p rất có khả năng được scalar-replace. Từ Java 9 (JEP 280) string concatenation compile thành invokedynamic tới StringConcatFactory, chỉ truyền các giá trị p.x, p.y (đã đọc khỏi p) làm tham số — bản thân p không bị store ra ngoài nên NoEscape.

JIT dùng trực tiếp p_x = x, p_y = y; object Point không bao giờ tồn tại. Chỉ chuỗi kết quả của makeConcatWithConstants mới là object thật được trả về. Verify bằng JMH -prof gc (gc.alloc.rate.norm).

Bài tiếp theo: Safepoint và Stop-The-World — tại sao GC pause dài hơn GC log báo

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Safepoint và STW — vì sao GC pause dài hơn GC log báo