Java là ngôn ngữ hai bước. javac biến source text thành file .class chứa bytecode; java khởi động một JVM để load bytecode đó rồi thực thi. Gần như mọi rắc rối trong tuần đầu tiên của người mới đều nằm ở hai bước này chứ không phải ở ngôn ngữ: truyền sai argument cho java, một classpath không chứa thứ bạn tưởng nó chứa, hoặc một file .class cũ được chạy thay cho code bạn vừa sửa.
Bài này đi hết con đường đó, từ dòng chữ bạn gõ cho tới native instruction mà HotSpot sinh ra ở cuối. Mọi câu lệnh, mọi byte và mọi message lỗi bên dưới đều được chạy thật trên OpenJDK 21.0.6 và dán nguyên văn.
![]()
Bắt đầu từ hai câu lệnh, sau đó mở file nằm giữa chúng ra xem, rồi theo nó đi vào JVM.
Mô hình hai bước: javac biên dịch, java chạy
Tạo file Main.java:
public class Main {
public static void main(String[] args) {
System.out.println("Hello, Java!");
}
}
Compile rồi chạy:
javac Main.java
java Main
Hello, Java!
javac sinh ra một file mới nằm ngay cạnh source:
Main.class 414 bytes
Main.java 117 bytes
Điểm bất đối xứng giữa hai câu lệnh là lỗi phổ biến nhất của tuần đầu tiên. javac nhận tên file. java nhận tên class. Không có ngoại lệ. Nên câu này fail:
java Main.class
Error: Could not find or load main class Main.class
Caused by: java.lang.ClassNotFoundException: Main.class
Launcher không nhìn thấy một file nào cả. Nó đọc Main.class như một class name, hiểu đó là class tên class nằm trong package tên Main, rồi đi tìm Main/class.class. Không có file đó nên bạn nhận ClassNotFoundException. Bỏ phần đuôi đi là chạy.
Có thể chứng minh nó tìm đúng ở chỗ đó. Copy file class sang Main/class.class rồi chạy lại đúng câu lệnh sai đó — lần này launcher tìm thấy một file ở đường dẫn kia và từ chối vì một lý do khác:
Error: Could not find or load main class Main.class
Caused by: java.lang.NoClassDefFoundError: Main/class (wrong name: Main)

Hình trên là bản đồ cho phần còn lại của bài. Hãy đọc nó như một pipeline chạy từ trái sang phải với một điểm bàn giao ở giữa: mọi thứ ở hàng trên xảy ra trước khi chương trình tồn tại, mọi thứ ở hàng dưới xảy ra khi nó đang chạy. Nhãn màu đỏ dưới mỗi stage là lỗi stage đó sinh ra khi fail — đó chính là cách bạn đi ngược từ một message về đúng nguyên nhân.
Bên trong file .class thực sự có gì?
File .class không phải machine code, cũng không phải text. Nó là một định dạng nhị phân nhỏ và được đặc tả rất chặt: một header, một constant pool, và một mảng bytecode cho mỗi method.
Phần header: cafebabe và class file version
xxd Main.class | head -4

Mấy byte đó chính là ba vùng đầu tiên của một định dạng có thứ tự rất chặt:
00000000: cafe babe 0000 0041 001d 0a00 0200 0307 .......A........
00000010: 0004 0c00 0500 0601 0010 6a61 7661 2f6c ..........java/l
00000020: 616e 672f 4f62 6a65 6374 0100 063c 696e ang/Object...<in
00000030: 6974 3e01 0003 2829 5609 0008 0009 0700 it>...()V.......
Bốn byte đầu là magic number 0xCAFEBABE. Mọi file class hợp lệ đều mở đầu bằng chúng, file nào không có sẽ bị loại trước khi đọc thêm bất cứ thứ gì. Hai byte tiếp theo là minor version (0000), hai byte sau nữa là major version: 0041 là 65 ở hệ thập phân.
Con số đó cho biết file được build cho bản Java nào. Quy tắc là major version = bản Java + 44:
| Bản Java | Major version |
|---|---|
| 8 | 52 |
| 11 | 55 |
| 17 | 61 |
| 21 | 65 |
javap -v in ra đúng thông tin đó mà không cần đọc hex:
javap -v Main
public class Main
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
JVM từ chối mọi file class mang version nó không biết, và từ chối ngay ở bước load, trước khi chạy một instruction nào. Compile bằng JDK 21 rồi đưa kết quả cho JVM Java 17:
javac Main.java
"$JAVA17_HOME/bin/java" Main
Error: LinkageError occurred while loading main class Main
java.lang.UnsupportedClassVersionError: Main 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
65.0 là class file mà compiler của JDK 21 tạo ra; 61.0 là mức cao nhất mà JVM Java 17 chấp nhận. Đây chính là message bạn gặp khi compile bằng JDK mới rồi chạy trên JRE cũ. Nó luôn là chuyện lệch version, không bao giờ là lỗi code — và chỉ lệch theo một chiều: file class của Java 17 chạy trên JVM Java 21 hoàn toàn bình thường.
Bytecode: đọc javap -c
javap -c disassemble bytecode. Toàn bộ class Main:

