javap có sẵn trong mọi bản JDK và hầu như không ai dùng. Nhưng nó là cách nhanh nhất để trả lời dứt điểm những câu hỏi mà đọc mã nguồn chỉ đoán được: lambda thật ra là gì, nối chuỗi có tốn kém không, switch trên String hoạt động ra sao.

javap -c -p TenLop.class        # bytecode, kể cả thành viên private
javap -c -p -v TenLop.class     # thêm bảng hằng và các thuộc tính

JVM là máy ngăn xếp

static int cong(int a, int b) { int c = a + b; return c * 2; }
  0: iload_0     // đẩy tham số 0 lên ngăn xếp
  1: iload_1     // đẩy tham số 1
  2: iadd        // lấy hai giá trị trên cùng, cộng, đẩy kết quả
  3: istore_2    // cất vào biến cục bộ 2 (chính là c)
  4: iload_2
  5: iconst_2    // đẩy hằng 2
  6: imul
  7: ireturn

Không có thanh ghi. Mọi phép tính đều là lấy từ đỉnh ngăn xếp, tính, đẩy kết quả lên.

Chữ cái đầu là kiểu: i cho int, l cho long, f cho float, d cho double, a cho tham chiếu. Nên aload_0 trong phương thức không tĩnh chính là this.

Thiết kế này là lý do bytecode Java gọn và dễ kiểm chứng, và cũng là lý do JIT phải làm nhiều việc — nó dịch máy ngăn xếp thành mã thanh ghi của CPU thật.

Bốn lệnh gọi, và một bất ngờ

Math.max(1, 2);   s.length();   l.size();   o.rieng();   // rieng() là private
  invokestatic    #7   // Method java/lang/Math.max:(II)I
  invokevirtual   #13  // Method java/lang/String.length:()I
  invokeinterface #19  // InterfaceMethod java/util/List.size:()I
  invokevirtual   #24  // Method rieng:()V          <- phương thức PRIVATE

invokestatic cho phương thức tĩnh, invokevirtual cho phương thức thực thể (có tra bảng ảo), invokeinterface cho lời gọi qua giao diện.

Nhưng dòng cuối bất ngờ: o.rieng()private mà lại dịch thành invokevirtual. Theo sách vở nó phải là invokespecial.

Lý do nằm ở một thuộc tính trong tệp .class:

  NestMembers:

Từ Java 11, các lớp lồng nhau tạo thành một "tổ" (nest), và thành viên cùng tổ truy cập trực tiếp thành viên private của nhau. Trước đó, trình biên dịch phải sinh ra những phương thức cầu nối tổng hợp tên kiểu access$000 — thứ hay xuất hiện trong dấu vết ngăn xếp cũ và làm người đọc bối rối. Nestmates bỏ hẳn chúng.

Đây đúng là loại chi tiết mà đọc mã nguồn không bao giờ thấy, còn javap thì nói thẳng.

Lambda không phải lớp ẩn danh

Đây là phần đáng giá nhất của bài, vì hiểu nhầm này rất phổ biến.

static Supplier<String> bangLambda() { return () -> "chào"; }
  0: invokedynamic #29,  0   // InvokeDynamic #0:get:()Ljava/util/function/Supplier;
  5: areturn

  private static java.lang.String lambda$bangLambda$0();
    0: ldc     #74            // String chào
    2: areturn

Thân lambda thành một phương thức tĩnh tổng hợp trong chính lớp đó. Chỗ dùng nó là một lệnh invokedynamic — nghĩa là "lúc chạy hãy hỏi ai đó xem gọi cái gì".

Người trả lời nằm trong bảng BootstrapMethods:

  REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory(...)

LambdaMetafactory sinh lớp lúc chạy ở lần đầu tiên lệnh đó được thực thi, rồi nhớ kết quả cho các lần sau.

Còn lớp ẩn danh thì hoàn toàn khác:

  0: new           #33   // class Co$1
  3: dup
  4: invokespecial #35   // Method Co$1."<init>":()V

  tệp .class sinh ra: Co.class, Co$1.class

Một tệp .class riêng, sinh lúc biên dịch.

Khác biệt đó đo được

Tôi viết 200 lambda và 200 lớp ẩn danh tương đương:

  200 lambda      ->   1 tệp .class,  32 KB
  200 lớp ẩn danh -> 201 tệp .class, 820 KB

Hai mươi sáu lần về dung lượng, và quan trọng hơn là 201 lớp phải nạp thay vì 1. Với một ứng dụng lớn dùng lambda khắp nơi, đây là khác biệt thật về kích thước jar và thời gian nạp lớp.

Nhưng bức tranh chưa đủ nếu dừng ở đó. Lambda dời chi phí sang lúc chạy:

  số lớp JVM sinh/nạp lúc chạy cho chỉ 2 lambda: 32
  lớp thật của lambda: Chay$$Lambda/0x0000006801000a08
  s1.getClass() == s2.getClass()? false

Ba mươi hai lớp cho hai lambda — phần lớn là hạ tầng LambdaForm của MethodHandle, và nó chỉ phải dựng một lần cho cả ứng dụng. Nhưng mỗi lambda vẫn có lớp riêng sinh lúc chạy, nên s1.getClass() khác s2.getClass().

Kết luận cân bằng: lambda gọn hơn hẳn ở đĩa và ở số lớp biên dịch, nhưng tốn hơn ở lần khởi động đầu tiên. Đây chính là lý do các khung làm việc quan tâm tới thời gian khởi động — Quarkus, Micronaut — hạn chế lambda ở đường chạy khởi động, và là một trong những thứ ảnh Native Image xử lý bằng cách dựng sẵn.

