Java — Từ Zero đến Senior/Stack trace và cause chain — cơ chế bên dưới exception
~22 phútException HandlingMiễn phí

Stack trace và cause chain — cơ chế bên dưới exception

Stack trace là gì, JVM build qua fillInStackTrace native thế nào, cost ~1-10μs. Exception table trong bytecode. Cause chain wrap exception qua layer. Suppressed exception. Tối ưu hot path.

Mở log production thấy:

RuntimeException: user not found
    at UserRepository.findById(UserRepository.java:42)
    at UserService.getCurrentUser(UserService.java:18)
    at UserController.profile(UserController.java:25)
    at ...
Caused by: SQLException: connection refused: localhost:5432
    at org.postgresql.Driver.connect(Driver.java:456)

10 giây sau, bạn biết chính xác lỗi gì (user not found), ở đâu (UserRepository dòng 42), qua đường nào (controller → service → repository), và nguyên nhân gốc (DB connection refused).

Không có stack trace, bạn sẽ mất hàng giờ đoán mò. Debug Java production thực sự dựa vào stack trace — đây là công cụ số 1 developer.

Bài này đi sâu vào cơ chế bên dưới exception:

  • Stack trace là gì, JVM build thế nào, cost bao nhiêu.
  • Exception table trong bytecode — cách JVM dispatch catch.
  • Cause chain — wrap exception qua layer mà không mất thông tin.
  • Suppressed exception — exception trong finally không đè exception chính.
  • Tối ưu hot path khi exception là hot.

Bài 1 đã cover cú pháp try/catch/finally. Bài này cover cơ chế runtime — hiểu thì biết cách debug sâu, biết chỗ nào có thể tối ưu.

1. Stack trace — bản đồ đi qua stack

Stack trace là log ghi lại đường đi của exception qua call stack.

Ví dụ cơ bản

public class Demo {
    public static void main(String[] args) {
        level1();
    }
    static void level1() { level2(); }
    static void level2() { level3(); }
    static void level3() {
        throw new RuntimeException("something failed");
    }
}

Chạy → JVM in:

Exception in thread "main" java.lang.RuntimeException: something failed
    at Demo.level3(Demo.java:9)
    at Demo.level2(Demo.java:7)
    at Demo.level1(Demo.java:6)
    at Demo.main(Demo.java:4)

Đọc stack trace:

  • Dòng đầu: java.lang.RuntimeException: something failedclass exception + message.
  • Dòng 2: at Demo.level3(Demo.java:9)điểm ném — method level3 tại file Demo.java dòng 9.
  • Dòng 3-5: caller chain từ gần đến xa gốc — level2level1main.

Đọc từ trên xuống = đọc theo thứ tự gọi ngược. Dòng trên cùng (sau exception name) là nơi exception sinh ra. Dòng cuối cùng là điểm bắt đầu của call stack.

Chiến lược đọc stack trace production

  1. Dòng đầu — xem class + message → hiểu loại lỗi.
  2. Dòng 2-5 — code của bạn — thường nguyên nhân nằm đây.
  3. Dòng sâu hơn — framework/stdlib — bỏ qua trừ khi thật sự cần.
  4. Tìm "Caused by:" — nếu có, là exception gốc wrapped — đọc tương tự.

2. Stack trace được build thế nào

Khi new RuntimeException(...) tạo, constructor của Throwable gọi fillInStackTrace()native method. JVM walk qua call stack hiện tại, ghi lại mỗi stack frame:

  • Class name.
  • Method name.
  • File name (từ metadata bytecode SourceFile attribute).
  • Line number (từ metadata bytecode LineNumberTable attribute — compile với -g).

Kết quả array StackTraceElement[] lưu trong Throwable object, kể cả khi chưa throw. Chỉ in ra khi gọi printStackTrace().

Cost của fillInStackTrace

Walk stack + ghi frame là operation tốn: ~1-10 microsecond mỗi exception tuỳ stack depth. Không đáng kể với exception thường (1 vài exception per request).

Nhưng đáng kể khi:

  • Hot path throw/catch: code throw exception trong loop 1M lần/giây → fillInStackTrace thành bottleneck (~10% CPU).
  • Control flow bằng exception: lạm dụng throw/catch thay cho if/else → slow.