Nhìn theo operand stack thì bốn instruction đó hết bí ẩn ngay:
javap -c Main
Compiled from "Main.java"
public class Main {
public Main();
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: ldc #13 // String Hello, Java!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
Có hai chi tiết đáng để ý trước khi xem từng instruction. Thứ nhất, có một constructor public Main() mà bạn chưa từng viết — javac tự chèn constructor mặc định vào. Thứ hai, các số bên trái (0, 3, 5, 8) là byte offset trong method chứ không phải số dòng, nên chúng không liên tiếp: getstatic dài ba byte, ldc dài hai byte.
JVM là một stack machine. Nó không có register để bạn gọi tên; instruction đẩy operand lên một operand stack rồi lấy ra. Nhờ vậy main đọc từ trên xuống rất dễ:
| Instruction | Nó làm gì |
|---|---|
getstatic #7 | Đẩy giá trị của một static field lên stack. Ở đây là System.out, một PrintStream |
ldc #13 | Lấy một hằng từ constant pool và đẩy lên stack. Ở đây là string Hello, Java! |
invokevirtual #15 | Lấy argument và receiver ra khỏi stack, rồi gọi instance method PrintStream.println(String) |
return | Rời method, không trả về giá trị nào |
#7, #13 và #15 là index vào constant pool, tức bảng chứa tên, string và reference nằm ở đầu file. Đó cũng là lý do trong bản dump hex ở trên bạn đọc được chữ như java/lang/Object và java/io/PrintStream — constant pool lưu symbolic reference theo tên, và JVM resolve chúng thành địa chỉ thật lúc chạy. aload_0 trong constructor đẩy local variable số 0, tức this, còn invokespecial gọi constructor của superclass.
Nên xem bytecode một lần, càng sớm càng tốt, chính là để nó thôi làm một từ bí ẩn. Nó ngắn, có tài liệu đầy đủ, và javap -c trả lời được những câu hỏi về thứ compiler thật sự sinh ra mà đọc source bao nhiêu cũng không ra.
Những flag của javac đáng quan tâm
Bạn sẽ gặp bốn flag này từ rất lâu trước khi cần đến build tool.
-d: file .class sẽ nằm ở đâu
Mặc định javac ghi mỗi file .class ngay cạnh source, khiến file sinh ra lẫn vào cây source. -d đẩy chúng sang chỗ khác và tự tạo thư mục:
javac -d out Main.java
./Main.java
./out/Main.class
java -cp out Main
Hello, Java!
Để output compile trong out/ (hoặc build/, target/) là quy ước mà mọi build tool đều theo, và nó cho phép bạn xóa nguyên thư mục để build lại từ đầu.
--release: bytecode nhắm tới phiên bản Java nào
--release N compile dựa trên API của bản N và đóng dấu file class cho bản N:
javac --release 17 -d out17 Main.java
javap -v -cp out17 Main
public class Main
minor version: 0
major version: 61
Major version 61 là Java 17, nên file class đó chạy được trên JVM Java 17 lẫn JVM Java 21 này — bytecode chạy xuôi chiều lên trên, không bao giờ chạy ngược xuống.
Điểm quan trọng của --release là nó kiểm tra cả API, thứ mà cặp -source/-target cũ không làm. Compile một file gọi String.repeat (có từ Java 11) và Stream.toList (có từ Java 16) với release 8:
javac --release 8 -d out8 New.java
warning: [options] source value 8 is obsolete and will be removed in a future release
warning: [options] target value 8 is obsolete and will be removed in a future release
New.java:3: error: cannot find symbol
System.out.println("abc".repeat(2));
^
symbol: method repeat(int)
location: class String
New.java:4: error: cannot find symbol
var s = java.util.stream.Stream.of(1, 2, 3).toList();
^
symbol: class var
location: class New
Nó fail ngay lúc compile, đúng như mong muốn. Với -source 8 -target 8 thì code sẽ compile trót lọt rồi ném NoSuchMethodError trên JVM cũ.
-Xlint:all: bật cảnh báo lên
javac mặc định rất kiệm lời. Nó báo có gì đó không ổn rồi từ chối nói rõ:
javac Lint.java
Note: Lint.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
-Xlint:all bật toàn bộ nhóm cảnh báo:
javac -Xlint:all Lint.java
Lint.java:6: warning: [rawtypes] found raw type: List
List raw = new ArrayList();
^
missing type arguments for generic class List<E>
Lint.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
raw.add("x");
^
3 warnings
Output ở đây đã được rút gọn; javac còn in thêm một cảnh báo [rawtypes] nữa và phần giải thích type-variable. Chú ý tên nhóm trong ngoặc vuông — [rawtypes], [unchecked]. Bạn có thể bật riêng từng nhóm (-Xlint:unchecked) hoặc tắt bớt một nhóm (-Xlint:all,-serial). Nên bật ngay từ ngày đầu; số bug thật mà nó lôi ra đủ nhiều để bù cho phần ồn ào.
-cp dùng cả lúc compile lẫn lúc chạy
javac cần tìm được các type bạn import, và nó dùng đúng cơ chế classpath mà launcher dùng. Thiếu nó:
javac -d out App.java
App.java:1: error: package com.lib does not exist
import com.lib.Util;
^
Có nó:
javac -cp libs/util.jar -d out App.java
java -cp out:libs/util.jar App
CLASSPATH!
Compile được với một library không có nghĩa library đó nằm sẵn trên classpath lúc chạy. Bạn phải truyền hai lần, cho cả hai câu lệnh.
| Flag | Dùng với | Công dụng |
|---|---|---|
-d <dir> | javac | Ghi file class vào thư mục này |
-cp / -classpath / --class-path | javac và java | Nơi tìm các class không thuộc JDK |
--release <N> | javac | Compile theo API bản N và sinh bytecode cho bản N |
-Xlint:all | javac | Bật mọi nhóm cảnh báo |
-sourcepath <dir> | javac | Nơi tìm source của các type bạn không liệt kê |
-jar <file> | java | Chạy main class ghi trong manifest của jar |
Package và ánh xạ thư mục
Package không phải một namespace bạn khai báo cho có — nó là một đường dẫn thư mục mà tool đi vào thật. File mở đầu bằng package com.example; phải nằm ở com/example/ dưới một source root, và file class của nó phải nằm ở com/example/ dưới một classpath root.
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println(new Greeter().greet("Java"));
}
}
javac -d out src/com/example/Main.java src/com/example/Greeter.java
./out/com/example/Greeter.class
./out/com/example/Main.class
javac đã dựng lại cấu trúc package dưới out/ giúp bạn; bạn không bao giờ phải tự tạo mấy thư mục đó. Giờ chạy bằng fully qualified name — package cộng với class name, nối bằng dấu chấm:
java -cp out com.example.Main
Hello, Java!
Gõ sai tên thì launcher nói thẳng nó không tìm thấy cái gì:
java -cp out Main
Error: Could not find or load main class Main
Caused by: java.lang.ClassNotFoundException: Main
Quên classpath thì bạn nhận lỗi cùng dạng, kèm tên đầy đủ:
java com.example.Main
Error: Could not find or load main class com.example.Main
Caused by: java.lang.ClassNotFoundException: com.example.Main
Còn một điểm nữa về việc liệt kê file source. javac không tự compile một dependency mà nó thấy ở thư mục bên cạnh. Chỉ compile mỗi Main.java sẽ fail, vì Greeter không nằm trên source path nào:
javac -d out2 src/com/example/Main.java
src/com/example/Main.java:5: error: cannot find symbol
System.out.println(new Greeter().greet("Java"));
^
symbol: class Greeter
Hoặc liệt kê hết mọi file, hoặc trỏ -sourcepath vào source root và để javac tự tìm rồi compile những gì nó cần:
javac -d out3 $(find src -name "*.java")
javac -sourcepath src -d out4 src/com/example/Main.java
Cả hai cách đều sinh ra Greeter.class và Main.class trong thư mục output.

