Coi mảng như một món đồ cổ trong nhà thì dễ hiểu nó hơn. Nó có từ Java 1.0, được đóng thẳng vào JVM, ra đời trước cả Collection lẫn generics. Nên nó chơi theo luật của thời nó: độ dài là trường chứ không phải phương thức, nó không thèm ghi đè equals, và nó có một đặc điểm về kiểu mà mọi thứ sinh sau đều đã học cách tránh. Hiểu mảng phần lớn là hiểu những chỗ nó khác với đồ dùng hằng ngày.

Mảng là cấu trúc dữ liệu đầu tiên ai cũng học, rồi sau đó gần như không dùng nữa vì đã có List.

Nhưng nó vẫn đáng hiểu cho tử tế, vì ba lý do: List bên trong chính là một mảng, mọi API cũ trong thư viện chuẩn đều nhận mảng, và mảng có một đặc điểm về kiểu mà không cấu trúc nào khác trong Java còn giữ — một đặc điểm gây ra lỗi lúc chạy mà trình biên dịch không hề cảnh báo.

Khai báo và giá trị mặc định

int[] so = new int[3];
String[] chuoi = new String[3];
boolean[] co = new boolean[3];
int[3]     : [0, 0, 0]
String[3]  : [null, null, null]
boolean[3] : [false, false, false]

Khác với biến cục bộ — thứ bắt buộc phải gán trước khi dùng — ô của mảng luôn có giá trị mặc định. Số là 0, boolean là false, kiểu tham chiếu là null.

Chi tiết này quan trọng hơn vẻ ngoài của nó. new String[3] cho bạn một mảng ba ô rỗng, chứ không phải ba chuỗi rỗng. Duyệt qua rồi gọi .length() là NPE ngay. Người mới hay nhầm chỗ này vì int[3] thì dùng được luôn.

Ba cách khai báo đều hợp lệ, nhưng tôi chỉ dùng cách đầu:

int[] a = {1, 2, 3};                  // gọn nhất, chỉ dùng được lúc khai báo
int[] b = new int[]{1, 2, 3};         // dài hơn, nhưng truyền thẳng vào tham số được
int c[] = {1, 2, 3};                  // cú pháp kiểu C, hợp lệ nhưng đừng dùng

Cách thứ ba làm dấu ngoặc vuông rời khỏi kiểu, khiến int c[], d; trở nên khó đọc — c là mảng còn d thì không. Đặt ngoặc cạnh kiểu thì ý nghĩa rõ ràng: int[] là "kiểu mảng số nguyên".

length không có dấu ngoặc

Một chi tiết nhỏ mà ai cũng gõ nhầm vài lần:

mang.length      // mảng: TRƯỜNG, không ngoặc
chuoi.length()   // String: PHƯƠNG THỨC, có ngoặc
danhSach.size()  // Collection: phương thức tên khác hẳn

Ba thứ cùng nghĩa "độ dài" mà ba cách gọi. Không có lý do thiết kế nào hay ho ở đây, chỉ là di sản lịch sử: mảng có từ Java 1.0 và được cài thẳng trong JVM, String là lớp, còn Collections đến sau.

Mảng nhiều chiều thực ra là mảng của mảng

Java không có mảng hai chiều thật. int[][] là mảng chứa các mảng, và hệ quả là các hàng không bắt buộc dài bằng nhau:

int[][] deu = new int[2][3];      // hai hàng, mỗi hàng ba cột
int[][] rang = new int[2][];      // hai hàng, chưa biết dài bao nhiêu
rang[0] = new int[]{1};
rang[1] = new int[]{1, 2, 3, 4};
đều  : [[0, 0, 0], [0, 0, 0]]
cưa  : [[1], [1, 2, 3, 4]]
rang[1].length = 4

Kiểu mảng "răng cưa" này tiết kiệm bộ nhớ khi dữ liệu vốn không đều — ma trận tam giác, danh sách hàng xóm của đồ thị, lịch theo tháng. Đổi lại, mỗi hàng là một đối tượng riêng nằm rải rác trên heap, nên duyệt chậm hơn mảng thật sự liền khối như trong C.

Duyệt mảng hai chiều thì for-each lồng nhau là dễ đọc nhất:

for (int[] hang : bang) {
    for (int o : hang) {
        System.out.print(o + " ");
    }
    System.out.println();
}

println(mảng) in ra rác

