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[][]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 ab 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[]>đú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ó equalstoString 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.

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.