Skip to content

Commit 1346f67

Browse files
committed
docs(post): finalize arraylist vs linkedlist article and publish
1 parent 6f12c86 commit 1346f67

1 file changed

Lines changed: 74 additions & 59 deletions

File tree

  • src/content/posts/2026-02-25-arraylist-vs-linkedlist-cache-locality

src/content/posts/2026-02-25-arraylist-vs-linkedlist-cache-locality/index.mdx

Lines changed: 74 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
22
title: "ArrayList vs LinkedList: Big-O는 맞는데, 왜 대부분 ArrayList가 더 빠를까?"
3-
description: "Java 컬렉션 선택을 Big-O가 아닌 CPU 캐시와 메모리 레이아웃 관점에서 정리하고, ArrayDeque 선택 기준과 Project Valhalla의 변화 가능성까지 다룹니다."
3+
description: "Java 컬렉션 선택을 Big-O가 아닌 CPU 캐시와 메모리 레이아웃 관점에서 정리하고, ArrayDeque 선택 기준과 JMH로 재현하는 방법까지 다룹니다."
44
date: 2026-02-25
55
category: "Java > Performance"
6-
tags: ["java", "jvm", "performance", "collections", "arraylist", "linkedlist", "cache", "valhalla"]
6+
tags: ["java", "jvm", "performance", "collections", "arraylist", "linkedlist", "cache"]
77
draft: false
8-
publish: false
8+
publish: true
99
---
1010

1111
Java 컬렉션을 처음 배울 때 보통 이렇게 외웁니다.
@@ -18,7 +18,7 @@ Java 컬렉션을 처음 배울 때 보통 이렇게 외웁니다.
1818

1919
이번 글에서는 그 이유를 Big-O가 아니라 **CPU 캐시와 메모리 레이아웃** 관점에서 정리해보겠습니다.
2020

21-
> 이 글에서 말하는 "메모리 모델"은 JMM(Java Memory Model: happens-before, visibility)이 아니라, CPU cache hierarchy + locality 관점입니다.
21+
> 이 글에서 말하는 "메모리 모델"은 JMM(Java Memory Model: happens-before, visibility)이 아니라, CPU cache hierarchy + locality 일반론에 가깝습니다.
2222
2323
---
2424

@@ -43,11 +43,15 @@ Big-O는 연산 횟수의 증가율을 설명합니다. 하지만 실제 실행
4343

4444
## 2) ArrayList와 LinkedList의 핵심 차이: 레이아웃
4545

46-
`ArrayList`는 내부적으로 `Object[]` 배열을 사용합니다.[^arraylist-javadoc]
47-
반면 `LinkedList`는 노드(prev/item/next)가 연결된 구조입니다.[^linkedlist-javadoc]
46+
`ArrayList`의 OpenJDK 구현은 내부 버퍼 필드로 `transient Object[] elementData`사용합니다.[^openjdk-arraylist-source]
47+
반면 `LinkedList`의 OpenJDK 구현은 `Node<E> first/last``Node<E> { item, next, prev }`를 유지하는 이중 연결 구조입니다.[^openjdk-linkedlist-source]
4848

49-
[^arraylist-javadoc]: ArrayList Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayList.html
50-
[^linkedlist-javadoc]: LinkedList Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/LinkedList.html
49+
여기서 `Object[]`라고 쓰는 이유는 "런타임에서 제네릭 타입 매개변수가 지워진다(type erasure)"는 규칙과 맞닿아 있습니다.[^jls-erasure][^oracle-erasure]
50+
51+
[^openjdk-arraylist-source]: OpenJDK ArrayList source (`transient Object[] elementData`) — https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ArrayList.java#L157
52+
[^openjdk-linkedlist-source]: OpenJDK LinkedList source (`Node<E> first/last`, `Node<E>{item,next,prev}`) — https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/LinkedList.java#L104
53+
[^jls-erasure]: JLS 4.6 Type Erasure — https://docs.oracle.com/javase/specs/jls/se24/html/jls-4.html#jls-4.6
54+
[^oracle-erasure]: Oracle Tutorial, Type Erasure (unbounded type parameter는 `Object`로 대체) — https://docs.oracle.com/javase/tutorial/java/generics/erasure.html
5155

