Đây là bài khép lại chặng nền tảng, và nó nói về thứ mà khoá học nào cũng lướt qua trong ba phút: gói và classpath.
Tôi cho rằng ba phút đó là quá ít. Vì hai lỗi phổ biến nhất khi triển khai ứng dụng Java lên máy chủ đều bắt nguồn từ đây, và chúng có tên gần giống hệt nhau: ClassNotFoundException và NoClassDefFoundError. Nhiều người viết Java nhiều năm vẫn dùng lẫn lộn hai cái tên này.
Cuối bài bạn sẽ phân biệt được, bằng một phép thử tự chạy được trong hai phút.
Gói: không gian tên, không phải thư mục
package com.vidu;
public class Chao { ... }
Câu package phải là dòng lệnh đầu tiên của file, trước cả import.
Tên gói theo quy ước là tên miền viết ngược: công ty sở hữu vidu.com thì gói bắt đầu bằng com.vidu. Quy ước này có lý do thực dụng — tên miền là duy nhất toàn cầu, nên gói cũng duy nhất, và hai thư viện khác nhau không đụng tên.
Vài luật cần nhớ:
Tên gói viết thường hết, không gạch dưới, không gạch nối. com.vidu.donhang, không phải com.vidu.donHang hay com.vidu.don_hang.
Tên gói phải khớp cây thư mục. Lớp trong com.vidu bắt buộc nằm ở com/vidu/Chao.java.
Không khai package thì lớp rơi vào gói mặc định. Chạy được với file lẻ, nhưng lớp trong gói mặc định không import được từ nơi khác — nên mọi dự án thật đều phải có gói.
Biên dịch cả cây với -d:
$ javac -d out $(find src -name "*.java")
$ find out -type f
out/com/vidu/Chao.class
out/com/vidu/TroThu.class
javac tự dựng thư mục theo tên gói. Đây là lý do dự án Java nào cũng có cây thư mục sâu — không phải để cho đẹp, mà vì trình biên dịch và JVM đều tìm lớp theo đúng cấu trúc đó.
Classpath: nơi JVM đi tìm lớp
Chạy đúng cách:
$ java -cp out com.vidu.Chao
Chào từ com.vidu.Chao
Gói: com.vidu
Hai chi tiết:
-cp out bảo JVM: gốc để tìm lớp là thư mục out.
com.vidu.Chao là tên đầy đủ của lớp, không phải đường dẫn file. Không có .class, không có dấu gạch chéo.
Bỏ -cp đi thì sao?
$ java com.vidu.Chao
Error: Could not find or load main class com.vidu.Chao
Caused by: java.lang.ClassNotFoundException: com.vidu.Chao
Không có -cp, classpath mặc định là thư mục hiện tại. JVM đi tìm ./com/vidu/Chao.class, không thấy, và báo lỗi.
Classpath nhận nhiều mục, ngăn bởi dấu hai chấm trên Linux/macOS và dấu chấm phẩy trên Windows:
java -cp out:thuvien/gson.jar:cauhinh com.vidu.Chao
Thứ tự có ý nghĩa: JVM lấy lớp ở mục đầu tiên tìm thấy. Đây là nguồn gốc của loại lỗi khó chịu nhất khi hai thư viện cùng chứa một lớp trùng tên — chương trình chạy với bản nào là tuỳ vào thứ tự classpath. Ta sẽ gặp lại chuyện này ở phần nói về xung đột phụ thuộc trong Maven.
Hai lỗi nghe giống nhau
Giờ tới phần chính. Tôi cho gọi Class.forName với một lớp không tồn tại, đồng thời gọi một lớp TroThu có thật:
try {
Class.forName("com.vidu.KhongTonTai");
} catch (ClassNotFoundException e) {
System.out.println("ClassNotFoundException: " + e.getMessage());
}
System.out.println("Trợ thủ nói: " + TroThu.noi());
Chạy bình thường:
ClassNotFoundException: com.vidu.KhongTonTai
Trợ thủ nói: tôi có mặt
Giờ tôi xoá TroThu.class đi rồi chạy lại, không biên dịch lại:
Chào từ com.vidu.Chao
Gói: com.vidu
ClassNotFoundException: com.vidu.KhongTonTai
Exception in thread "main" java.lang.NoClassDefFoundError: com/vidu/TroThu
Hai lỗi khác nhau, và khác biệt nằm ở chỗ ai đi tìm lớp:
ClassNotFoundException |
NoClassDefFoundError |
|
|---|---|---|
| Loại | Exception — bắt được |
Error — không nên bắt |
| Ai gây ra | Mã của bạn tìm lớp theo tên lúc chạy | JVM tìm lớp mà mã đã tham chiếu lúc biên dịch |
| Nguyên nhân điển hình | Tên lớp gõ sai trong chuỗi, driver JDBC không có trong classpath | Lúc biên dịch có lớp đó, lúc chạy thì không |
| Ý nghĩa | "Bạn hỏi một lớp không có" | "Lớp này lẽ ra phải có mà biến mất" |
ClassNotFoundException xuất hiện khi bạn gọi Class.forName(), ClassLoader.loadClass(), hoặc dùng phản chiếu. Bạn đưa vào một chuỗi, và chuỗi đó không khớp lớp nào. Đây là lỗi kiểm tra được, nên Java bắt bạn xử lý.
NoClassDefFoundError thì khác hẳn về bản chất. Mã của tôi gọi TroThu.noi() — lúc biên dịch lớp đó có mặt, javac thấy nó, kiểm tra kiểu xong xuôi. Tới lúc chạy JVM mới phát hiện file .class biến mất. Đó là môi trường chạy không khớp môi trường biên dịch — thứ đáng lẽ không bao giờ được xảy ra, nên Java xếp nó vào Error chứ không phải Exception.
Trong thực tế, NoClassDefFoundError gần như luôn có nghĩa: jar bị thiếu khi đóng gói, hai phiên bản thư viện xung đột, hoặc bản build trên máy khác bản triển khai. Thấy nó thì đừng đi sửa mã — đi kiểm tra xem cái gì có mặt trong classpath lúc chạy.
ExceptionInInitializerError, còn mọi lần sau báo NoClassDefFoundError — vì JVM đã đánh dấu lớp đó hỏng. Gặp NoClassDefFoundError mà chắc chắn lớp có trong classpath thì hãy tìm ngược lên log xem có ExceptionInInitializerError ở phía trên không.
Đóng gói thành jar
Một file jar thực chất là file zip có thêm META-INF/MANIFEST.MF:
$ jar --create --file chao.jar --main-class com.vidu.Chao -C out .
$ java -jar chao.jar
Chào từ com.vidu.Chao
$ jar --list --file chao.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/vidu/
com/vidu/Chao.class
com/vidu/TroThu.class
--main-class ghi lớp khởi động vào manifest, nhờ đó java -jar biết chạy từ đâu.
Một điểm rất hay bị nhầm: khi dùng java -jar, tuỳ chọn -cp bị bỏ qua hoàn toàn. Classpath lúc đó lấy từ mục Class-Path trong manifest. Muốn thêm thư viện thì hoặc khai trong manifest, hoặc gộp tất cả vào một jar duy nhất — cách mà Spring Boot và maven-shade-plugin vẫn làm.
import: chỉ là viết tắt
import java.util.List; // một lớp cụ thể
import java.util.*; // cả gói — không đệ quy xuống gói con
import static java.lang.Math.max; // import tĩnh: dùng max(...) thay vì Math.max(...)
Điều đáng biết: import không làm chương trình nặng thêm hay chậm đi. Nó thuần tuý là cách nói với trình biên dịch rằng "khi tôi viết List, ý tôi là java.util.List". Sau khi biên dịch, trong file .class chỉ còn tên đầy đủ.
Nên tranh cãi "import cả gói bằng dấu sao có làm nặng không" là không có cơ sở. Lý do nên tránh dấu sao là rõ ràng, không phải hiệu năng: java.util.List và java.awt.List cùng tên, import cả hai gói bằng dấu sao là trình biên dịch bó tay.
java.lang được import sẵn, nên String, Integer, Math dùng thẳng không cần khai.
Về import tĩnh, tôi theo một quy tắc: chỉ dùng khi tên đủ tự giải thích ở nơi sử dụng. Trong test thì rất hợp — assertEquals(...) đọc gọn hơn Assertions.assertEquals(...) nhiều. Nhưng import static com.vidu.Config.* rồi rải TIMEOUT khắp nơi thì người đọc phải đi tìm xem hằng số đó từ đâu ra.
Classpath và module-path
Từ Java 9, ngoài -cp còn có --module-path. Khác biệt gọn thế này:
Classpath là một cái túi phẳng. Mọi lớp public trong đó đều dùng được từ bất cứ đâu, không có ranh giới. Trùng lớp thì lấy cái đầu tiên tìm thấy, im lặng.
Module-path đòi mỗi jar khai một module-info.java nói rõ nó xuất gói nào ra ngoài và cần module nào. Lớp public trong gói không được xuất thì bên ngoài không đụng tới được, kể cả bằng phản chiếu.
Module giải quyết đúng hai vấn đề: đóng gói thật sự ở mức thư viện, và biết được cây phụ thuộc lúc khởi động thay vì lúc chạy mới vỡ.
Thực tế năm 2026 thì phần lớn ứng dụng nghiệp vụ vẫn chạy trên classpath, còn module chủ yếu dùng trong chính JDK và khi cần jlink để cắt runtime nhỏ lại — việc mà ta sẽ làm ở phần đóng gói Docker. Biết là có, hiểu vì sao có, và đừng thấy module-info.java mà hoảng.
Cấu trúc thư mục quy ước
Mọi dự án Maven và Gradle đều theo bố cục này, và bạn nên theo kể cả khi không dùng build tool:
du-an/
├── src/main/java/com/vidu/... mã nguồn
├── src/main/resources/ file cấu hình, không phải mã
├── src/test/java/com/vidu/... mã kiểm thử
└── target/ (hoặc build/) kết quả biên dịch
Cái hay của quy ước là bất kỳ ai mở dự án của bạn ra cũng biết ngay chỗ nào chứa gì, không cần hỏi. Đây cũng là lý do phần tiếp theo về Maven sẽ thấy nhẹ nhàng — nó chỉ đang tự động hoá đúng bố cục này.
Hết chặng nền tảng
Mười bài đầu đã đi qua: hệ sinh thái, cài đặt, bytecode, kiểu dữ liệu, toán tử, điều kiện, chuỗi, mảng, phương thức, và tổ chức mã. Đủ để đọc hiểu và viết được một chương trình Java hoàn chỉnh.
Từ ngày mai ta vào chặng hướng đối tượng — mười bốn bài, bắt đầu từ lớp và đối tượng, đi qua kế thừa và đa hình, tới record, sealed và pattern matching của Java hiện đại. Đây là chặng quyết định mã của bạn dễ sửa hay khó sửa sau sáu tháng.