Effective Java Item 69: "Use exceptions only for exceptional conditions" — đừng dùng exception cho control flow thông thường.

Tối ưu: override fillInStackTrace

Với exception "benign" không cần stack trace, override trả this (no-op):

public class FastException extends RuntimeException {
    @Override
    public Throwable fillInStackTrace() {
        return this;   // Khong build stack trace
    }
}

Exception vẫn throw/catch được, nhưng không có stack trace — tiết kiệm build cost. JDK dùng pattern này trong ConcurrentHashMap cho "retry" signal internal.

Cảnh báo: mất stack trace → debug khó. Chỉ dùng khi chắc chắn exception là benign không cần trace.

StackTraceElement — truy cập programmatic

try { ... }
catch (Exception e) {
    StackTraceElement[] trace = e.getStackTrace();
    for (StackTraceElement frame : trace) {
        System.out.println(frame.getClassName() + "." + frame.getMethodName()
            + " (" + frame.getFileName() + ":" + frame.getLineNumber() + ")");
    }
}

Dùng khi cần xử lý stack trace thủ công — log aggregator, bug report tool, monitoring dashboard.

Java 9+: StackWalker

Stack trace "eager" build khi exception tạo. Nếu chỉ cần 1-2 frame đầu, lãng phí.

StackWalker (Java 9, JEP 259) cho phép lazy walk với filter:

StackWalker walker = StackWalker.getInstance();
List<String> top3 = walker.walk(frames -> frames
    .limit(3)
    .map(frame -> frame.getClassName() + "." + frame.getMethodName())
    .toList());

Không build stack trace đầy đủ. Dùng trong logging framework (SLF4J), profiler, monitoring để tránh cost khi chỉ cần vài frame đầu.

3. Exception table bytecode — cách JVM dispatch

Khi throw chạy, JVM không "if check exception type" — nó dùng exception table có sẵn trong bytecode của method.

Ví dụ

Với code:

try { riskyOp(); }
catch (IOException e) { handleIO(e); }
catch (SQLException e) { handleSQL(e); }

Javac compile thành bytecode kèm exception table:

Code:
  0: invokestatic riskyOp
  3: goto 20                 // skip handler neu try OK

Exception table:
  from    to     target     type
  0       3      6          java/io/IOException
  0       3      13         java/sql/SQLException

  6: astore_1                // handler IOException
  7: aload_1
  8: invokestatic handleIO
  11: goto 20

  13: astore_1               // handler SQLException
  14: aload_1
  15: invokestatic handleSQL
  18: goto 20

  20: return

Cơ chế dispatch

Khi throw, JVM:

  1. Tìm exception table của method hiện tại.
  2. Check: PC (program counter) hiện tại có trong try range (from-to) không? Nếu có, exception class có match type không?
  3. Match → nhảy tới target (catch block). Stack frame giữ nguyên. Execution tiếp tục trong catch.
  4. Không matchpop stack frame của method hiện tại, quay về caller. Lặp lại bước 1 với method caller.
  5. Nếu stack rỗng (chưa ai catch) → main thread: JVM in stack trace và exit. Other thread: thread terminate.

Đây là "unwind" — quay ngược stack, đóng từng frame cho đến khi tìm handler.

Lợi ích

Không có code "if exception == X" runtime — dispatch là table lookup thuần ở cấp bytecode. Throw+catch ở Java rẻ (~100ns) — chi phí chính là fillInStackTrace (~1-10μs) khi tạo exception.

Xem bytecode bằng javap -c -p -v YourClass.class sẽ thấy exception table đầy đủ.

4. Cause chain — wrap exception qua layer

Khi bắt exception ở layer thấp rồi throw exception mới ở layer cao, giữ cause:

public User findUser(long id) {
    try {
        return jdbcTemplate.findById(id);
    } catch (SQLException e) {
        throw new UserRepositoryException("Failed to find user " + id, e);
        //                                                                  ^ cause!
    }
}

UserRepositoryException mang message mới (domain level), nhưng SQLException gốc lưu trong cause chain. Stack trace in ra:

UserRepositoryException: Failed to find user 42
    at UserRepository.findUser(UserRepository.java:25)
    at UserService.getUser(UserService.java:12)
    ...
Caused by: java.sql.SQLException: connection refused: localhost:5432
    at org.postgresql.Driver.connect(Driver.java:456)
    ...

"Caused by" giữ toàn bộ chain — developer thấy cả tầng domain và tầng thấp. Mất cause = mất context = debug khó.

Pattern wrap với constructor

Custom exception phải có constructor nhận (String message, Throwable cause):

public class UserRepositoryException extends RuntimeException {
    public UserRepositoryException(String msg, Throwable cause) {
        super(msg, cause);   // Luu cause vao Throwable
    }
}

super(msg, cause) lưu cause qua Throwable.initCause(cause). Stack trace print đi qua cause tự động.

Mất cause — bug thường gặp

catch (IOException e) {
    throw new RuntimeException("bad");   // Mat stack trace goc!
}

Stack trace sau chỉ show "RuntimeException: bad" — không có Caused by. Developer mất thông tin lỗi thực sự.

Fix: truyền cause:

throw new RuntimeException("bad", e);

Rule: không bao giờ bỏ cause khi wrap.

getCause() — truy cập programmatic

catch (UserRepositoryException e) {
    Throwable cause = e.getCause();
    if (cause instanceof SQLException sql) {
        // Xu ly SQL cu the - retry neu la timeout
    }
}

Walk cause chain:

Throwable cur = e;
while (cur != null) {
    log.error("Layer: " + cur.getMessage());
    cur = cur.getCause();
}

Hữu ích cho logging có cấu trúc, hoặc match lỗi theo root cause.

5. Suppressed exception (Java 7+)

Case đặc biệt: exception trong finally đè exception chính:

try {
    throw new FirstException();
} finally {
    throw new SecondException();   // De FirstException
}

FirstException mất (bị overwritten bởi SecondException). Debug khó vì không biết exception gốc.

Java 7 thêm suppressed exception — exception phụ được ghi đè nhưng vẫn lưu trong object:

try (Resource r = openResource()) {
    doWork(r);   // throw ConnectException
    // close() throw IOException khi try-with-resources close
}

Primary exception: ConnectException. Suppressed: IOException from close. JVM in:

ConnectException: connect failed
    at ...
    Suppressed: IOException: close failed
        at Resource.close(...)

try-with-resources tự động handle suppressed. Thủ công:

try {
    primary.addSuppressed(secondary);
    throw primary;
} ...

6. Ví dụ cause chain + suppressed combined

public class DbOps {
    public void processOrder(long orderId) {
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement("...");
            stmt.setLong(1, orderId);
            stmt.executeUpdate();
        } catch (SQLException e) {
            throw new OrderProcessingException("Failed to process order " + orderId, e);
        }
    }
}

public class OrderProcessingException extends RuntimeException {
    public OrderProcessingException(String msg, Throwable cause) {
        super(msg, cause);
    }
}

Giả sử: SQL fail + connection close cũng fail. Stack trace:

OrderProcessingException: Failed to process order 42
    at DbOps.processOrder(DbOps.java:8)
    at OrderService.handle(...)
Caused by: SQLException: deadlock detected
    at ...
    Suppressed: SQLException: connection already closed
        at Connection.close(...)

Thông tin đầy đủ:

  • Primary: domain-level OrderProcessingException với message business.
  • Caused by: lỗi gốc SQLException: deadlock — developer biết retry với backoff.
  • Suppressed: SQLException: connection already closed — context phụ, không phải root cause.

Debug ticket chỉ cần "order 42, deadlock, retry" — đã đủ actionable.

7. Tối ưu hot path — khi exception là bottleneck

Dấu hiệu bottleneck

  • Profiler flame graph cho thấy fillInStackTrace chiếm >5% CPU.
  • Code throw exception trong loop với high throughput (vd stream processor, parser).
  • Logs không cần stack trace (exception là signal thôi).

Kỹ thuật 1: Override fillInStackTrace