Classpath là gì?
Classpath là một danh sách có thứ tự các root: thư mục và file jar. Khi JVM cần class com.example.Main, nó thay mọi dấu chấm bằng dấu gạch chéo, thêm .class, rồi tìm đúng đường dẫn tương đối đó dưới từng root theo thứ tự, dừng lại ở root đầu tiên có file.
com.example.Main -> com/example/Main.class
Đó là phép ghép chuỗi máy móc, không phải một cuộc tìm kiếm. Launcher không bao giờ quét ổ đĩa để mò ra class trùng tên; nếu file không nằm đúng đường dẫn được tính ra dưới một trong các root thì với JVM nó không tồn tại. Hai vế phải khớp nhau: khai báo package trong source, và thư mục bạn trỏ -cp vào.
Nhiều entry được ngăn cách bằng : trên macOS và Linux, bằng ; trên Windows:
java -cp out:libs/util.jar App
Dấu * ở cuối một entry sẽ nở ra thành mọi file jar trong thư mục đó. JVM là bên nở nó ra chứ không phải shell, nên hãy đặt trong dấu nháy:
java -cp "out:libs/*" App
Wildcard chỉ lấy file jar — không lấy file class, không lấy thư mục con, và cũng không đi đệ quy.
Classpath mặc định, và cái bẫy khi set CLASSPATH
Nếu bạn không truyền -cp và biến môi trường CLASSPATH chưa được set, classpath chính là thư mục hiện tại. Đó là lý do javac Main.java && java Main chạy được mà không cần flag nào:
env -u CLASSPATH java Main
Hello, Java!
Bây giờ set CLASSPATH thành bất cứ thứ gì, thư mục hiện tại mặc định đó biến mất:
CLASSPATH=/some/other/dir java Main
Error: Could not find or load main class Main
Caused by: java.lang.ClassNotFoundException: Main
Main.class không hề bị di chuyển. Entry . mặc định bị thay thế chứ không phải được bổ sung. Đây là cái bẫy khó chịu thật sự, vì biến này thường được set một lần trong shell profile rồi quên mất, và mọi thứ đột nhiên hỏng ở đúng thư mục hôm qua vẫn chạy. Có hai lối ra — truyền -cp tường minh để đè lên biến, hoặc tự thêm . vào biến:
CLASSPATH=/some/other/dir java -cp . Main
CLASSPATH=.:/some/other/dir java Main
Cả hai đều in Hello, Java!. Nên ưu tiên -cp trên dòng lệnh; một biến môi trường lặng lẽ đổi cách mọi JVM trên máy resolve class không đáng để tiết kiệm vài ký tự.
⚠️
java -jar app.jarbỏ qua hoàn toàn-cp. Jar chạy theo cách này lấy classpath từ manifest, nên thêm-cp libs/util.jarcạnh-jarkhông thay đổi gì và class thiếu vẫn nổ lúc chạy.
NoClassDefFoundError khác ClassNotFoundException thế nào
Hai cái tên này trông như thay thế được cho nhau, nhưng không. Compile một chương trình có Main dùng Greeter, rồi xóa Greeter.class đi và chạy:
java -cp out-broken com.example.Main
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/Greeter
at com.example.Main.main(Main.java:5)
Caused by: java.lang.ClassNotFoundException: com.example.Greeter
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
Còn đây là hỏi một class theo tên lúc chạy, qua reflection:
Class.forName("com.example.Missing");
Exception in thread "main" java.lang.ClassNotFoundException: com.example.Missing
at java.base/java.lang.Class.forName(Class.java:412)
at Reflect.main(Reflect.java:3)
NoClassDefFoundError | ClassNotFoundException | |
|---|---|---|
| Loại | Error | Checked Exception |
| Xuất hiện khi | JVM resolve một reference mà compiler đã ghi lại, và class đó giờ không còn | Code hỏi một class bằng chuỗi tên, và không có |
| Nguyên nhân điển hình | Class có lúc compile nhưng không có trên classpath lúc chạy | Tên sai chính tả, hoặc jar plugin không được ship kèm |
| Để ý định dạng | Gạch chéo: com/example/Greeter | Dấu chấm: com.example.Greeter |
Stack trace đầu tiên cho thấy quan hệ giữa hai bên: class loader ném ClassNotFoundException, và phần resolution bọc nó lại thành NoClassDefFoundError. Thấy dạng Error thì soi classpath lúc chạy. Thấy dạng exception đứng một mình thì soi chuỗi tên đang được truyền vào.
Chạy thẳng một file source: java Main.java
Từ Java 11 (JEP 330), launcher nhận thẳng một file .java. Nó compile file đó trong bộ nhớ rồi chạy, không ghi file .class nào ra đĩa:
java App.java
Run straight from source: 42
App.java
Thư mục vẫn chỉ có đúng một file. Đó là toàn bộ sức hấp dẫn: để học, để thử nhanh một ý, hoặc để viết script với dòng #!/usr/bin/java --source 21 ở đầu, bạn không có artifact nào phải quản lý.
Nó còn cho thấy một điều rất đáng nhớ. Sửa Main.java mà không compile lại, rồi chạy theo cả hai cách:
java Main # chạy file .class từ lần javac gần nhất
java Main.java # compile source hiện tại, trong bộ nhớ
Hello, Java!
Hello from the EDITED source
java Main chạy bytecode, và bytecode chỉ đổi khi bạn chạy javac. Nếu một thay đổi vừa sửa mà không thấy tác dụng, gần như chắc chắn bạn đang chạy file class cũ.
Có hai giới hạn. Source-file mode chỉ dành cho một file: trên Java 21, tách cùng chương trình ra App.java và Util.java sẽ fail, vì chỉ file được nêu tên mới được compile.
java App.java
App.java:3: error: cannot find symbol
System.out.println("two files: " + Util.twice(21));
^
1 error
error: compilation failed
Java 22 gỡ giới hạn này bằng JEP 458, compile thêm các file cùng thư mục khi cần. Giới hạn thứ hai mới là giới hạn thật: source-file mode compile lại ở mỗi lần khởi động, không sinh ra thứ gì để ship, và không có cách nào khai báo dependency. Nó là công cụ để học và viết script, không phải một hệ thống build.
jshell: REPL để thử một expression
Khi câu hỏi chỉ là "expression này ra giá trị gì", cả hai câu lệnh trên đều không phải công cụ đúng. jshell là một REPL đi kèm JDK từ Java 9, nhận expression và statement mà không cần class hay main bao quanh:
jshell> int x = 21
x ==> 21
jshell> x * 2
$2 ==> 42
jshell> "Java".length()
$3 ==> 4
jshell> String.join("-", "a", "b", "c")
$4 ==> "a-b-c"
Một declaration sẽ in lại giá trị vừa gán; một expression trần được gán vào tên tự sinh ($2, $3) để bạn dùng lại ở dòng sau. Dấu chấm phẩy không bắt buộc. /exit để thoát, /list để xem lại những gì đã gõ, /help intro để đọc phần giới thiệu.
Đây là cách nhanh nhất để trả lời một câu hỏi về standard library. Nó không phải nơi để xây dựng thứ gì — không có file nào, và không gì sống sót sau phiên làm việc.
Đóng gói: tạo file jar chạy được
Một file jar là file zip kèm manifest. --main-class ghi vào manifest entry khiến nó chạy được:
jar --create --file app.jar --main-class com.example.Main -C out .
jar --list --file app.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/Greeter.class
com/example/Main.class
-C out . nghĩa là "chuyển vào out rồi thêm mọi thứ từ đó", và đó là thứ giữ cho các thư mục package nằm ngay gốc archive thay vì bị lồng dưới out/. Rồi:
java -jar app.jar
Hello, Java!
Manifest là text thuần và bạn đọc được nó từ trong archive:
Manifest-Version: 1.0
Created-By: 21.0.6 (Homebrew)
Main-Class: com.example.Main
Main-Class chính là thứ -jar đọc. Không có nó thì launcher chẳng biết chạy gì:
java -jar nomain.jar
no main manifest attribute, in nomain.jar
Một jar không có Main-Class vẫn dùng tốt như library — đưa nó lên classpath rồi tự nêu tên class:
java -cp app.jar com.example.Main
Hello, Java!
Dự án thật không gõ tay javac và jar; Maven hoặc Gradle chạy đúng những câu lệnh này thay bạn, kèm resolve dependency và một phase test. Mọi thứ ở trên chính là phần các tool đó làm bên dưới.
JVM chạy bytecode của bạn như thế nào
java Main tạo một process, dựng một JVM bên trong nó, rồi hỏi JVM đó lấy class Main. Những gì diễn ra sau đó có thứ tự cố định.
1. Loading: ba class loader, và cơ chế lazy
Việc load class được ủy quyền lên một chuỗi ba loader dựng sẵn:
System.out.println("String -> " + String.class.getClassLoader());
System.out.println("SQLData -> " + java.sql.SQLData.class.getClassLoader());
System.out.println("Loaders -> " + Loaders.class.getClassLoader());
System.out.println("parent -> " + Loaders.class.getClassLoader().getParent());
String -> null
SQLData -> jdk.internal.loader.ClassLoaders$PlatformClassLoader@2503dbd3
Loaders -> jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5
parent -> jdk.internal.loader.ClassLoaders$PlatformClassLoader@2503dbd3
Loader bootstrap lo phần lõi trong java.base và được viết bằng native code, nên String.class.getClassLoader() trả về null chứ không phải một object. Loader platform lo các module còn lại của JDK. Loader application lo classpath của bạn, và parent của nó là platform loader. Một yêu cầu luôn được đẩy lên parent trước, nên không gì bạn đặt trên classpath có thể che được java.lang.String.

