Đọ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ó.
Sơ đồ luồng wrap qua layer:
graph TD
A["Repository: SQLException (loi goc)"] -->|"new UserRepositoryException(msg, e)"| B["Service: UserRepositoryException mang cause"]
B -->|propagate| C["Controller / global handler"]
C --> D["Log: stack trace day du + Caused by: SQLException"]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) {
// 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 ...
▸
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.
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");
}
▸
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ỏ.
Q3Vì sao nên wrap SQLException thành UserRepositoryException ở repository layer, thay vì để SQLException propagate thẳng lên controller?▸
Abstraction: caller của repository (service, controller) làm việc với khái niệm domain ("không tìm thấy user"), không nên biết chi tiết hạ tầng ("SQL state 08001"). Nếu sau này đổi từ JDBC sang MongoDB, mọi nơi catch SQLException phải sửa — wrap thành exception domain thì chỉ repository đổi.
Checked → unchecked: SQLException là checked — để propagate thẳng buộc mọi method trên đường đi khai throws SQLException, ô nhiễm signature toàn hệ thống.
Vẫn giữ đầy đủ thông tin: wrap đúng cách (new UserRepositoryException(msg, e)) giữ nguyên SQLException trong cause chain — log production in cả 2 tầng qua "Caused by:". Wrap không làm mất gì, miễn là truyền cause.
Đây là Effective Java Item 73: "Throw exceptions appropriate to the abstraction" — exception translation kèm chaining.
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?▸
Walk cause chain bằng getCause() cho đến khi gặp null:
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
// root la exception goc nhatSau đó match theo type: if (root instanceof SQLException sql) → retry nếu là timeout/deadlock; instanceof IOException → lỗi hạ tầng mạng/disk.
Pattern này phổ biến trong global handler và logging framework — phân loại lỗi theo root cause chứ không theo lớp wrap ngoài cùng, vì lớp ngoài chỉ là context của từng layer. Lưu ý vòng walk dừng được vì cause chain hữu hạn (JDK chặn self-cause).
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: Exception table và cost của exception — throw rẻ, tạo đắt
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