Đọc stack trace và cause chain — giữ dấu vết lỗi qua mọi layer
Cách đọc stack trace production trong 10 giây, JVM build stack trace qua fillInStackTrace thế nào, cause chain giữ exception gốc khi wrap qua layer, và suppressed exception.
TL;DR: Stack trace là log ghi lại đường đi của exception qua call stack — JVM build nó ngay lúc new Exception() qua native method fillInStackTrace(), trước cả khi throw. Đọc từ trên xuống: dòng đầu là nơi exception sinh ra, dòng cuối là điểm bắt đầu call stack, "Caused by:" là exception gốc bị wrap. Khi bắt exception ở layer thấp rồi ném exception mới ở layer cao, luôn truyền cause vào constructor — quên cause là mất toàn bộ thông tin lỗi gốc, pitfall debug số 1 của Java. Suppressed exception (Java 7+) đảm bảo exception phát sinh lúc close resource không đè mất exception chính.
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 của developer.
Bài này tập trung vào đọc và giữ dấu vết lỗi:
- Stack trace là gì, JVM build thế nào, đọc thế nào cho nhanh.
- Cause chain — wrap exception qua layer mà không mất thông tin.
- Suppressed exception — exception trong lúc close không đè exception chính.
Bài 1 đã cover cú pháp try/catch/finally. Phần cơ chế bytecode (exception table) và chi phí của exception đi vào bài 3.
1. Stack trace — bản đồ đi qua call 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().
Walk stack có chi phí — khoảng 1-10 microsecond mỗi exception tuỳ stack depth. Với workload bình thường (vài exception mỗi request) hoàn toàn không đáng kể; với hot path throw hàng triệu lần mỗi giây thì thành bottleneck. Bài 3 mổ xẻ chi phí này và các kỹ thuật tối ưu.
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.
3. 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. Đặt hai bản in cạnh nhau thì thấy rõ mất gì:

Exception mới thay hẳn trace cũ chứ không nối vào; truyền cause là cách duy nhất giữ lại tầng dưới.
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) {
// Handle SQL-specific case - retry if 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.
4. 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;
} ...
5. 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.
6. 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: Throw Throwable/Exception chung trong API.
public void foo() throws Throwable { ... }
✅ Declare cụ thể: throws IOException, SQLException.
❌ Nhầm 4: 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;
}
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- Throwable javadoc — getMessage, getCause, getStackTrace, addSuppressed.
- JLS §11.3 — Run-Time Handling of an Exception — semantic propagate + handler.
- Effective Java Item 75 "Include failure-capture information in detail messages".
Ghi chú: Javadoc của Throwable là nơi định nghĩa chính thức cả cause chain (initCause, getCause) lẫn suppressed exception (addSuppressed, getSuppressed). Item 75 nhắc message exception phải kèm mọi giá trị gây lỗi — kết hợp với cause chain đầy đủ, một dòng log production là đủ để debug.
8. Tóm tắt
- Stack trace = log đường đi qua method. Build lúc
new Exception()quafillInStackTrace()native, kể cả khi chưa throw. - Đọc stack trace: dòng đầu = nơi ném, dòng sau = caller chain, "Caused by:" = exception gốc.
- Chiến lược production: class + message → frame code của bạn → tìm "Caused by".
- Cause chain:
new X(msg, cause)giữ exception gốc qua layer.getCause()truy cập. Luôn giữ cause khi wrap. - Mất cause khi wrap = mất thông tin lỗi thực sự — pitfall debug số 1.
- Suppressed exception (Java 7+): exception phụ lúc close không đè primary.
addSuppressedcho code manual, try-with-resources tự lo. - Chi phí build stack trace ~1-10μs/exception — chi tiết cost + tối ưu ở bài 3.
9. 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 ... - Q2Đ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"); } - Q3Vì sao nên wrap SQLException thành UserRepositoryException ở repository layer, thay vì để SQLException propagate thẳng lên controller?
- Q4Một exception bị wrap qua 3 tầng. Làm thế nào lấy root cause (exception gốc nhất) để phân loại lỗi?
- Q5Suppressed exception (Java 7+) giải quyết vấn đề gì?
Bài tiếp theo: Exception table và cost của exception — throw rẻ, tạo đắt
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
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