Cả hai lần tìm đều bắt đầu ở application loader; java.lang.String được trả lời ngay trên đường đi lên, còn Main chỉ tìm thấy sau khi request đã lên tới đỉnh chuỗi rồi quay ngược xuống.
Việc load là lazy: một class chỉ được load và initialize ở lần đầu thực sự được dùng, không phải lúc chương trình khởi động. Chương trình dưới đây khai báo hai class có static initializer và chỉ chạm vào một:
class Alpha {
static { System.out.println("Alpha initialized"); }
static String hello() { return "Alpha.hello()"; }
}
class Beta {
static { System.out.println("Beta initialized"); }
static String hello() { return "Beta.hello()"; }
}
public class Lazy {
public static void main(String[] args) {
System.out.println("main started");
System.out.println(Alpha.hello());
System.out.println("Beta is never used");
}
}
main started
Alpha initialized
Alpha.hello()
Beta is never used
Beta initialized không bao giờ in ra, dù Beta.class nằm ngay cạnh Alpha.class trên đĩa. Cũng để ý là Alpha initialized xuất hiện sau main started — class không bị chạm tới cho đến lúc gọi. -verbose:class xác nhận điều đó ở mức class loader:
java -verbose:class Lazy 2>&1 | grep -E "] (Alpha|Beta|Lazy) "
[0.017s][info][class,load] Lazy source: file:/private/tmp/lazydemo/
[0.017s][info][class,load] Alpha source: file:/private/tmp/lazydemo/
Không hề có dòng nào cho Beta. Cũng lần chạy đó load tổng cộng 448 class, gần như toàn bộ đến từ java.base — đó là nơi thời gian khởi động JVM đi mất.
2. Verification
Trước khi bất kỳ bytecode nào của một class đã load được chạy, verifier kiểm tra nó: operand stack không bao giờ underflow hay overflow, type khớp với thứ mỗi instruction mong đợi, các bước nhảy rơi vào trong method, local variable slot được khởi tạo trước khi dùng. Nhờ vậy JVM mới dám chạy an toàn một file class không phải do chính nó compile.
Sửa hỏng một byte của file class hợp lệ — đổi return cuối cùng của một method void thành ireturn, tức trả về một int — và nó không có cơ hội nào để làm bậy:
Error: Unable to initialize main class Main
Caused by: java.lang.VerifyError: Operand stack underflow
Exception Details:
Location:
Main.main([Ljava/lang/String;)V @8: ireturn
Reason:
Attempt to pop empty stack.
VerifyError ngoài đời gần như luôn là vấn đề build: artifact hỏng, một agent hoặc framework rewrite bytecode chạy sai, hoặc các class đến từ những version lệch nhau của cùng một library.
3. Linking và initialization
Linking resolve các symbolic reference lấy từ constant pool — java/io/PrintStream.println trở thành một method thật — và chuẩn bị static field với giá trị mặc định (0, false, null). Bản thân bước resolution trong HotSpot cũng lazy, nên một dependency thiếu chỉ lộ ra ở thời điểm dùng lần đầu chứ không phải lúc khởi động.
Initialization sau đó chạy các static field initializer và static block của class, theo thứ tự trong source, đúng một lần cho mỗi class. Nếu đoạn code đó ném exception, JVM bọc lại thành ExceptionInInitializerError và đánh dấu class là không dùng được nữa cho tới hết process.
4. Execution: interpreter trước, JIT sau
Execution engine bắt đầu bằng cách interpret bytecode, từng instruction một. Interpret thì portable và khởi động tức thì, nhưng chậm. Ép JVM chỉ interpret bằng -Xint là thấy ngay cái giá — một vòng lặp 20 triệu lần, sáu round trong một lần chạy cho mỗi kiểu:
| Round | java Warmup | java -Xint Warmup |
|---|---|---|
| 1 | 13 ms | 99 ms |
| 2 | 11 ms | 96 ms |
| 3 | 10 ms | 95 ms |
| 4 | 10 ms | 88 ms |
| 5 | 10 ms | 90 ms |
| 6 | 9 ms | 90 ms |
Chậm khoảng chín lần trên máy này cho cùng một vòng lặp. Đây là con số tham khảo chứ không phải một benchmark.
Vì vậy HotSpot theo dõi method và vòng lặp nào chạy nhiều rồi giao những chỗ nóng cho một JIT compiler, thứ biến bytecode của chúng thành machine code cho đúng CPU đang chạy. Có hai JIT compiler hoạt động theo tier: C1 compile nhanh và cho code khá; C2 compile chậm và cho code rất tốt, dựa trên dữ liệu profile mà C1 thu thập. -XX:+PrintCompilation cho thấy một method được đẩy từ tier 3 (C1 kèm profiling) lên tier 4 (C2):
20 6 % 3 Warmup2::work @ 4 (25 bytes)
20 7 3 Warmup2::work (25 bytes)
20 8 % 4 Warmup2::work @ 4 (25 bytes)
21 6 % 3 Warmup2::work @ 4 (25 bytes) made not entrant
21 9 4 Warmup2::work (25 bytes)
22 7 3 Warmup2::work (25 bytes) made not entrant
Con số sau timestamp là id của lần compile, % đánh dấu on-stack replacement cho một vòng lặp đang chạy, cột kế tiếp là tier. made not entrant là bản tier 3 bị cho nghỉ khi bản tier 4 đã sẵn sàng.
Đó là lý do một process Java chạy lâu sẽ nhanh dần lên, và là lý do request đầu tiên tới một service vừa deploy luôn là request chậm. Đo cùng một khối công việc tám lần liên tiếp cho thấy quá trình warm-up rất rõ (số tham khảo lấy từ một lần chạy thật; năm lần chạy lại đều cho cùng một dáng):
round 1: 1958 us
round 2: 507 us
round 3: 507 us
round 4: 521 us
round 5: 506 us
round 6: 501 us
round 7: 508 us
round 8: 501 us
Nó cũng kéo theo một hệ quả thực tế khi đo đạc: một benchmark không warm-up là đang đo interpreter, không phải đo code của bạn.
Runtime data area: heap, stack, metaspace
JVM chia bộ nhớ của nó thành các vùng có vòng đời khác nhau. Heap chứa mọi object và array, được chia sẻ cho mọi thread, và là thứ garbage collector quản lý; kích thước đặt bằng -Xms và -Xmx, mặc định HotSpot chọn mức tối đa bằng một phần tư RAM vật lý. Mỗi thread có stack riêng gồm các frame, mỗi lời gọi method một frame, chứa local variable và operand stack của lời gọi đó — đây là vùng hết chỗ thì thành StackOverflowError, và nó không được garbage collect mà được pop ra khi method return. Metaspace chứa metadata của class: cấu trúc class đã load, bytecode của method và runtime constant pool. Nó nằm trong native memory, lớn dần theo nhu cầu, và là vùng đầy lên khi ứng dụng load class không giới hạn. Bạn xem được giá trị mặc định máy mình chọn:
java -XX:+PrintFlagsFinal -version
size_t InitialHeapSize = 268435456 {product} {ergonomic}
size_t MaxHeapSize = 4294967296 {product} {ergonomic}
size_t MaxMetaspaceSize = 18446744073709551615 {product} {default}
intx ThreadStackSize = 2048 {pd product} {default}
Tức heap khởi tạo 256 MB và tối đa 4 GB trên máy 16 GB, mỗi thread 2 MB stack, còn giới hạn metaspace thì gần như không giới hạn.
Nên dùng lệnh nào?
| Tình huống | Câu lệnh | Vì sao |
|---|---|---|
| Một file, để học hoặc thử nhanh | java Main.java | Không sinh artifact; compile lại từ source ở mỗi lần chạy. Chỉ một file, nếu dưới Java 22 |
| Thử một expression | jshell | Không cần class, không cần main, có kết quả ngay |
| Dự án nhiều file | javac -d out $(find src -name "*.java") rồi java -cp out com.example.Main | Compile một lần, chạy nhiều lần; output tách khỏi source |
| Có dùng library | javac -cp libs/util.jar -d out ... rồi java -cp "out:libs/*" ... | Library phải có mặt trên cả hai classpath |
| Gửi cho người khác | jar --create --file app.jar --main-class com.example.Main -C out . rồi java -jar app.jar | Một file duy nhất, tự mô tả entry point |
| Bất cứ dự án thật nào | Maven hoặc Gradle | Chạy toàn bộ những thứ trên, cộng dependency và test |
Troubleshooting: các message lỗi thật
| Message | Chuyện gì đã xảy ra | Cách sửa |
|---|---|---|
error: cannot find symbol | javac không resolve được một tên | Sai chính tả, thiếu import, hoặc thiếu một entry -cp |
error: class Other is public, should be declared in a file named Other.java | Tên public class không khớp tên file | Đổi một trong hai cho khớp |
Could not find or load main class Main.class | Bạn truyền tên file cho java | Bỏ phần đuôi: java Main |
Could not find or load main class Main với code có package | Bạn dùng tên ngắn thay vì fully qualified name | java -cp out com.example.Main |
Could not find or load main class com.example.Main | Class không nằm ở com/example/Main.class dưới root nào | Trỏ -cp vào thư mục output, kiểm tra khai báo package |
Main method not found in class NoMain | Class có tồn tại nhưng không có public static void main(String[]) | Chạy đúng class có main |
NoClassDefFoundError: com/example/Greeter | Có lúc compile, thiếu lúc chạy | Thêm jar hoặc thư mục đó vào -cp lúc chạy |
ClassNotFoundException: com.example.Missing | Một chuỗi tên được tra cứu và không khớp gì | Kiểm tra chính tả và xem artifact đã được ship chưa |
UnsupportedClassVersionError ... class file version 65.0 | Compile bằng JDK mới hơn JVM đang chạy | Chạy JVM mới hơn, hoặc compile với --release |
VerifyError | Bytecode bị hỏng | Build lại từ đầu; nghi ngờ agent rewrite bytecode hoặc jar hỏng |
no main manifest attribute, in app.jar | Jar không có Main-Class | Build lại với --main-class, hoặc dùng java -cp app.jar <class> |
| Sửa code mà không thấy đổi gì | java chạy file .class cũ | Compile lại, hoặc chạy java Main.java |
FAQ
Có phải cứ sửa code là phải compile lại không?
Đúng, nếu bạn chạy bằng java Main. Launcher đọc file .class và không biết gì về source của bạn. Sửa Main.java mà không compile lại thì java Main vẫn thản nhiên in ra output cũ — đã kiểm chứng ở trên. IDE giấu chuyện này đi bằng cách compile mỗi lần save, còn java Main.java tránh được vì nó compile ở mỗi lần chạy.
Vì sao java Main.java chạy được mà không cần javac?
Đó là source-file mode, thêm vào Java 11 qua JEP 330. Launcher thấy đuôi .java, compile file vào bộ nhớ, rồi chạy class top-level đầu tiên nó tìm được. Không có gì được ghi ra đĩa. Cách này tiện để học và không phù hợp cho dự án, vì nó compile lại ở mỗi lần khởi động và không sinh ra artifact nào.
Java là ngôn ngữ compiled hay interpreted?
Cả hai, theo thứ tự. javac compile source thành bytecode trước khi chạy. Sau đó JVM interpret bytecode đó, và JIT compile những phần chạy nhiều thành native code ngay trong lúc chương trình chạy. Cách mô tả chính xác là "compile ra bytecode, rồi JIT compile lúc runtime".
-cp đặt ở đâu trong câu lệnh?
Trước tên class, vì mọi thứ đứng sau tên class đều được truyền vào chương trình của bạn dưới dạng args. java -cp out com.example.Main là đúng; java com.example.Main -cp out sẽ đưa -cp và out vào main như argument và dùng classpath mặc định. Cũng nhớ rằng -cp bị bỏ qua hoàn toàn khi bạn dùng -jar.
java -cp app.jar Main và java -jar app.jar khác nhau ra sao?
-cp app.jar Main đặt jar lên classpath rồi chạy class bạn nêu tên; bạn có thể thêm entry khác vào classpath. -jar app.jar chạy class ghi ở Main-Class trong manifest và thay classpath bằng đúng một file jar đó.
Dự án thật có gõ javac bằng tay không?
Không. Maven và Gradle sinh ra rồi chạy những câu lệnh này, resolve dependency, và tạo file jar. Biết chúng sinh ra cái gì mới là thứ giúp bạn đọc được lỗi của chúng, mà lỗi thì gần như luôn là lỗi classpath khoác áo khác.
Kết luận
Pipeline này ngắn và soi được đến từng chi tiết. javac biến một file thành file class mà version, constant pool và bytecode của nó bạn đọc được bằng xxd và javap. java biến một class name thành một đường dẫn, resolve nó theo các classpath root, rồi load, verify, link, initialize và execute — interpret trước, JIT compile những gì nóng lên. Mọi message lỗi trong bài đều gọi tên stage đã sinh ra nó, và đó là công cụ debug nhanh nhất bạn có.
Bài 6 rời pipeline để bước vào chính ngôn ngữ: variable và data type, tám primitive type và điểm khác biệt của chúng so với reference type, cùng ý nghĩa của khác biệt đó với phép gán, phép so sánh và bộ nhớ.