Thuật toán & Cấu trúc dữ liệu — Thực chiến/Memory model & cache locality — Vì sao LinkedList chậm hơn int[] 25 lần
~22 phútNền tảng — Cách đo và suy nghĩ về thuật toánMiễn phí lượt xem

Memory model & cache locality — Vì sao LinkedList chậm hơn int[] 25 lần

Cùng O(n) nhưng int[] nhanh hơn LinkedList<Integer> 25 lần khi sum 10 triệu phần tử. Bài này giải thích cache locality từ bản chất: CPU memory hierarchy, cache line, object layout trong JVM, và khi nào Big-O không đủ để dự đoán hiệu năng thực tế.

Bạn cần tính tổng 10 triệu phần tử. Hai đoạn code bên dưới có cùng logic, cùng độ phức tạp O(n), chạy trên cùng máy:

// Sum using primitive int array
int[] arr = new int[10_000_000];
// ... fill arr ...
long sum = 0;
for (int x : arr) sum += x;
// Elapsed: ~8 ms
// Sum using LinkedList of boxed Integer
LinkedList<Integer> list = new LinkedList<>();
// ... fill list ...
long sum = 0;
for (int x : list) sum += x;
// Elapsed: ~200 ms

Cùng O(n). Cùng số phép cộng. Cách nhau 25 lần. Big-O không giải thích được điều này.

Bài này giải thích cache locality từ bản chất — vì sao memory layout quan trọng hơn algorithm chọn khi input vừa, và lúc nào Big-O lừa bạn ở production.

1. Analogy — Thư viện và bàn đọc sách

Hình dung bạn đang đọc 10 cuốn sách theo thứ tự.

int[] giống kệ sách liên tiếp: tất cả cuốn xếp cạnh nhau trên cùng một kệ. Lấy xong cuốn 1, với tay lấy ngay cuốn 2 — không đứng dậy, không đi đâu cả.

LinkedList<Integer> giống hệ thống mục lục phân tán: mỗi cuốn sách có ghi chú "đọc tiếp ở kệ 7, tầng 3, phòng B". Đọc xong cuốn 1 phải đi bộ 5 phút đến kệ đó mới lấy được cuốn 2. CPU cache giống cái bàn đọc nhỏ chỉ chứa được vài cuốn sách gần nhất — nếu cuốn tiếp theo không trên bàn, phải đứng dậy đi lấy.

Đời thườngConcept
Cuốn sáchPhần tử dữ liệu
Kệ sách liên tiếpint[] — bộ nhớ liên tục
Ghi chú "đến kệ 7 tầng 3"Con trỏ next trong LinkedList.Node
Bàn đọc sách nhỏCPU cache (L1/L2)
Lấy sách không cần đứng dậyCache hit — đọc từ cache nhanh
Đi bộ 5 phút sang kệ khácCache miss — đọc từ RAM chậm
Mỗi chuyến đi lấy sáchMemory latency ~100ns
💡 Cách nhớ

Cache locality = dữ liệu cần dùng tiếp theo đã nằm sẵn trên "bàn đọc" (cache). Array sequential access tối đa hóa điều này; pointer chasing phá vỡ nó.

2. CPU memory hierarchy — Bậc thang latency

CPU không đọc RAM trực tiếp theo từng byte. Giữa CPU và RAM có một chuỗi bộ nhớ đệm nhỏ nhưng nhanh hơn:

CấpLatencyDung lượng điển hìnhGhi chú
Registerdưới 1 nsvài chục đến vài trăm byteTrực tiếp trong CPU
L1 cache~1 ns32–64 KBMỗi core có riêng
L2 cache~3–10 ns256 KB–1 MBMỗi core có riêng
L3 cache~10–30 ns4–64 MBShared giữa các core
DRAM~100 nsGBMain memory
SSD/NVMe~10–100 µsTB~1.000x chậm hơn DRAM
HDD~10 msTB~100.000x chậm hơn DRAM

Để dễ so sánh — nếu L1 cache là 1 giây:

  • L2 tương đương 3–10 giây
  • L3 tương đương 10–30 giây
  • DRAM tương đương 100 giây — gần 2 phút
  • SSD tương đương vài ngày
  • HDD tương đương vài tháng