public class ControlFlowException extends RuntimeException {
    @Override public Throwable fillInStackTrace() { return this; }
}

Dùng cho exception nội bộ, caller biết ignore stack trace.

Kỹ thuật 2: Static instance (tránh tạo mới)

public class ParseError extends RuntimeException {
    public static final ParseError INSTANCE = new ParseError();
    private ParseError() { super(null, null, false, false); }
}

// Su dung:
if (!parseOK) throw ParseError.INSTANCE;

Constructor (null, null, false, false) — 2 boolean sau cùng tắt writableStackTraceenableSuppression. Throwable không lưu stack trace, không suppressed.

Kỹ thuật 3: Không dùng exception cho control flow

// BAD
try {
    return Integer.parseInt(s);
} catch (NumberFormatException e) {
    return defaultValue;
}

// GOOD (Java)
if (s.matches("-?\\d+")) return Integer.parseInt(s);
else return defaultValue;

Check trước, tránh throw khi biết có thể fail. Parser production thường viết theo phong cách LL(1) grammar — predict trước, không throw lung tung.

8. Pitfall tổng hợp

Nhầm 1: Mất cause khi wrap.

catch (IOException e) { throw new RuntimeException("failed"); }

✅ Truyền cause: new RuntimeException("failed", e).

Nhầm 2: printStackTrace() production.

e.printStackTrace();

log.error(message, e); — dùng logger framework.

Nhầm 3: Exception cho control flow thường xuyên.

for (String s : lines) {
    try { process(Integer.parseInt(s)); }
    catch (NumberFormatException e) { skip(); }
}

✅ Check trước hoặc dùng Optional: nếu nhiều line invalid expected, overhead fillInStackTrace đáng kể.

Nhầm 4: Throw Throwable/Exception chung trong API.

public void foo() throws Throwable { ... }

✅ Declare cụ thể: throws IOException, SQLException.

Nhầm 5: Quên suppressed exception khi handle resource manual.

try {
    doWork();
} finally {
    resource.close();   // Co the throw, de primary exception
}

✅ Try-with-resources auto handle, hoặc manual:

try { doWork(); }
catch (Exception primary) {
    try { resource.close(); }
    catch (Exception secondary) { primary.addSuppressed(secondary); }
    throw primary;
}

9. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JVMS §2.10 giải thích exception table — đây là lý do throw+catch ở Java rẻ ở cấp bytecode. Chi phí chính là fillInStackTrace. Với workload normal (vài exception/request) hoàn toàn OK. Với hot path (1M throw/sec) mới cần tối ưu qua override fillInStackTrace hoặc static instance.

10. Tóm tắt

  • Stack trace = log đường đi qua method. Build lúc new Exception() qua fillInStackTrace() native.
  • Đọc stack trace: dòng đầu = nơi ném, dòng sau = caller chain, "Caused by:" = exception gốc.
  • fillInStackTrace cost ~1-10μs — hot path throw nhiều có thể bottleneck. Override trả this nếu không cần stack.
  • StackWalker Java 9+ lazy walk, efficient cho logging framework.
  • Exception table bytecode — dispatch không runtime check, chi phí chính là fillInStackTrace.
  • Cause chain: new X(msg, cause) giữ exception gốc qua layer. getCause() truy cập. Luôn giữ cause khi wrap.
  • Suppressed exception (Java 7+): exception phụ trong try-with-resources không đè primary. addSuppressed manual.
  • Tối ưu hot path: override fillInStackTrace, static instance, không dùng exception cho control flow.

11. Tự kiểm tra

Tự kiểm tra
Q1
Đọc stack trace sau, nơi nào là "điểm ném exception" và nơi nào là "điểm bắt đầu call stack"?
RuntimeException: user not found
  at UserRepository.findById(UserRepository.java:42)
  at UserService.getCurrentUser(UserService.java:18)
  at UserController.profile(UserController.java:25)
  at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  at ...

Điểm ném: dòng ngay sau exception name — UserRepository.findById tại dòng 42. Đây là nơi throw new RuntimeException(...) chạy.

Điểm bắt đầu call stack: dòng cuối cùng trước framework frame — UserController.profile. Đây là entry point của request từ framework.

