Hình dung hệ thống bưu chính. Tên gói com.vidu.donhang là địa chỉ đầy đủ đảo ngược — quốc gia, thành phố, rồi mới tới số nhà — nên không hai ngôi nhà nào trên thế giới trùng địa chỉ. Classpath là danh sách khu phố mà người đưa thư sẽ lục tìm. Và hai lỗi hay bị lẫn của bài này chính là hai kiểu thất bại của người đưa thư: ClassNotFoundException là bạn đưa một địa chỉ không có thật để tìm; NoClassDefFoundError là ngôi nhà có trên bản vẽ lúc xây nhưng đã bị dỡ mất trước ngày dọn vào. Cùng là "không tới được nhà", nhưng nguyên nhân trái ngược nhau — và lẫn hai cái là đi sửa nhầm chỗ.

Đâ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.

Còn một biến thể gây bối rối: nếu một lớp ném ngoại lệ trong khối khởi tạo tĩnh, lần nạp đầu tiên báo 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.

Muốn tự tay phân biệt hai lỗi ở trên thì thử trong ba mươi giây: chạy java -cp out com.vidu.Chao (chạy được), rồi java com.vidu.Chao (bỏ -cp, JVM chỉ tìm trong thư mục hiện tại nên báo ClassNotFoundException — lớp không tìm thấy ở đâu cả). Còn NoClassDefFoundError thì khác hẳn: lớp có đó nhưng lần khởi tạo đầu tiên đã hỏng — gặp nó thì đừng đi tìm jar thiếu, hãy đọc dòng Caused by phía trên, nguyên nhân thật nằm ở đó.

Mẫu số chung

Ba thứ của bài này — không gian tên (gói), đường tìm mã (classpath), và hai kiểu "không tìm thấy lớp" — là bài toán mà ngôn ngữ nào cũng phải giải, chỉ khác cách đặt tên.

  • Python gần nhất: gói là thư mục, import gần y hệt, và sys.path chính là classpath — danh sách thư mục trình thông dịch lục tìm. Cặp lỗi cũng có: ModuleNotFoundError (như ClassNotFoundException — xin một module không có) so với ImportError khi module có mà thứ bên trong hỏng.
  • Node.js biến "đường tìm" thành thuật toán đi ngược cây node_modules, và Cannot find module là lỗi quốc dân — thường vì quên npm install, đúng kiểu "có lúc build, thiếu lúc chạy" của NoClassDefFoundError.
  • Go rẽ hướng an toàn hơn: import theo đường dẫn repo (github.com/...) nên tên toàn cầu duy nhất giống quy ước tên miền ngược của Java, và vì Go liên kết tĩnh thành một file nhị phân, gần như không có chuyện "lớp biến mất lúc chạy" — cả cây phụ thuộc bị đóng băng vào lúc build.
  • C# có namespace cho không gian tên và assembly (file .dll) cho đơn vị nạp, với TypeLoadException đóng vai NoClassDefFoundError. Rust gói mọi thứ vào crate và giải phụ thuộc lúc biên dịch, nên cũng thuộc nhóm "đóng băng lúc build" như Go.

Sợi chỉ chung đáng mang theo có hai vế. Một: tên duy nhất toàn cầu là bài toán thật, và cách giải luôn là mượn một không gian tên đã duy nhất sẵn — tên miền ngược (Java), đường dẫn repo (Go), tên trên registry (npm). Hai, và quan trọng hơn: có hai kiểu "không tìm thấy" hoàn toàn khác nhau — xin một cái tên không tồn tại (bug trong mã, sửa mã) và thứ có lúc build nhưng thiếu lúc chạy (bug môi trường, đừng đụng mã mà đi soát cái gì thực sự có mặt lúc chạy). Ngôn ngữ liên kết tĩnh như Go, Rust gần như xoá được vế thứ hai; ngôn ngữ nạp động như Java, Python, Node thì sống chung với nó — và biết mình đang gặp vế nào là nửa đường tới lời giải.

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.