Thuật toán & Cấu trúc dữ liệu — Thực chiến/Array vs ArrayList — Khi nào primitive thắng wrapper
~20 phútCấu trúc tuyến tínhMiễn phí lượt xem

Array vs ArrayList — Khi nào primitive thắng wrapper

int[] dùng 40 MB cho 10 triệu phần tử; ArrayList<Integer> dùng gần 480 MB — chênh lệch 12 lần. Bài này phân biệt int[], Integer[], Arrays.asList(), List.of(), và ArrayList — biết khi nào pick gì cho production.

Junior Java dev viết code xử lý 10 triệu bản ghi — reach ngay ArrayList<Integer> vì quen tay. Build xong, deploy, rồi nhận alert OOMKilled: container bị kill vì vượt memory limit 512 MB. Nhìn lại: int[] cho cùng dữ liệu chỉ cần khoảng 40 MB; ArrayList<Integer> ngốn gần 480 MB — gấp 12 lần — cộng thêm GC pressure liên tục vì 10 triệu Integer object trên heap.

Bài này phân biệt int[], Integer[], ArrayList<Integer>, List.of(), Arrays.asList() — biết khi nào pick gì cho production.

1. Analogy — Tủ lạnh

int[] giống tủ lạnh mua đúng kích thước: bạn đo trước cần chứa bao nhiêu, mua cái vừa vặn. Không mở rộng được, nhưng không lãng phí kho chứa. Giá thành thấp, dùng ngay.

ArrayList giống chính sách "mua tủ nhỏ, khi đầy thì mua tủ to hơn và chuyển đồ sang". Linh hoạt hơn — không cần biết trước sẽ chứa bao nhiêu. Nhưng mỗi lần chuyển tủ là một operation tốn kém (copy toàn bộ), và luôn có một khoảng trống dự phòng chưa dùng.

Tủ lạnhJava construct
Tủ fixed size, mua đúng kích thướcint[] — kích thước cố định, không grow
Tủ "mua to hơn khi đầy, chuyển đồ sang"ArrayList — dynamic resize, copy khi grow
Chứa được mọi loại thực phẩm kể cả nullInteger[] — boxed, hỗ trợ null
Khóa niêm phong, không thêm/bớtList.of() — hoàn toàn immutable
Niêm phong kích thước nhưng thay được đồArrays.asList() — fixed size, mutable elements
💡 Cách nhớ

Biết trước kích thước, cần tốc độ tối đa: int[]. Cần linh hoạt grow: ArrayList. Cần immutable constant: List.of().

2. API matrix — Chọn đúng construct cho đúng việc

ConstructThay đổi sizeThay đổi elementBoxingUse case
int[]KhôngKhôngHot path, primitives, kích thước biết trước
Integer[]KhôngCần null, cần generic type
Arrays.asList(arr)KhôngTùy elementFixed-view, ngăn add/remove
List.of(...)KhôngKhông (immutable)Constant, defensive copy result
new ArrayList<>()Default growable list
new ArrayList<>(capacity)Biết trước kích thước, tránh grow

Sự khác biệt quan trọng giữa Arrays.asList()List.of(): Arrays.asList() trả về list có thể thay element (gọi set()), nhưng không thể add/remove (size cố định). List.of() hoàn toàn immutable — cả size lẫn element đều không thay đổi được.

3. Capacity vs size — Bẫy tưởng obvious nhất

Beginner hay nhầm list.size() với internal capacity. Đây là nguồn gốc của nhiều IndexOutOfBoundsException khó hiểu.

// new ArrayList<>(100) tao ra list voi capacity = 100, nhung size = 0
List<Integer> list = new ArrayList<>(100);

System.out.println(list.size());     // 0 -- chua add gi
// list.get(50); --> IndexOutOfBoundsException!
// Capacity la 100 nhung size = 0, khong co element nao o index 50

// Phai add truoc:
for (int i = 0; i < 100; i++) {
    list.add(i);
}
System.out.println(list.size());     // 100
System.out.println(list.get(50));    // 50 -- OK

