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.
Cả bài này gói trong một hình dung: có loại biến giữ thẳng giá trị trong hộp của nó, có loại biến chỉ giữ một mảnh giấy ghi địa chỉ tới nơi chứa giá trị. Nắm được cái phân đôi ấy là hiểu được cả câu đố trên lẫn một nửa số lỗi khó chịu của Java. 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 — cái hộp chứa thẳng con số.
| 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 | ký tự rỗng |
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 một chuỗi chỉ chứa một emoji ngón tay cái vẫn cho length() bằng 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. Đây là loại hộp chỉ giữ mảnh giấy ghi địa chỉ.
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 — hai mảnh giấy ghi cùng một địa chỉ.
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.
Mẫu số chung
Cái phân đôi ở đầu bài — biến giữ thẳng giá trị hay giữ một địa chỉ — không phải chuyện riêng của Java. Nó là một trong những khác biệt nền tảng nhất của mọi ngôn ngữ, và nó sinh ra cùng một cặp câu hỏi ở đâu cũng vậy: hai biến này là cùng một vật (đồng nhất), hay chỉ bằng nhau về nội dung? Toán tử == trên tham chiếu hỏi câu đầu; equals hỏi câu sau. Lẫn hai câu đó là gốc của cả họ lỗi.
Và cái bẫy 127/128 lặp lại gần như y hệt ở nơi khác:
- Python coi mọi thứ là tham chiếu, và tách rạch ròi
is(đồng nhất) với==(bằng nội dung). Nó cũng cache số nguyên nhỏ — khoảng −5 đến 256 — nêna is bchoTruevới 256 nhưngFalsevới 257. Đúng cùng một cái bẫy, chỉ đổi biên từ 127 sang 256, và cùng một bài học: đừng dùngisđể so giá trị. - Go phân biệt kiểu giá trị (struct, mảng — gán là sao chép) với con trỏ/slice/map (giống tham chiếu — gán là chia sẻ). Truyền một struct lớn vào hàm là copy cả cục; truyền slice là chia sẻ ruột. Cùng câu chuyện "tôi có sửa gì đâu mà dữ liệu đổi", chỉ khác ranh giới.
- JavaScript chia nguyên thuỷ với đối tượng y hệt, và
===trên hai object là so địa chỉ, không so nội dung.
Nên câu hỏi đúng khi hai giá trị "trông giống nhau mà so ra khác" không phải "ngôn ngữ này lạ đời" mà là "tôi đang so đồng nhất hay bằng nội dung, và biến này giữ thẳng giá trị hay giữ một địa chỉ". Trả lời được hai câu đó thì mọi cái bẫy trong bài này tự tan.
Muốn tự khắc sâu, mở jshell lên và kiểm ba điều, mỗi cái ba mươi giây nhưng đọng lại lâu hơn cả bài: gõ Integer.valueOf(127) == Integer.valueOf(127) rồi đổi 127 thành 128, xem kết quả lật; gõ một chuỗi một emoji rồi gọi .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ì — 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.
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ì.