println(mảng)      : [I@778d1062
Arrays.toString    : [0, 0, 0]

[I@778d1062 không phải lỗi. Mảng không ghi đè toString(), nên nó dùng bản mặc định của Object: tên kiểu, dấu @, rồi mã băm ở hệ mười sáu. [I nghĩa là "mảng của int" — đúng ký hiệu mô tả kiểu mà ta đã gặp trong constant pool ở phần thứ ba.

Nên nhớ hai phương thức này, bạn sẽ dùng chúng mỗi lần gỡ lỗi:

Arrays.toString(mang)        // mảng một chiều
Arrays.deepToString(bang)    // mảng nhiều chiều

So sánh mảng: equals cũng không giúp được

a == b             : false
a.equals(b)        : false
Arrays.equals(a,b) : true
Arrays.equals 2D   : false
Arrays.deepEquals  : true

Với a và b cùng chứa {1, 2, 3}.

== so địa chỉ, chuyện này đã quen. Nhưng equals cũng cho false, vì mảng không ghi đè equals — nó dùng bản của Object, mà bản đó chính là so địa chỉ. Đây là chỗ khác hẳn String và các lớp thông thường, và là lỗi rất dễ mắc.

Dùng Arrays.equals cho mảng một chiều. Với mảng nhiều chiều thì Arrays.equals vẫn sai — vì nó so từng phần tử bằng equals, mà phần tử ở đây lại là mảng. Phải dùng Arrays.deepEquals.

Hệ quả kéo theo: đừng dùng mảng làm khoá HashMap. Mã băm của nó dựa trên địa chỉ, nên hai mảng nội dung giống nhau rơi vào hai ô khác nhau và bạn không bao giờ lấy lại được giá trị. Cần khoá tổng hợp thì dùng List.of(...) hoặc một record.

Hiệp biến: lỗ hổng kiểu duy nhất Java để lọt tới lúc chạy

Đây là phần đáng nhớ nhất của bài này.

Object[] doiTuong = new String[2];   // biên dịch được!
doiTuong[0] = 42;                    // nổ lúc chạy
ArrayStoreException: java.lang.Integer

Mảng trong Java hiệp biến: String[] được coi là một loại Object[]. Nghe hợp lý, nhưng nó phá vỡ an toàn kiểu — gán vào biến Object[] thì trình biên dịch cho phép đặt bất cứ Object nào vào, trong khi đối tượng thật bên dưới chỉ chứa được String.

Java vá chỗ này bằng cách kiểm tra lúc chạy: mỗi lần ghi vào mảng, JVM kiểm kiểu thật, sai thì ném ArrayStoreException.

Generics không hiệp biến, chính vì bài học này:

List<Object> ds = new ArrayList<String>();   // lỗi biên dịch

Trình biên dịch chặn ngay, không để lọt tới lúc chạy. Đây là lý do generics dùng ký tự đại diện (? extends, ? super) — chủ đề của phần nói về PECS sau này.

Còn một cái giá nữa: vì phải kiểm kiểu mỗi lần ghi, ghi vào mảng đối tượng chậm hơn ghi vào mảng số nguyên thuỷ. Không đáng kể trong mã thường, nhưng đáng biết khi tối ưu vòng lặp nóng.

Vượt biên

Index 3 out of bounds for length 3

Thông báo của Java 21 nói rõ chỉ số nào và độ dài bao nhiêu — dễ chịu hơn hẳn ArrayIndexOutOfBoundsException: 3 trơ trọi của các bản cũ. Mảng đánh chỉ số từ 0, nên chỉ số hợp lệ cuối cùng luôn là length - 1.

Những phương thức của Arrays đáng thuộc

Arrays.sort(mang);                       // sắp xếp tại chỗ
Arrays.sort(mang, Comparator.reverseOrder());   // với mảng đối tượng
Arrays.fill(mang, -1);                   // gán cùng một giá trị cho mọi ô
int[] moi = Arrays.copyOf(mang, 10);     // sao chép, phần dôi lấy giá trị mặc định
int[] lat = Arrays.copyOfRange(mang, 1, 4);     // cắt đoạn [1, 4)
int vt = Arrays.binarySearch(mang, 5);   // chỉ đúng khi mảng ĐÃ sắp xếp
Arrays.stream(mang).sum();               // bắc cầu sang Stream

Cần sao chép nhanh giữa hai mảng có sẵn thì System.arraycopy là hàm cấp thấp nhất, và cũng là thứ mà ArrayList gọi bên trong mỗi lần bạn add làm nó phải lớn ra.

Arrays.asList(mang) trả về một danh sách gắn với chính mảng đó: sửa một bên thì bên kia đổi theo, và gọi add hay remove sẽ ném UnsupportedOperationException. Cần một danh sách độc lập thì viết new ArrayList<>(Arrays.asList(mang)).

Thêm một cái bẫy nhỏ mà rất hay gặp: Arrays.asList(mangSoNguyenThuy) với int[] cho ra một List<int[]> có đúng một phần tử — chính cái mảng — chứ không phải danh sách các số. Vì generics không nhận kiểu nguyên thuỷ. Muốn đúng ý thì Arrays.stream(mang).boxed().toList().

Vậy khi nào dùng mảng, khi nào dùng List

Dùng List cho gần như mọi việc. Nó co giãn được, có equals và toString tử tế, hợp với Stream, và an toàn kiểu thật sự.

Dùng mảng khi: làm việc với dữ liệu nguyên thuỷ và số lượng lớn (một int[] một triệu phần tử tốn ít bộ nhớ hơn List<Integer> cùng cỡ tới vài lần, vì không phải đóng hộp); khi API bắt buộc, như String[] args hay byte[] trong I/O; hoặc khi kích thước cố định và biết trước.

Nếu chỉ giữ lại một thứ trong đầu sau bài này, hãy để nó là đoạn hai dòng sau, và tự trả lời trước khi đọc tiếp: nó biên dịch được không, và chạy thì sao?

Object[] a = new String[2];
a[0] = 42;

Biên dịch trót lọt, và ném ArrayStoreException lúc chạy. Đó là hiệp biến của mảng — lỗ hổng kiểu duy nhất Java để lọt tới lúc chạy, và là lý do generics được thiết kế bất biến.

Còn một bài tập nhỏ đáng làm: tìm trong log của dự án bạn đang làm.

grep -rn "\[I@\|\[Ljava" <thư mục log>

Mỗi kết quả là một chỗ đang in địa chỉ mảng thay vì nội dung — sửa bằng Arrays.toString.

Mẫu số chung

Cái hố hiệp biến này thú vị ở chỗ nó không phải lỗi riêng của Java, mà là một ngã ba thiết kế mà mỗi ngôn ngữ rẽ một hướng — và hướng rẽ nói lên triết lý của cả ngôn ngữ đó.

C# đi cùng đường với Java: mảng cũng hiệp biến, object[] a = new string[2]; a[0] = 42; biên dịch được rồi ném ArrayTypeMismatchException lúc chạy — y hệt, chỉ khác tên. Cả hai kế thừa quyết định này từ những ngày generics chưa ra đời, khi mà không có hiệp biến thì không viết nổi một hàm sắp xếp dùng chung cho mọi kiểu mảng. Cái giá là một phép kiểm tra lúc chạy mỗi lần ghi, mãi mãi.

Go rẽ hướng khác hẳn: không có kế thừa mảng, không có hiệp biến, nên không có cái hố này để mà vá. Muốn "một hàm nhận mọi loại slice" thì dùng generics (từ 1.18) hoặc any với type switch — kiểm ở chỗ người viết chọn, không phải chỗ JVM ép.

Rust đẩy triết lý tới cùng: hệ thống kiểu bắt lệch kiểu lúc biên dịch, không có khái niệm "ném ngoại lệ vì gán sai kiểu vào ô" — nếu nó chạy thì nó đúng kiểu, chấm hết.

Điểm chung của cả ba lời giải hiện đại — generics Java bất biến, generics Go, kiểu Rust — là kéo phép kiểm tra từ lúc chạy về lúc biên dịch. Cùng một mẫu tư duy lặp lại khắp nghề: một lỗi bắt được lúc biên dịch rẻ hơn nhiều lần cùng lỗi đó bắt được lúc chạy, vì lúc chạy nghĩa là nó đã lọt qua mọi vòng test và đang nổ trên máy người dùng. Mảng hiệp biến là hoá thạch của thời người ta chưa có công cụ để làm điều đó — và biết nó là hoá thạch giúp bạn hiểu vì sao mọi thứ sinh sau nó đều tránh.

Ngày mai ta nói về phương thức và tham số — và trả lời cho xong câu hỏi gây tranh cãi bất tận: Java truyền tham số theo giá trị hay theo tham chiếu.