Capacity là số slot sẵn sàng trong backing array elementData — chỉ ảnh hưởng đến khi nào grow xảy ra. Size là số phần tử thực sự đã được add. get(index) check index >= size và ném exception — không quan tâm đến capacity.

Bài 07 Module 1 (Case Study ArrayList.grow()) đã phân tích chi tiết cơ chế grow 1.5x và tại sao capacity tồn tại độc lập với size.

⚠️ Pre-size không có nghĩa là pre-fill

new ArrayList<>(n) chỉ cấp phát backing array kích thước n — không tạo sẵn n element. Sau constructor, size = 0. Muốn list có n slot đã fill với giá trị mặc định, phải dùng Collections.nCopies(n, defaultValue) hoặc loop add() thủ công.

4. Memory footprint — Tại sao 12x

Hiểu con số 12x xuất phát từ đâu để biết khi nào nó quan trọng.

int[10_000_000]:

  • Array header: 16 byte (object header 12 byte + array length 4 byte).
  • Payload: 10M × 4 byte = 40 MB.
  • Tổng: xấp xỉ 40 MB.

Integer[10_000_000] (boxed, fixed-size array):

  • Array header: 16 byte.
  • 10M reference (compressed OOPs, heap dưới 32 GB): 10M × 4 byte = 40 MB.
  • 10M Integer object, mỗi cái 16 byte (12 byte header + 4 byte int value): 10M × 16 = 160 MB.
  • Tổng: xấp xỉ 200 MB — gấp 5 lần int[].

ArrayList<Integer> với 10M element:

  • Tương đương Integer[] backing array: khoảng 200 MB.
  • ArrayList object overhead: 24 byte (header + trường size + trường modCount + reference đến elementData).
  • Capacity slack trung bình ~17% (bài 07 Module 1): thêm khoảng 34 MB dự phòng.
  • Tổng: xấp xỉ 234 MB — gấp gần 6 lần int[].

Nhưng bài 04 Module 1 đã chỉ ra LinkedList<Integer> còn tệ hơn — mỗi phần tử thêm Node object 32 byte, đưa tổng lên 480 MB (12x). ArrayList<Integer> là giữa đường: boxing overhead giữ nguyên nhưng ít nhất không có Node overhead.

ConstructRAM cho 10M elementSo với int[]
int[]~40 MB1x (baseline)
Integer[]~200 MB5x
ArrayList<Integer>~234 MB~6x
LinkedList<Integer>~480 MB12x

Bài 04 Module 1 (cache locality) đã giải thích thêm: int[] không chỉ tiết kiệm RAM mà còn cache-friendly hơn — prefetcher hoạt động tốt khi data liền kề, không bị boxing phân tán.

5. Iterator fail-fast — ConcurrentModificationException

Một pitfall hay gặp khi làm việc với ArrayList: xóa element trong lúc đang iterate.

List<Integer> numbers = new ArrayList<>(List.of(1, -2, 3, -4, 5));

// WRONG: modify list during enhanced for-loop
for (Integer x : numbers) {
    if (x < 0) {
        numbers.remove(x); // ConcurrentModificationException!
    }
}

Tại sao xảy ra? ArrayList dùng counter modCount — tăng mỗi khi có structural modification (add, remove, clear). Iterator snapshot giá trị expectedModCount khi được tạo. Mỗi lần gọi next(), iterator so sánh modCount hiện tại với expectedModCount — nếu khác nhau, ném ConcurrentModificationException ngay lập tức.

Cơ chế này là fail-fast: phát hiện bug sớm thay vì để iterator đi trên dữ liệu đã bị thay đổi và cho kết quả sai. Bài 07 Module 1 đã đề cập modCount trong context subList() — cùng mechanism.

Hai cách đúng:

// Cach 1: dung Iterator.remove() -- cap nhat ca modCount lan expectedModCount
Iterator<Integer> it = numbers.iterator();
while (it.hasNext()) {
    int x = it.next();
    if (x < 0) {
        it.remove(); // safe: dong bo modCount
    }
}