5256
```
5357
ArrayList (contiguous references)
@@ -68,7 +72,9 @@ Node <-> Node <-> Node <-> Node ...
6872

6973
## 3) 왜 캐시가 승부를 바꾸는가
7074

71-
CPU는 메모리를 바이트 단위가 아니라 **cache line 단위**로 가져옵니다(대부분 64B).
75+
CPU는 메모리를 바이트 단위가 아니라 **cache line 단위**로 가져옵니다(많은 x86_64 환경에서 64B, 아키텍처별 차이 있음).[^cacheline-caveat]
76+
77+
[^cacheline-caveat]: Cache line 크기는 CPU 아키텍처/구현에 따라 다를 수 있습니다. 예: Intel Optimization Reference Manual(2024)과 Arm AArch64 CTR_EL0 문서 참고. 또한 Intel 매뉴얼은 데이터 의존적 주소 추적(pointer chasing) 패턴에서 prefetch 효율이 떨어질 수 있음을 설명합니다. 이 글에서는 서버 환경에서 흔한 x86_64 기준 설명을 사용합니다. https://cdrdv2-public.intel.com/814198/248966-Optimization-Reference-Manual-V1-050.pdf, https://developer.arm.com/documentation/ddi0601/latest/AArch64-Registers/CTR-EL0--Cache-Type-Register
7278

7379
배열은 연속 메모리라서, `arr[i]`를 읽을 때 주변 원소 참조들도 같이 캐시에 올라옵니다.
7480
그래서 다음 접근이 캐시 히트가 될 가능성이 큽니다(공간 지역성).
@@ -79,7 +85,7 @@ CPU는 메모리를 바이트 단위가 아니라 **cache line 단위**로 가
7985
- pointer chasing으로 캐시/TLB 미스가 누적되기 쉽고
8086
- 하드웨어 prefetcher가 잘 먹히지 않는 경우가 많습니다
8187

82-
Oracle dev.java의 공식 비교 자료도 이 점을 명확히 설명합니다.[^devjava-al-vs-ll]
88+
Oracle dev.java의 공식 비교 자료도 ArrayList/LinkedList 선택 시 성능 관점을 함께 설명합니다.[^devjava-al-vs-ll]
8389

8490
[^devjava-al-vs-ll]: ArrayList vs LinkedList, dev.java (Oracle) — https://dev.java/learn/api/collections-framework/arraylist-vs-linkedlist/
8591

@@ -99,17 +105,14 @@ Oracle dev.java의 공식 비교 자료도 이 점을 명확히 설명합니다.
99105

100106
그래서 일반적인 List 사용에서는 `ArrayList`가 더 빠른 경우가 훨씬 많습니다.
101107

102-
`RandomAccess` 마커 인터페이스가 존재하는 이유도 바로 이 차이를 알고리즘이 구분하기 위함입니다.[^randomaccess-javadoc]
103-
104-
[^randomaccess-javadoc]: RandomAccess Javadoc, Oracle Java SE 17 — https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/RandomAccess.html
105108

106109
---
107110

108111
## 5) Java 2D 배열: "완전 flat"이 아니라 array-of-arrays
109112

110113
여기서 자주 헷갈리는 포인트가 있습니다.
111114

112-
Java의 `int[][]`는 C 스타일의 단일 연속 2D 블록이 아니라, **배열의 배열**입니다.[^jls-arrays]
115+
Java의 `int[][]`는 C 스타일의 단일 연속 2D 블록이 아니라, **배열 변수의 배열**입니다.[^jls-arrays]
113116

114117
[^jls-arrays]: JLS Chapter 10 (Arrays), multidimensional arrays are arrays of arrays — https://docs.oracle.com/javase/specs/jls/se24/html/jls-10.html
115118

@@ -146,9 +149,9 @@ Deque/Queue/Stack 용도로는 `LinkedList`보다 `ArrayDeque`가 기본 선택
146149

147150
Javadoc도 직접 이렇게 말합니다.
148151

149-
> "ArrayDeque는 queue로 사용할 때 LinkedList보다 faster일 가능성이 높다."[^arraydeque-javadoc]
152+
> "ArrayDeque는 queue로 사용할 때 LinkedList보다 더 빠를 가능성이 높다."[^arraydeque-javadoc]
150153
151-
[^arraydeque-javadoc]: ArrayDeque Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayDeque.html
154+
[^arraydeque-javadoc]: "This class is likely to be faster than Stack when used as a stack, and faster than LinkedList when used as a queue." ArrayDeque Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayDeque.html
152155

153156
물론 예외는 있습니다.
154157

@@ -159,26 +162,25 @@ Javadoc도 직접 이렇게 말합니다.
159162

160163
---
161164

162-
## 7) 앞으로의 변수: Project Valhalla
165+
## 7) 왜 지금 더 차이가 커졌나: memory wall
163166

164-
Project Valhalla(JEP 401)는 value class/value object를 통해 JVM이 더 공격적인 메모리 최적화를 할 여지를 넓히는 방향입니다.[^jep401][^valhalla]
167+
제가 이 주제를 "요즘 CPU에서 더 중요해졌다"고 보는 이유는, 알고리즘 교과서가 틀려서가 아니라 **하드웨어 밸런스가 바뀌었기 때문**입니다.
165168

166-
[^jep401]: JEP 401: Value Classes and Objects (Preview) — https://openjdk.org/jeps/401
167-
[^valhalla]: OpenJDK Project Valhalla — https://openjdk.org/projects/valhalla/
169+
Wulf/McKee가 정리한 고전적인 memory wall 논점은 단순합니다. CPU 성능 향상 속도가 DRAM 지연 개선 속도보다 더 빨라서, 메모리 접근의 상대 비용이 시간이 갈수록 커진다는 점입니다.[^memory-wall]
168170

169-
핵심은 "가능성"입니다.
171+
실무적으로는 "캐시 미스 한 번이 몇 ns인가"보다, **"코어가 그동안 몇 cycle을 놀게 되느냐"**가 더 중요합니다.
172+
McCalpin은 이미 1990년대 중반에 "cache miss 한 번 처리하는 동안 최신 프로세서는 100개 이상의 부동소수점 연산을 수행할 수 있다"고 정리했고, 지연(latency) 지배가 강해지는 방향을 지적했습니다.[^mccalpin-balance]
170173

171-
- value object는 identity가 없으므로
172-
- JVM이 flattening/scalarization 같은 표현 최적화를 적용할 수 있고
173-
- 특정 경우 locality/footprint에 유리해질 수 있습니다
174+
그래서 제 해석은 이렇습니다.
174175

175-
하지만 여기서 과장하면 안 됩니다.
176+
- pointer chasing이 과거에 없던 문제가 된 게 아니라
177+
- **예전보다 miss penalty(특히 cycle 기준)가 더 커져서**
178+
- locality 차이가 실제 벽시계 시간에 더 크게 번역되기 쉬워졌습니다.
176179

177-
- 어떤 레이아웃이 항상 보장되는 것은 아님
178-
- 모든 타입/모든 상황에서 flattening이 일어나는 것도 아님
179-
- 현재는 preview 성격이므로 버전/구현별 차이를 전제로 봐야 함
180+
즉 "옛날엔 LinkedList도 충분히 빨랐다"는 단정은 조심해야 하지만, "현대 CPU에서는 locality를 놓치면 비용이 더 빠르게 커진다"는 방향은 역사적으로도 일관된 설명입니다.
180181

181-
즉 Valhalla는 "Linked 구조가 자동으로 사라진다"가 아니라, **"JVM 최적화 가능 공간이 커진다"**로 이해하는 것이 정확합니다.
182+
[^memory-wall]: Wulf, McKee, "Hitting the Memory Wall: Implications of the Obvious" (1995) — https://libraopen.lib.virginia.edu/downloads/4b29b598d
183+
[^mccalpin-balance]: John D. McCalpin, "Memory Bandwidth and Machine Balance in Current High Performance Computers" (1995) — https://www.cs.virginia.edu/~mccalpin/papers/balance/
182184

183185
---
184186

@@ -189,55 +191,68 @@ Project Valhalla(JEP 401)는 value class/value object를 통해 JVM이 더 공
189191
1. 특별한 이유가 없으면 `List``ArrayList`부터 시작
190192
2. Deque가 필요하면 `ArrayDeque`부터 시작
191193
3. LinkedList를 선택할 때는 "왜 LinkedList여야 하는지"를 코드 코멘트/리뷰에 남김
192-
4. 성능 이슈는 `System.nanoTime()` 루프가 아니라 JMH로 검증[^jmh]
193-
194-
[^jmh]: OpenJDK JMH — https://github.com/openjdk/jmh
195194

196195
Big-O는 여전히 중요합니다. 다만 **현대 CPU에서 데이터가 어떻게 배치되고 이동하는지**까지 같이 봐야, 실제 서비스 성능을 예측할 수 있습니다.
197196

198197
---
199198

200-
## 9) 재현 가능한 JMH 예시 (로컬 clone 기준)
199+
## 9) 벤치마크
201200

202-
말로만 "ArrayList가 보통 빠르다"고 끝내지 않기 위해, 이 저장소에 JMH 예시를 같이 두었습니다.
201+
이제 실제로 얼마나 차이가 나는지, 어떤 상황에서 ArrayList가 우세하고 LinkedList가 우세한지를 직접 알아보도록 합시다.
203202

204203
- 예제 경로(canonical): `examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality`
205-
- GitHub 링크 템플릿: `https://github.com/<owner>/<repo>/tree/main/examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality`
204+
- GitHub 링크: `https://github.com/Clickin/Clickin.github.io/tree/main/examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality`
206205
- 벤치마크 코드: `examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality/src/main/java/io/clickin/bench/ListAndDequeBenchmark.java`
207206
- 실행 스크립트: `examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality/run-local-jmh.sh`
208207

