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ạnh | Java construct |
|---|---|
| Tủ fixed size, mua đúng kích thước | int[] — 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ả null | Integer[] — boxed, hỗ trợ null |
| Khóa niêm phong, không thêm/bớt | List.of() — hoàn toàn immutable |
| Niêm phong kích thước nhưng thay được đồ | Arrays.asList() — fixed size, mutable elements |
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
| Construct | Thay đổi size | Thay đổi element | Boxing | Use case |
|---|---|---|---|---|
int[] | Không | Có | Không | Hot path, primitives, kích thước biết trước |
Integer[] | Không | Có | Có | Cần null, cần generic type |
Arrays.asList(arr) | Không | Có | Tùy element | Fixed-view, ngăn add/remove |
List.of(...) | Không | Không (immutable) | Có | Constant, defensive copy result |
new ArrayList<>() | Có | Có | Có | Default growable list |
new ArrayList<>(capacity) | Có | Có | Có | Biết trước kích thước, tránh grow |
Sự khác biệt quan trọng giữa Arrays.asList() và 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.
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
Integerobject, 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. ArrayListobject overhead: 24 byte (header + trườngsize+ trườngmodCount+ reference đếnelementData).- 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.
| Construct | RAM cho 10M element | So với int[] |
|---|---|---|
int[] | ~40 MB | 1x (baseline) |
Integer[] | ~200 MB | 5x |
ArrayList<Integer> | ~234 MB | ~6x |
LinkedList<Integer> | ~480 MB | 12x |
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.
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
Source code và spec:
- OpenJDK 21 — ArrayList.java —
elementData,modCount,grow(). Đọc cùng với bài 07 Module 1 để hiểu toàn bộ lifecycle. - JEP 401 — Primitive Classes (Project Valhalla, preview) — khi hoàn thiện,
Integercó thể được lưu inline trong array như primitive, giảm boxing overhead đáng kể. Theo dõi để biết khi nàoArrayList<Integer>tiếp cận hiệu năngint[]. - Effective Java, Item 28 — Prefer lists to arrays — Bloch giải thích covariance (array) vs invariance (generic list), reifiability, và khi nào dùng mảng thay list là đúng (primitive performance).
Phân tích và bài viết:
- Aleksey Shipilev — blog về JVM internals, benchmarks
int[]vsArrayList<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ơnArrayList<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-6xint[].- Capacity khác size:
new ArrayList<>(100)tạo capacity = 100 nhưng size = 0;get(50)némIndexOutOfBoundsExceptiondù capacity đủ. Arrays.asList(int[])là bẫy: trả vềList<int[]>size 1, không phảiList<Integer>. DùngArrays.stream(arr).boxed().toList().Arrays.asList(...)fixed-size: có thểset()nhưng khôngadd()/remove()—UnsupportedOperationException.List.of()immutable hoàn toàn: cả size lẫn element. Khác vớiCollections.unmodifiableList()chỉ wrap — modify qua reference gốc vẫn được.- Iterator fail-fast:
ConcurrentModificationExceptionkhi modifyArrayListtrực tiếp trong iteration. Fix bằngIterator.remove()hoặcremoveIf(). - Return
List<T>, khôngArrayList<T>: public API nên khai báo interface — future-proof, không lộ implementation. - Hot path:
IntStreamtránh boxing;System.arraycopy()là JVM intrinsic nhanh hơn manual loop.
10. Tự kiểm tra
Q1Vì sao Arrays.asList(new int[]{1,2,3}) không cho List<Integer> size 3 mà trả về size 1?▸
Vì 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.
Q2new 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.
Q31 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.
Q4Khi 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).
Q5Iterator 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
modCountnhư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ảexpectedModCountlẫnmodCountđồng thời — nên check vẫn pass. Đây là cách đúng để remove trong khi iterate.
Q6Public 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?