// Cach 2: removeIf() -- Java 8+, gon hon, dung ListIterator ben trong
numbers.removeIf(x -> x < 0);
// Result: [1, 3, 5]

removeIf() gọn hơn và hiệu quả hơn: nó dùng ListIterator hoặc index-based traversal bên trong, thực hiện một lần duyệt duy nhất, và chỉ tăng modCount một lần ở cuối — không phải mỗi lần remove.

Khi nào CME không trigger

Iterator của ArrayList là fail-fast nhưng không guaranteed: nếu thay đổi xảy ra từ thread khác mà không có happens-before relationship, modCount có thể không visible do CPU cache — CME không trigger, nhưng iterator trả về kết quả sai. Đây là "best-effort" detection chỉ đáng tin trong single-threaded context. Với multi-threaded, dùng CopyOnWriteArrayList hoặc external synchronization.

6. Pitfall tổng hợp

Pitfall 1 — Arrays.asList(int[]) trả về List<int[]>, không phải List<Integer>:

int[] arr = {1, 2, 3};

// WRONG: boxed sai
List wrong = Arrays.asList(arr);
System.out.println(wrong.size()); // 1 -- list chua 1 phan tu la ca mang int[]!
System.out.println(wrong.get(0)); // [I@1b6d3586 -- toString cua int[]

// CORRECT: stream boxing
List<Integer> correct = Arrays.stream(arr).boxed().toList();
System.out.println(correct.size()); // 3
System.out.println(correct.get(0)); // 1

Java generics bị type erasure — Arrays.asList(T... a) với T = int[] (vì int không phải generic type) tạo ra List<int[]> kích thước 1, không phải List<Integer>. Dùng Arrays.stream(arr).boxed().toList() để có List<Integer> đúng.

Pitfall 2 — Arrays.asList(...) không cho phép add/remove:

List<Integer> fixed = Arrays.asList(1, 2, 3);
fixed.set(0, 99);  // OK -- thay element duoc
fixed.add(4);      // UnsupportedOperationException!
fixed.remove(0);   // UnsupportedOperationException!

Arrays.asList() trả về java.util.Arrays$ArrayList — wrapper trực tiếp quanh backing array, không phải java.util.ArrayList. Backing array có kích thước cố định nên add/remove không được. Nếu cần growable list từ varargs: new ArrayList<>(Arrays.asList(1, 2, 3)).

Pitfall 3 — List.of() immutable cả structure lẫn element, khác Collections.unmodifiableList():

List<Integer> immutable = List.of(1, 2, 3);
immutable.set(0, 99); // UnsupportedOperationException!
immutable.add(4);     // UnsupportedOperationException!

// Collections.unmodifiableList() chi wrap -- modify qua reference goc van duoc
List<Integer> mutable = new ArrayList<>(List.of(1, 2, 3));
List<Integer> wrapped = Collections.unmodifiableList(mutable);
mutable.add(4);             // OK -- modify qua reference goc
System.out.println(wrapped.size()); // 4 -- thay doi luon ca wrapped!

List.of() là thực sự immutable — không có reference nào đến backing data cho phép modify. Collections.unmodifiableList() chỉ wrap, không copy — modify qua reference gốc vẫn được và phản ánh vào wrapped view. Prefer List.of() khi muốn đảm bảo immutability.

7. Applied to tech — Khi nào primitive thực sự thắng

Hot loop, numerical code: Quant finance (risk calculation trên portfolio), ML inner loop (dot product, matrix multiply), game physics (position update 60fps) — tất cả đều cần int[], double[], float[] thay vì ArrayList. Lý do: zero boxing overhead, cache-friendly layout, hardware prefetcher hoạt động tối đa. Thực đo JMH trên Java 21: sum 10M int[] khoảng 8ms, ArrayList<Integer> khoảng 25ms — chênh 3x chỉ vì boxing.

API design — trả về interface, không concrete class:

// WRONG: lo implementation detail ra ngoai
public ArrayList<String> getNames() { ... }

