Java Internals & Concurrency/Class file và javap — đọc instruction JVM từ binary
28/39
Bài 28 / 39~14 phútJVM InternalsMiễn phí lượt xem

Class file và javap — đọc instruction JVM từ binary

JVM là máy stack-based, không phải register-based như x86. Class file là binary với magic 0xCAFEBABE, constant pool và bytecode từng method. javap dump bytecode để debug compile, hiểu operand stack, local variable slot và method descriptor.

TL;DR: Compiler javac không sinh machine code — nó sinh bytecode: instruction của một máy ảo stack-based. Mọi tính toán đi qua operand stack (push arg, pop kết quả), khác CPU x86 dùng register. File .class là binary với magic 0xCAFEBABE, major version (65 = Java 21), constant pool (bảng symbolic reference mà bytecode index vào), và bytecode từng method. javap -c disassemble bytecode, javap -v in thêm constant pool. Pitfall lớn nhất: compile target version cao hơn JRE runtime → UnsupportedClassVersionError. Đọc được bytecode là nền tảng để hiểu method dispatch (bài 03) và JIT (bài 04).

Bạn viết:

int x = a + b * 2;

Compiler không sinh ra "máy lệnh CPU" — nó sinh ra bytecode, instruction của một máy ảo trừu tượng JVM. Cùng file .class chạy trên Linux x86, macOS arm64, Windows — JVM mỗi platform translate bytecode → CPU instruction native lúc runtime.

Bytecode quan trọng vì:

  1. đơn vị compile Java — compiler frontend (javac) và optimizer backend (JIT) giao tiếp qua bytecode.
  2. boundary tương thích — Kotlin, Scala, Groovy, Clojure compile sang cùng bytecode → chạy chung JVM.
  3. tool debug ultimate: lambda gen ra gì, String.format thật sự gọi gì, switch expression compile thành table hay lookup — javap cho câu trả lời chính xác.
  4. nền tảng hiểu JIT (bài 04). JIT đọc bytecode, profile, sinh native code optimize. Không hiểu bytecode → không debug được vì sao JIT không inline method bạn nghĩ là hot.

Bài này đi qua phần "tĩnh": stack-based VM (khác register-based), cấu trúc class file, javap, operand stack + local variable, và constant pool. Phần "động" — 5 lệnh invoke*invokedynamic — nằm ở bài 03.

1. Analogy — Máy tính bỏ túi RPN vs máy thường

Máy tính bỏ túi thường (Casio): bạn bấm 2 + 3 = để được 5. Trong CPU x86 cũng vậy — instruction có register: add eax, ebx (cộng eax với ebx, kết quả eax).

Máy tính RPN (Reverse Polish Notation, HP 12C, HP 50G): bạn nhập 2 ENTER 3 + để được 5. Mỗi số push vào stack. Operator pop 2 số trên cùng, push kết quả.

JVM là máy RPN. Mọi tính toán qua operand stack. Mọi instruction pop arg từ stack, push kết quả.

Java:    int sum = a + b;
Bytecode: iload a    -> push a vao stack
          iload b    -> push b vao stack
          iadd       -> pop 2 so, cong, push ket qua
          istore sum -> pop ket qua, luu vao local sum
Đời thườngJVM
Máy tính bỏ túi CasioCPU x86/arm (register-based)
Máy tính RPN HPJVM (stack-based)
Stack RPNOperand stack
Phím sốiconst_*, bipush
Phím toániadd, imul, isub
Bộ nhớ tạm M+Local variable (istore/iload)
💡 Cách nhớ

JVM = máy RPN. Mọi instruction pop arg từ operand stack, push kết quả. Khác CPU x86 dùng register. Khi đọc bytecode, hình dung stack — mỗi dòng làm gì với stack top.

2. Class file — anatomy

Trước khi đọc bytecode, biết class file chứa gì.

.class file là binary, layout cố định (JVMS §4):