209-
로컬에서는 아래처럼 동일한 설정으로 바로 실행할 수 있습니다.
208+
아래는 제 로컬 기기에서 수행한 벤치마크 결과입니다.
210209

211-
```bash
212-
git clone https://github.com/<owner>/<repo>.git
213-
cd <repo>
214-
./examples/blog/2026-02-25-arraylist-vs-linkedlist-cache-locality/run-local-jmh.sh
215-
```
210+
- 설정: `run-local-jmh.ps1` (full preset)
211+
- JMH: warmup 5회(300ms), measurement 8회(300ms), fork 1, JVM args `-Xms1g -Xmx1g`
212+
- Java: OpenJDK Temurin 25.0.1+8 LTS (`results/java-version.txt`)
213+
- 머신: 13th Gen Intel Core i5-13600K, 14C/20T, MaxClock 3.5GHz (`results/lscpu.txt`)
214+
215+
| Scenario | size | Array/ArrayDeque (ns/op) | LinkedList (ns/op) | 관측 |
216+
|---|---:|---:|---:|---|
217+
| `getMiddle` | 65536 | `ArrayList` 0.829 | 33934.146 | LinkedList가 약 40,934배 느림 |
218+
| `iterate` | 65536 | `ArrayList` 42397.478 | 72603.586 | LinkedList가 약 1.71배 느림 |
219+
| `middleInsertThenRemove` | 65536 | `ArrayList` 3582.505 | 95199.332 | LinkedList가 약 26.57배 느림 |
220+
| `headInsertThenRemove` | 65536 | `ArrayList` 7098.527 | 12.275 | LinkedList가 약 578.29배 빠름 |
221+
| `tailAddThenRemove` | 65536 | `ArrayList` 5.324 | 12.210 | ArrayList가 약 2.29배 빠름 |
222+
| `iteratorRemoveAndRestore` | 65536 | `ArrayList` 7092.985 | 11.381 | LinkedList가 약 623.23배 빠름 |
223+
| `deque offerLast/pollFirst` | 65536 | `ArrayDeque` 6.027 | 12.584 | ArrayDeque가 약 2.09배 빠름 |
224+
| `deque offerFirst/pollLast` | 65536 | `ArrayDeque` 5.538 | 12.517 | ArrayDeque가 약 2.26배 빠름 |
225+
226+
아래는 같은 머신에서 `findByValue(first/middle/last)`를 full preset으로 포함해 측정한 결과입니다.
227+
228+
| Scenario | size | ArrayList (ns/op) | LinkedList (ns/op) | 관측 |
229+
|---|---:|---:|---:|---|
230+
| `findByValueFirst` | 65536 | 0.922 | 0.905 | 거의 동일 |
231+
| `findByValueMiddle` | 65536 | 13078.958 | 41568.032 | LinkedList가 약 3.18배 느림 |
232+
| `findByValueLast` | 65536 | 43165.948 | 78492.869 | LinkedList가 약 1.82배 느림 |
216233