// CORRECT: caller khong can biet la ArrayList hay LinkedList hay ImmutableList
public List<String> getNames() { ... }

Return List<T> thay vì ArrayList<T> để future-proof: bên trong có thể đổi thành Collections.unmodifiableList(), List.of(), hoặc custom implementation mà không break caller.

System.arraycopy() — JVM intrinsic nhanh hơn manual loop:

int[] src = {1, 2, 3, 4, 5};
int[] dst = new int[5];

// JVM intrinsic -- compile truc tiep thanh memcpy-equivalent
System.arraycopy(src, 0, dst, 0, src.length);

// ArrayList.toArray(T[]) dung System.arraycopy ben duoi
Integer[] arr = list.toArray(new Integer[0]);

System.arraycopy() được JIT biên dịch thành instruction tương đương memcpy — nhanh hơn vòng lặp for tự viết khoảng 2-5x cho array lớn. ArrayList.toArray() dùng nó bên dưới.

Stream API — IntStream tránh boxing:

int[] primitiveArr = {1, 2, 3, 4, 5};
List<Integer> boxedList = List.of(1, 2, 3, 4, 5);

// IntStream -- no boxing, aggregate O(n) khong co object allocation
int sum1 = Arrays.stream(primitiveArr).sum(); // IntStream

// Stream<Integer> -- boxing tung element truoc khi sum
int sum2 = boxedList.stream().mapToInt(Integer::intValue).sum(); // can mapToInt de ve IntStream

Arrays.stream(int[]) trả về IntStream — stream của primitive int, không boxing. list.stream() trả về Stream<Integer> — mỗi element là Integer object. Với aggregate operation (sum, average, count) trên data lớn, dùng IntStream/LongStream/DoubleStream để tránh boxing overhead đáng kể.

Nếu bạn đang học về generics và collections trong Java context, khóa Java Module 08 — Generics and Collections có phân tích sâu hơn về type erasure và wildcard liên quan đến ArrayList.

8. Deep Dive tài liệu gốc

📚 Deep Dive — nguồn tham khảo

Source code và spec:

Phân tích và bài viết:

  • Aleksey Shipilev — blog về JVM internals, benchmarks int[] vs ArrayList<Integer>: canonical reference cho boxing overhead và memory footprint thực tế trên JVM hiện đại.

Cross-link:

  • Bài 04 Module 1: cache locality — lý do int[] cache-friendly hơn ArrayList<Integer> ngoài boxing overhead.
  • Bài 07 Module 1: ArrayList.grow() — cơ chế 1.5x, modCount, lazy initialization.

9. Tóm tắt

  • int[] dùng 4 byte/element; Integer[] dùng 20 byte/element (reference + object); ArrayList<Integer> thêm capacity slack — tổng gấp 5-6x int[].
  • Capacity khác size: new ArrayList<>(100) tạo capacity = 100 nhưng size = 0; get(50) ném IndexOutOfBoundsException dù capacity đủ.
  • Arrays.asList(int[]) là bẫy: trả về List<int[]> size 1, không phải List<Integer>. Dùng Arrays.stream(arr).boxed().toList().
  • Arrays.asList(...) fixed-size: có thể set() nhưng không add()/remove()UnsupportedOperationException.
  • List.of() immutable hoàn toàn: cả size lẫn element. Khác với Collections.unmodifiableList() chỉ wrap — modify qua reference gốc vẫn được.
  • Iterator fail-fast: ConcurrentModificationException khi modify ArrayList trực tiếp trong iteration. Fix bằng Iterator.remove() hoặc removeIf().
  • Return List<T>, không ArrayList<T>: public API nên khai báo interface — future-proof, không lộ implementation.
  • Hot path: IntStream tránh boxing; System.arraycopy() là JVM intrinsic nhanh hơn manual loop.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Arrays.asList(new int[]{1,2,3}) không cho List<Integer> size 3 mà trả về size 1?

