Hai hôm trước ta có một file Xin.class nặng 1005 byte. Hôm nay mở nó ra.
Tôi biết có người sẽ nghĩ: mới ngày thứ ba mà đã bytecode, có sớm quá không? Tôi cho là không, vì một khi bạn nhìn thấy javac thực sự sinh ra cái gì, rất nhiều thứ về sau sẽ tự sáng ra — vì sao String bất biến mà nối chuỗi trong vòng lặp lại chậm, vì sao generics "biến mất" lúc chạy, lambda thực chất là gì. Không cần thuộc lòng tập lệnh, chỉ cần biết cách nhìn.
Và công cụ để nhìn nằm sẵn trong JDK.
Lớp vỏ
Bắt đầu nhẹ nhàng. javap không tham số cho ta phần công khai của lớp:
$ javap Xin
Compiled from "Xin.java"
public class Xin {
public Xin();
public static void main(java.lang.String[]);
}
Nhớ lại mã nguồn:
public class Xin {
public static void main(String[] args) {
System.out.println("Chào, đây là Java " + Runtime.version().feature());
}
}
Tôi không viết hàm khởi tạo nào, nhưng public Xin() vẫn xuất hiện. javac tự thêm hàm khởi tạo mặc định khi lớp không có hàm khởi tạo nào. Chi tiết nhỏ này giải thích một lỗi kinh điển: khi bạn tự viết một hàm khởi tạo có tham số, hàm mặc định biến mất, và mã cũ đang gọi new Xin() lập tức không biên dịch được nữa.
Bytecode
Giờ mới tới phần đáng xem. Thêm -c:
$ javap -c Xin
public class Xin {
public Xin();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: invokestatic #13 // Method java/lang/Runtime.version:()Ljava/lang/Runtime$Version;
6: invokevirtual #19 // Method java/lang/Runtime$Version.feature:()I
9: invokedynamic #25,0 // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
14: invokevirtual #29 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: return
}
Một dòng println duy nhất trong mã nguồn nở ra thành sáu lệnh. Đọc từ trên xuống:
getstatic lấy trường tĩnh System.out — chính là đối tượng PrintStream mà ta vẫn gọi println trên đó — và đẩy nó lên một ngăn xếp.
invokestatic gọi Runtime.version(), phương thức tĩnh nên gọi thẳng.
invokevirtual gọi .feature() trên đối tượng vừa nhận được. "Virtual" nghĩa là phương thức có thể bị lớp con ghi đè, nên JVM phải xác định lúc chạy xem gọi phiên bản nào. Đây chính là đa hình, nhìn từ tầng bytecode.
invokedynamic — ta sẽ nói riêng ngay dưới đây.
invokevirtual lần nữa, lần này là println với chuỗi đã ghép xong.
return kết thúc.
Điều đáng chú ý: JVM là máy ngăn xếp, không phải máy thanh ghi như CPU thật. Mọi lệnh đều lấy toán hạng từ ngăn xếp và đẩy kết quả trở lại ngăn xếp. Đó là lý do bytecode gọn và dễ sinh ra, nhưng cũng là lý do JIT phải làm việc vất vả để dịch nó thành mã máy chạy nhanh — chuyện của phần nói về JIT.
Còn hàm khởi tạo mặc định thì làm đúng một việc: aload_0 đẩy this lên ngăn xếp, invokespecial gọi hàm khởi tạo của Object. Nói cách khác, super() mà bạn không hề viết vẫn luôn được gọi.
Vì sao nối chuỗi lại thành invokedynamic
Trong mã nguồn tôi chỉ viết một dấu cộng:
"Chào, đây là Java " + Runtime.version().feature()
Nhiều tài liệu cũ sẽ bảo bạn rằng javac biến nó thành StringBuilder.append(). Điều đó đúng cho tới Java 8. Từ Java 9, javac sinh ra invokedynamic gọi tới makeConcatWithConstants.
Khác biệt nằm ở chỗ: với StringBuilder, cách ghép chuỗi bị đóng cứng vào file .class ngay lúc biên dịch. Với invokedynamic, file .class chỉ ghi ý định — "hãy nối những thứ này lại" — còn chiến lược nối cụ thể do thư viện chạy quyết định ở lần gọi đầu tiên. JVM đời sau tối ưu tốt hơn thì mã cũ tự hưởng lợi, không phải biên dịch lại.
Nhân tiện, đây cũng là lý do phải cẩn thận với lời khuyên tối ưu đọc được trên mạng. Câu "luôn dùng StringBuilder thay cho dấu cộng" ngày nay chỉ còn đúng trong vòng lặp. Với một biểu thức nối vài mảnh như trên, dấu cộng vừa ngắn gọn vừa nhanh ngang, đôi khi nhanh hơn.
Vòng lặp thì vẫn là chuyện khác hẳn, vì mỗi vòng lại tạo một chuỗi mới:
// Chậm: mỗi vòng lặp tạo một String mới, cấp phát rồi vứt đi
String s = "";
for (int i = 0; i < 10_000; i++) {
s += i;
}
// Nhanh: một bộ đệm duy nhất, lớn dần
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) {
sb.append(i);
}
String s = sb.toString();
Ta sẽ đo chênh lệch này bằng JMH ở phần nói về String, và con số sẽ lớn hơn bạn đoán khá nhiều.
Constant pool: nơi cất tên
Bạn có để ý những dấu #1, #7, #13 trong bytecode không? Chúng là chỉ mục vào constant pool — bảng hằng số của file .class. Xem bằng javap -v:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
Cấu trúc này trỏ lồng vào nhau: #1 là một tham chiếu phương thức, gồm lớp #2 và cặp tên–kiểu #3; #3 lại gồm tên #5 và mô tả #6. Xuống tận cùng đều là chuỗi UTF-8.
Làm vậy để làm gì? Để không lặp lại chuỗi. Nếu lớp của bạn gọi System.out mười lần, chuỗi java/lang/System chỉ nằm trong file đúng một lần, mười chỗ kia đều trỏ tới cùng chỉ mục.
Vài ký hiệu trong đó đáng học vì bạn sẽ gặp lại chúng trong thông báo lỗi:
| Ký hiệu | Nghĩa |
|---|---|
V |
void |
I |
int |
J |
long (không phải L, vì L đã dùng cho đối tượng) |
Z |
boolean |
D, F |
double, float |
Ljava/lang/String; |
một đối tượng kiểu String |
[I |
mảng int |
([Ljava/lang/String;)V |
nhận mảng String, trả về void |
Dòng cuối cùng chính là chữ ký của main. Lần tới gặp một stack trace đầy Ljava/util/List; bạn sẽ đọc được nó.
Cấu trúc một file .class
Ngoài constant pool, javap -v còn cho thấy vài thứ đáng chú ý:
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
major version: 65 là Java 21. Bảng quy đổi rất dễ nhớ: 45 tương ứng Java 1.1, và cứ mỗi phiên bản cộng 1. Java 8 là 52, Java 11 là 55, Java 17 là 61, Java 21 là 65.
Con số này là nguồn gốc của một lỗi mà ai làm Java lâu năm đều gặp ít nhất một lần:
java.lang.UnsupportedClassVersionError: Xin has been compiled by a more recent
version of the Java Runtime (class file version 65.0), this version of the Java
Runtime only recognizes class file versions up to 61.0
Dịch ra: file .class được biên dịch bằng JDK 21, còn máy đang chạy JDK 17. JVM cũ không đọc được file mới. Chiều ngược lại thì được — JVM mới luôn chạy được file cũ, đó là cam kết tương thích ngược mà Java giữ suốt ba mươi năm.
javac --release 17. Cờ này khác -target ở chỗ nó chặn luôn cả những API chỉ có từ 21, nên bạn không thể vô tình dùng nhầm rồi tới lúc chạy mới vỡ.
Vậy còn java làm gì
Tóm lại toàn bộ hành trình:
javac đọc mã nguồn, kiểm tra kiểu, rồi sinh ra file .class chứa constant pool và bytecode. Nó không tối ưu gì nhiều — trái với suy nghĩ thường gặp, javac là một trình biên dịch khá "ngây thơ".
java nạp file đó qua class loader, kiểm tra tính hợp lệ của bytecode — đây là bước quan trọng về an toàn, JVM không tin bất kỳ file .class nào cho tới khi kiểm xong — rồi bắt đầu thông dịch từng lệnh.
Đoạn nào chạy nhiều lần thì JIT vào cuộc, dịch sang mã máy thật và thay thế bản thông dịch. Mọi tối ưu nghiêm túc đều xảy ra ở đây, lúc chạy, khi JVM đã biết mã của bạn thực sự hành xử thế nào — thông tin mà javac không thể có được lúc biên dịch.
Đó là lý do một chương trình Java thường chạy chậm ở vài giây đầu rồi nhanh dần lên, và cũng là lý do đo hiệu năng Java mà không có bước làm nóng thì con số thu được vô nghĩa.
Thử tự tay
Nếu muốn thứ gì đó đọng lại, tôi đề nghị bạn làm bài tập nhỏ này thay vì đọc thêm:
Viết một lớp có vòng lặp for cộng dồn, biên dịch, rồi javap -c xem thử. Tìm lệnh goto — vòng lặp trong bytecode không hề có khái niệm "for", nó chỉ là một phép nhảy về sau. Rồi thử đổi for thành while viết tương đương và so sánh: bytecode sinh ra giống hệt nhau.
Đó là bài học đầu tiên về việc cú pháp và thứ thực sự chạy là hai chuyện khác nhau.
Ngày mai ta quay lại mặt đất với kiểu dữ liệu — và bắt đầu bằng câu đố kinh điển vì sao Integer.valueOf(127) == Integer.valueOf(127) trả về true còn với 128 thì lại false.