Nghĩ về bytecode như một thứ "tiếng phổ thông" thì cả kiến trúc Java sáng ra. javac không dịch thẳng mã nguồn của bạn ra tiếng máy của con CPU trước mặt — nó dịch ra một ngôn ngữ trung gian mà mọi JVM trên mọi hệ máy đều đọc được. Tới lúc chạy, JVM của từng máy mới dịch nốt chặng cuối sang tiếng máy bản địa. Đó chính là bí mật sau câu khẩu hiệu "viết một lần, chạy mọi nơi", và hôm nay ta mở cái bản dịch trung gian ấy ra đọc.

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.

Cần biên dịch bằng JDK 21 nhưng phải chạy được trên JVM 17? Dùng 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.

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.

Mẫu số chung

Cái kiến trúc "biên dịch ra ngôn ngữ trung gian, rồi một máy ảo chạy nó" không phải của riêng Java — nó là một trong những lựa chọn nền tảng mà mỗi ngôn ngữ phải cân nhắc, và chia các ngôn ngữ thành hai phe rõ rệt.

  • .NET (C#) gần như là bản sao kiến trúc: biên dịch ra CIL/IL (tương đương bytecode), chạy trên CLR (tương đương JVM), cũng JIT lúc chạy, cũng có "độ nóng". Công cụ ildasm đóng đúng vai javap. Ai hiểu JVM sang .NET thấy quen tới mức kỳ lạ — cả hai đều sinh năm 1995–2000 với cùng một ý tưởng.
  • Python cũng biên dịch ra bytecode (.pyc) và chạy trên một máy ngăn xếp y như JVM — gõ import dis; dis.dis(f) là thấy bytecode của một hàm, và bạn sẽ nhận ra cấu trúc rất giống. Khác chỗ CPython chỉ thông dịch, không JIT (đó là lý do nó chậm, và là lý do PyPy với JIT ra đời).
  • JavaScript trong trình duyệt: V8 biên dịch ra bytecode nội bộ rồi JIT những đoạn nóng — cùng mô hình "thông dịch trước, JIT sau" của JVM, chỉ giấu kín hơn.
  • Phe ngược lại là Go, Rust, C/C++: biên dịch thẳng ra mã máy bản địa, không bytecode, không máy ảo, không JIT. Đổi lại tính "chạy mọi nơi" lấy tốc độ khởi động tức thì và không có giai đoạn làm nóng — một binary Go chạy nhanh ngay từ mili giây đầu, trong khi JVM cần thời gian hâm.

Sợi chỉ chung đáng mang theo: "biên dịch" không phải một bước duy nhất mà là một phổ — từ "dịch thẳng ra mã máy một lần" (Go, Rust) tới "dịch ra ngôn ngữ trung gian rồi dịch tiếp lúc chạy" (Java, C#, JS). Chọn phe trung gian + JVM/JIT nghĩa là đánh đổi: được chạy mọi nơi và tối ưu theo hành vi thật lúc chạy (JIT biết nhánh nào hay chạy, kiểu nào hay gặp — thứ trình biên dịch tĩnh không biết), trả giá bằng độ nóng và một máy ảo phải mang theo. Biết mình đang ở phe nào giải thích gần hết những điều "lạ" của Java: vì sao phải làm nóng trước khi đo, vì sao cùng một mã chạy được trên Windows lẫn Linux mà không biên dịch lại, và vì sao một dòng println lại nở thành sáu lệnh của một thứ tiếng không phải tiếng máy nào cả.

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.