DRAM chậm hơn L1 khoảng 100 lần. Mỗi cache miss khi traverse LinkedList buộc CPU đợi 100ns — nghe nhỏ nhưng nhân với 10 triệu phần tử là 1 giây đợi thuần túy.

Nguồn số liệu

Latency numbers trên là xấp xỉ cho x86-64 server hiện đại (2020+). Số thực tế thay đổi theo CPU generation, clock speed, và DRAM type. Tham khảo Brendan Gregg và bài "Latency Numbers Every Programmer Should Know" của Peter Norvig để có bảng cụ thể hơn cho từng kiến trúc.

3. Cache line và prefetch — Cơ chế bên dưới

3.1 Cache line là gì

CPU không đọc từng byte riêng lẻ. Mỗi lần cần dữ liệu từ RAM, nó đọc cả một cache line — khối 64 byte liên tiếp (chuẩn trên x86 và ARM hiện đại).

Nghĩa thực tế: khi bạn đọc arr[0] trong int[], CPU tự động nạp cả arr[0] đến arr[15] vào cache (mỗi int 4 byte, 16 phần tử tổng cộng 64 byte). Lần đọc arr[1], arr[2], ..., arr[15] kế tiếp đều là cache hit — gần như miễn phí.

3.2 Hardware prefetcher

CPU hiện đại có hardware prefetcher — circuit chuyên dự đoán dữ liệu sẽ cần và nạp trước vào cache.

Khi thấy pattern truy cập tuần tự arr[0], arr[1], arr[2], ..., prefetcher nhận ra pattern và bắt đầu nạp arr[16], arr[17], ... trước khi code đến đó. Kết quả: mỗi phần tử trong int[] sequential traversal gần như không tốn thời gian chờ.

3.3 Pointer chasing phá vỡ prefetch

LinkedList.Node chứa con trỏ next trỏ đến node tiếp theo — node đó có thể nằm ở bất kỳ đâu trên heap. Pattern truy cập trông như:

Node A tại 0x1000 → Node B tại 0x8F20 → Node C tại 0x3A40 → ...

Địa chỉ nhảy ngẫu nhiên. Prefetcher không thể dự đoán — mỗi bước traverse là một cache miss buộc CPU đợi 100ns để fetch từ RAM. Với 10 triệu node: 10 triệu cache miss × 100ns = 1 giây chờ thuần túy.

Diagram cache hit/miss khi traverse:

int[] sequential access:
[0x1000: 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]
  ^-- load one cache line, prefetch next line
  read 1: HIT (just loaded)
  read 2: HIT
  read 3: HIT
  ... 16 reads from 1 cache line fetch

LinkedList pointer chase:
Node@0x1000 -> next=0x8F20 -> MISS (fetch from RAM, wait 100ns)
Node@0x8F20 -> next=0x3A40 -> MISS (fetch from RAM, wait 100ns)
Node@0x3A40 -> next=0xC510 -> MISS (fetch from RAM, wait 100ns)
  ... 1 cache miss per node

4. Java object overhead — Vì sao LinkedList thật sự tệ

Vấn đề không chỉ là pointer chasing. LinkedList<Integer> còn chịu gánh nặng object overhead rất nặng so với int[].

4.1 Kích thước mỗi phần tử

int primitive trong int[]: 4 byte. Chỉ vậy thôi.

Integer boxed object: 12 byte object header (compressed OOPs trên 64-bit JVM) + 4 byte giá trị int + 0 byte padding (tổng vừa đúng 16 byte, aligned). Tổng: 16 byte — 4 lần int primitive.

LinkedList.Node: object header 12 byte + trường item (reference 4 byte với compressed OOPs) + trường prev (4 byte ref) + trường next (4 byte ref) + 4 byte padding = 28–32 byte tùy JVM và alignment.

Tổng cho 1 phần tử trong LinkedList<Integer>:

  • Integer object: 16 byte
  • LinkedList.Node: 32 byte
  • Tổng: 48 byte — so với 4 byte của int[]

Gấp 12 lần về RAM. 10 triệu phần tử: int[] chiếm 40 MB; LinkedList<Integer> chiếm khoảng 480 MB — chưa kể fragmentation của GC heap.

