Một bạn mới vào team nhắn tôi qua Slack, giọng đã hơi hoảng: "Anh ơi, tính năng phát hiện đồng hạng của em chạy đúng hết khi test, mà lên staging thì có hai học viên cùng điểm nó lại không nhận ra. Em đọc lại cả tiếng rồi mà không thấy sai ở đâu."
Tôi kéo ghế ngồi xuống cạnh bạn. Bạn ấy đếm điểm mỗi học viên vào một Map, rồi so để xem hai người có bằng điểm nhau không:
Map<String, Integer> diem = tinhDiemMoiHocVien();
// ...
if (diem.get(hocVienA) == diem.get(hocVienB)) {
danhDauDongHang();
}
Bạn ấy đã test rất kỹ với dữ liệu mẫu: hai học viên cùng 5 điểm — nhận ra. Cùng 20 điểm — nhận ra. Nhưng trên staging, hai người cùng 350 điểm thì hệ thống làm ngơ. Cùng một dòng code, cùng một phép so sánh, chỉ khác con số.
Tôi mỉm cười, vì tôi biết chính xác cái cảm giác bí bách đó — nhiều năm trước tôi cũng mất một buổi chiều ở đúng dạng lỗi này. Vấn đề không nằm ở logic. Nó nằm ở một chữ mà cả hai chúng tôi từng tưởng mình đã hiểu: ==.
Chỗ bạn ấy sập. Map<String, Integer>.get() trả về một object Integer, và == trên object so sánh địa chỉ tham chiếu, không so giá trị. Nó chỉ "tình cờ đúng" với Integer từ −128 tới 127, vì Java cache sẵn dải này nên hai giá trị bằng nhau trong dải trỏ về cùng một object. Vượt qua 127, mỗi giá trị là một object riêng nằm ở địa chỉ khác nhau, nên == trả về false.
Điều bạn ấy nhìn nhầm: == so cái gì
Câu đầu tiên tôi hỏi: "diem.get(...) trả về kiểu gì?" Bạn ấy định nói int, rồi khựng lại — Map<String, Integer> thì get trả về Integer, một object, chứ không phải primitive.
Tôi vẽ cho bạn ấy hình này: cứ hình dung mỗi biến object là một mảnh giấy ghi địa chỉ nhà. == chỉ hỏi "hai mảnh giấy có ghi cùng một địa chỉ không?" — nó không bao giờ bước vào trong nhà. Muốn biết đồ đạc bên trong hai căn nhà có giống nhau không, đó là việc của .equals(). Hai object Integer cùng mang giá trị 350 là hai căn nhà khác địa chỉ, bày biện y hệt: .equals() bảo "giống nhau", == bảo "khác". Bạn ấy đã giao nhầm việc cho ==.
Nói bằng thuật ngữ: với 8 kiểu primitive (int, long, boolean…), biến giữ thẳng giá trị, nên 5 == 5 luôn đúng. Còn với object, biến không giữ giá trị — nó chỉ giữ địa chỉ trỏ tới object nằm trên heap, đúng như mảnh giấy ghi địa chỉ kia.
(Tôi bảo bạn ấy đọc kỹ lại bài Kiểu tham chiếu để nắm phần null/NPE, và bài Toán tử số học và so sánh cho bản thân toán tử ==.)
Nhưng vì sao số nhỏ lại "đúng"?
Đây đúng là câu bạn ấy hỏi lại ngay: "Nếu == luôn so địa chỉ, sao dữ liệu test 5 điểm với 20 điểm của em vẫn nhận đúng? Đáng lẽ nó phải sai ngay từ đầu chứ?"
Hỏi trúng chỗ rồi đấy — và thủ phạm tên là Integer cache. Đây không phải cơ chế tối ưu tuỳ hứng của riêng JVM nào: đặc tả ngôn ngữ Java (JLS §5.1.7) bắt buộc mọi JVM cache dải −128 tới 127 khi autoboxing, nên hành vi "số nhỏ thì == tình cờ đúng" giống hệt nhau ở mọi nơi — chỉ mép trên của cache là nới được.
Khi một giá trị int được "đóng hộp" thành Integer (ví dụ lúc bạn bỏ số vào Map<String, Integer>), Java ngầm chạy Integer.valueOf(...) — thao tác này gọi là autoboxing. Và Integer.valueOf không phải lúc nào cũng new: nó giữ sẵn một mảng cache các Integer cho dải −128 tới 127, và trả về đúng instance đã cache nếu giá trị nằm trong dải đó.
Nên hai giá trị 20 trỏ về cùng một object, hai địa chỉ trùng nhau, và == trả về true. Toàn bộ điểm trong dữ liệu test rơi vào dải cache, nên chúng tình cờ đúng cả. Còn 350 nằm ngoài dải: mỗi giá trị là một Integer mới nằm ở địa chỉ khác, nên == trả về false. Ranh giới 127/128 chẳng có phép màu gì, chỉ là mép của cái cache.
Chúng tôi ngồi kiểm chứng lại: 127 vs 128
Tôi bảo bạn ấy mở một file trống và chạy thử, để tự mắt thấy cho chắc — nghe tôi nói suông chưa đủ:
Integer a = 127, b = 127;
System.out.println(a == b); // true -- 127 nam trong cache -128..127
System.out.println(a.equals(b)); // true
Integer c = 128, d = 128;
System.out.println(c == d); // false -- 128 ngoai cache, hai object khac nhau
System.out.println(c.equals(d)); // true -- .equals() so gia tri -> luon dung
String s1 = "hello", s2 = "hello";
System.out.println(s1 == s2); // true -- cung string pool
System.out.println(s1 == new String("hello")); // false -- object moi tren heap
System.out.println(s1.equals(new String("hello"))); // true
Rồi tôi cho bạn ấy làm thêm một thí nghiệm: chạy lại với cờ -XX:AutoBoxCacheMax=1000. Lần này 128 == 128 lại ra true — vì vừa nới mép cache lên 1000. Bạn ấy à lên một tiếng: nếu ranh giới di chuyển được bằng một cờ JVM, thì rõ ràng nó là chuyện của cache, không phải của con số.
Cách sửa: .equals() hoặc Objects.equals() an toàn null
Bug của bạn ấy gói gọn trong đúng một chỗ — và cách sửa cũng vậy:
// SAI: so hai Integer bang == -- dung voi diem nho, sap voi diem lon
if (diem.get(hocVienA) == diem.get(hocVienB)) { danhDauDongHang(); }
// DUNG: so gia tri
if (diem.get(hocVienA).equals(diem.get(hocVienB))) { danhDauDongHang(); }
// AN TOAN NHAT: khong so NPE khi mot hoc vien chua co diem (get tra null)
if (java.util.Objects.equals(diem.get(hocVienA), diem.get(hocVienB))) { danhDauDongHang(); }
Objects.equals(x, y) trả true nếu cả hai null, false nếu chỉ một null, và gọi x.equals(y) khi x khác null — nên nó vừa so đúng giá trị, vừa không ném NPE khi Map chưa có key (một cái bẫy rất dễ gặp ngay sau cái bẫy này). Chỉ một lưu ý nghiệp vụ nhỏ: Objects.equals(null, null) trả true — nếu không muốn coi hai học viên cùng chưa có điểm là đồng hạng, bạn cần kiểm null riêng trước khi so.
Trước khi đứng dậy, tôi nói với bạn ấy điều mà buổi chiều năm xưa đã dạy tôi: một đoạn code "chạy đúng" trên máy bạn chưa chắc là đúng — đôi khi nó chỉ đang may mắn nằm trong vùng an toàn của một cơ chế tối ưu nào đó. Test với dữ liệu 20 điểm và chạy thật với 350 điểm là hai thế giới khác nhau. Bạn ấy gật đầu, rồi lặng lẽ sửa thêm cả bộ dữ liệu mẫu để có những con số vượt 127.
Nếu bạn cũng đang dựng nền Java để không dính những cái bẫy kiểu này, cơ chế wrapper và autoboxing đầy đủ nằm trong bài Kiểu tham chiếu — null, NPE, wrapper và autoboxing thuộc khoá Java Foundations.
Sẵn sàng học sâu hơn?
Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.
Đọc tiếp
Bài viết liên quan
Vì sao không lưu tiền bằng double — và dùng BigDecimal sao cho đúng?
Báo cáo tổng lãi lệch 9 đồng dù từng chi nhánh khớp: cộng 10 triệu dòng bằng double, sai số mỗi phép cộng gom thành tiền thật. Cơ chế IEEE 754 và BigDecimal.
Vì sao @Transactional không rollback dù đã đặt đúng chỗ?
@Transactional nằm trên method mà dữ liệu vẫn lưu một nửa? Vì nó là AOP proxy: self-invocation bỏ qua proxy, còn checked exception mặc định không rollback.
N+1 query: vì sao một lần xuất sao kê bắn ra 5.001 câu SQL?
Xuất sao kê treo chục giây vì Hibernate bắn 5.001 câu SQL thay vì 1 — còn màn danh sách phân trang thì che giấu bug. Cơ chế N+1 query trong JPA và 4 cách fix.