Java là gì?
Xem thửTình huống mở đầu
Minh viết chương trình Greeter dài 5 dòng, biên dịch bằng JDK 17 trên laptop Linux và được file Greeter.class nặng 915 byte. Minh gửi file đó cho ba bạn: Linh dùng Java 17 trên Windows, Nam dùng Java 17 trên macOS, Hoa dùng Java 11 ở phòng lab. Kịch bản này mình dựng ra, nhưng lỗi của Hoa là lỗi thật: mình đã chạy file đó bằng Java 11 và sẽ cho bạn xem kết quả ở các phần sau.
Linh và Nam gõ java Greeter là thấy lời chào. Hoa gõ đúng lệnh ấy và nhận về một khối chữ đỏ có chữ UnsupportedClassVersionError. Hoa nhắn lại: "Java chạy mọi nơi mà, sao máy tớ không chạy?" Cùng một file, cùng một lệnh, hai kết quả. Trong 915 byte đó có gì khiến một máy chấp nhận còn máy kia từ chối?
Ôn nhanh bài trước: máy tính hiểu trực tiếp chữ trong file .java, hay chỉ hiểu mã máy? Vì sao một chương trình Java cần bước biên dịch?
Đáp án: máy tính chỉ chạy được mã máy, còn chữ trong file .java là văn bản cho con người đọc. Vì vậy phải có bước dịch, và bài này cho bạn thấy Java dịch hai chặng thay vì một.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Đọc được tám byte đầu của một file
.classbằng một chương trình Java ngắn và đổi số major ra số Java. - Giải thích được vì sao cùng một file
.classchạy trên nhiều hệ điều hành nhưng vẫn bị một JVM cũ hơn từ chối. - Dự đoán được JVM nào chạy được file nào khi biết
--releaselúc biên dịch. - Chọn được
--releasehợp lý khi gửi chương trình cho người dùng có Java cũ hơn.
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 về cách Java chạy trên nhiều thiết bị, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Trước khi vào chi tiết, hãy nhìn đường đi của một file từ lúc bạn gõ tới lúc nó chạy trên máy người khác.
Cách đọc hình: đi từ trái sang phải. Ô trắng Greeter.java là mã nguồn bạn viết, ô xanh dương javac là trình dịch, ô vàng Greeter.class là kết quả. Từ ô vàng có ba mũi tên tới ba ô xanh lục: JVM trên Linux, JVM trên Windows, JVM trên macOS. Cả ba mũi tên xuất phát từ cùng một ô, nghĩa là cả ba máy nhận đúng cùng một file. Màu chỉ là tín hiệu phụ, tên trong từng ô mới là thông tin chính.
Điều mới mẻ của Java là chặng dịch được chia đôi. javac dịch mã nguồn thành bytecode, một dạng trung gian giống nhau trên mọi máy. Sau đó mỗi máy có một JVM riêng, đọc bytecode và làm việc với hệ điều hành của máy đó. Nhờ vậy bạn biên dịch một lần và gửi file đi khắp nơi.
Xuyên suốt bài, mình dùng một hình ảnh: Greeter.java là bản nháp công thức, Greeter.class là tấm thẻ công thức chuẩn, và mỗi JVM là một đầu bếp trong bếp của từng hệ điều hành. Dự án xuyên suốt là việc Minh gửi gói Greeter cho ba bạn Linh, Hoa và Nam. 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: Java gồm năm mảnh ghép
Java là tên chung của năm mảnh làm việc cùng nhau: ngôn ngữ (luật viết trong file .java), javac (trình dịch), bytecode (lệnh trung gian lưu trong file .class), JVM (máy ảo đọc và chạy bytecode) và thư viện chuẩn (các lớp có sẵn như String, List, Files). Trong ẩn dụ của bài, ngôn ngữ là cách viết công thức, bytecode là tấm thẻ chuẩn, JVM là đầu bếp, còn thư viện chuẩn là kho nguyên liệu có sẵn.
Cách đọc hình: năm ô nằm ngang theo thứ tự từ trái sang phải: Language, javac, Bytecode, JVM, Standard library. Bốn mũi tên cho thấy đường đi: ngôn ngữ vào javac, javac ra bytecode, bytecode vào JVM, và thư viện chuẩn đi vào JVM từ bên phải. Ba ô đầu giống nhau trên mọi máy, hai ô cuối (JVM và thư viện) là phần có bản riêng cho từng hệ điều hành.
Khái niệm 2: file .class có tem phiên bản ở đầu
Mỗi file .class mở đầu bằng tám byte. Bốn byte đầu gọi là magic, luôn có giá trị CA FE BA BE để JVM nhận ra đây là file bytecode. Hai byte kế là minor và hai byte sau nữa là major. Số major là tem phiên bản dán trên tấm thẻ công thức: nó cho biết file được dịch cho Java nào. Từ Java 5 trở đi, số Java bằng major trừ 44.
Cách đọc hình: bốn ô nối nhau từ trái sang phải. Ô đầu là byte 0 đến 3 (magic) chứa CA FE BA BE. Ô thứ hai là byte 4 đến 5 (minor) chứa 00 00, tức 0. Ô thứ ba là byte 6 đến 7 (major) chứa 00 3D, tức 61. Ô cuối là cách đọc: Java bằng 61 trừ 44, tức 17. Chỉ cần hai byte major là biết file cần Java nào.
Bảng dưới đây là các cặp thường gặp. Hai cột đầu bạn sẽ gặp lại nhiều lần trong khóa học:
| Bản Java | Số major | Cách tính |
|---|---|---|
| Java 8 | 52 | 8 cộng 44 |
| Java 11 | 55 | 11 cộng 44 |
| Java 17 | 61 | 17 cộng 44 |
| Java 21 | 65 | 21 cộng 44 |
Khái niệm 3: sàn phiên bản, JVM mới chạy file cũ nhưng JVM cũ không chạy file mới
Mỗi JVM biết một giới hạn: số major cao nhất mà nó hiểu. Trước khi chạy dòng code nào, JVM so major của file với giới hạn của mình. Nếu major của file không lớn hơn giới hạn thì chạy, nếu lớn hơn thì ném UnsupportedClassVersionError. Đó là sàn phiên bản: file đặt ra mức Java tối thiểu mà máy phải có.
Tùy chọn --release N của javac bảo trình dịch tạo bytecode cho Java N. Mình đã thử cả chín cặp (file release 8, 11, 17 trên JVM Java 8, 11, 17) trên máy này [Đã chạy: Java 8 bản 1.8.0_452, Java 11 bản 11.0.27 và Java 17 bản 17.0.15 cài sẵn trên máy; file dịch bằng JDK 17.0.15 với --release]. Kết quả:
| File biên dịch với | Chạy trên Java 8 | Chạy trên Java 11 | Chạy trên Java 17 |
|---|---|---|---|
--release 8 (major 52) | chạy được | chạy được | chạy được |
--release 11 (major 55) | bị từ chối | chạy được | chạy được |
--release 17 (major 61) | bị từ chối | bị từ chối | chạy được |
Cách đọc hình: ba hàng là ba file (release 8, 11, 17), ba cột là ba JVM (Java 8, 11, 17). Mỗi ô ghi sẵn lý do: các ô chạy được ghi "runs" kèm so sánh major không lớn hơn giới hạn, ba ô bị từ chối ghi "refused" kèm so sánh major lớn hơn giới hạn. Ba ô bị từ chối đều nằm ở nửa dưới bên trái, tức là file mới trên JVM cũ. Màu lục và đỏ chỉ nhắc lại chữ "runs" và "refused".
Đây là thông báo thật của lần chạy java Greeter bằng Java 11 với file release 17 [Đã chạy, Java 11.0.27]:
Error: LinkageError occurred while loading main class Greeter
java.lang.UnsupportedClassVersionError: Greeter has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 55.0
Đọc kỹ: "class file version 61.0" là major của file, "up to 55.0" là giới hạn của JVM. Thông báo của Java 8 cùng ý nhưng ghi "up to 52.0" và mở đầu bằng một dòng Error: A JNI error has occurred [Đã chạy, Java 8 bản 1.8.0_452]. Chỗ ẩn dụ không còn đúng: một đầu bếp thật gặp món lạ vẫn có thể thử đoán, còn JVM thì không đoán. Tem cao hơn giới hạn là từ chối ngay, không thử.
Hãy xem từng bước Minh gửi file cho ba bạn rồi sửa bằng --release:
Đang tải Animation…
Bản chữ tương đương của animation: file release 17 (major 61) chạy được ở máy Linh (Java 17), bị từ chối ở máy Hoa (Java 11) và máy Nam (Java 8). Dịch lại với release 11 (major 55) thì Hoa chạy được, Nam vẫn bị từ chối. Dịch lại với release 8 (major 52) thì cả ba chạy được.
Concept Check 1. Minh biên dịch Greeter.java với --release 11 rồi gửi cho Nam (Java 8), Hoa (Java 11) và Linh (Java 17). Ai chạy được file?
- A. Chỉ Hoa, vì file chỉ chạy đúng trên phiên bản Java mà nó được biên dịch cho.
- B. Hoa và Linh, vì Nam chỉ hiểu major tới 52 còn file mang major 55.
- C. Cả ba người, vì bytecode độc lập với phiên bản Java của máy chạy.
- D. Nam và Hoa, vì JVM cũ hơn luôn đọc được file mới hơn nếu nó nhỏ.
Đáp án: B. Quy tắc là major của file không lớn hơn giới hạn của JVM: 55 không lớn hơn 55 (Hoa) và 61 (Linh), nhưng lớn hơn 52 (Nam). A sai vì JVM mới hơn vẫn chạy được file cũ hơn. C nhầm "độc lập với hệ điều hành" thành "độc lập với phiên bản". D đảo ngược quy tắc: chính JVM cũ mới là bên bị chặn.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Chương trình đầu tiên của dự án đọc tám byte đầu của một file .class có sẵn trong JDK, là String.class, rồi in từng phần ra màn hình.
ClassHeader.java
import java.io.DataInputStream;
import java.io.IOException;
// Đọc tám byte đầu của file String.class nằm sẵn trong JDK
public class ClassHeader {
public static void main(String[] args) throws IOException {
// try có ngoặc tròn: luồng tự đóng khi khối kết thúc
try (DataInputStream in = new DataInputStream(String.class.getResourceAsStream("String.class"))) {
// 4 byte đầu: số nhận dạng, luôn là CAFEBABE
int magic = in.readInt();
// 2 byte kế: phiên bản phụ
int minor = in.readUnsignedShort();
// 2 byte kế nữa: phiên bản chính, thứ tự đọc phải đúng thứ tự trong file
int major = in.readUnsignedShort();
System.out.println("magic = " + Integer.toHexString(magic).toUpperCase());
System.out.println("minor = " + minor);
System.out.println("major = " + major);
// Từ Java 5 trở đi: số Java bằng major trừ 44
System.out.println("java = " + (major - 44));
}
}
}
Predict: hãy dừng lại và đoán: chương trình in ra mấy dòng, và dòng java in số nào? Gợi ý: JDK của bạn là Java mấy thì dòng đó thường là chính số ấy.
Kết quả mong đợi [Đã chạy, JDK 17.0.15; nếu bạn dùng JDK khác thì dòng major và java đổi theo, ví dụ Java 21 cho major 65]:
magic = CAFEBABE
minor = 0
major = 61
java = 17
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
import java.io.DataInputStream; và import java.io.IOException; | Báo cho Java biết bạn dùng hai lớp của gói java.io | Hai lớp này không có sẵn tên ngắn, phải khai báo ở đầu file |
throws IOException ở dòng main | Cho phép main ném lỗi đọc file ra ngoài | Đọc file có thể thất bại, và Java bắt bạn khai báo điều đó |
String.class.getResourceAsStream("String.class") | Mở luồng đọc file String.class nằm cạnh lớp String trong JDK | Dùng file có sẵn nên bạn chưa cần tự biên dịch gì |
new DataInputStream(...) trong try (...) | Bọc luồng để đọc số nguyên từ byte, và tự đóng luồng khi try xong | DataInputStream ghép byte thành số giúp bạn, try có ngoặc tròn đóng file không cần gọi tay |
in.readInt() | Đọc 4 byte, ghép thành một số nguyên | Magic dài đúng 4 byte |
in.readUnsignedShort() hai lần | Mỗi lần đọc 2 byte thành số không âm, lần đầu là minor, lần hai là major | Minor và major mỗi số dài 2 byte, thứ tự đọc phải đúng thứ tự trong file |
Integer.toHexString(magic).toUpperCase() | Đổi số magic sang chữ số hệ mười sáu viết hoa | Nhìn CAFEBABE dễ nhận ra hơn số thập phân |
major - 44 | Trừ 44 để ra số Java | Đây là quy tắc đổi của khái niệm 2 |
Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy và vị trí đọc trong file:
Đ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):
| Dòng chạy | Số byte đã đọc | Giá trị đọc được | Ghi chú |
|---|---|---|---|
mở DataInputStream | 0 | chưa có | Chưa đọc gì |
int magic = in.readInt() | 4 | CA FE BA BE | Số nhận dạng file bytecode |
int minor = in.readUnsignedShort() | 6 | 00 00 bằng 0 | Phiên bản phụ |
int major = in.readUnsignedShort() | 8 | 00 3D bằng 61 | Phiên bản chính |
các dòng println | 8 | magic, minor, major, java | java = 61 - 44 = 17 |
Bạn vừa làm được: tự tay đọc tem phiên bản của một file .class thật và đổi nó ra số Java.
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. Chương trình ClassHeader chạy trên máy của một bạn khác và in major = 65. File String.class đó cần tối thiểu Java mấy?
- A. Java 65, vì số major chính là số phiên bản Java.
- B. Java 109, vì phải cộng 44 vào số major.
- C. Java 17, vì đó là bản mà khóa học này dùng làm chuẩn.
- D. Java 21, vì số Java bằng major trừ 44.
Đáp án: D. 65 trừ 44 bằng 21. A bỏ qua phép đổi: major và số Java là hai thang số khác nhau. B làm ngược phép tính. C nhầm phiên bản khóa học dùng với phiên bản của JDK trên máy bạn kia. [Suy luận từ quy tắc đã đo ở bảng major.]
Concept Check 3. Linh dùng Java 17 và nhận file Greeter.class biên dịch với --release 8 (major 52). Điều gì xảy ra khi Linh chạy java Greeter?
- A. Chạy bình thường, vì 52 không lớn hơn giới hạn 61 của JVM Java 17.
- B. Bị từ chối, vì file quá cũ so với JVM nên phải biên dịch lại.
- C. Chạy được nhưng in cảnh báo vì file cũ hơn JVM một chút.
- D. Bị từ chối, vì Java 17 chỉ chấp nhận file có đúng major 61.
Đáp án: A. JVM mới chạy được file cũ. B và D cùng nhầm sàn phiên bản thành điều kiện bằng nhau. C sai vì không có cảnh báo nào: bảng chín cặp ở trên cho thấy --release 8 chạy sạch trên cả Java 8, 11 và 17.
Concept Check 4. Hoa dùng Java 11 và gặp UnsupportedClassVersionError với file release 17. Cách xử lý nào đúng nhất?
- A. Cài lại hệ điều hành của Hoa vì nó không nhận file lạ.
- B. Đổi đuôi
.classthành.javađể Java 11 tự biên dịch lại. - C. Nhờ Minh biên dịch lại với
--release 11, hoặc Hoa nâng JVM lên Java 17. - D. Sửa tay hai byte major trong file thành 55 để qua được bước kiểm tra.
Đáp án: C. Cả hai cách đều làm cho tem phiên bản và JVM khớp nhau. A sai vì lỗi không nằm ở hệ điều hành. B sai vì file .class là bytecode, không phải mã nguồn. D có thể qua bước kiểm tra tem, nhưng bytecode bên trong vẫn có thể dùng tính năng của Java 17 nên lỗi sẽ xuất hiện ở chỗ khó đoán hơn.
Làm cùng mình (Guided Practice)
Bạn sẽ dựng dần class PassportCheck qua năm bước nhỏ: nó đổi major ra số Java, hỏi JVM của bạn giới hạn là bao nhiêu, rồi quyết định một file có chạy được hay không. Gõ vào file PassportCheck.java và chạy bằng java PassportCheck.java. Hỗ trợ giảm dần: bước 1 làm sẵn gần hết, bước 5 chỉ còn đề.
Bước 1: đổi major ra số Java.
public class PassportCheck {
static int javaOf(int major) {
// TODO 1: trả về số Java ứng với major
return 0;
}
public static void main(String[] args) {
System.out.println("step 1: " + javaOf(52) + " " + javaOf(55) + " " + javaOf(61));
}
}
Chạy xong bạn sẽ thấy: step 1: 8 11 17.
Bước 2: hỏi JVM của bạn giới hạn là bao nhiêu. Thêm vào class một hàm và một dòng in:
static int jvmLimit() {
// TODO 2: trả về major cao nhất mà JVM này hiểu
return 0;
}
System.out.println("step 2: " + jvmLimit());
Chạy xong bạn sẽ thấy: step 2: 61 [Đã chạy, JDK 17.0.15; JDK khác cho số khác]. Gợi ý: Runtime.version().feature() trả về số Java của JVM đang chạy, ở đây là 17.
Bước 3: file chạy được không?
static boolean canRun(int fileMajor) {
// TODO 3: true khi fileMajor không lớn hơn giới hạn của JVM
return false;
}
System.out.println("step 3: " + canRun(52) + " " + canRun(65));
Chạy xong bạn sẽ thấy: step 3: true false.
Vẽ ra giấy: kẻ một đường thẳng, ghi các số 52, 55, 61, 65 lên đó rồi đánh dấu giới hạn 61 của JVM. Đối chiếu: mọi số từ 61 trở về bên trái chạy được, 65 nằm bên phải giới hạn nên bị từ chối. Hình này chính là hình chín cặp ở khái niệm 3, chỉ nhìn theo một cột.
Bước 4: lời khuyên bằng chữ. Bạn tự quyết cách viết.
static String advice(int fileMajor) {
// TODO 4: trả về "runs here" nếu chạy được, ngược lại "needs Java N or newer"
return "";
}
System.out.println("step 4: " + advice(65));
Chạy xong bạn sẽ thấy: step 4: needs Java 21 or newer. Gợi ý: dùng canRun cho điều kiện và javaOf cho số N.
Bước 5: đọc major thật. Viết hàm readMajor() trả về major của String.class, bằng cách chép phần đọc tám byte của ClassHeader (nhớ thêm throws IOException cho cả hàm và cho main). Rồi in advice(readMajor()) với nhãn step 5: .
Chạy xong bạn sẽ thấy: step 5: runs here, vì String.class của chính JDK này luôn chạy được trên JVM của nó. Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự dựng một bộ kiểm tra "file này có chạy được trên máy này không" từ ba phép tính đơn giản. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 01-nhap-mon/01-java-la-gi 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)
Ba lỗi dưới đây là những lỗi người mới gặp khi mang file .class sang máy khác hoặc khi chọn --release. Mỗi dòng đã được chạy thật [Đã chạy, JDK 17.0.15 và các JVM Java 8 bản 1.8.0_452 và Java 11 bản 11.0.27 trên cùng máy; thông báo có thể khác chữ ở phiên bản 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 |
|---|---|---|---|
java Greeter bằng Java 11 với file --release 17 | UnsupportedClassVersionError: Greeter has been compiled by a more recent version ... (class file version 61.0) ... up to 55.0 | Major 61 của file lớn hơn giới hạn 55 của JVM | Biên dịch lại với --release 11, hoặc nâng JVM |
java Greeter bằng Java 8 với file --release 11 | UnsupportedClassVersionError: ... (class file version 55.0) ... up to 52.0 | Cùng nguyên nhân, chỉ khác con số | Biên dịch lại với --release 8, hoặc nâng JVM |
javac --release 8 trên mã có List.of(1, 2) | error: cannot find symbol, symbol: method of(int,int) | --release 8 chỉ cho dùng API có ở Java 8, mà List.of bắt đầu từ Java 9 | Dùng API cũ hơn, hoặc nâng --release lên 9 trở lên |
Lỗi thứ ba cho thấy --release làm hai việc: chọn số major của file và chặn các API mới hơn mức bạn chọn. Hình dưới đây gom mô hình sai và mô hình đúng về lỗi đầu tiên.
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" đi từ ô đầu tới "Any JVM on any machine runs it" rồi tới dự đoán sai "Java 11 prints the greeting". Hàng dưới màu lục là mô hình đúng: mũi tên "reality" đi tới "The JVM must know major 61" rồi tới dự đoán đúng "Java 11 throws UnsupportedClassVersionError". Hai hàng bắt đầu từ cùng một file và chỉ khác nhau ở niềm tin về điều kiện chạy.
Bạn vừa làm được: đọc ba thông báo lỗi liên quan tới phiên bản và nói được vì sao mỗi lỗi xảy ra.
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 file release 8, 11, 17 (major 52, 55, 61) và ba JVM Java 8, 11, 17 (giới hạn 52, 55, 61). Kẻ bảng ba hàng ba cột và điền "chạy được" hoặc "bị từ chối" cho từng ô. Kết quả mong đợi: sáu ô chạy được và ba ô bị từ chối. Tiêu chí nghiệm thu: không nhìn lại bảng ở khái niệm 3, và nói được ba ô bị từ chối nằm ở góc nào. Gợi ý: so major của file với giới hạn của JVM, từng ô một.
Bài 2. Đọc tem của một lớp khác. Dữ liệu vào: file ClassHeader.java (đã có sẵn comment tiếng Việt, bạn đọc chúng trước). Sửa nó để đọc ArrayList.class thay cho String.class. Kết quả mong đợi: bốn dòng in ra giống hệt lần chạy với String.class [Đã chạy]. Tiêu chí nghiệm thu: bạn giải thích được vì sao hai lớp của cùng một JDK có cùng major. Gợi ý: ArrayList nằm ở gói java.util, nên viết java.util.ArrayList.class.getResourceAsStream("ArrayList.class").
Bài 3. Hai cách cứu Hoa. Dữ liệu vào: Hoa dùng Java 11 và gặp UnsupportedClassVersionError với file release 17. Viết hai cách xử lý, mỗi cách một câu, rồi nói cách nào hợp khi Hoa không có quyền cài phần mềm lên máy phòng lab. Kết quả mong đợi: hai cách khác nhau, một cách sửa ở phía người gửi và một cách sửa ở phía người chạy. Tiêu chí nghiệm thu: cả hai cách đều làm cho major của file không lớn hơn giới hạn của JVM. Gợi ý: ai là người có thể thay đổi tem, và ai là người có thể thay đổi giới hạn?
Mức Vận dụng
Bài 4. ShipCheck cho gói Greeter. Viết class ShipCheck có hàm static String check(String friend, int fileRelease, int friendJava) trả về một dòng lời khuyên. Nếu fileRelease không lớn hơn friendJava thì kết thúc dòng bằng runs, ngược lại bằng refused, rebuild with --release N với N là friendJava. Dữ liệu vào là ba lần gọi trong main: check("Linh", 17, 17), check("Hoa", 17, 11), check("Nam", 17, 8).
Kết quả mong đợi:
Linh: release 17 on Java 17: runs
Hoa: release 17 on Java 11: refused, rebuild with --release 11
Nam: release 17 on Java 8: refused, rebuild with --release 8
Tiêu chí nghiệm thu: ba dòng khớp từng ký tự, và hàm chỉ có một chỗ so sánh. Gợi ý: ghép phần đầu dòng (tên, release, Java) vào một biến head trước, rồi nối đuôi theo điều kiện.
Mức Thử thách (tuỳ chọn)
Bài 5. Tự dịch và tự đọc. Cần máy có lệnh javac. Dịch Greeter.java bằng javac --release 11 Greeter.java, rồi sửa ClassHeader để đọc file từ đường dẫn truyền qua dòng lệnh (args[0]) thay cho String.class. Kết quả mong đợi: dòng major = 55. Tiêu chí nghiệm thu: đổi --release sang 8 và dịch lại thì dòng đó thành major = 52, và bạn nói được hai dòng này khớp với bảng nào ở khái niệm 2. Gợi ý: thay DataInputStream(String.class.getResourceAsStream(...)) bằng DataInputStream(new FileInputStream(args[0])).
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 1). Tám byte đầu của một file .class gồm những phần nào, mỗi phần dài mấy byte, và số major 52 ứng với Java mấy?
Câu 2 (dự đoán kết quả code, mục tiêu 3). Trên một JVM Java 11, đoạn sau in ra gì?
// Giới hạn major của JVM đang chạy
int limit = Runtime.version().feature() + 44;
System.out.println(limit);
// File major 65 có chạy được không
System.out.println(65 <= limit);
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 3). Minh lần lượt biên dịch Greeter với --release 17, rồi --release 11, rồi --release 8 và gửi từng file cho Nam, người dùng Java 8. Sau mỗi lần, Nam chạy được hay bị từ chối?
Câu 4 (tìm lỗi, mục tiêu 4). Một bạn biên dịch bằng JDK 21 không kèm --release, gửi file cho thầy dùng Java 17 và thầy gặp UnsupportedClassVersionError. Chỉ ra lỗi trong cách làm và nói cách sửa ở phía người gửi.
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 nói: "Java chạy mọi nơi, vậy file nào cũng chạy được trên mọi máy chứ?" Hãy dùng hình ảnh tấm thẻ công thức và đầu bếp để giải thích vì sao câu đó đúng một nửa.
Điều cần nhớ
- File
.classmở đầu bằng magicCAFEBABE, minor, rồi major, và số Java bằng major trừ 44 (mục tiêu 1). - Bytecode giống nhau trên mọi hệ điều hành, nên một file chạy được ở nhiều nơi, nhưng JVM kiểm tra tem phiên bản trước khi chạy (mục tiêu 2).
- JVM chạy file khi major của file không lớn hơn giới hạn của nó: JVM mới chạy file cũ, JVM cũ từ chối file mới (mục tiêu 3).
- Gửi chương trình cho người khác thì biên dịch bằng
--releasethấp nhất mà họ cần (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cách làm | Kết quả |
|---|---|---|
| Biết file cần Java mấy | Đọc major ở byte 6 đến 7, trừ 44 | Số Java tối thiểu |
| Biết JVM hiểu tới đâu | Runtime.version().feature() + 44 | Giới hạn major |
| File có chạy được không | So major của file với giới hạn | Chạy khi không lớn hơn |
| Gửi cho người dùng Java cũ | javac --release N | Major thấp, API bị giới hạn |
Gặp UnsupportedClassVersionError | Dịch lại với --release thấp hơn, hoặc nâng JVM | Tem và JVM khớp nhau |
Cách đọc hình: hàng trên là bốn ô vàng ghi cặp Java và major: 8 là 52, 11 là 55, 17 là 61, 21 là 65. Hàng dưới là ba bước từ trái sang phải: Convert (Java bằng major trừ 44), Decide (chạy khi major của file không lớn hơn giới hạn JVM), Fix a refusal (nâng JVM hoặc dịch lại với --release thấp hơn).
Câu hỏi để dành cho ngày mai: máy của mình chỉ có runtime nhưng vẫn chạy được file .class, còn lệnh javac thì không có. Vậy "có Java" nghĩa là có những gì, và JDK, JRE, JVM khác nhau thế nào?
Đà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. JVM kiểm tra tem ngay khi nạp lớp, trước khi chạy dòng code nào của lớp đó. Dấu vết lỗi của Java 8 cho thấy điều này: dòng đầu tiên của stack là java.lang.ClassLoader.defineClass1, tức bước nạp lớp, không phải một dòng trong main [Đã chạy, Java 8 bản 1.8.0_452]. Vì vậy khi thấy UnsupportedClassVersionError, đừng tìm lỗi trong thân phương thức: chương trình chưa hề chạy.
So với lựa chọn khác. Ngôn ngữ biên dịch thẳng ra mã máy cần một bản build riêng cho mỗi cặp hệ điều hành và loại CPU. Java đổi cách chia việc: bạn gửi một file bytecode, còn việc làm việc với từng hệ điều hành thuộc về JVM của máy đó. Cái giá phải trả là máy nào cũng phải có JVM đủ mới, đúng như bạn vừa thấy.
Hiểu lầm nâng cao. Nhiều người nghĩ --release 8 chỉ là đổi số major. Thật ra nó còn chặn API: ở bảng lỗi bạn đã thấy List.of bị từ chối vì chưa có ở Java 8. Theo tài liệu của javac, cặp tùy chọn cũ -source và -target không có khả năng chặn API này, nên nên dùng --release (xem JEP 247 ở phần đọc thêm).
Giới hạn của bài. Bài này chưa nói JDK, JRE và JVM khác nhau thế nào (bài sau), chưa nói JVM làm gì để chạy nhanh, và chưa nói cách cài JDK. Phần hệ điều hành cũng chưa đo hết: chương trình OsFacts.java trong kho GitHub in vài thuộc tính lấy từ hệ điều hành, ví dụ dấu phân cách đường dẫn. Trên máy mình nó in Linux, : và / [Đã chạy, Linux, JDK 17.0.15], còn máy bạn có thể in khác [Có thể khác nhau].
Đọc thêm (tài liệu chính thống):
- The Java Virtual Machine Specification, Java SE 17: The class File Format
- JEP 247: Compile for Older Platform Versions
- Tài liệu lệnh javac, JDK 17
- The Java Tutorials: What Is the Java Virtual Machine?
Bài tiếp theo
Bài sau mở rộng chính hình bạn vừa xem: JDK, JRE và JVM khác nhau thế nào, vì sao máy có runtime vẫn chạy được file .class mà không có lệnh javac, và JVM làm gì để chương trình không chậm như một trình thông dịch.
Mini-dự án nối tiếp: ở bài sau, PassportCheck sẽ thêm một việc: in luôn thư mục cài Java của máy và cho biết máy này có lệnh javac hay không. Hãy giữ lại PassportCheck.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 năm dòng step ở trên [Đã chạy, JDK 17.0.15]:
// Bước 1: đổi major ra số Java
return major - 44;
// Bước 2: số Java của JVM cộng 44 là giới hạn major
return Runtime.version().feature() + 44;
// Bước 3: phép so sánh đã là câu trả lời
return fileMajor <= jvmLimit();
// Bước 4: chọn lời khuyên theo điều kiện
if (canRun(fileMajor)) {
return "runs here";
}
return "needs Java " + javaOf(fileMajor) + " or newer";
// Bước 5: đọc tám byte đầu, chỉ giữ lại major
static int readMajor() throws IOException {
try (DataInputStream in = new DataInputStream(String.class.getResourceAsStream("String.class"))) {
// Đọc và bỏ qua magic và minor để tới đúng vị trí của major
in.readInt();
in.readUnsignedShort();
return in.readUnsignedShort();
}
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
return major - 44; | Trừ 44 khỏi major rồi trả về | Đây là quy tắc đổi của khái niệm 2 |
Runtime.version().feature() + 44 | Hỏi JVM đang chạy là Java mấy rồi cộng 44 | Đây là phép đổi ngược: từ số Java ra giới hạn major |
return fileMajor <= jvmLimit(); | So sánh và trả về true hoặc false | Điều kiện chạy chính là phép so sánh này, không cần if |
if (canRun(fileMajor)) { ... } | Chọn câu trả lời theo điều kiện | Chạy được thì nói chạy được, ngược lại nói cần Java nào |
in.readInt(); và in.readUnsignedShort(); đứng một mình | Đọc và bỏ qua magic và minor | Phải đọc cho đủ tám byte để tới đúng chỗ của major, dù không cần giá trị |
return in.readUnsignedShort(); | Đọc hai byte kế và trả về làm major | Đây là vị trí byte 6 đến 7 |
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật của chín cặp]
| File biên dịch với | Java 8 (giới hạn 52) | Java 11 (giới hạn 55) | Java 17 (giới hạn 61) |
|---|---|---|---|
--release 8 (52) | chạy được | chạy được | chạy được |
--release 11 (55) | bị từ chối | chạy được | chạy được |
--release 17 (61) | bị từ chối | bị từ chối | chạy được |
Ba ô bị từ chối nằm ở nửa dưới bên trái: file mới trên JVM cũ.
Lời giải Bài 2. [Đã chạy, in ra CAFEBABE, 0, 61, 17]. Chỉ cần đổi một dòng:
// ArrayList nằm ở gói java.util nên phải ghi đủ tên gói
try (DataInputStream in = new DataInputStream(java.util.ArrayList.class.getResourceAsStream("ArrayList.class"))) {
Hai lớp cùng nằm trong một JDK và được dịch cùng một đợt với cùng một --release, nên major giống nhau.
Lời giải Bài 3. Cách một, sửa ở phía người gửi: Minh biên dịch lại với --release 11 để major thành 55, vừa với giới hạn của Hoa. Cách hai, sửa ở phía người chạy: Hoa nâng JVM lên Java 17 để giới hạn thành 61. Khi Hoa không có quyền cài phần mềm, chỉ cách một dùng được, vì nó không đòi hỏi gì ở máy phòng lab. [Suy luận]
Lời giải Bài 4. [Đã chạy, ba dòng khớp với kết quả mong đợi]
static String check(String friend, int fileRelease, int friendJava) {
// Phần đầu dòng dùng chung cho cả hai nhánh
String head = friend + ": release " + fileRelease + " on Java " + friendJava + ": ";
// File chạy được khi release của file không lớn hơn Java của người nhận
if (fileRelease <= friendJava) {
return head + "runs";
}
return head + "refused, rebuild with --release " + friendJava;
}
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
String head = friend + ": release " + ... | Nối tên và hai con số thành phần đầu dòng | Cả hai nhánh dùng chung phần đầu, nên viết một lần |
if (fileRelease <= friendJava) | So sánh số release với số Java của bạn | So số Java cũng đủ, vì major chỉ là số Java cộng 44 ở cả hai vế |
return head + "runs"; | Trả về dòng hoàn chỉnh cho nhánh chạy được | Kết thúc sớm, không cần else |
return head + "refused, rebuild with --release " + friendJava; | Trả về lời khuyên dùng đúng số Java của bạn | Dịch với đúng --release của người nhận là mức thấp nhất đủ dùng |
Gợi ý lời giải Bài 5. Sửa ClassHeader thành đọc file từ đường dẫn truyền vào [Đã chạy, major 55 với --release 11 và major 52 với --release 8]:
// Đọc file .class có đường dẫn truyền qua dòng lệnh
try (DataInputStream in = new DataInputStream(new FileInputStream(args[0]))) {
// Bỏ qua magic và minor
in.readInt();
in.readUnsignedShort();
System.out.println("major = " + in.readUnsignedShort());
}
Nhớ thêm import java.io.FileInputStream;. Chạy java FileHeader.java Greeter.class với file vừa dịch. Hai số 55 và 52 khớp với hàng Java 11 và Java 8 trong bảng major ở khái niệm 2.
Đáp án Tự kiểm tra.
- Bốn byte magic (
CA FE BA BE), hai byte minor, hai byte major, tổng cộng tám byte. Major 52 ứng với Java 8, vì 52 trừ 44 bằng 8. Kiểm tra mục tiêu 1. - In ra
55rồifalse[Suy luận]. Java 11 chofeature()bằng 11, cộng 44 bằng 55, và 65 lớn hơn 55 nên phép so sánh sai. Kiểm tra mục tiêu 3. - Release 17 bị từ chối (61 lớn hơn 52), release 11 bị từ chối (55 lớn hơn 52), release 8 chạy được (52 không lớn hơn 52) [Suy luận, khớp với kết quả đã chạy của chín cặp]. Kiểm tra mục tiêu 3.
- Lỗi là không đặt
--release: JDK 21 mặc định tạo bytecode cho Java 21 (major 65), mà thầy chỉ biết tới 61. Sửa ở phía người gửi: biên dịch với--release 17. Kiểm tra mục tiêu 4. - Tấm thẻ công thức giống nhau ở mọi bếp, nên ở bếp Windows hay bếp Linux đều đọc được. Nhưng thẻ có tem phiên bản, và đầu bếp cũ không đọc được thẻ viết bằng ký hiệu mới. Vì vậy "chạy mọi nơi" đúng với hệ điều hành, còn với phiên bản Java thì máy phải đủ mới. Kiểm tra mục tiêu 2.