Exception table và cost của exception — throw rẻ, tạo đắt
Exception table trong bytecode giúp JVM dispatch catch bằng table lookup, không tốn runtime check. Chi phí thật nằm ở fillInStackTrace — anti-pattern control flow, kỹ thuật tối ưu hot path, và StackWalker.
TL;DR: JVM dispatch catch bằng exception table nằm sẵn trong bytecode của method — một table lookup thuần, không có "if check type" runtime, nên throw+catch chỉ tốn ~100ns. Chi phí thật của exception nằm ở fillInStackTrace() (~1-10μs) chạy ngay lúc new Exception(). Workload bình thường không sao; hot path throw hàng triệu lần mỗi giây thì thành bottleneck — đó là lý do của rule "không dùng exception cho control flow" (Effective Java Item 69). Khi thật sự cần throw trong hot path, tối ưu bằng override fillInStackTrace, static instance với writableStackTrace=false; còn khi chỉ cần vài frame đầu của stack, dùng StackWalker lazy walk thay vì build cả stack trace.
Bài 2 dạy đọc stack trace và giữ cause chain. Câu hỏi tiếp theo của một engineer tò mò: exception tốn bao nhiêu? Throw một exception có chậm không? Vì sao mọi sách đều dặn "đừng dùng exception cho control flow"? Bài này trả lời bằng cách mổ bytecode và đo chi phí từng bước.
1. Exception table — cách JVM dispatch catch
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 // call static riskyOp()
3: goto 19 // try OK -> skip handlers, jump to return
Exception table:
from to target type
0 3 6 java/io/IOException
0 3 14 java/sql/SQLException
6: astore_1 // [catch IOException] store exception into local var 1
7: aload_1 // load exception as argument
8: invokestatic handleIO // call static handleIO(e)
11: goto 19 // jump to return
14: astore_1 // [catch SQLException] store exception into local var 1
15: aload_1 // load exception as argument
16: invokestatic handleSQL // call static handleSQL(e)
19: return // end of method
Để ý offset: invokestatic và goto đều dài 3 byte, astore_1/aload_1 dài 1 byte — nên sau goto tại offset 11 là handler thứ hai tại 14, và sau invokestatic tại 16 là return tại 19. Handler cuối không cần goto — nó rơi thẳng (fall through) xuống 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.
graph TD
A["throw exception"] --> B["Tim exception table cua method hien tai"]
B --> C{"PC trong try range va type match?"}
C -->|Co| D["Nhay toi target - catch block chay"]
C -->|Khong| E["Pop stack frame, quay ve caller"]
E --> F{"Con caller tren stack?"}
F -->|Co| B
F -->|Khong| G["JVM in stack trace, thread terminate"]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 đủ.
2. Cost thật của exception — fillInStackTrace
Như đã thấy ở bài 2, constructor của Throwable gọi fillInStackTrace() — native method walk toàn bộ call stack, ghi từng frame. Đây là operation tốn: ~1-10 microsecond mỗi exception tuỳ stack depth. Không đáng kể với exception thường (một 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.
Chính JVM cũng "ăn gian" để né chi phí này
JIT compiler C2 có optimization OmitStackTraceInFastThrow (bật mặc định): khi một implicit exception (NullPointerException, ArithmeticException, ArrayIndexOutOfBoundsException...) bị ném lặp lại quá nhiều lần tại cùng một vị trí hot, JIT recompile và thay bằng một exception preallocated, không có stack trace lẫn message. Đó là lý do đôi khi log production thấy NPE "trống trơn" không một dòng at ....
Flag -XX:-OmitStackTraceInFastThrow tắt optimization này để luôn giữ stack trace khi cần debug — đổi lại chịu cost build trace cho mọi lần ném.
Dấu hiệu exception đang là bottleneck
- Profiler flame graph cho thấy
fillInStackTracechiếm hơn 5% CPU. - Code throw exception trong loop với high throughput (vd stream processor, parser).
- Logs không cần stack trace (exception chỉ là signal).
3. Kỹ thuật 1 — 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; // Skip building stack trace
}
}
Exception vẫn throw/catch được, nhưng không có stack trace — tiết kiệm toàn bộ chi phí walk stack. Dùng cho exception nội bộ mà caller biết là signal, không cần trace.
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.
4. Kỹ thuật 2 — static instance + tắt writableStackTrace
public class ParseError extends RuntimeException {
public static final ParseError INSTANCE = new ParseError();
private ParseError() { super(null, null, false, false); }
}
// Usage:
if (!parseOK) throw ParseError.INSTANCE;
Constructor protected này của Throwable có chữ ký chi tiết như sau:
protected Throwable(
String message,
Throwable cause,
boolean enableSuppression,
boolean writableStackTrace
)
Trong đó, 2 tham số boolean sau cùng đóng vai trò quyết định hiệu năng:
enableSuppression(false): vô hiệu hoá tính năng suppressed exception (các exception gom kèm khi dùng try-with-resources).writableStackTrace(false): vô hiệu hoá hoàn toàn việc ghi stack trace. JVM không chạy native methodfillInStackTrace()— loại bỏ toàn bộ chi phí duyệt stack frame khi exception được khởi tạo.
Static instance + tắt stack trace giúp throw nhanh hơn nhiều lần — phù hợp cho hot path xử lý dữ liệu lớn, nơi exception chỉ đóng vai trò signal. Đổi lại exception này dùng chung 1 object, không mang thông tin per-throw nào.
5. Kỹ thuật 3 — không dùng exception cho control flow
// BAD - relies on exception for normal flow
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return defaultValue;
}
// GOOD - check before parse, no throw on expected input
if (isNumeric(s)) return Integer.parseInt(s);
else return defaultValue;
// Helper checks digits manually (avoids regex cost in hot path)
public static boolean isNumeric(String s) {
if (s == null || s.isEmpty()) return false;
int start = 0;
if (s.charAt(0) == '-') {
if (s.length() == 1) return false;
start = 1;
}
for (int i = start; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) return false;
}
return true;
}
Check trước, tránh throw khi biết có thể fail. Lưu ý: biểu thức s.matches("-?\\d+") tuy gọn nhưng mỗi lần gọi phải compile regex bên trong — chi phí CPU lớn. Duyệt ký tự thủ công qua helper isNumeric ở trên nhanh hơn nhiều lần trong hot path thực sự. Parser production thường viết theo phong cách LL(1) grammar — predict trước, không throw tràn lan.
6. StackWalker (Java 9+) — lazy walk khi chỉ cần vài frame
Stack trace "eager" build khi exception tạo. Nếu chỉ cần 1-2 frame đầu (logging framework muốn biết caller là ai), build cả mảng là 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.
Ví dụ hoàn chỉnh chạy được ngay:
import java.util.List;
import java.util.stream.Collectors;
public class StackWalkerDemo {
public static void main(String[] args) {
level1();
}
static void level1() {
level2();
}
static void level2() {
printLazyStack();
}
static void printLazyStack() {
// Create a StackWalker instance
StackWalker walker = StackWalker.getInstance();
// Lazy walk - only take the top 3 frames
List<String> frames = walker.walk(stream -> stream
.limit(3)
.map(frame -> frame.getClassName() + "." + frame.getMethodName() + ":" + frame.getLineNumber())
.collect(Collectors.toList())
);
System.out.println("--- Top 3 frames of the call stack ---");
frames.forEach(System.out::println);
}
}
Output:
--- Top 3 frames of the call stack ---
StackWalkerDemo.printLazyStack:19
StackWalkerDemo.level2:14
StackWalkerDemo.level1:10
7. Pitfall tổng hợp
❌ Nhầm 1: 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 là expected, overhead fillInStackTrace đáng kể.
❌ Nhầm 2: Override fillInStackTrace cho exception cần debug.
public class PaymentFailedException extends RuntimeException {
@Override public Throwable fillInStackTrace() { return this; } // mat trace!
}
✅ Chỉ tắt stack trace cho exception signal nội bộ trong hot path đã đo bằng profiler. Exception nghiệp vụ cần debug thì giữ trace.
❌ Nhầm 3: Build cả stack trace khi chỉ cần frame đầu.
String caller = new Throwable().getStackTrace()[1].getClassName(); // build het de lay 1 frame
✅ StackWalker.getInstance().walk(...) lazy walk, dừng sớm sau frame cần lấy.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JVMS §2.10 — Exceptions — bytecode + exception table chi tiết.
- JVMS §3.12 — Throwing and Handling Exceptions — ví dụ compile try/catch thành exception table.
- 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.
9. Tóm tắt
- Exception table nằm sẵn trong bytecode mỗi method — JVM dispatch catch bằng table lookup (from/to/target/type), không runtime check.
- Không match → pop frame, quay caller, lặp — đó là unwind. Hết stack → in trace, thread terminate.
- Throw+catch rẻ (~100ns). Chi phí thật là
fillInStackTrace(~1-10μs) lúcnew Exception(). - JIT C2 có OmitStackTraceInFastThrow: exception nóng bị thay bằng instance preallocated không trace — tắt bằng
-XX:-OmitStackTraceInFastThrowkhi cần debug. - Tối ưu hot path: override
fillInStackTracetrảthis, hoặc static instance vớiwritableStackTrace=false. - Đừng dùng exception cho control flow — check trước (Effective Java Item 69).
StackWalker(Java 9+) lazy walk khi chỉ cần vài frame — logging framework, profiler.javap -c -p -vđể tự xem exception table của class bất kỳ.
10. Tự kiểm tra
Q1Exception 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 14 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.
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 (một 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/enum result, check trước khi parse. Effective Java Item 69.
Fix 2: override fillInStackTrace trả this:
public class FastException extends RuntimeException {
@Override
public Throwable fillInStackTrace() { return this; }
}Fix 3: static instance với constructor tắt writableStackTrace.
Chính JVM cũng làm tương tự: JIT C2 với OmitStackTraceInFastThrow thay implicit exception nóng bằng instance preallocated không stack trace.
Cảnh báo: mất stack trace → debug khó. Chỉ dùng khi exception là benign không cần trace.
Q3Vì sao nói "throw rẻ nhưng tạo exception đắt"? Hai chi phí này nằm ở bước nào?▸
Hai bước tách biệt:
- Tạo (
new Exception()): constructor củaThrowablegọifillInStackTrace()— walk toàn bộ call stack, ghi từng frame. ~1-10μs, tỉ lệ với stack depth. Đây là phần đắt. - Throw + dispatch: JVM tra exception table của từng frame — table lookup + unwind. ~100ns. Đây là phần rẻ.
Hệ quả thực dụng: nếu buộc phải dùng exception trong hot path, giảm chi phí ở bước tạo (static instance, writableStackTrace=false, override fillInStackTrace) — chứ không phải tránh throw/catch. Còn tốt nhất vẫn là thiết kế để input expected không sinh exception.
Q4Log production thấy NullPointerException nhưng không có stack trace — vì sao, và làm sao lấy lại trace để debug?▸
NullPointerException nhưng không có stack trace — vì sao, và làm sao lấy lại trace để debug?Đó là JIT C2 optimization OmitStackTraceInFastThrow (bật mặc định): khi một implicit exception (NPE, ArithmeticException, ArrayIndexOutOfBounds...) bị ném lặp lại rất nhiều lần tại cùng một điểm hot, JIT recompile và thay bằng exception preallocated — không message, không stack trace — để né chi phí fillInStackTrace mỗi lần ném.
Cách lấy lại trace:
- Chạy lại với flag
-XX:-OmitStackTraceInFastThrow— tắt optimization, mọi lần ném đều build trace đầy đủ (chấp nhận tốn CPU hơn). - Hoặc tìm trong log những lần ném đầu tiên sau khi JVM khởi động — trước khi JIT kích hoạt optimization, các lần ném đầu vẫn có trace đầy đủ.
Bonus insight: hiện tượng này cũng là tín hiệu code đang ném cùng một exception với tần suất rất cao tại một điểm — đáng xem lại logic thay vì chỉ bật flag.
Q5Khi nào dùng StackWalker thay vì e.getStackTrace() hoặc new Throwable().getStackTrace()?▸
StackWalker thay vì e.getStackTrace() hoặc new Throwable().getStackTrace()?getStackTrace() trả về mảng đầy đủ đã build eager lúc tạo Throwable — nếu chỉ cần 1-2 frame đầu (vd logging framework cần biết caller class), bạn trả chi phí walk cả stack để rồi vứt gần hết.
StackWalker (Java 9, JEP 259) walk lazy: stream frame được tiêu thụ đến đâu walk đến đó, limit(3) nghĩa là chỉ chạm 3 frame rồi dừng:
StackWalker.getInstance().walk(frames -> frames
.limit(3)
.map(f -> f.getClassName() + "." + f.getMethodName())
.toList());Dùng StackWalker khi: cần thông tin caller không gắn với exception (logging, audit, framework magic), hoặc cần filter/limit frame. Dùng getStackTrace() khi đã có sẵn exception và cần in/parse toàn bộ trace của nó — lúc đó chi phí build đã trả rồi.
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?
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