217-
아래는 이 글 작성 시점에 실행한 **quick run 결과**입니다.
234+
핵심은 `first`에서는 차이가 작지만, 탐색 길이가 길어질수록(`middle`, `last`) pointer chasing 비용이 누적되면서 LinkedList가 더 불리해진다는 점입니다.
218235

219-
- 설정: `./run-local-jmh.sh --quick` (quick preset)
236+
메모리 할당량도 같이 비교하기 위해, `arrayListBuildFromPayload` vs `linkedListBuildFromPayload``-prof gc`로 추가 측정했습니다.
220237

221-
| Scenario | size | Array/ArrayDeque (ns/op) | LinkedList (ns/op) | 관측 |
238+
| Scenario | size | ArrayList | LinkedList | 관측 |
222239
|---|---:|---:|---:|---|
223-
| `getMiddle` | 65536 | `ArrayList` 1.627 | 114522.376 | LinkedList가 약 70,000배 느림 |
224-
| `iterate` | 65536 | `ArrayList` 167263.719 | 277359.265 | LinkedList가 약 1.66배 느림 |
225-
| `middleInsertThenRemove` | 65536 | `ArrayList` 3576.092 | 218811.664 | LinkedList가 약 61배 느림 |
226-
| `headInsertThenRemove` | 65536 | `ArrayList` 7530.176 | 33.369 | LinkedList가 약 225배 빠름 |
227-
| `tailAddThenRemove` | 65536 | `ArrayList` 15.028 | 31.444 | ArrayList가 약 2.09배 빠름 |
228-
| `iteratorRemoveAndRestore` | 65536 | `ArrayList` 7434.923 | 25.838 | LinkedList가 약 288배 빠름 |
229-
| `deque offerLast/pollFirst` | 65536 | `ArrayDeque` 15.159 | 35.446 | ArrayDeque가 약 2.34배 빠름 |
230-
| `deque offerFirst/pollLast` | 65536 | `ArrayDeque` 14.968 | 34.369 | ArrayDeque가 약 2.30배 빠름 |
240+
| `buildFromPayload` 시간 (ns/op) | 65536 | 100677.722 | 158964.547 | LinkedList가 약 1.58배 느림 |
241+
| `buildFromPayload` 할당량 (B/op) | 65536 | 262186.3 | 1572867.7 | LinkedList가 약 6.00배 더 많이 할당 |
242+
243+
즉 이 케이스에서는 LinkedList가 실행시간뿐 아니라, 노드 객체 생성 비용 때문에 메모리 할당량에서도 명확히 불리했습니다.
231244