ClassFile {
    u4             magic = 0xCAFEBABE       // Marker JVM nhan dien
    u2             minor_version
    u2             major_version            // 65 = Java 21, 61 = Java 17, 52 = Java 8
    u2             constant_pool_count
    cp_info        constant_pool[count-1]   // String, class name, method ref, ...
    u2             access_flags             // public, final, abstract, ...
    u2             this_class
    u2             super_class
    u2             interfaces_count
    u2             interfaces[count]
    u2             fields_count
    field_info     fields[count]
    u2             methods_count
    method_info    methods[count]           // Bytecode trong day
    u2             attributes_count
    attribute_info attributes[count]        // SourceFile, LineNumberTable, ...
}

Quan trọng nhất:

  • Constant pool: bảng symbolic references — string literal, tên class, tên method, tên field. Bytecode tham chiếu các slot trong pool qua index 2-byte.
  • methods: mỗi method có bytecode array, max stack depth, max local count.

Magic number 0xCAFEBABE

4 byte đầu mọi class file. Hex CAFE BABE — James Gosling chọn vì dễ nhớ và đọc được. JVM check magic — không match → reject "not a class file".

Major version

JavaMajor
1.045
852
1155
1761
2165

Class compile target Java 21 (major 65) chạy trên JRE 17 → UnsupportedClassVersionError. JRE chỉ chạy class major ≤ JRE version. Đây là lý do "compile target version phải ≤ runtime version".

javac --release 17 Foo.java → ép major = 61, đảm bảo chạy được JRE 17+.

3. javap — đọc bytecode

javap (Java disAssembler) ship với JDK. Dùng:

javap -c MyClass.class           # Disassemble bytecode
javap -p -c MyClass.class        # Include private member
javap -v MyClass.class           # Verbose: constant pool + attributes

Ví dụ đơn giản

public class Hello {
    public int add(int a, int b) {
        int sum = a + b;
        return sum;
    }
}

Compile và disassemble:

javac Hello.java
javap -c Hello.class

Output:

public class Hello {
  public Hello();
    Code:
       0: aload_0
       1: invokespecial #1   // Method java/lang/Object."<init>":()V
       4: return

  public int add(int, int);
    Code:
       0: iload_1            // Push a (local slot 1) vao stack
       1: iload_2            // Push b (local slot 2) vao stack
       2: iadd               // Pop 2 int, cong, push ket qua
       3: istore_3           // Pop, luu vao local slot 3 (sum)
       4: iload_3            // Push sum
       5: ireturn            // Return int top of stack
}

Đọc cột:

  • 0:, 1:, 2: — bytecode offset (vị trí trong byte array).
  • iload_1 — opcode + operand. _1 là implicit operand (local slot 1).
  • Comment sau // từ constant pool — javap resolve symbolic ref thành text.

Phân tích add method

a + b chỉ cần 3 instruction + return:

iload_1     # Push a
iload_2     # Push b
iadd        # Pop 2 int -> push tong
ireturn     # Pop -> return

Local slot:

  • Slot 0: this (instance method có this ở slot 0).
  • Slot 1: a (param 1).
  • Slot 2: b (param 2).
  • Slot 3: sum (local).

Tại sao iadd không có operand? — Vì JVM stack-based: opcode iadd ngầm định "pop 2 int trên cùng stack, push tổng". Không cần chỉ định argument.

So với x86:

mov eax, [a]
add eax, [b]
mov [sum], eax
ret

x86 cần chỉ rõ register eax. Bytecode không cần — stack ngầm định.

iconst_*bipush

Push constant nhỏ vào stack:

iconst_m1   # Push -1
iconst_0    # Push 0
iconst_1    # Push 1
iconst_2    # Push 2
iconst_3    # Push 3
iconst_4    # Push 4
iconst_5    # Push 5
bipush 100  # Push byte (8-bit, -128 ~ 127)
sipush 1000 # Push short (16-bit, -32768 ~ 32767)
ldc #5      # Load constant tu pool (int 32-bit, float, String, ...)