Hệ quả thực dụng cần nhớ: đừng dùng getClass() để phân biệt lambda, và đừng trông đợi tên lớp có ý nghĩa gì — Chay$$Lambda/0x... đổi mỗi lần chạy.

Nối chuỗi cũng là invokedynamic

static String noi(String a, int b) { return "x=" + a + ", y=" + b; }
  2: invokedynamic #36  // makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;

Và trong bảng hằng:

  #108 = Utf8    x=, y=

Chuỗi mẫu với  đánh dấu chỗ cần chèn giá trị.

Trước Java 9, trình biên dịch sinh ra new StringBuilder().append(...).append(...).toString(). Từ Java 9, nó nhường cho StringConcatFactory quyết định lúc chạy — và bản hiện thực mặc định dựng một MethodHandle tính sẵn kích thước rồi chép thẳng, nhanh hơn StringBuilder đáng kể.

Điều này lật đổ một lời khuyên cũ: "đừng nối chuỗi bằng +, hãy dùng StringBuilder". Với một biểu thức nối nhiều mảnh, + giờ nhanh hơn.

Nhưng lời khuyên ấy vẫn đúng trong vòng lặp, và javap cho thấy chính xác vì sao. Đây là thân hàm nối chuỗi trong vòng lặp:

  17: aload_2
  20: aaload
  23: aload_1
  26: invokedynamic #9  // makeConcatWithConstants:(String;String;)String;
  35: goto          11        <- nhảy ngược, quay lại đầu vòng

Chỉ có một lệnh invokedynamic trong bytecode — nhưng nó nằm bên trong vòng lặp, phía trên lệnh goto nhảy ngược. Nên nó chạy một lần cho mỗi vòng, và mỗi lần sinh ra một chuỗi mới rồi vứt chuỗi cũ đi. Với n phần tử là n chuỗi trung gian, độ phức tạp bậc hai.

StringBuilder viết tay tránh được đúng chỗ đó: một bộ đệm duy nhất cho cả vòng lặp.

Đóng hộp hiện ra rất rõ

  dongHop: invokestatic  Integer.valueOf:(I)Ljava/lang/Integer;
  moHop:   invokevirtual Integer.intValue:()I

Không có phép màu nào — chỉ là trình biên dịch chèn hai lời gọi. Đây là cách nhanh nhất để tìm chỗ đóng hộp ngoài ý muốn trong vòng lặp nóng: javap -c rồi tìm valueOf.

switch trên chuỗi chạy hai bước

   5: invokevirtual String.hashCode:()I
   8: lookupswitch  { 97: 36, 98: 50, default: 61 }
  36: ldc           // String a
  39: invokevirtual String.equals:(Ljava/lang/Object;)Z
  42: ifeq          61

Bước một: tính hashCode() rồi nhảy bằng lookupswitch. Bước hai: so equals() để loại trường hợp trùng mã băm.

Nên switch trên chuỗi không phải một phép tra bảng — nó là băm cộng so sánh, và với ít nhánh thì chuỗi if-else cũng tương đương. Nó cũng giải thích vì sao nhãn case phải là hằng biên dịch được: mã băm phải tính được lúc biên dịch.

Hai lệnh nhảy cần phân biệt khi đọc bytecode: tableswitch khi các giá trị liền nhau (nhảy trực tiếp theo chỉ số, rất nhanh), lookupswitch khi rời rạc (tìm nhị phân).

Dùng javap để làm gì trong thực tế

Kiểm tra một tối ưu có thật hay không. Bạn sửa mã cho "nhanh hơn" — javap cho biết bytecode có đổi không. Rất nhiều tối ưu thủ công bị trình biên dịch sinh ra y hệt.

Tìm đóng hộp và nối chuỗi ẩn trong vòng lặp nóng.

Hiểu ngoại lệ khó hiểu. NoSuchMethodError với chữ ký lạ thường lộ nguyên nhân khi bạn nhìn chữ ký thật trong bytecode.

Kiểm tra jar bị biên dịch bằng phiên bản nào:

javap -v Lop.class | grep major

Số 65 là Java 21, 61 là Java 17, 52 là Java 8. Đây là cách kiểm nhanh nhất khi gặp UnsupportedClassVersionError.

Đọc thư viện không có mã nguồn. Không thay được trình dịch ngược, nhưng đủ để trả lời "phương thức này có thật sự làm gì không hay chỉ trả về null".

Thử ba mươi giây

Lấy một phương thức bất kỳ trong dự án của bạn có vòng lặp, rồi:

javap -c TenLop.class | sed -n '/tenPhuongThuc/,/^$/p'

Tìm lệnh goto nhảy ngược (số đích nhỏ hơn số dòng hiện tại) — đó là điểm cuối vòng lặp. Mọi lệnh nằm phía trên nó và dưới nhãn đích đều chạy lại mỗi vòng.

Ba mươi giây đó đổi cách bạn đọc mã: valueOf, invokedynamic, hay một lời gọi tưởng vô hại nằm trong khoảng ấy là chạy n lần, và bytecode nói điều đó rõ hơn mọi cách đọc mã nguồn.

Ngày mai: JIT — vì sao mã Java chạy chậm lúc đầu rồi nhanh dần, hai tầng biên dịch, và cách xem JIT đã quyết định những gì.