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 .class bằ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 .class chạ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 --release lúc biên dịch.
  • Chọn được --release hợ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.

Hình: tám byte đầu của file .class và cách đọc ra số Java

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 JavaSố majorCách tính
Java 8528 cộng 44
Java 115511 cộng 44
Java 176117 cộng 44
Java 216521 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ớiChạy trên Java 8Chạy trên Java 11Chạy trên Java 17
--release 8 (major 52)chạy đượcchạy đượcchạy được
--release 11 (major 55)bị từ chốichạy đượcchạy được
--release 17 (major 61)bị từ chốibị từ chốichạ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]:

text
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

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]:

text
magic = CAFEBABE
minor = 0
major = 61
java  = 17

Đọc code từng dòng:

Dòng codeMá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.ioHai lớp này không có sẵn tên ngắn, phải khai báo ở đầu file
throws IOException ở dòng mainCho 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 JDKDù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 xongDataInputStream 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ênMagic dài đúng 4 byte
in.readUnsignedShort() hai lầnMỗi lần đọc 2 byte thành số không âm, lần đầu là minor, lần hai là majorMinor 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 hoaNhìn CAFEBABE dễ nhận ra hơn số thập phân
major - 44Trừ 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ạySố byte đã đọcGiá trị đọc đượcGhi chú
mở DataInputStream0chưa cóChưa đọc gì
int magic = in.readInt()4CA FE BA BESố nhận dạng file bytecode
int minor = in.readUnsignedShort()600 00 bằng 0Phiên bản phụ
int major = in.readUnsignedShort()800 3D bằng 61Phiên bản chính
các dòng println8magic, minor, major, javajava = 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 .class thà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.

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:

java
    static int jvmLimit() {
        // TODO 2: trả về major cao nhất mà JVM này hiểu
        return 0;
    }
java
        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?

java
    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;
    }
java
        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.

java
    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 "";
    }
java
        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ỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
java Greeter bằng Java 11 với file --release 17UnsupportedClassVersionError: Greeter has been compiled by a more recent version ... (class file version 61.0) ... up to 55.0Major 61 của file lớn hơn giới hạn 55 của JVMBiên dịch lại với --release 11, hoặc nâng JVM
java Greeter bằng Java 8 với file --release 11UnsupportedClassVersionError: ... (class file version 55.0) ... up to 52.0Cù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 9Dù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:

text
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ì?

java
// 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 .class mở đầu bằng magic CAFEBABE, 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 --release thấp nhất mà họ cần (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCách làmKết quả
Biết file cần Java mấyĐọc major ở byte 6 đến 7, trừ 44Số Java tối thiểu
Biết JVM hiểu tới đâuRuntime.version().feature() + 44Giới hạn major
File có chạy được khôngSo major của file với giới hạnChạy khi không lớn hơn
Gửi cho người dùng Java cũjavac --release NMajor thấp, API bị giới hạn
Gặp UnsupportedClassVersionErrorDịch lại với --release thấp hơn, hoặc nâng JVMTem 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):

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]:

java
// 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 codeMá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() + 44Hỏ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ệnChạ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à minorPhả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ớiJava 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 đượcchạy đượcchạy được
--release 11 (55)bị từ chốichạy đượcchạy được
--release 17 (61)bị từ chốibị từ chốichạ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:

java
// 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]

java
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 codeMá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òngCả 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ạnSo 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 đượcKế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ạnDị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]:

java
// Đọ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.

  1. 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.
  2. In ra 55 rồi false [Suy luận]. Java 11 cho feature() 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.
  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.
  4. 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.
  5. 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.