232-
관찰 포인트는 가지입니다.
245+
관찰 포인트는 가지입니다.
233246

234-
1. `read/iterate/deque` 계열에서는 Array 구조가 일관되게 유리합니다.
235-
2. 반대로 `head insert/remove`, `iterator remove`처럼 LinkedList의 구조적 강점이 드러나는 시나리오도 분명히 존재합니다.
247+
1. `getMiddle`처럼 임의 접근 성격이 강한 연산은 Array 계열(`ArrayList`)이 압도적으로 유리합니다.
248+
2. `head insert/remove`, `iterator remove`처럼 노드 재연결이 핵심인 연산은 `LinkedList`가 매우 크게 앞섭니다.
249+
3. `deque offer/poll` 시나리오에서는 `ArrayDeque``LinkedList`보다 꾸준히 빠른 기본값으로 관측됩니다.
250+
251+
252+
---
236253

237-
즉 "항상 ArrayList"가 아니라, **실제 접근 패턴을 기준으로 선택해야 한다**가 이 실험의 핵심 결론입니다.
254+
## 10) 정리
238255

239256
결론은 단순합니다.
240257

241-
- 이론 복잡도만 보면 LinkedList가 좋아 보일 때가 있어도
242-
- 일반적인 Java 애플리케이션의 대부분 시나리오에서는
243-
- `ArrayList`(그리고 Deque라면 `ArrayDeque`)가 더 좋은 기본값입니다.
258+
- 이론 복잡도만 보면 LinkedList가 좋아보일 수 있지만 일반적인 Java 애플리케이션의 대부분 시나리오에서는 `ArrayList`(그리고 Deque라면 `ArrayDeque`)를 사용합시다.

0 commit comments

Comments
 (0)