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 failed— class exception + message. - Dòng 2:
at Demo.level3(Demo.java:9)— điểm ném — methodlevel3tại fileDemo.javadòng 9. - Dòng 3-5: caller chain từ gần đến xa gốc —
level2→level1→main.
Đọ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
- Dòng đầu — xem class + message → hiểu loại lỗi.
- Dòng 2-5 — code của bạn — thường nguyên nhân nằm đây.
- Dòng sâu hơn — framework/stdlib — bỏ qua trừ khi thật sự cần.
- 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
SourceFileattribute). - Line number (từ metadata bytecode
LineNumberTableattribute — 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:
- Tìm exception table của method hiện tại.
- Check: PC (program counter) hiện tại có trong try range (
from-to) không? Nếu có, exception class có matchtypekhông? - Match → nhảy tới
target(catch block). Stack frame giữ nguyên. Execution tiếp tục trong catch. - Không match → pop stack frame của method hiện tại, quay về caller. Lặp lại bước 1 với method caller.
- 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
OrderProcessingExceptionvớ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
fillInStackTracechiế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 writableStackTrace và enableSuppression. 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
Spec / reference chính thức:
- JVMS §2.10 — Exceptions — bytecode + exception table chi tiết.
- Throwable javadoc — getMessage, getCause, getStackTrace, addSuppressed.
- JEP 259: StackWalker API — Java 9 lazy stack walk.
- StackWalker javadoc — API chi tiết.
- Effective Java Item 69 "Use exceptions only for exceptional conditions".
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()quafillInStackTrace()native. - Đọc stack trace: dòng đầu = nơi ném, dòng sau = caller chain, "Caused by:" = exception gốc.
fillInStackTracecost ~1-10μs — hot path throw nhiều có thể bottleneck. Override trảthisnếu không cần stack.StackWalkerJava 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.
addSuppressedmanual. - Tối ưu hot path: override fillInStackTrace, static instance, không dùng exception cho control flow.
11. 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 ...
▸
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:
- Dòng đầu — xem class + message → loại lỗi.
- Dòng 2-5 — code của bạn — thường nguyên nhân.
- Dòng sâu hơn — framework/stdlib — bỏ qua trừ khi cần.
- Tìm "Caused by:" — nếu có, exception gốc wrapped.
Q2Vì sao fillInStackTrace có thể là bottleneck trong hot path, và fix thế nào?▸
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");
}
▸
try {
connectDb();
} catch (SQLException e) {
throw new DataAccessException("DB failed");
}Mất cause chain — SQLException 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ỏ.
Q4Exception 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/SQLExceptionKhi throw, JVM:
- Lấy PC (program counter) hiện tại.
- Check exception table: PC có trong range
from-tokhông? Type có match không? - 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.
Q5Suppressed 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 ConnectExceptionJava 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?