Debug tip:

  1. Dòng đầu — xem class + message → loại lỗi.
  2. Dòng 2-5 — code của bạn — thường nguyên nhân.
  3. Dòng sâu hơn — framework/stdlib — bỏ qua trừ khi cần.
  4. Tìm "Caused by:" — nếu có, exception gốc wrapped.
Q2
Vì sao fillInStackTrace có thể là bottleneck trong hot path, và fix thế nào?

fillInStackTrace là native method JVM walk toàn bộ call stack, ghi mỗi frame (class, method, file, line). Tốn ~1-10μs mỗi lần.

Bình thường exception hiếm (1 vài/request) → không đáng kể. Nhưng nếu dùng exception như control flow (throw/catch trong loop 1M/s), 1M × 5μs = 5 giây CPU chỉ cho fillInStackTrace → bottleneck.

Fix 1: không dùng exception cho control flow. Trả `Optional`/`null`/enum result. Effective Java Item 69.

Fix 2: override fillInStackTrace trả this:

public class FastException extends RuntimeException {
  @Override
  public Throwable fillInStackTrace() { return this; }
}

JDK dùng pattern này trong ConcurrentHashMap cho "retry" signal.

Fix 3: static instance với constructor tắt writableStackTrace.

Cảnh báo: mất stack trace → debug khó. Chỉ dùng khi exception là benign không cần trace.

Q3
Đoạn sau mất thông tin gì khi debug, và fix thế nào?
try {
  connectDb();
} catch (SQLException e) {
  throw new DataAccessException("DB failed");
}

Mất cause chainSQLException gốc bị bỏ. Stack trace sau chỉ show:

DataAccessException: DB failed
  at ...

Không có "Caused by: SQLException: connection refused" — developer không biết lỗi thực sự (timeout? auth? DB down?).

Fix: truyền cause qua constructor thứ 2:

throw new DataAccessException("DB failed", e);

Với DataAccessException có constructor nhận (String, Throwable):

public class DataAccessException extends RuntimeException {
  public DataAccessException(String msg, Throwable cause) {
      super(msg, cause);
  }
}

Stack trace mới giữ cả domain context và low-level detail. Rule không bao giờ bỏ.

Q4
Exception table trong bytecode là gì, và vì sao throw+catch rẻ ở cấp JVM?

Khi compile try/catch, javac sinh bytecode có exception table: mapping (range bytecode → handler, exception type). Ví dụ:

from  to  target  type
0     3   6       java/io/IOException
0     3   13      java/sql/SQLException

Khi throw, JVM:

  1. Lấy PC (program counter) hiện tại.
  2. Check exception table: PC có trong range from-to không? Type có match không?
  3. Match → nhảy tới target. Không match → pop stack frame, quay caller, lặp.

Đây là table lookup thuần — không có "if check" runtime. Dispatch rẻ ~100ns mỗi throw-catch.

Chi phí chính của exception là fillInStackTrace (~1-10μs khi tạo). Với workload normal không đáng kể. Hot path 1M throw/s mới cần tối ưu.

Xem bytecode: javap -c -p -v YourClass.class.

Q5
Suppressed exception (Java 7+) giải quyết vấn đề gì?

Vấn đề trước Java 7: exception trong finally đè exception của try — mất thông tin gốc:

try {
  throw new ConnectException();   // primary
} finally {
  resource.close();   // throw IOException - de ConnectException
}
// Caller chi thay IOException, mat ConnectException

Java 7 thêm addSuppressed — exception phụ ghi đè nhưng vẫn lưu trong Throwable. Stack trace in cả 2:

ConnectException: connect failed
  at ...
  Suppressed: IOException: close failed
      at Resource.close(...)

Try-with-resources tự động handle. Manual:

try { doWork(); }
catch (Exception primary) {
  try { resource.close(); }
  catch (Exception secondary) { primary.addSuppressed(secondary); }
  throw primary;
}

Nhờ suppressed, debug production giữ được root cause (connect fail) thay vì chỉ thấy consequence (close fail).

Bài tiếp theo: Checked vs Unchecked — cuộc tranh cãi kéo dài 20 năm

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