Có một câu hỏi phỏng vấn Java kinh điển đến mức gần như thành trò đùa. Cho đoạn mã này:
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);
System.out.println(c == d);
Nó in ra gì?
127 == 127 : true
128 == 128 : false
Người mới thấy vô lý. Người đã biết thì trả lời gọn "vì Integer cache". Nhưng câu trả lời gọn đó không giúp bạn tránh được lỗi thật, vì lỗi thật không bao giờ hiện ra dưới dạng hai dòng rõ ràng như trên — nó nấp trong một điều kiện if giữa một hàm hai trăm dòng, và chạy đúng suốt sáu tháng cho tới ngày dữ liệu vượt qua 127.
Nên hôm nay ta đi từ gốc.
Java có đúng tám kiểu nguyên thuỷ
Kiểu nguyên thuỷ không phải đối tượng. Nó không có phương thức, không kế thừa Object, và biến giữ thẳng giá trị chứ không giữ tham chiếu tới chỗ khác.
| Kiểu | Kích thước | Khoảng giá trị | Mặc định |
|---|---|---|---|
byte |
8 bit | −128 .. 127 | 0 |
short |
16 bit | −32.768 .. 32.767 | 0 |
int |
32 bit | −2.147.483.648 .. 2.147.483.647 | 0 |
long |
64 bit | ±9,22 tỷ tỷ | 0L |
float |
32 bit | ~7 chữ số có nghĩa | 0.0f |
double |
64 bit | ~15 chữ số có nghĩa | 0.0d |
char |
16 bit | một đơn vị mã UTF-16 | '\u0000' |
boolean |
(không quy định) | true / false |
false |
Kiểm chứng bằng chính thư viện chuẩn thay vì tin bảng:
byte : -128 .. 127
int : -2147483648 .. 2147483647
long : -9223372036854775808 .. 9223372036854775807
Ba điều đáng nhớ trong bảng trên.
char không phải một ký tự. Nó là một đơn vị mã UTF-16. Chữ cái tiếng Việt có dấu vẫn vừa một char, nhưng emoji thì cần hai — nên "👍".length() trả về 2, chứ không phải 1. Đây là nguồn gốc của kha khá lỗi cắt chuỗi.
boolean không được đặc tả quy định kích thước. JVM thường dùng cả một byte, đôi khi cả một word, vì CPU truy cập theo byte nhanh hơn theo bit.
Giá trị mặc định chỉ áp dụng cho trường của lớp. Biến cục bộ trong một phương thức không có giá trị mặc định — dùng khi chưa gán là javac báo lỗi ngay, và đó là một trong những thứ tử tế nhất mà trình biên dịch làm cho bạn.
Kiểu tham chiếu: biến giữ địa chỉ
Mọi thứ không nằm trong tám dòng trên đều là kiểu tham chiếu: String, mảng, lớp bạn tự viết, và cả Integer với Double.
Khác biệt cốt lõi nằm ở chỗ biến giữ cái gì:
int x = 5; // ô nhớ của x chứa số 5
int y = x; // sao chép số 5 sang ô nhớ của y
int[] a = {1, 2, 3}; // ô nhớ của a chứa ĐỊA CHỈ của mảng
int[] b = a; // sao chép địa chỉ — hai biến trỏ cùng một mảng
b[0] = 99;
System.out.println(a[0]); // in ra 99
Đây là gốc rễ của rất nhiều lỗi "tôi có sửa gì đâu mà dữ liệu đổi". Khi truyền một List vào phương thức và phương thức đó gọi add, danh sách bên ngoài cũng đổi theo, vì cả hai đang nhìn vào cùng một đối tượng.
Nói cho chính xác: Java luôn truyền tham số theo giá trị. Chỉ là với kiểu tham chiếu thì cái "giá trị" được sao chép chính là địa chỉ. Nên gán lại biến bên trong phương thức không ảnh hưởng gì tới bên ngoài, còn sửa nội dung đối tượng thì có. Ta sẽ mổ kỹ chuyện này ở phần nói về phương thức và tham số.
Đóng hộp và mở hộp
Mỗi kiểu nguyên thuỷ có một lớp bọc: int → Integer, double → Double, char → Character.
Cần lớp bọc vì generics không nhận kiểu nguyên thuỷ. Không có List<int>, chỉ có List<Integer>.
Từ Java 5, trình biên dịch tự chuyển qua lại:
List<Integer> ds = new ArrayList<>();
ds.add(5); // đóng hộp: 5 → Integer.valueOf(5)
int x = ds.get(0); // mở hộp: Integer → intValue()
Tiện, nhưng che mất hai thứ đáng lẽ bạn phải thấy.
Thứ nhất: chi phí. Mỗi lần đóng hộp là một đối tượng trên heap. Vòng lặp cộng dồn mà lỡ khai Long thay vì long sẽ tạo ra hàng triệu đối tượng rác — một lỗi hiệu năng kinh điển mà nhìn mã thì không thấy gì bất thường:
// Chậm hơn nhiều so với vẻ ngoài của nó
Long tong = 0L;
for (long i = 0; i < 10_000_000; i++) {
tong += i; // mở hộp, cộng, đóng hộp lại — mỗi vòng một đối tượng mới
}
Thứ hai: null. Kiểu nguyên thuỷ không bao giờ null, lớp bọc thì có. Và khi mở hộp một giá trị null:
NPE khi mở hộp null: Cannot invoke "java.lang.Integer.intValue()" because "<local6>" is null
Chú ý dòng mã sinh ra lỗi này chỉ là int x = khong; — không hề có dấu chấm nào, không hề gọi phương thức nào mà bạn nhìn thấy. Trình biên dịch chèn .intValue() vào giúp bạn, và cái NPE đến từ chỗ đó. Đây là kiểu lỗi làm người mới bối rối lâu nhất.
NullPointerException trống trơn. Riêng cải tiến này đã đủ là lý do để nâng phiên bản.
Quay lại chuyện 127 và 128
Giờ thì giải thích được rồi.
Integer.valueOf() giữ sẵn một bộ nhớ đệm cho các giá trị từ −128 đến 127. Trong khoảng đó, gọi bao nhiêu lần cũng trả về cùng một đối tượng. Ngoài khoảng đó, mỗi lần gọi tạo một đối tượng mới.
Integer a = 127, b = 127; // cùng một đối tượng trong cache
Integer c = 128, d = 128; // hai đối tượng khác nhau
Toán tử == trên kiểu tham chiếu so sánh địa chỉ, không so sánh giá trị. Nên a == b là true, còn c == d là false. Trong khi đó:
128.equals : true
Vì sao lại có cái cache này? Vì các số nhỏ xuất hiện quá thường xuyên — chỉ số vòng lặp, mã trạng thái, số lượng — nên giữ sẵn tiết kiệm được rất nhiều lần cấp phát. Byte, Short, Long, Character cũng có cache tương tự. Boolean thì luôn dùng lại TRUE và FALSE.
Quy tắc rút ra, và tôi nghĩ đây là điều quan trọng nhất trong cả bài:
== để so sánh hai lớp bọc. Luôn dùng equals, hoặc mở hộp về kiểu nguyên thuỷ rồi mới so sánh. Cái bẫy ở đây là mã sai vẫn chạy đúng với số nhỏ, nên nó lọt qua mọi vòng kiểm thử rồi mới hỏng trên sản xuất.
Một biến thể ác hơn, hay gặp trong mã thật:
Integer soLuong = donHang.getSoLuong(); // có thể null
if (soLuong == 0) { ... } // NPE nếu null
Ở đây một vế là lớp bọc, một vế là nguyên thuỷ, nên Java mở hộp vế trái để so sánh số — và nổ nếu nó null. Viết Integer.valueOf(0).equals(soLuong) thì an toàn, hoặc kiểm tra null trước cho rõ ràng.
var: đừng nhầm với kiểu động
Java 10 thêm var:
var ten = "Minh"; // String
var tuoi = 30; // int
var ds = new ArrayList<String>(); // ArrayList<String>
var không biến Java thành ngôn ngữ kiểu động. Kiểu vẫn được chốt lúc biên dịch, chỉ là bạn không phải gõ ra. ten = 5; vẫn lỗi biên dịch như thường.
Nó dùng tốt nhất khi vế phải đã nói rõ kiểu:
var khach = new HashMap<String, List<DonHang>>(); // gọn, vẫn rõ
Và dùng dở khi vế phải chẳng nói lên điều gì:
var kq = xuLy(dulieu); // kq là cái gì? phải đi mở hàm xuLy ra mới biết
Tôi theo một quy tắc đơn giản: nếu người đọc phải nhảy sang chỗ khác mới biết kiểu là gì, thì viết kiểu ra. var để tiết kiệm chữ, không phải để giấu thông tin.
Ba thứ đáng thử ngay
Mở jshell lên và tự tay kiểm ba điều sau, mỗi cái mất ba mươi giây nhưng đọng lại lâu hơn cả bài viết:
Gõ Integer.valueOf(127) == Integer.valueOf(127) rồi đổi 127 thành 128, xem kết quả lật.
Gõ "👍".length() và đoán trước khi Enter.
Khai List<Integer> ds = new ArrayList<>(); ds.add(1); ds.remove(1); rồi xem ds còn gì. Gợi ý: có remove(int) xoá theo chỉ số và remove(Object) xoá theo giá trị, và trình biên dịch chọn cái đầu. Đây là cái bẫy autoboxing khó chịu nhất trong thư viện chuẩn, cũng là lý do List.remove luôn đáng dừng lại một giây khi đọc mã người khác.
Ngày mai ta nói về toán tử — và bắt đầu bằng chuyện Integer.MAX_VALUE + 1 cho ra một số âm mà không báo lỗi gì.