4.2 Đo bằng Java Object Layout (JOL)

JOL là tool chính thức để đo object size trên JVM thực tế:

// Add dependency: org.openjdk.jol:jol-core:0.17
import org.openjdk.jol.info.ClassLayout;

System.out.println(ClassLayout.parseClass(Integer.class).toPrintable());
// Output (64-bit JVM, compressed OOPs):
// java.lang.Integer object internals:
//  OFFSET  SIZE   TYPE DESCRIPTION
//       0    12        (object header)
//      12     4    int Integer.value
// Instance size: 16 bytes

Số liệu JOL là source of truth — kết quả thực tế tùy JVM flag và version. Compressed OOPs bật mặc định khi heap dưới 32 GB; tắt bằng -XX:-UseCompressedOops sẽ tăng object header lên 16 byte và reference lên 8 byte.

⚠️ Object size thay đổi theo JVM config

Với -XX:-UseCompressedOops: Integer tăng lên 24 byte, reference trong Node tăng lên 8 byte → Node có thể lên 40 byte. Luôn đo bằng JOL thay vì hardcode con số.

5. Benchmark — Con số thực tế

Số đo xấp xỉ trên JVM hiện đại (Java 21, warmup đủ, JMH):

Thao tácint[]ArrayList<Integer>LinkedList<Integer>
Sum 10M phần tử~8 ms~25 ms~200 ms
Random access 10k lần~0.05 ms (O(1))~0.05 ms (O(1))~50 ms (O(n))
Sequential insert 10MN/A (fixed size)~200 ms~400 ms
RAM cho 10M phần tử~40 MB~200 MB~480 MB

ArrayList<Integer> chậm hơn int[] vì boxing — mỗi phần tử là Integer object 16 byte thay vì 4 byte, L1 cache chứa ít phần tử hơn, nhưng layout vẫn liền kề nên prefetcher còn hoạt động được. LinkedList<Integer> chịu cả hai vấn đề: boxing và pointer chasing.

💡 Khi nào LinkedList thực sự thắng

LinkedList thắng khi bạn insert/delete ở đầu hoặc giữa danh sách đã có sẵn iterator trỏ đến vị trí đó — thao tác là O(1) so với ArrayListO(n) (phải shift). Nhưng trong thực tế, overhead cache miss của LinkedList thường lớn hơn lợi thế O(1) insert trừ khi danh sách rất lớn và pattern insert/delete rất dày.

6. Pitfall tổng hợp

Pitfall 1: Dùng LinkedList vì "O(1) insert" nhưng quên traverse cost.

// Mistaken: add at index i -- must traverse to i first
list.add(50_000, newElement); // O(n) traverse + O(1) pointer update = O(n) total

LinkedList.add(index, value) phải gọi node(index) để tìm vị trí — traverse từ đầu hoặc cuối đến index, tốn O(n/2). Chỉ thao tác pointer update sau khi tìm vị trí mới là O(1). Tổng vẫn O(n) — và còn chậm hơn ArrayList.add(index, value) trong thực đo vì cache miss khi traverse.

Pitfall 2: Object header size thay đổi theo JVM config.

Với -XX:-UseCompressedOops (heap vượt 32 GB hoặc tắt tường minh), object header tăng từ 12 lên 16 byte, reference từ 4 lên 8 byte. Integer object: 24 byte thay vì 16. Node: có thể lên 48 byte. Không hardcode những con số này trong code production — dùng JOL để đo khi cần.

Pitfall 3: Tin Big-O quá, chọn TreeMap cho 100 phần tử.

// Overkill for small n: TreeMap O(log n) with pointer chase overhead
Map<String, Integer> scores = new TreeMap<>(); // 100 entries

// Often faster in practice for small n: ArrayList linear scan, cache friendly
List<Map.Entry<String, Integer>> entries = new ArrayList<>(100);
// linear scan O(n) but n=100, data fits entirely in L1 cache

TreeMap traversal là pointer chasing qua red-black tree nodes — nhiều cache miss. ArrayList linear scan qua 100 phần tử nằm liên tiếp trong bộ nhớ — toàn bộ fit trong L1 cache (100 × 8 byte reference = 800 byte, ít hơn 32 KB L1). Thực đo nhiều trường hợp n dưới 500: ArrayList scan nhanh hơn TreeMap. Đo trước khi kết luận từ asymptotic complexity.

