Cách Java hoạt động: JDK, JRE, JVM
Xem thửTình huống mở đầu
Hoa cài Java theo hướng dẫn, mở terminal gõ javac -version và nhận về command not found. Nhưng java -version vẫn in 17.0.15, và gõ java Hello.java thì chương trình biên dịch rồi chạy ngay, in lời chào. Hoa cài lại hai lần, mất hơn 20 phút, kết quả vẫn thế. Cuối cùng Hoa chạy một chương trình 16 dòng và thấy hai dòng khó hiểu: javac command = false nhưng compiler API = true.
Mình đã chạy đúng chương trình đó trên máy của mình và thấy y hệt: máy này chỉ có runtime kiểu Ubuntu, không có lệnh javac, vậy mà vẫn biên dịch được. Làm sao một máy vừa không có trình biên dịch vừa biên dịch được? Hãy giữ câu hỏi này, mình sẽ trả lời bằng số đo thật.
Ôn nhanh bài trước: số major 61 ứng với Java mấy, và JVM Java 11 xử lý một file mang major 61 ra sao?
Đáp án: Java 17, vì 61 trừ 44 bằng 17. JVM Java 11 chỉ hiểu tới major 55 nên ném UnsupportedClassVersionError và từ chối file.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Chạy chương trình
MachineCheckđể biết máy mình có lệnhjavacvà có trình biên dịch trong JVM hay không. - Phân biệt JVM, JRE và JDK bằng quan hệ chứa nhau, và nói mỗi bộ có thêm gì so với bộ nhỏ hơn.
- Giải thích được vì sao máy không có lệnh
javacvẫn chạy đượcjava Hello.java(điều gì xảy ra bên trong runtime). - Mô tả các giai đoạn JVM đi qua khi chạy một lớp và nhận ra biên dịch JIT trong nhật ký
-XX:+PrintCompilation.
Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.
Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Bài trước bạn thấy một file .class đi tới nhiều máy. Bài này nhìn vào bên trong một máy: ở đó có những gì để viết, dịch và chạy chương trình.
Cách đọc hình: có hai khung nét đứt, khung trên và khung dưới. Khung trên có ba ô nối từ trái sang phải: Hello.java, javac, Hello.class, mang nhãn "Needs the JDK". Mũi tên đi xuống từ Hello.class vào khung dưới, nơi có ba ô: java launcher, JVM, Class libraries, mang nhãn "A runtime is enough". Thư viện lớp đi vào JVM bằng mũi tên "used by". Màu chỉ là tín hiệu phụ, hai nhãn khung mới là điều cần đọc.
Hình cho thấy một điều dễ bỏ qua: khung dưới đứng độc lập được. Một máy chỉ có khung dưới chạy được mọi file .class có sẵn, nhưng không tự tạo ra được file mới. Muốn viết và dịch bạn cần cả khung trên, và bộ có cả hai khung gọi là JDK.
Xuyên suốt bài, mình dùng một hình ảnh: JVM là người đầu bếp, runtime là cả căn bếp (cửa nhận đơn, đầu bếp và kho nguyên liệu), còn JDK là căn bếp cộng thêm xưởng viết công thức. Dự án xuyên suốt là "phiếu khám máy": một chương trình nhỏ hỏi máy bạn có gì. Có chỗ hình ảnh này không còn đúng, và mình sẽ chỉ rõ chỗ đó khi tới.
Khái niệm cốt lõi
Bài này có ba khái niệm. Mỗi khái niệm đi kèm một hình.
Khái niệm 1: JDK chứa runtime, runtime chứa JVM
JVM (máy ảo Java) là chương trình nạp và chạy bytecode. Runtime, còn gọi là JRE, là gói đủ để chạy chương trình đã dịch: bộ khởi chạy java, JVM và các thư viện lớp. JDK là runtime cộng thêm công cụ phát triển như javac (dịch), jar (đóng gói), javap (xem bytecode), jshell (gõ thử từng dòng). Trong ẩn dụ, JVM là đầu bếp, runtime là căn bếp, JDK là căn bếp có thêm xưởng viết công thức.
Cách đọc hình: có hai khung lồng nhau. Khung ngoài mang nhãn "JDK" chứa ô Developer tools ở hàng trên và khung trong. Khung trong mang nhãn "Runtime (JRE)" chứa ba ô: java launcher, JVM, Class libraries. Ô công cụ nằm trong khung ngoài nhưng ngoài khung trong, nên công cụ chỉ có ở JDK.
Từ Java 9, thư viện lớp được chia thành module như java.base, java.sql, jdk.compiler. Vì vậy hai gói "runtime" của hai hãng có thể khác nhau về số module, và đó là chìa khóa cho khái niệm kế tiếp.
Khái niệm 2: lệnh javac và module trình biên dịch là hai thứ riêng
Đây là khái niệm quan trọng nhất của bài. Lệnh javac là một file chạy được nằm trong thư mục bin của JDK. Module jdk.compiler là phần mã trình biên dịch nằm trong ảnh runtime, mà chương trình Java gọi được qua ToolProvider. Hai thứ này thường đi cùng nhau, nhưng không bắt buộc. Lệnh java Hello.java không tìm file javac: nó nhờ chính JVM biên dịch trong bộ nhớ bằng module jdk.compiler.
Trên máy mình, thư mục bin của runtime Ubuntu openjdk-17-jre-headless chỉ có bốn file: java, jpackage, keytool, rmiregistry [Đã chạy, xem bằng ls trên Ubuntu 20.04]. Không có javac, nhưng module vẫn còn.
Cách đọc hình: hai hàng, mỗi hàng một mũi tên "needs". Hàng trên: Terminal: javac -version cần File bin/javac, ô đích màu đỏ nhạt ghi "not in java.home on this machine". Hàng dưới: Terminal: java Hello.java cần Module jdk.compiler, ô đích màu lục ghi "present in the runtime image". Hai chữ "needs" cho thấy hai lệnh cần hai thứ khác nhau.
Chỗ ẩn dụ không còn đúng: trong bếp thật, thiếu xưởng viết công thức thì không có ai viết được công thức mới. Ở đây, JVM giữ sẵn một "người viết công thức" bên trong (module trình biên dịch), nên bếp vẫn tự dịch được nhờ java Hello.java, dù cửa xưởng (lệnh javac) không có.
Concept Check 1. Máy của Hoa in javac command = false và compiler API = true. Kết luận nào đúng?
- A. Phải cài lại Java vì chương trình không tìm thấy trình biên dịch nào trên máy.
- B. Máy không chạy được
java Hello.javavì không có lệnhjavacđể dịch file. - C. Máy có runtime chứa module trình biên dịch nhưng thiếu lệnh
javac; muốn gõjavacphải cài JDK. - D. Máy là một JDK đầy đủ, vì trình biên dịch có mặt nên mọi công cụ khác cũng có mặt.
Đáp án: C. Hai dòng in ra trả lời hai câu hỏi khác nhau, và chúng cho đáp án khác nhau. A sai vì module trình biên dịch có mặt, nên máy không thiếu gì để chạy java Hello.java. B nhầm hai thứ làm một: java Hello.java dùng module chứ không dùng file javac. D sai vì bin chỉ có bốn công cụ, không có javac, jar hay javap.
Khái niệm 3: JVM nạp, kiểm tra, thông dịch, rồi biên dịch JIT
Khi chạy một lớp, JVM đi qua các giai đoạn. Nạp lớp đọc file bytecode vào bộ nhớ. Kiểm tra xác minh bytecode hợp lệ và số major không vượt giới hạn, đây là nơi UnsupportedClassVersionError sinh ra. Thông dịch chạy từng lệnh bytecode một. Biên dịch JIT (just-in-time, dịch ngay lúc chạy) dịch những đoạn chạy nhiều thành mã máy để các lần sau chạy thẳng.
Cách đọc hình: năm ô nằm ngang theo thứ tự từ trái sang phải: Load, Verify, Interpret, JIT compile, Machine code. Bốn mũi tên nối chúng, riêng mũi tên giữa Interpret và JIT compile mang nhãn "hot", nghĩa là chỉ đoạn chạy nhiều mới được chuyển sang JIT. Ô cuối ghi "reused on later calls", tức mã máy được dùng lại.
Để thấy JIT làm việc, Warmup chạy cùng một hàm tính tổng 20 triệu lần lặp, năm vòng, và đo thời gian mỗi vòng. Hai cách quan sát, từ chắc chắn đến dễ dao động: xem nhật ký biên dịch của JVM, rồi mới xem thời gian.
Warmup.java
// Chạy cùng một hàm nhiều vòng để thấy tác dụng của biên dịch JIT
public class Warmup {
static long work() {
long sum = 0;
// 20 triệu lần lặp đủ nặng để JVM coi đây là đoạn chạy nhiều
for (int i = 0; i < 20_000_000; i++) {
sum += i % 7;
}
return sum;
}
public static void main(String[] args) {
for (int round = 1; round <= 5; round++) {
long start = System.nanoTime();
work();
// Đổi nano giây sang mili giây
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("round " + round + ": " + ms + " ms");
}
}
}
Bảng giải thích code:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
for (int i = 0; i < 20_000_000; i++) { sum += i % 7; } | Lặp 20 triệu lần, mỗi lần cộng phần dư của i chia 7 vào sum | Vòng lặp đủ nặng để JVM phải coi nó là đoạn nóng. Dấu gạch dưới trong 20_000_000 chỉ để dễ đọc |
long start = System.nanoTime(); | Ghi lại thời điểm bắt đầu tính bằng nano giây | Dùng hiệu hai lần đo để biết một vòng mất bao lâu |
work(); | Gọi lại cùng một hàm ở mỗi vòng | Mỗi lần gọi là một cơ hội để JIT nhận ra hàm này nóng |
(System.nanoTime() - start) / 1_000_000 | Đổi hiệu số nano giây sang mili giây | Số mili giây dễ đọc hơn |
Cách quan sát 1: nhật ký biên dịch. Cờ -XX:+PrintCompilation bảo JVM in một dòng mỗi khi nó biên dịch một hàm. Mình lọc các dòng của Warmup::work bằng grep [Đã chạy, JDK 17.0.15]. Cột thời gian và số thứ tự sẽ khác ở máy bạn [Có thể khác nhau]:
$ java -XX:+PrintCompilation Warmup.java 2>&1 | grep "Warmup::work"
9451 1201 % 3 Warmup::work @ 4 (26 bytes)
9451 1202 3 Warmup::work (26 bytes)
10144 1203 % 4 Warmup::work @ 4 (26 bytes)
10251 1201 % 3 Warmup::work @ 4 (26 bytes) made not entrant
10967 1203 % 4 Warmup::work @ 4 (26 bytes) made not entrant
13903 1238 % 4 Warmup::work @ 4 (26 bytes)
13930 1239 4 Warmup::work (26 bytes)
13966 1202 3 Warmup::work (26 bytes) made not entrant
$ java -Xint -XX:+PrintCompilation Warmup.java 2>&1 | grep -c "Warmup::work"
0
Cách đọc một dòng: cột đầu là số mili giây kể từ lúc JVM khởi động, cột hai là số thứ tự của lần biên dịch, dấu % nghĩa là biên dịch ngay giữa lúc vòng lặp đang chạy, số 3 hoặc 4 là mức biên dịch, rồi tới tên hàm và kích thước bytecode (26 byte). Dòng có chữ made not entrant nghĩa là bản biên dịch cũ bị thay bằng bản mới. Điều chắc chắn rút ra được từ log: hàm Warmup::work được biên dịch ra mã máy ở mức 3 rồi mức 4, còn với -Xint thì không có dòng nào. Ý nghĩa của từng mức mình chỉ nói theo cách HotSpot đánh số thông thường: mức cao hơn là mã máy được tối ưu kỹ hơn [Suy luận, mình không đo riêng từng mức].
Cách quan sát 2: thời gian mỗi vòng. Mình chạy java Warmup.java ba lần và java -Xint Warmup.java ba lần trong cùng phiên làm việc [Đã chạy, JDK 17.0.15]. Lúc đó máy đang rất bận (load average khoảng 27 đến 31 trên máy 4 nhân), nên số dao động mạnh [Có thể khác nhau]:
java Warmup.java 635, 1305, 1100, 942, 508 ms
java Warmup.java 732, 209, 331, 376, 340 ms
java Warmup.java 1360, 432, 289, 631, 435 ms
java -Xint Warmup.java 2488, 4610, 7272, 6138, 4916 ms
java -Xint Warmup.java 4345, 2433, 5245, 7825, 8358 ms
java -Xint Warmup.java 4451, 3039, 3673, 6619, 5233 ms
Số liệu này chỉ cho phép một kết luận an toàn: vòng chậm nhất của cả ba lần chạy mặc định (1360 ms) vẫn nhanh hơn vòng nhanh nhất của cả ba lần -Xint (2433 ms). Mình không rút ra "vòng 5 nhanh hơn vòng 1 bao nhiêu lần" vì trong ba lần chạy mặc định xu hướng giữa các vòng không đều, do máy bận. Muốn so tốc độ chính xác bạn cần máy rảnh và nhiều lần đo hơn. Hãy xem từng bước:
Đang tải Animation…
Bản chữ tương đương của animation: nạp lớp, kiểm tra major, thông dịch (không có dòng nào của Warmup::work trong nhật ký, và với -Xint JVM ở bước này mãi), rồi khi đoạn nóng được biên dịch JIT thì nhật ký có dòng mức 3 rồi mức 4 và các lần gọi sau dùng mã máy.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của dự án "phiếu khám máy": nó hỏi máy hai câu về trình biên dịch và in ra bốn dòng.
MachineCheck.java
import java.nio.file.Files;
import java.nio.file.Path;
import javax.tools.ToolProvider;
// Phiếu khám máy: hỏi hai câu độc lập về trình biên dịch
public class MachineCheck {
public static void main(String[] args) {
// Thư mục cài Java đang chạy chương trình này
String home = System.getProperty("java.home");
// Câu hỏi một: có file lệnh javac trong thư mục bin không (Windows có đuôi .exe)
boolean hasCommand = Files.exists(Path.of(home, "bin", "javac"))
|| Files.exists(Path.of(home, "bin", "javac.exe"));
// Câu hỏi hai: JVM có sẵn trình biên dịch trong bộ nhớ không, null nghĩa là không
boolean hasApi = ToolProvider.getSystemJavaCompiler() != null;
System.out.println("java.version = " + System.getProperty("java.version"));
System.out.println("java.home = " + home);
System.out.println("javac command = " + hasCommand);
System.out.println("compiler API = " + hasApi);
}
}
Predict: hãy dừng lại và đoán: trên máy của bạn, dòng javac command in true hay false, và dòng compiler API in gì? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi trên máy của mình [Đã chạy, OpenJDK 17.0.15 gói openjdk-17-jre-headless của Ubuntu; máy bạn có thể khác nhau, đặc biệt dòng java.home]:
java.version = 17.0.15
java.home = /usr/lib/jvm/java-17-openjdk-amd64
javac command = false
compiler API = true
Để so sánh, mình tải bộ JDK 17 của Eclipse Temurin theo liên kết mà API của Adoptium (adoptium.net) trả về, giải nén vào một thư mục tạm (không cài vào máy) rồi chạy cùng chương trình bằng chính bin/java trong thư mục đó [Đã chạy, Eclipse Temurin 17.0.21 cho Linux x64, đã đối chiếu mã băm SHA-256 với giá trị API của Adoptium công bố; bỏ dòng java.home vì chứa đường dẫn riêng]:
java.version = 17.0.21
javac command = true
compiler API = true
Cùng một chương trình, hai máy cho hai đáp án khác nhau ở dòng javac command, và chỉ khác nhau vì thư mục bin của JDK có thêm file javac. Cách làm này bạn tự lặp lại được: tải bản JDK 17 cho hệ điều hành của bạn, giải nén, rồi gõ <thư mục giải nén>/bin/java MachineCheck.java.
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
import ... ba dòng | Báo cho Java biết bạn dùng Files, Path và ToolProvider | Ba lớp này không có sẵn tên ngắn, phải khai báo ở đầu file |
System.getProperty("java.home") | Hỏi JVM thư mục cài Java đang chạy chương trình | Đây là nơi runtime nằm, và thư mục bin của nó chứa các lệnh |
Files.exists(Path.of(home, "bin", "javac")) | Kiểm tra file bin/javac có tồn tại không | Đây là câu hỏi một: có file lệnh không |
Files.exists(... "javac.exe") ở dòng kế tiếp | Kiểm tra thêm tên có đuôi .exe | Trên Windows file lệnh có đuôi .exe. Hai dấu gạch đứng ở cuối dòng trên nghĩa là "hoặc" |
ToolProvider.getSystemJavaCompiler() != null | Hỏi JVM xem có trình biên dịch đi kèm không, và so với null | Đây là câu hỏi hai: có module trình biên dịch không. null nghĩa là không có gì |
bốn dòng println | In từng kết quả kèm nhãn | Nhãn có dấu = thẳng hàng để bạn đọc nhanh |
Bây giờ xem từng bước:
Đang tải Animation…
Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật trên máy này):
| Dòng chạy | home | hasCommand | hasApi | Ghi chú |
|---|---|---|---|---|
String home = ... | /usr/lib/jvm/java-17-openjdk-amd64 | chưa có | chưa có | Chỉ lấy đường dẫn |
boolean hasCommand = ... | như trên | false | chưa có | bin không có javac và javac.exe |
boolean hasApi = ... | như trên | false | true | Module jdk.compiler có mặt |
bốn dòng println | như trên | false | true | In ra bốn dòng |
Bạn vừa làm được: tự hỏi máy mình hai câu về trình biên dịch và đọc đúng hai đáp án khác nhau.
Concept Check xen kẽ
Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.
Concept Check 2. Quan hệ chứa nhau nào đúng?
- A. JDK chứa runtime, và runtime chứa JVM cùng thư viện lớp.
- B. Runtime chứa JDK, vì muốn chạy chương trình cần đủ mọi công cụ.
- C. JVM chứa runtime, vì máy ảo là thành phần lớn nhất của Java.
- D. JDK, runtime và JVM ngang hàng nhau, mỗi thứ phải cài riêng.
Đáp án: A. Hình hai khung lồng nhau cho thấy công cụ chỉ nằm ở khung ngoài. B đảo ngược: chạy chương trình không cần javac. C nhầm máy ảo với cả gói: JVM chỉ là người đầu bếp trong căn bếp. D sai vì thực tế bạn cài một JDK là có cả runtime bên trong.
Concept Check 3. Chạy java -XX:+PrintCompilation Warmup.java thì nhật ký có nhiều dòng Warmup::work với số 3 rồi số 4. Chạy thêm cờ -Xint thì không có dòng nào. Kết luận hợp lý nhất là gì?
- A. Hàm
workbị lỗi nên JVM phải thử biên dịch lại nhiều lần mới xong. - B. Cờ
-Xintlàm JVM bỏ qua lớpWarmupnên không còn gì để ghi vào nhật ký. - C. Nhật ký chỉ ghi những hàm chạy chậm bất thường, còn hàm nhanh thì không bao giờ có dòng.
- D. JVM mặc định biên dịch JIT hàm nóng
workra mã máy qua hai mức, còn-Xinttắt biên dịch nên nhật ký trống.
Đáp án: D. Đây đúng là cơ chế của giai đoạn 3 và 4, và log trong bài là bằng chứng. A sai vì dòng made not entrant chỉ nghĩa là bản cũ được thay bằng bản tốt hơn, không phải lỗi. B sai vì với -Xint chương trình vẫn chạy bình thường, chỉ là toàn bộ bằng thông dịch. C sai vì nhật ký ghi hàm được biên dịch, và chính hàm nóng mới được biên dịch. [Suy luận từ cơ chế đã học, khớp với log đã chạy.]
Concept Check 4. Chạy java -Xint Warmup.java khác gì chạy java Warmup.java?
- A. Cờ
-Xinttăng bộ nhớ cho JVM nên chương trình nhanh hơn. - B. Cờ
-Xinttắt JIT nên JVM chỉ thông dịch, mỗi vòng chậm hơn nhiều. - C. Cờ
-Xintbỏ qua bước kiểm tra bytecode nên chương trình chạy nhanh hơn. - D. Cờ
-Xintđổi hàmworksang chạy bằng trình biên dịchjavac.
Đáp án: B. Trong ba lần chạy của bài, mọi vòng của -Xint đều chậm hơn mọi vòng của lần chạy mặc định. A nhầm chữ int với bộ nhớ, còn C và D là hai bước khác hoàn toàn: kiểm tra bytecode và javac không dính gì tới cờ này.
Làm cùng mình (Guided Practice)
Bạn sẽ dựng dần class JvmReport, một bản phiếu khám máy sâu hơn MachineCheck, qua bốn bước nhỏ. Gõ vào file JvmReport.java và chạy bằng java JvmReport.java. Hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 4 chỉ còn đề.
Bước 1: tên JVM và số Java.
public class JvmReport {
public static void main(String[] args) {
// TODO 1: in "step 1: " + tên JVM + " / Java " + số Java của JVM
}
}
Chạy xong bạn sẽ thấy: step 1: OpenJDK 64-Bit Server VM / Java 17 [Đã chạy, JDK 17.0.15; máy bạn có thể khác nhau]. Gợi ý: tên JVM là thuộc tính java.vm.name, số Java là Runtime.version().feature().
Bước 2: đếm module của runtime. Thêm vào main:
// TODO 2: in "step 2: " + số module + " modules", lấy từ ModuleLayer.boot().modules().size()
Chạy xong bạn sẽ thấy: step 2: 62 modules [Đã chạy, Có thể khác nhau: số này tùy bản runtime]. Gợi ý: ModuleLayer.boot().modules() là tập các module đang nạp, và .size() là số phần tử của nó.
Bước 3: module nào có mặt? Thêm hàm này vào class:
static boolean hasModule(String name) {
// TODO 3: true nếu ModuleLayer.boot().findModule(name) có kết quả
return false;
}
Rồi in step 3: java.base= kèm kết quả cho java.base, jdk.compiler, jdk.jshell. Chạy xong bạn sẽ thấy: step 3: java.base=true jdk.compiler=true jdk.jshell=true.
Vẽ ra giấy: kẻ ba ô ghi tên ba module, đánh dấu ô nào có mặt trên máy bạn. Đối chiếu: nếu có jdk.compiler thì java Hello.java chạy được, đó là sợi dây nối với khái niệm 2. Gợi ý: findModule trả về một Optional, và .isPresent() cho biết nó có giá trị không.
Bước 4: phán quyết bằng chữ. Bạn tự quyết cách viết.
static String verdict(boolean hasCompilerModule) {
// TODO 4: trả về một câu kết luận, ví dụ "this runtime can compile source, so java Hello.java works"
return "";
}
Rồi in step 4: kèm verdict(hasModule("jdk.compiler")). Chạy xong bạn sẽ thấy: step 4: this runtime can compile source, so java Hello.java works. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự viết một phiếu khám máy từ ba câu hỏi nhỏ. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 01-nhap-mon/02-jdk-jre-jvm của kho GitHub, nhưng bạn không cần mở nó để học.
Thử phá nó đi (Lỗi thường gặp)
Hai tình huống dưới đây là những lần máy "làm bạn bất ngờ" khi bạn chuyển giữa các kiểu cài đặt. Mỗi dòng đã được chạy thật [Đã chạy, Ubuntu 20.04, JDK 17.0.15; chữ của thông báo có thể khác ở hệ điều hành hoặc shell khác].
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
javac -version trên máy chỉ có runtime | bash: javac: command not found | Không có file javac trong các thư mục của PATH | Cài gói JDK, rồi mở terminal mới |
java --limit-modules java.base MachineCheck.java | java.lang.InternalError: Module jdk.compiler not in boot Layer | Runtime bị cắt chỉ còn java.base, nên không còn module trình biên dịch để dịch file nguồn | Dịch trước bằng javac, rồi chạy java MachineCheck, hoặc dùng runtime có jdk.compiler |
Dòng thứ hai là cách mình mô phỏng một runtime tối giản bằng cờ --limit-modules. Khi đó java vẫn khởi động, vẫn chạy được file .class đã dịch sẵn nếu chỉ cần java.base, nhưng không còn tự dịch được file .java. Hình dưới đây gom mô hình sai và mô hình đúng về lỗi thứ nhất.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: mũi tên nét đứt "expected" từ ô đầu tới "No javac command, so no compiler at all" rồi tới "java Hello.java must fail". Hàng dưới màu lục là mô hình đúng: mũi tên "reality" tới "Command and module are two separate things" rồi tới "java Hello.java works if jdk.compiler is present". Ô đầu của cả hai hàng là "javac not found, java -version works fine".
Bạn vừa làm được: đọc hai thông báo lỗi liên quan tới cài đặt và phân biệt lỗi thiếu lệnh với lỗi thiếu module.
Tự tay làm (Bài tập)
Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Điền bảng trạng thái. Dữ liệu vào: ba kiểu cài đặt A, B, C. A là JDK 17 đầy đủ. B là runtime kiểu Ubuntu có jdk.compiler nhưng không có javac. C là runtime chỉ còn java.base. Kẻ bảng với bốn cột: có lệnh javac không, có module jdk.compiler không, java Hello.java chạy được không, java Hello chạy được không (giả sử Hello.class đã dịch sẵn và chỉ cần java.base). Kết quả mong đợi: một bảng ba hàng bốn cột. Tiêu chí nghiệm thu: mỗi ô có đáp án "có" hoặc "không" và hàng C có đúng một ô "chạy được". Gợi ý: chạy java Hello.java cần module, chạy java Hello thì không.
Bài 2. Đọc phiếu của người khác. Dữ liệu vào là kết quả MachineCheck của một bạn (dữ liệu giả định để luyện tập):
java.version = 11.0.22
javac command = true
compiler API = true
Trả lời ba câu: máy đó là JDK hay chỉ runtime, Java mấy, và file .class biên dịch cho Java 11 mang số major nào. Kết quả mong đợi: ba câu trả lời ngắn. Tiêu chí nghiệm thu: câu ba dùng quy tắc major = Java + 44 của bài trước. Gợi ý: javac command = true nói gì về bin?
Bài 3. Sửa lời kết luận sai. Dữ liệu vào: Hoa nói "javac -version báo không tìm thấy, nghĩa là máy mình không biên dịch được gì, phải cài lại Java lần nữa". Viết hai ba câu chỉ ra chỗ sai trong lời kết luận và việc nên làm tiếp theo. Kết quả mong đợi: một đoạn ngắn nhắc tới cả hai thứ, lệnh javac và module jdk.compiler. Tiêu chí nghiệm thu: đoạn của bạn nêu một lệnh hoặc chương trình để kiểm tra, không đoán. Gợi ý: nhớ lại hai hàng của hình "command_vs_module".
Mức Vận dụng
Bài 4. Hộp đồ nghề ToolBox. Viết class ToolBox kiểm tra năm công cụ java, keytool, javac, jar, javap có nằm trong thư mục bin của java.home không, in từng dòng dạng tên true hoặc tên false, rồi in một dòng phán quyết verdict: JDK nếu có javac, ngược lại verdict: runtime only, no javac command. Hãy viết một hàm has(String home, String tool) dùng lại cho cả năm lần kiểm tra, và nhớ thử cả tên có đuôi .exe.
Kết quả mong đợi trên máy của mình [Đã chạy, Có thể khác nhau tùy máy bạn]:
java true
keytool true
javac false
jar false
javap false
verdict: runtime only, no javac command
Tiêu chí nghiệm thu: năm dòng đầu đúng thứ tự, và dòng cuối thay đổi khi bạn chạy bằng một JDK đầy đủ (chạy bằng bản Temurin 17.0.21 đầy đủ đã giải nén ở trên, mình thấy năm dòng đều true và verdict: JDK [Đã chạy]). Gợi ý: bạn đã có Files.exists và Path.of trong MachineCheck.
Mức Thử thách (tuỳ chọn)
Bài 5. Tìm JIT trên máy bạn. Cần máy chạy được java Warmup.java. Chạy Warmup với -XX:+PrintCompilation rồi lọc dòng Warmup::work, sau đó chạy lại với thêm -Xint. Kết quả mong đợi: một danh sách dòng log và một con số 0 do máy bạn tạo ra [Có thể khác nhau, đặc biệt các cột thời gian]. Tiêu chí nghiệm thu: bạn chỉ ra được dòng nào là mức 3, dòng nào là mức 4, và giải thích bằng hình năm giai đoạn vì sao -Xint không có dòng nào. Nếu -Xint chạy rất lâu trên máy chậm, hãy giảm 20_000_000 xuống 5_000_000. Gợi ý: -Xint giữ JVM ở giai đoạn thông dịch mãi, nên không có gì được biên dịch.
Tự kiểm tra (Retrieval Practice)
Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.
Câu 1 (nhớ lại, mục tiêu 2). Hãy sắp xếp JDK, runtime và JVM theo quan hệ chứa nhau, và nói JDK có thêm gì so với runtime.
Câu 2 (dự đoán kết quả code, mục tiêu 1). Một máy chỉ có runtime kiểu Ubuntu chạy MachineCheck. Hai dòng cuối in ra gì?
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 4). Một lớp vừa được nạp. Hãy liệt kê thứ tự các giai đoạn của JVM cho tới khi có mã máy, và nói giai đoạn nào có thể ném UnsupportedClassVersionError.
Câu 4 (tìm lỗi, mục tiêu 3). Một bạn nói: "Máy mình không có lệnh javac nên java Hello.java chắc chắn báo lỗi". Chỉ ra chỗ sai và nói điều kiện nào thì bạn đó đúng.
Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn chưa biết lập trình hỏi: "JDK với JRE khác nhau chỗ nào?" Hãy trả lời bằng hình ảnh căn bếp và xưởng viết công thức.
Điều cần nhớ
- Chạy
MachineCheckhoặcToolBoxlà cách chắc chắn nhất để biết máy bạn có lệnhjavacvà module trình biên dịch hay không (mục tiêu 1). - JDK chứa runtime, runtime chứa JVM cùng thư viện lớp, và JDK có thêm công cụ như
javac,jar,javap(mục tiêu 2). - Lệnh
javacvà modulejdk.compilerlà hai thứ riêng, nên có runtime vẫn chạy đượcjava Hello.javadù không có lệnhjavac(mục tiêu 3). - JVM nạp lớp, kiểm tra, thông dịch, rồi biên dịch JIT đoạn chạy nhiều ra mã máy, và bạn thấy điều đó trong nhật ký
-XX:+PrintCompilation(mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Lệnh | Cho biết |
|---|---|---|
| Biết JVM nào đang chạy chương trình | java -version | Phiên bản và hãng |
| Biết có JDK chưa | javac -version | Lệnh có hay chưa |
| Phiếu khám đầy đủ | java MachineCheck.java | java.home, lệnh, module |
| Xem số module | ModuleLayer.boot().modules().size() | Kích cỡ runtime |
| Thấy JIT làm việc | java -XX:+PrintCompilation Warmup.java | Dòng Warmup::work mức 3 rồi mức 4 |
| So JIT và thông dịch | java -Xint Warmup.java | Thời gian mỗi vòng, không có dòng log |
Cách đọc hình: ba hàng, mỗi hàng một lệnh ở bên trái và điều nó cho biết ở bên phải: java -version cho biết JVM nào chạy chương trình, javac -version cho biết bạn có JDK không, java MachineCheck.java cho bức tranh đầy đủ gồm java.home, lệnh và API trình biên dịch.
Câu hỏi để dành cho ngày mai: mỗi lần chạy java Hello.java, JVM phải dịch file trước rồi mới chạy. Nếu bạn dịch một lần bằng javac rồi chạy java Hello một trăm lần, cách nào nhanh hơn, và nhanh hơn bao nhiêu? Hãy đoán trước khi sang bài sau.
Đào sâu
Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.
Cơ chế bên trong. Runtime ngày nay là một ảnh ghép từ các module. Trên máy mình, ModuleLayer.boot().modules().size() cho 62 khi chạy bằng java JvmReport.java, còn java --list-modules liệt kê 70 module có thể dùng [Đã chạy, JDK 17.0.15]. Hai con số khác nhau vì chỉ các module cần cho chương trình hiện tại mới được nạp vào lớp khởi động. Khi biên dịch JvmReport trước rồi chạy java --limit-modules java.base -cp <thư mục chứa JvmReport.class> JvmReport, con số đó giảm còn 1 và dòng bước 3 in jdk.compiler=false [Đã chạy]. JVM cũng tự khai báo mình với chương trình: java -XshowSettings:properties -version in java.class.version = 61.0, java.specification.version = 17 và java.vm.name = OpenJDK 64-Bit Server VM [Đã chạy, JDK 17.0.15]. Số 61.0 chính là số major lớn nhất mà JVM này chịu nạp, nối lại với bài trước. Trình biên dịch JIT của HotSpot chia thành nhiều tầng, và cờ -XX:+PrintFlagsFinal cho biết TieredCompilation = true cùng TieredStopAtLevel = 4 [Đã chạy], nhưng bài này chưa đo từng tầng.
So với lựa chọn khác. Khi chưa có lệnh javac, bạn vẫn gọi được trình biên dịch qua module bằng java -m jdk.compiler/com.sun.tools.javac.Main -version, và máy mình in javac 17.0.15 [Đã chạy]. Đây cũng là cách mình chạy các thí nghiệm dịch trong khóa học khi chuẩn bị bài. Với người học, cách đơn giản nhất vẫn là cài JDK để có lệnh javac đúng nghĩa.
Thí nghiệm khác cho thấy JDK mang theo hiểu biết về các bản Java cũ: mình biên dịch Warmup.java hai lần, mặc định và với --release 8, rồi dùng java -m jdk.jdeps/com.sun.tools.javap.Main -v đọc file .class. Bản mặc định có major version: 61, bản --release 8 có major version: 52, và bản 52 vẫn chạy được trên JVM 17 [Đã chạy, JDK 17.0.15]. Cờ --release 8 còn kiểm cả thư viện: biên dịch MachineCheck.java với cờ này báo error: cannot find symbol ở Path.of, vì hàm đó chưa có trong Java 8 [Đã chạy]. Đây là chỗ một JDK làm được mà một runtime thuần chạy không làm được.
Hiểu lầm nâng cao. Nhiều người nghĩ "JRE là gói tải riêng bắt buộc". Từ khi có module, nhà cung cấp có thể phát hành runtime cắt gọn với ít module hơn, như thí nghiệm --limit-modules đã mô phỏng. Vì vậy đừng giả định mọi runtime đều có jdk.compiler: hãy hỏi máy bằng chương trình như JvmReport.
Giới hạn của bài. Bài này chưa nói cách cài JDK (bài sau), chưa đo việc nạp lớp theo nhu cầu, chưa nói bộ gom rác và bộ nhớ, và chưa so sánh các cấu hình JIT khác. Số đo thời gian của Warmup lấy từ máy đang bận [Có thể khác nhau], nên mình chỉ tin vào nhật ký biên dịch và vào việc -Xint chậm hơn, không tin vào con số tuyệt đối hay tỷ lệ.
Đọc thêm (tài liệu chính thống):
- The Java Tutorials: What Is the Java Virtual Machine?
- JEP 261: Module System
- Tài liệu lệnh java, JDK 17
- The Java Virtual Machine Specification, Java SE 17: Loading, Linking, and Initializing
Bài tiếp theo
Bài sau biến những hiểu biết này thành thao tác: kiểm tra JDK trên máy bạn, viết chương trình Hello World, chạy nó bằng hai cách và đọc các lỗi đầu tiên mà javac và java có thể báo.
Mini-dự án nối tiếp: ở bài sau, JvmReport sẽ thêm một việc: in thêm mã thoát của chương trình và thời gian chạy hai cách. Hãy giữ lại JvmReport.java bạn vừa viết làm điểm xuất phát.
Lời giải và đáp án
Hãy thử xong rồi mới đọc phần này.
Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng bốn dòng step ở trên [Đã chạy, JDK 17.0.15]:
// Bước 1: tên JVM và số Java do chính JVM khai báo
System.out.println("step 1: " + System.getProperty("java.vm.name") + " / Java " + Runtime.version().feature());
// Bước 2: đếm module đã nạp lúc khởi động
System.out.println("step 2: " + ModuleLayer.boot().modules().size() + " modules");
// Bước 3: findModule trả về Optional, isPresent hỏi nó có giá trị không
return ModuleLayer.boot().findModule(name).isPresent();
// Bước 4: có module trình biên dịch thì java Hello.java tự dịch được
if (hasCompilerModule) {
return "this runtime can compile source, so java Hello.java works";
}
return "this runtime cannot compile source";
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
System.getProperty("java.vm.name") | Lấy tên JVM đang chạy | JVM tự khai báo tên của nó trong các thuộc tính hệ thống |
Runtime.version().feature() | Lấy số Java, ví dụ 17 | Đây là số Java đã đổi sẵn, không cần tự cộng trừ |
ModuleLayer.boot().modules().size() | Đếm module đã nạp lúc khởi động | Cho bạn biết runtime lớn hay nhỏ |
ModuleLayer.boot().findModule(name).isPresent() | Tìm module theo tên và hỏi có kết quả không | findModule trả về Optional, và isPresent() là cách hỏi có giá trị không |
if (hasCompilerModule) { ... } | Chọn câu kết luận theo điều kiện | Có module trình biên dịch thì java Hello.java chạy được |
Ba lệnh in còn lại của main chỉ ghép nhãn step 3: và step 4: với kết quả của hasModule và verdict.
Lời giải Bài 1. [Suy luận cho hàng A, đã chạy cho hàng B trên máy này và hàng C bằng --limit-modules java.base]
| Kiểu cài đặt | Có lệnh javac | Có jdk.compiler | java Hello.java | java Hello |
|---|---|---|---|---|
| A: JDK 17 đầy đủ | có | có | chạy được | chạy được |
| B: runtime Ubuntu | không | có | chạy được | chạy được |
C: chỉ java.base | không | không | không chạy được | chạy được |
Hàng C chỉ chạy được java Hello: file .class đã dịch sẵn thì JVM chỉ cần nạp và chạy, không cần trình biên dịch.
Lời giải Bài 2. [Suy luận] Máy đó là JDK vì javac command = true, tức file javac có trong bin. Java mấy: 11.0.22 nghĩa là Java 11. File biên dịch cho Java 11 mang major 11 cộng 44, tức 55.
Lời giải Bài 3. Chỗ sai là gộp hai thứ làm một. javac -version chỉ kiểm tra lệnh javac có trong PATH không. Máy vẫn có thể có module jdk.compiler trong runtime, và khi đó java Hello.java vẫn dịch và chạy được. Việc nên làm là chạy MachineCheck để xem dòng compiler API, rồi nếu cần lệnh javac thì cài gói JDK, thay vì cài lại Java một cách mù quáng.
Lời giải Bài 4. [Đã chạy, sáu dòng khớp với kết quả mong đợi]
// Thử cả tên không đuôi (Linux, macOS) và tên có đuôi .exe (Windows)
static boolean has(String home, String tool) {
return Files.exists(Path.of(home, "bin", tool)) || Files.exists(Path.of(home, "bin", tool + ".exe"));
}
// In một dòng cho mỗi công cụ để gọi được năm lần ngắn gọn
static void show(String home, String tool) {
System.out.println(tool + " " + has(home, tool));
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
Files.exists(Path.of(home, "bin", tool)) | Kiểm tra file bin/<tool> | Trên Linux và macOS file lệnh không có đuôi |
... Files.exists(Path.of(home, "bin", tool + ".exe")) | Kiểm tra thêm bin/<tool>.exe | Trên Windows file lệnh có đuôi .exe, và toán tử "hoặc" cho kết quả đúng nếu một trong hai có |
static void show(...) | In một dòng tên true hoặc tên false | Gọi năm lần với năm tên, tránh viết lặp năm dòng dài |
Cuối main, một if gọi has(home, "javac") để chọn dòng verdict.
Gợi ý lời giải Bài 5. Chạy java -XX:+PrintCompilation Warmup.java 2>&1 | grep "Warmup::work" rồi lặp lại với -Xint và grep -c. Trên máy mình lần mặc định in tám dòng, dòng đầu mang số 3 và các dòng mang số 4 xuất hiện sau đó, còn lần -Xint in 0 [Đã chạy, JDK 17.0.15; Có thể khác nhau ở máy bạn]. Giải thích: bản mặc định có JIT biên dịch hàm work sau khi nó chạy đủ nhiều, còn -Xint giữ mọi vòng ở giai đoạn thông dịch.
Đáp án Tự kiểm tra.
- JDK chứa runtime, runtime chứa JVM, bộ khởi chạy
javavà thư viện lớp. JDK có thêm công cụ phát triển nhưjavac,jar,javap,jshell. Kiểm tra mục tiêu 2. - In ra
javac command = falserồicompiler API = true[Đã chạy trên máy của mình]. Runtime kiểu Ubuntu không có filejavacnhưng có modulejdk.compiler. Kiểm tra mục tiêu 1. - Nạp lớp, kiểm tra, thông dịch, biên dịch JIT, rồi mã máy được dùng lại.
UnsupportedClassVersionErrorsinh ra ở giai đoạn kiểm tra. Kiểm tra mục tiêu 4. - Chỗ sai là nhầm lệnh
javacvới module trình biên dịch:java Hello.javadùng module. Bạn đó chỉ đúng nếu runtime của họ không có modulejdk.compiler, ví dụ runtime chỉ cònjava.base. Kiểm tra mục tiêu 3. - Căn bếp (runtime) đủ để nấu các món đã có công thức: có cửa nhận đơn, đầu bếp và kho nguyên liệu. JDK là căn bếp đó cộng thêm xưởng viết công thức, tức
javacvà các công cụ khác. Kiểm tra mục tiêu 2.