Vì sao tách? Optimize size: iconst_0 1 byte, bipush 0 2 byte. Số dùng nhiều (-1 đến 5) có opcode riêng.

4. Operand stack và local variable

Mỗi method invocation tạo stack frame:

+---------------------+
| Operand Stack       |  <- max_stack (compute boi compiler)
+---------------------+
| Local Variables     |  <- max_locals
+---------------------+
| Frame Data          |  <- return PC, exception table reference, ...
+---------------------+

Frame nằm trên JVM Stack của thread (không phải heap). Method return → pop frame.

Stack tracing ví dụ

int compute(int x) {
    int y = x * 2;
    return y + 10;
}

Bytecode:

0: iload_1       # stack: [x]
1: iconst_2      # stack: [x, 2]
2: imul          # stack: [x*2]
3: istore_2      # stack: []      local: [..., y=x*2]
4: iload_2       # stack: [y]
5: bipush 10     # stack: [y, 10]
7: iadd          # stack: [y+10]
8: ireturn       # return

Stack snapshot mỗi dòng — đọc tay theo dõi. Compiler tính trước max_stack = 2 (max depth tại bất kỳ điểm nào) và max_locals = 3 (this + x + y).

Verifier (mục bài 01) check: bytecode không ghi quá max_stack, không pop khi rỗng, không type mismatch (iadd cần 2 int trên top).

Type-prefix opcode

Opcode prefix biểu thị type:

  • i — int
  • l — long
  • f — float
  • d — double
  • a — reference (object, array)
  • b — byte (chỉ trong load/store array)
  • c — char
  • s — short

Thao tác cộng: iadd, ladd, fadd, dadd. JVM không có generic add — type cố định để verifier check.

iload_1  # push int
lload_1  # push long (chiem 2 slot vi long 64-bit)
aload_1  # push reference
iadd     # cong int
ladd     # cong long

longdouble chiếm 2 slot local (vì JVM slot 32-bit, 64-bit cần 2 slot). Slot 1 và 2 dùng cho long ở slot 1.

5. Constant pool

Mỗi class có constant pool — bảng symbolic reference. Bytecode tham chiếu pool qua index.

javap -v Hello.class
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 = Class              #8             // Hello
   #8 = Utf8               Hello
   ...

Loại entry phổ biến:

  • Utf8 — string literal text (tên method, tên class, signature).
  • Class — reference 1 class (chứa index Utf8 chứa tên).
  • Methodref — reference 1 method (Class + NameAndType).
  • NameAndType — name + descriptor pair (vd <init>:()V).
  • String — string literal Java.
  • Integer, Long, Float, Double — numeric constant.

Bytecode invokespecial #1 nghĩa "invoke method tại pool slot 1" — tức java/lang/Object.<init>:()V.

Method descriptor

Format compact biểu diễn signature:

(int, String) -> boolean        => (ILjava/lang/String;)Z
() -> void                      => ()V
(byte[]) -> int                 => ([B)I
(Object, int[]) -> long[]       => (Ljava/lang/Object;[I)[J

Quy tắc:

  • B byte, S short, I int, J long, F float, D double, C char, Z boolean.
  • V void.
  • L<class>; reference — Ljava/lang/String;.
  • [<type> array — [I int array, [[I int 2D, [Ljava/lang/String; String array.

Format này từ JVMS §4.3.3 — format network/file binary cô đọng. Nhìn quen sau vài lần.

6. Pitfall tổng hợp

Nhầm 1: Tưởng int chiếm 1 slot, long 1 slot.

JVM Long chiem 2 slot local. lstore_1 ghi long vao slot 1+2.

✅ Memorize: int/float/ref = 1 slot; long/double = 2 slot.

Nhầm 2: Nghĩ bytecode chậm vì là VM.

// Bytecode chay tren JIT-compiled native code, sau warmup
// gan bang C performance.

✅ Bytecode là input cho JIT, không phải execution model thực sự.

Nhầm 3: Compile target version cao hơn JRE runtime.

javac --release 21 Foo.java   # Class major 65
java -version                  # JRE 17 (major 61)
java Foo                       # UnsupportedClassVersionError

✅ Compile target ≤ JRE version. CI matrix test multiple version.

Nhầm 4: Đọc bytecode mà bỏ qua constant pool.

javap -c chi disassemble bytecode + comment.
javap -v in ca constant pool, attribute, line table.

✅ Debug deep cần -v.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JVMS §6 dày 200+ trang, mỗi opcode 1 entry. Không cần thuộc — biết tra. Khi debug bytecode lạ, copy opcode ra search trong §6 — có description chính xác (operand, stack effect, exception).

8. Tóm tắt

  • JVM là stack-based VM — instruction pop arg từ operand stack, push kết quả. Khác CPU x86/arm register-based.
  • Class file binary, magic 0xCAFEBABE, major version chỉ JDK target. Major lớn hơn runtime → UnsupportedClassVersionError.
  • Constant pool chứa symbolic reference (string, class name, method ref). Bytecode index vào pool.
  • Stack frame mỗi method invocation: operand stack + local variables + frame data. Frame trên JVM Stack thread.
  • Local slot: int/float/ref = 1 slot; long/double = 2 slot. Slot 0 thường là this cho instance method.
  • Opcode type-prefix: i* int, l* long, f* float, d* double, a* reference. Verifier check type strict.
  • Method descriptor: (ILjava/lang/String;)Z = nhận int + String, trả boolean. Format JVMS §4.3.3.
  • javap -c dump bytecode. javap -v thêm constant pool + attribute. Tool debug essential.
  • Bytecode → JIT → native code. Bytecode không chạy trực tiếp sau warmup — JIT specialize.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao JVM thiết kế stack-based thay vì register-based như CPU x86?

3 lý do design:

  1. Portability: stack-based bytecode không assume số lượng register. Cùng bytecode chạy được trên CPU 8 register (x86 32-bit) hay 32 register (arm64). Register-based bytecode phải fix số register, không portable.
  2. Compact instruction: iadd 1 byte, không operand. Register-based add r1, r2, r3 4 byte (opcode + 3 register index). Class file nhỏ → load nhanh, network transfer rẻ.
  3. Compiler đơn giản: compile expression tree sang stack-based natural — duyệt postorder, push left, push right, op. Register-based cần register allocation algorithm phức tạp.

Trade-off: stack-based interpret chậm hơn register-based ~30% (mỗi op cần push/pop). Nhưng JIT (bài 04) compile bytecode → native register-based code lúc runtime → bù toàn bộ overhead. Stack-based là interface intermediate, không phải execution final.

Dalvik (Android) chọn register-based bytecode vì target mobile — interpret tier vẫn quan trọng (battery, memory) → register-based nhanh hơn 30%. Trade-off khác.

Q2
Đoạn sau compile ra bao nhiêu instruction? int x = 5; int y = x + 3; return y * 2;

Khoảng 8-9 instruction (chưa kể setup):

iconst_5     # push 5
istore_1     # x = 5         (slot 1)
iload_1      # push x
iconst_3     # push 3
iadd         # pop 2, push x+3
istore_2     # y = ...       (slot 2)
iload_2      # push y
iconst_2     # push 2
imul         # pop 2, push y*2
ireturn      # return

Quan sát:

  • Mỗi thao tác Java thường = 1-2 bytecode. Code đơn giản → bytecode đơn giản.
  • iconst_* 1 byte cho số 0-5. Số 100 sẽ là bipush 100 (2 byte). Số 1000 sẽ là sipush 1000 (3 byte). Số 100000 phải ldc #N (load từ constant pool).
  • Local slot 0 nếu instance method = this. Slot 1 = x, slot 2 = y.

JIT optimize: nhận thấy expression compile-time → có thể constant-fold thành return 16 (vì x = 5, y = 8, return 16) → bytecode chỉ bipush 16; ireturn. Nhưng bytecode emit từ javac thì verbose như trên — javac không constant-fold qua local variable.

Q3
Vì sao class compile target Java 21 không chạy được trên JRE 17?

Class file có field major_version 2 byte. JVM check:

if (class.major_version > supported_max_version) {
  throw UnsupportedClassVersionError;
}

JRE 21 hỗ trợ major ≤ 65 (Java 21). JRE 17 hỗ trợ major ≤ 61 (Java 17). Class compile target Java 21 có major = 65, vượt 61 → JRE 17 reject.

Lý do design strict: feature ngôn ngữ mới (vd switch pattern Java 21) emit bytecode opcode hoặc attribute mới. JVM cũ không hiểu → behavior undefined nếu cho chạy.

Backward compatible: JRE 21 chạy class major 45 (Java 1.0) bình thường. Forward không.

Cách compile cho target cũ:

javac --release 17 Foo.java        # Major 61, dung API JDK 17
javac --target 17 --source 17 ...  # Cu hon, can co --bootclasspath

--release Java 9+ làm cả 2: ép major + ép API surface = JDK 17 (không cho dùng API mới hơn).

Production: CI matrix build với JDK 17 (release target), test trên JDK 17 + 21 — đảm bảo class chạy được nhiều version. Maven enforcer plugin check bytecode version.

Q4
Vì sao constant pool tồn tại — tại sao không inline string/method ref trực tiếp vào bytecode?

3 lý do:

  1. Compact bytecode: bytecode tham chiếu pool qua index 2 byte. invokevirtual #5 3 byte. Inline "java/util/List.add:(Ljava/lang/Object;)Z" = 40+ byte. Mỗi method gọi inline → bytecode bloat 10x.
  2. String deduplication: cùng string xuất hiện 100 lần trong code → 1 entry pool, 100 reference 2-byte. Không inline → 100 copy.
  3. Symbolic resolution lazy: pool entry dạng symbolic (text "java/util/List"). JVM resolve thành direct reference (con trỏ Class object) khi cần. Class chưa dùng → không resolve. Lazy loading như bài 01.

Hệ quả tinh tế: cùng class, JVM khác nhau (HotSpot vs OpenJ9 vs GraalVM) resolve cùng pool ra Class object khác nhau (mỗi JVM internal struct riêng). Bytecode portable, internal native code không.

Trade-off: indirection thêm 1 step (bytecode → pool index → resolved ref). JIT cache resolved ref trong inline cache → cost gần zero sau warmup.

Pool còn chứa BootstrapMethods attribute cho invokedynamic (bài 03) — bootstrap method tham chiếu qua pool index. Cấu trúc đồng nhất.

Q5
Vì sao longdouble chiếm 2 slot local variable trong khi int và reference chỉ 1 slot?

JVMS định nghĩa local variable slot là đơn vị 32-bit — quyết định từ thời Java 1.0 khi CPU 32-bit phổ biến. Giá trị 64-bit (long, double) cần 2 slot liên tiếp; lload_1 đọc cả slot 1 và 2.

Hệ quả với verifier: bytecode đọc slot 2 như int trong khi slot 1+2 đang giữ long → verifier reject lúc class load. Slot tracking strict là một phần của type safety bytecode.

Lưu ý: slot chỉ là abstraction của bytecode. Sau JIT compile, long nằm gọn trong 1 register 64-bit — không có cost "2 slot" lúc runtime trên CPU 64-bit. Đây là ví dụ điển hình cho việc bytecode mô tả semantic, còn JIT quyết định representation thực.

Bài tiếp theo: 5 lệnh invoke và invokedynamic — method dispatch trong JVM

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

Đặt 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