7. Cache locality trong thực tế

Effective Java khuyến nghị ArrayDeque thay LinkedList/Stack: ArrayDeque dùng mảng liên tiếp với circular buffer — cache-friendly cho cả push/pop ở cả hai đầu. LinkedList tốn thêm 2 pointer và 1 object header mỗi node, cộng với cache miss mỗi bước.

LMAX Disruptor — ring buffer zero pointer overhead: Disruptor pattern dùng single contiguous array (ring buffer) thay vì queue với linked nodes. Mỗi slot trong ring buffer cố định, thread ghi và đọc không cần allocate object mới — gần như loại bỏ hoàn toàn cache miss và GC pressure. Ngoài ra, các slot trong buffer được pad thêm byte để mỗi slot chiếm đúng 1 cache line, tránh false sharing (xem self-check). Module 2 sẽ phân tích chi tiết case study LMAX.

Database row store vs column store: Row store (PostgreSQL, MySQL) lưu tất cả cột của một row liền kề — cache-friendly cho SELECT * trên ít row (OLTP). Column store (Parquet, ClickHouse, Apache Arrow) lưu tất cả giá trị của một cột liền kề — cache-friendly cho SUM(revenue), AVG(price) trên nhiều row (OLAP/analytics). Chọn storage layout dựa trên query pattern là cache locality ở cấp database.

HashMap vs LinkedHashMap iteration: LinkedHashMap maintain insertion order qua doubly-linked list nối các entry với nhau. Khi iterate, traversal theo linked list — mỗi entry ở địa chỉ ngẫu nhiên trên heap, cache miss liên tục tương tự LinkedList. Nếu chỉ cần iterate theo insertion order một lần cuối, copy vào ArrayList và iterate mảng sẽ nhanh hơn đáng kể.

Game engine — Data-Oriented Design: Game engine truyền thống dùng OOP: mỗi Entity là object chứa position, velocity, health, renderer. Khi update physics cho 10.000 entity, phải load toàn bộ object (vài trăm byte) dù chỉ cần positionvelocity (16 byte). Entity-Component-System (ECS) tách ra: positions[], velocities[] — mỗi array liên tiếp trong bộ nhớ. Physics system chỉ đọc positions[]velocities[] — cache utilization gần 100%. Unity DOTS và Bevy (Rust) đều dùng ECS vì lý do này.

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

📚 Deep Dive — nguồn gốc và tài liệu tham khảo

Spec / reference chính thức:

  • Ulrich Drepper — "What Every Programmer Should Know About Memory" (2007) — paper 114 trang đầy đủ nhất về CPU cache, TLB, NUMA, prefetching. Có thể đọc từ phần 3 (CPU cache) nếu bỏ qua phần hardware cứng ở đầu.
  • Brendan Gregg — Latency Numbers blog — latency numbers cập nhật, cách đọc perf profiler và flame graph. Thực tế hơn paper Drepper cho day-to-day debugging.
  • JEP 401 — Primitive Classes (Valhalla) — preview feature đang phát triển. Khi hoàn thiện, Integer, Long có thể được lưu inline trong array như primitive — loại bỏ boxing overhead. Theo dõi để biết khi nào LinkedList<Integer> overhead giảm đáng kể.
  • Java Object Layout (JOL) — tool đo object size thực tế trên JVM đang chạy. Artifact: org.openjdk.jol:jol-core. Chạy jol-cli để xem layout bất kỳ class nào mà không cần viết code.
  • Khóa Java — Module JVM Internals — object header, heap layout, compressed OOPs chi tiết. Đọc sau bài này để hiểu sâu hơn về JVM memory model.

Ghi chú: Drepper 2007 vẫn là tài liệu chuẩn dù hardware đã thay đổi — nguyên lý cache hierarchy và cache line hoạt động giống nhau. Đọc section 3 trước rồi quay lại section 6 (prefetching) sau khi đã code thử benchmark JMH.

