Command Palette

Search for a command to run...

[Java Basics] Cách biên dịch và chạy chương trình Java: javac, java và JVM

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.

Cách biên dịch và chạy chương trình Java với javac và java

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)

Pipeline của javac và java, từng stage một, kèm lỗi mà mỗi stage sinh ra

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

Các vùng của một file .class, kèm những byte đầu tiên có thật ánh xạ lên

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 JavaMajor version
852
1155
1761
2165

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:

Operand stack sau mỗi bytecode instruction trong 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ễ:

InstructionNó 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 #13Lấy một hằng từ constant pool và đẩy lên stack. Ở đây là string Hello, Java!
invokevirtual #15Lấy argument và receiver ra khỏi stack, rồi gọi instance method PrintStream.println(String)
returnRời method, không trả về giá trị nào

#7, #13#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/Objectjava/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.

FlagDùng vớiCông dụng
-d <dir>javacGhi file class vào thư mục này
-cp / -classpath / --class-pathjavacjavaNơi tìm các class không thuộc JDK
--release <N>javacCompile theo API bản N và sinh bytecode cho bản N
-Xlint:alljavacBật mọi nhóm cảnh báo
-sourcepath <dir>javacNơi tìm source của các type bạn không liệt kê
-jar <file>javaChạ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.classMain.class trong thư mục output.

Cách classpath biến một fully qualified name thành một lần tìm file

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.jar bỏ 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.jar cạnh -jar khô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)
NoClassDefFoundErrorClassNotFoundException
LoạiErrorChecked Exception
Xuất hiện khiJVM resolve một reference mà compiler đã ghi lại, và class đó giờ không cònCode hỏi một class bằng chuỗi tên, và không có
Nguyên nhân điển hìnhClass có lúc compile nhưng không có trên classpath lúc chạyTên sai chính tả, hoặc jar plugin không được ship kèm
Để ý định dạngGạch chéo: com/example/GreeterDấ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.javaUtil.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 javacjar; 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.

Parent delegation qua bootstrap, platform và application class loader, với hai lần tìm class trái ngược nhau

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:

Roundjava Warmupjava -Xint Warmup
113 ms99 ms
211 ms96 ms
310 ms95 ms
410 ms88 ms
510 ms90 ms
69 ms90 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-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ốngCâu lệnhVì sao
Một file, để học hoặc thử nhanhjava Main.javaKhô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 expressionjshellKhông cần class, không cần main, có kết quả ngay
Dự án nhiều filejavac -d out $(find src -name "*.java") rồi java -cp out com.example.MainCompile một lần, chạy nhiều lần; output tách khỏi source
Có dùng libraryjavac -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ácjar --create --file app.jar --main-class com.example.Main -C out . rồi java -jar app.jarMột file duy nhất, tự mô tả entry point
Bất cứ dự án thật nàoMaven hoặc GradleChạy toàn bộ những thứ trên, cộng dependency và test

Troubleshooting: các message lỗi thật

MessageChuyện gì đã xảy raCách sửa
error: cannot find symboljavac không resolve được một tênSai 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.javaTê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.classBạn truyền tên file cho javaBỏ phần đuôi: java Main
Could not find or load main class Main với code có packageBạn dùng tên ngắn thay vì fully qualified namejava -cp out com.example.Main
Could not find or load main class com.example.MainClass không nằm ở com/example/Main.class dưới root nàoTrỏ -cp vào thư mục output, kiểm tra khai báo package
Main method not found in class NoMainClass có tồn tại nhưng không có public static void main(String[])Chạy đúng class có main
NoClassDefFoundError: com/example/GreeterCó lúc compile, thiếu lúc chạyThêm jar hoặc thư mục đó vào -cp lúc chạy
ClassNotFoundException: com.example.MissingMộ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.0Compile bằng JDK mới hơn JVM đang chạyChạy JVM mới hơn, hoặc compile với --release
VerifyErrorBytecode bị hỏngBuild lại từ đầu; nghi ngờ agent rewrite bytecode hoặc jar hỏng
no main manifest attribute, in app.jarJar không có Main-ClassBuild 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 .classCompile 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 -cpout 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 xxdjavap. 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ớ.

Bài viết liên quan

[Java Basics] Các thuật toán cơ bản với mảng trong Java: tìm max/min, tìm kiếm và sắp xếp

Tìm max, min, tổng, trung bình, lọc, đảo mảng, linear search và binary search, bubble/selection/insertion sort và Arrays.sort trong Java - mọi thuật toán đều được đo bằng counter comparison và swap, mọi output đều chạy thật trên JDK 21.

[Java Basics] Tính kế thừa trong Java: extends và super

extends hoạt động thế nào, subclass kế thừa gì và không kế thừa gì, vì sao constructor không bao giờ được kế thừa, super(...) xâu chuỗi constructor lên tới java.lang.Object rồi chạy ngược xuống ra sao, field hiding khác overriding chỗ nào, protected qua package, final class, vấn đề fragile base class, và khi nào composition là lựa chọn đúng.

[Java Basics] Best Practice Code Sạch trong Java cho Người Mới

Best practice code sạch cho người mới học Java trên OpenJDK 21: naming convention mà chính JDK đang theo, warning thật từ javac -Xlint:all, magic number, tách method dài theo từng bước giữ nguyên output, guard clause, == và equals, getter làm lộ state, và những code smell nên nhận ra sớm.

[Java Basics] Phương thức trong Java: khai báo và gọi method

Cách khai báo và gọi method trong Java: các thành phần của một declaration, static method so với instance method và lỗi non-static method cannot be referenced from a static context, return statement, call stack và StackOverflowError, cách đọc stack trace, Javadoc, cùng những lỗi compiler thật mà người mới hay gặp.