Arrays.asList(T... a) là generic method với T không thể là primitive. Khi truyền int[], Java không thể unbox thành T = int — thay vào đó toàn bộ int[] được coi là một object duy nhất với T = int[]. Kết quả là List<int[]> kích thước 1, chứa chính cái mảng đó.

Để có List<Integer> đúng, dùng Arrays.stream(arr).boxed().toList()Arrays.stream(int[]) trả về IntStream, rồi .boxed() convert từng element thành Integer.

Q2
new ArrayList<>(100).get(50) ném exception gì? Capacity và size khác nhau ở chỗ nào?

Ném IndexOutOfBoundsException với message kiểu Index 50 out of bounds for length 0. Dù capacity là 100, size = 0 vì chưa add element nào.

Capacity là kích thước backing array elementData bên trong — chỉ ảnh hưởng đến khi nào grow event xảy ra. Size là số element thực sự được add, là giá trị field size. get(index) check index >= size và ném exception — không quan tâm capacity. Pre-size bằng constructor chỉ cấp phát bộ nhớ sẵn, không tạo element.

Q3
1 triệu Integer boxed dùng RAM bằng bao nhiêu lần so với 1 triệu int primitive?

int primitive: 4 byte/element × 1M = 4 MB.

Integer boxed (trong array hay ArrayList): mỗi Integer object cần 12 byte header + 4 byte int value = 16 byte, cộng reference 4 byte (compressed OOPs) trong array = tổng 20 byte lưu trữ hiệu dụng mỗi slot. 1M × 20 byte ≈ 20 MB.

Gấp 5 lần. Nếu tính ArrayList<Integer> với capacity slack ~17%, con số tăng thêm một chút. Với LinkedList<Integer> còn thêm Node overhead — lên đến 12x như bài 04 Module 1 đã phân tích.

Q4
Khi nào nên prefer List.of() thay new ArrayList<>()?

Prefer List.of() khi:

  • Kết quả là constant hoặc read-only sau khi tạo — không add/remove/set sau đó.
  • Muốn defensive copy để trả về từ method mà không sợ caller modify.
  • Cần đảm bảo immutability thực sựCollections.unmodifiableList() chỉ wrap, modify qua reference gốc vẫn được; List.of() không có reference gốc nào.

Không dùng được khi cần null element (List.of() ném NullPointerException nếu truyền null), hoặc khi cần add/remove sau khi tạo (UnsupportedOperationException).

Q5
Iterator fail-fast hoạt động thế nào? Khi nào fail-fast KHÔNG trigger dù có modification?

ArrayList dùng field modCount — tăng mỗi structural modification (add, remove, grow). Khi tạo iterator, nó snapshot expectedModCount = modCount. Mỗi lần gọi next(), iterator check modCount == expectedModCount — nếu khác, ném ConcurrentModificationException.

Fail-fast không trigger trong hai trường hợp:

  • Multi-threaded không đồng bộ: thread B modify modCount nhưng thread A chưa thấy thay đổi do CPU cache, visibility barrier không có. Iterator có thể tiếp tục sai mà không ném exception — đây là "best-effort".
  • Dùng it.remove(): Iterator.remove() cập nhật cả expectedModCount lẫn modCount đồng thời — nên check vẫn pass. Đây là cách đúng để remove trong khi iterate.
Q6
Public API method trả về collection — ArrayList<T> hay List<T>? Vì sao?

Luôn khai báo List<T> — interface, không concrete class.

Lý do: caller chỉ cần biết contract (có thể iterate, get by index, check size) — không cần biết implementation bên trong là ArrayList, LinkedList, hay List.of(). Nếu khai báo ArrayList<T>, khi muốn đổi sang trả về Collections.unmodifiableList() hay List.of(), bạn phải thay đổi signature và break tất cả caller đang compile. Khai báo interface giữ flexibility để thay implementation mà không break API contract.

Effective Java Item 64 cũng khuyến nghị: "refer to objects by their interfaces" — tương tự với return type của method.

Bài tiếp theo: Linked list — singly vs doubly

Bài này có giúp bạn hiểu bản chất không?