9. Tóm tắt

  • Big-O ẩn constant — cùng O(n) nhưng cache miss liên tục khiến LinkedList chậm hơn int[] 25 lần vì memory latency không nằm trong ký hiệu Big-O.
  • CPU memory hierarchy: L1 ~1ns, L2 ~10ns, L3 ~30ns, DRAM ~100ns — mỗi cache miss khi traverse LinkedList phải đợi 100 lần lâu hơn so với cache hit.
  • Cache line 64 byte: CPU đọc theo khối 64 byte liên tiếp; int[] sequential traverse = 1 cache miss cho 16 phần tử; LinkedList pointer chase = 1 cache miss cho mỗi node.
  • Hardware prefetcher dự đoán sequential access và nạp trước — int[] tận dụng hoàn toàn; pointer chasing vô hiệu hóa prefetcher.
  • LinkedList<Integer> 12x RAM: 1 phần tử = 16 byte Integer + 32 byte Node = 48 byte, so với 4 byte int trong int[].
  • Khi LinkedList thực sự thắng: insert/delete tại vị trí đã biết (có iterator), không cần traverse — O(1) pointer update. Khi phải traverse trước thì tổng vẫn O(n) và cache miss còn tệ hơn ArrayList.
  • Nguyên tắc thực tế: data-oriented thinking — layout liền kề trong bộ nhớ quan trọng hơn abstract structure. Array, ArrayDeque, column store, ECS đều dựa trên cùng nguyên lý này.
  • Đo trước: TreeMap cho 100 phần tử thường chậm hơn ArrayList linear scan vì cache miss nhiều hơn, dù O(log n) thấp hơn O(n).

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Big-O không cover cache locality? Ký hiệu nào hoặc model nào bạn cần bổ sung để so sánh đầy đủ?

Big-O đo số phép toán theo n — bỏ hằng số, bỏ hardware. Nó không phân biệt giữa 1 phép toán tốn 1ns (L1 cache hit) và 1 phép toán tốn 100ns (DRAM fetch). Với O(n) trên 10 triệu phần tử, nếu mỗi bước là cache miss thì tổng thời gian chờ bộ nhớ là 1 giây — Big-O không nắm bắt được điều này.

Để so sánh đầy đủ cần bổ sung: (1) cache miss rate — đo bằng profiler hoặc perf stat trên Linux; (2) memory access pattern — sequential hay random; (3) working set size — dữ liệu có fit trong L1/L2 không. Trong nghiên cứu thuật toán có "cache-oblivious model" và "external memory model" để phân tích chính thức, nhưng trong thực tế dev thường dùng profiler và benchmark.

Q2
LinkedList<Integer> với 10 triệu phần tử dùng bao nhiêu RAM so với int[] cùng kích thước? Giải thích từng thành phần.

int[] 10 triệu phần tử: mỗi int là 4 byte, lưu liền kề → 10M × 4 = 40 MB. Ngoài ra có array header khoảng 16 byte — không đáng kể.

LinkedList<Integer> 10 triệu phần tử: mỗi phần tử có 2 object:

  • Integer object: 12 byte header + 4 byte value = 16 byte
  • LinkedList.Node: 12 byte header + 4 byte item ref + 4 byte prev ref + 4 byte next ref + 4 byte padding = 28–32 byte

Tổng mỗi phần tử: khoảng 48 byte → 10M × 48 = 480 MB. Gấp 12 lần int[]. Chưa kể GC fragmentation làm heap thực tế còn lớn hơn nữa.

Q3
Khi nào LinkedList thực sự thắng ArrayList — ngay cả khi chịu cache penalty?

Khi insert hoặc delete ở vị trí đã có iterator trỏ sẵn, không cần traverse:

  • ListIterator.add() hoặc ListIterator.remove() tại vị trí hiện tại: O(1) pointer update cho LinkedList vs O(n) shift cho ArrayList.
  • Khi danh sách rất lớn và bạn thường xuyên insert/delete ở giữa đã có iterator — lúc đó O(1) thực sự đáng kể hơn cache penalty của mỗi node.

Trong thực tế, ArrayDeque thường là lựa chọn tốt hơn cả hai khi cần deque/queue semantics: cache-friendly, amortized O(1) ở cả hai đầu. LinkedList phù hợp nhất trong các thuật toán cần splice (gộp/tách danh sách) hoặc implement LRU cache với HashMap kết hợp.

Q4
Cache line 64 byte có ý nghĩa gì cho việc thiết kế struct hoặc class trong Java? Cho ví dụ cụ thể.

64 byte cache line nghĩa là: nếu dữ liệu bạn cần dùng cùng lúc nằm trong 64 byte liên tiếp, chỉ tốn 1 cache miss để load tất cả. Nếu trải rộng hơn, tốn nhiều cache miss hơn.

Hệ quả khi thiết kế:

  • Nhóm field hay dùng cùng nhau: nếu xy của một Point luôn đọc/ghi cùng lúc, đặt chúng trong cùng class → cùng object → gần nhau trong heap.
  • Tách field hay đọc khỏi field hay ghi: nếu readCount (đọc thường xuyên) và writeBuffer (ghi thường xuyên) ở cùng object, hai thread đọc/ghi có thể invalidate cache line của nhau — false sharing (xem câu tiếp).
  • Array of structs vs struct of arrays: ECS dùng float[] posX, posY thay vì Point[] positions — khi chỉ cần posX, load 16 giá trị float liên tiếp thay vì 16 Point object mỗi cái 2 float + overhead.
Q5
False sharing là gì? Khi nào nó quan trọng trong Java multithreading?

False sharing xảy ra khi hai thread ghi vào hai biến khác nhau nhưng nằm trên cùng cache line. CPU cache hoạt động theo đơn vị cache line (64 byte) — khi thread A ghi vào biến của mình, nó invalidate cache line đó trên tất cả core khác, buộc thread B phải reload cache line để ghi vào biến của nó, dù hai biến hoàn toàn độc lập về logic.

Ví dụ trong Java:

class Counter {
  volatile long countA; // thread A writes here
  volatile long countB; // thread B writes here
  // Both are 8 bytes, adjacent in memory -> same cache line
  // Every write by A invalidates B's cache and vice versa
}

Fix bằng padding: LMAX Disruptor thêm long p1,p2,p3,p4,p5,p6,p7 giữa các field để mỗi field chiếm riêng 1 cache line. Java 8+ có @sun.misc.Contended (hoặc @jdk.internal.vm.annotation.Contended) để JVM tự pad. Quan trọng nhất trong các vòng lặp tight với nhiều thread ghi counter riêng — ví dụ worker pool với per-thread statistics.

Q6
Cho hai hàm sum dưới đây. Cái nào nhanh hơn và vì sao?
// Version A: sequential access
int sumSequential(int[] arr) {
  int s = 0;
  for (int i = 0; i < arr.length; i++) s += arr[i];
  return s;
}

// Version B: jump by stride 16
int sumStrided(int[] arr) {
  int s = 0;
  for (int i = 0; i < arr.length; i += 16) s += arr[i];
  return s;
}

Version A (sequential) nhanh hơn trên mỗi phần tử truy cập, nhưng truy cập nhiều phần tử hơn. Version B (strided) truy cập ít phần tử hơn (chỉ 1/16), nhưng mỗi lần truy cập là 1 cache miss riêng biệt.

Phân tích:

  • Version A: sequential access, prefetcher hoạt động tốt. Mỗi cache line 64 byte nạp 16 phần tử int. Tổng cache miss: n / 16. Phần tử cần đọc: n.
  • Version B: stride 16 × 4 byte = 64 byte — vừa đúng 1 cache line. Mỗi phần tử truy cập ở đầu một cache line mới, không tận dụng gì từ cache line đó. Tổng cache miss: n / 16. Phần tử cần đọc: n / 16.

Số cache miss như nhau, nhưng Version B đọc ít phần tử hơn 16 lần → Version B nhanh hơn tổng thời gian vì ít công việc hơn. Tuy nhiên, nếu so sánh thời gian trên cùng số phần tử được đọc, Version A hiệu quả hơn vì tận dụng được toàn bộ 64 byte mỗi cache line — cache miss cost trải đều trên 16 phần tử thay vì 1.

Bài học: stride bằng cache line size là trường hợp worst case về cache utilization — lãng phí 63 trong 64 byte mỗi lần fetch. Design struct để các field hay truy cập cùng lúc nằm trong cùng hoặc liền kề cache line.

Bài tiếp theo: Problem-solving framework

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