|
| 1 | +--- |
| 2 | +title: "ArrayList vs LinkedList: Big-O는 맞는데, 왜 대부분 ArrayList가 더 빠를까?" |
| 3 | +description: "Java 컬렉션 선택을 Big-O가 아닌 CPU 캐시와 메모리 레이아웃 관점에서 정리하고, ArrayDeque 선택 기준과 Project Valhalla의 변화 가능성까지 다룹니다." |
| 4 | +date: 2026-02-25 |
| 5 | +category: "Java > Performance" |
| 6 | +tags: ["java", "jvm", "performance", "collections", "arraylist", "linkedlist", "cache", "valhalla"] |
| 7 | +draft: false |
| 8 | +publish: false |
| 9 | +--- |
| 10 | + |
| 11 | +Java 컬렉션을 처음 배울 때 보통 이렇게 외웁니다. |
| 12 | + |
| 13 | +- `ArrayList.get(i)`는 O(1) |
| 14 | +- `LinkedList`는 중간 삽입/삭제가 O(1) |
| 15 | + |
| 16 | +그래서 직감적으로는 "삽입/삭제가 많으면 LinkedList가 유리하겠네"라고 생각하기 쉽습니다. |
| 17 | +그런데 실제 서비스 코드나 벤치마크에서는 대체로 반대 결과가 자주 나옵니다. |
| 18 | + |
| 19 | +이번 글에서는 그 이유를 Big-O가 아니라 **CPU 캐시와 메모리 레이아웃** 관점에서 정리해보겠습니다. |
| 20 | + |
| 21 | +> 이 글에서 말하는 "메모리 모델"은 JMM(Java Memory Model: happens-before, visibility)이 아니라, CPU cache hierarchy + locality 관점입니다. |
| 22 | +
|
| 23 | +--- |
| 24 | + |
| 25 | +## 1) Big-O는 틀린 게 아니라, 생략이 많다 |
| 26 | + |
| 27 | +Big-O는 연산 횟수의 증가율을 설명합니다. 하지만 실제 실행 시간은 여기에 다음 요소가 함께 붙습니다. |
| 28 | + |
| 29 | +- 메모리 접근 패턴(연속/불연속) |
| 30 | +- 캐시 미스와 TLB 미스 |
| 31 | +- 분기 예측 실패 |
| 32 | +- 객체 할당/GC 비용 |
| 33 | + |
| 34 | +즉, 현실 성능은 대략 아래처럼 결정됩니다. |
| 35 | + |
| 36 | +``` |
| 37 | +실행 시간 = 알고리즘 복잡도 + 메모리 계층 비용 + 런타임/JIT/GC 비용 |
| 38 | +``` |
| 39 | + |
| 40 | +그래서 복잡도 표만 보면 비슷해 보이는 연산이 실제로는 크게 벌어질 수 있습니다. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 2) ArrayList와 LinkedList의 핵심 차이: 레이아웃 |
| 45 | + |
| 46 | +`ArrayList`는 내부적으로 `Object[]` 배열을 사용합니다.[^arraylist-javadoc] |
| 47 | +반면 `LinkedList`는 노드(prev/item/next)가 연결된 구조입니다.[^linkedlist-javadoc] |
| 48 | + |
| 49 | +``` |
| 50 | +ArrayList (contiguous references) |
| 51 | +[ref][ref][ref][ref][ref]... |
| 52 | +
|
| 53 | +LinkedList (pointer chain) |
| 54 | +Node <-> Node <-> Node <-> Node ... |
| 55 | +``` |
| 56 | + |
| 57 | +여기서 중요한 포인트가 하나 있습니다. |
| 58 | + |
| 59 | +- `ArrayList`가 locality 이점을 주는 대상은 "객체 자체"가 아니라 우선 "참조 배열(Object[])"입니다. |
| 60 | +- 즉 원소가 참조하는 객체까지 항상 인접하다는 뜻은 아닙니다. |
| 61 | + |
| 62 | +그럼에도 순회/인덱스 접근에서 유리한 이유는, 참조 배열 자체가 연속 메모리이기 때문입니다. |
| 63 | + |
| 64 | +--- |
| 65 | + |
| 66 | +## 3) 왜 캐시가 승부를 바꾸는가 |
| 67 | + |
| 68 | +CPU는 메모리를 바이트 단위가 아니라 **cache line 단위**로 가져옵니다(대부분 64B). |
| 69 | + |
| 70 | +배열은 연속 메모리라서, `arr[i]`를 읽을 때 주변 원소 참조들도 같이 캐시에 올라옵니다. |
| 71 | +그래서 다음 접근이 캐시 히트가 될 가능성이 큽니다(공간 지역성). |
| 72 | + |
| 73 | +반대로 linked list는 다음 노드 주소를 따라가야 하므로, |
| 74 | + |
| 75 | +- 다음 데이터 위치를 미리 예측하기 어렵고 |
| 76 | +- pointer chasing으로 캐시/TLB 미스가 누적되기 쉽고 |
| 77 | +- 하드웨어 prefetcher가 잘 먹히지 않는 경우가 많습니다 |
| 78 | + |
| 79 | +Oracle dev.java의 공식 비교 자료도 이 점을 명확히 설명합니다.[^devjava-al-vs-ll] |
| 80 | + |
| 81 | +--- |
| 82 | + |
| 83 | +## 4) "LinkedList 삽입 O(1)"의 실무 함정 |
| 84 | + |
| 85 | +"LinkedList 삽입 O(1)"은 맞는 말입니다. 단, **삽입 위치의 노드를 이미 알고 있을 때**에 한해서입니다. |
| 86 | + |
| 87 | +대부분의 비즈니스 코드는 `add(index, value)` 또는 `get(index)` 형태를 사용합니다. |
| 88 | +이 경우 먼저 해당 위치까지 탐색(O(n))이 필요하고, 여기서 pointer chasing 비용이 더해집니다. |
| 89 | + |
| 90 | +결국 실무에서는 아래 패턴이 자주 나타납니다. |
| 91 | + |
| 92 | +- 이론: 삽입 자체는 O(1) |
| 93 | +- 현실: 위치 탐색 O(n) + 캐시 미스 + 노드 할당 비용 |
| 94 | + |
| 95 | +그래서 일반적인 List 사용에서는 `ArrayList`가 더 빠른 경우가 훨씬 많습니다. |
| 96 | + |
| 97 | +`RandomAccess` 마커 인터페이스가 존재하는 이유도 바로 이 차이를 알고리즘이 구분하기 위함입니다.[^randomaccess-javadoc] |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## 5) Java 2D 배열: "완전 flat"이 아니라 array-of-arrays |
| 102 | + |
| 103 | +여기서 자주 헷갈리는 포인트가 있습니다. |
| 104 | + |
| 105 | +Java의 `int[][]`는 C 스타일의 단일 연속 2D 블록이 아니라, **배열의 배열**입니다.[^jls-arrays] |
| 106 | + |
| 107 | +그래도 아래 순회는 성능 차이가 납니다. |
| 108 | + |
| 109 | +```java |
| 110 | +// row-wise (보통 유리) |
| 111 | +for (int i = 0; i < rows; i++) { |
| 112 | + for (int j = 0; j < cols; j++) { |
| 113 | + sum += a[i][j]; |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +// column-wise (보통 불리) |
| 118 | +for (int j = 0; j < cols; j++) { |
| 119 | + for (int i = 0; i < rows; i++) { |
| 120 | + sum += a[i][j]; |
| 121 | + } |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +이유는 간단합니다. |
| 126 | + |
| 127 | +- row-wise: 같은 `a[i]` 내부를 연속 접근(좋은 locality) |
| 128 | +- column-wise: 서로 다른 행 배열을 계속 점프(나쁜 locality) |
| 129 | + |
| 130 | +즉 Java 2D 배열은 "완전 flat"이라서가 아니라, **행 내부 연속성** 때문에 row-wise가 일반적으로 유리합니다. |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## 6) Deque는 보통 ArrayDeque를 기본값으로 |
| 135 | + |
| 136 | +Deque/Queue/Stack 용도로는 `LinkedList`보다 `ArrayDeque`가 기본 선택에 가깝습니다. |
| 137 | + |
| 138 | +Javadoc도 직접 이렇게 말합니다. |
| 139 | + |
| 140 | +> "ArrayDeque는 queue로 사용할 때 LinkedList보다 faster일 가능성이 높다."[^arraydeque-javadoc] |
| 141 | +
|
| 142 | +물론 예외는 있습니다. |
| 143 | + |
| 144 | +- `null` 원소를 꼭 저장해야 한다 (`ArrayDeque`는 null 금지) |
| 145 | +- `List` API까지 함께 필요하다 (`LinkedList`는 `List`도 구현) |
| 146 | + |
| 147 | +이런 요구가 없다면 Deque는 `ArrayDeque`를 먼저 고려하는 것이 보통 더 안전합니다. |
| 148 | + |
| 149 | +--- |
| 150 | + |
| 151 | +## 7) 앞으로의 변수: Project Valhalla |
| 152 | + |
| 153 | +Project Valhalla(JEP 401)는 value class/value object를 통해 JVM이 더 공격적인 메모리 최적화를 할 여지를 넓히는 방향입니다.[^jep401][^valhalla] |
| 154 | + |
| 155 | +핵심은 "가능성"입니다. |
| 156 | + |
| 157 | +- value object는 identity가 없으므로 |
| 158 | +- JVM이 flattening/scalarization 같은 표현 최적화를 적용할 수 있고 |
| 159 | +- 특정 경우 locality/footprint에 유리해질 수 있습니다 |
| 160 | + |
| 161 | +하지만 여기서 과장하면 안 됩니다. |
| 162 | + |
| 163 | +- 어떤 레이아웃이 항상 보장되는 것은 아님 |
| 164 | +- 모든 타입/모든 상황에서 flattening이 일어나는 것도 아님 |
| 165 | +- 현재는 preview 성격이므로 버전/구현별 차이를 전제로 봐야 함 |
| 166 | + |
| 167 | +즉 Valhalla는 "Linked 구조가 자동으로 사라진다"가 아니라, **"JVM 최적화 가능 공간이 커진다"**로 이해하는 것이 정확합니다. |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## 8) 실무 선택 가이드 |
| 172 | + |
| 173 | +제가 실제 코드 리뷰에서 자주 쓰는 기준은 아래입니다. |
| 174 | + |
| 175 | +1. 특별한 이유가 없으면 `List`는 `ArrayList`부터 시작 |
| 176 | +2. Deque가 필요하면 `ArrayDeque`부터 시작 |
| 177 | +3. LinkedList를 선택할 때는 "왜 LinkedList여야 하는지"를 코드 코멘트/리뷰에 남김 |
| 178 | +4. 성능 이슈는 `System.nanoTime()` 루프가 아니라 JMH로 검증[^jmh] |
| 179 | + |
| 180 | +Big-O는 여전히 중요합니다. 다만 **현대 CPU에서 데이터가 어떻게 배치되고 이동하는지**까지 같이 봐야, 실제 서비스 성능을 예측할 수 있습니다. |
| 181 | + |
| 182 | +--- |
| 183 | + |
| 184 | +## 9) 재현 가능한 JMH 예시 (로컬 + GitHub Actions) |
| 185 | + |
| 186 | +말로만 "ArrayList가 보통 빠르다"고 끝내지 않기 위해, 이 저장소에 JMH 예시를 같이 두었습니다. |
| 187 | + |
| 188 | +- 벤치마크 코드: `benchmarks/jmh-arraylist-vs-linkedlist/src/main/java/io/clickin/bench/ListAndDequeBenchmark.java` |
| 189 | +- 실행 워크플로: `.github/workflows/jmh-collections-benchmark.yml` |
| 190 | + |
| 191 | +로컬에서는 아래처럼 동일한 설정으로 바로 실행할 수 있습니다. |
| 192 | + |
| 193 | +```bash |
| 194 | +mvn -B -ntp -f benchmarks/jmh-arraylist-vs-linkedlist/pom.xml clean package |
| 195 | +java -jar benchmarks/jmh-arraylist-vs-linkedlist/target/jmh-benchmarks.jar \ |
| 196 | + io.clickin.bench.ListAndDequeBenchmark \ |
| 197 | + -wi 5 -i 8 -w 300ms -r 300ms -f 1 -tu ns \ |
| 198 | + -jvmArgs "-Xms1g -Xmx1g" \ |
| 199 | + -rf json -rff benchmarks/jmh-arraylist-vs-linkedlist/results/jmh-result.json |
| 200 | +``` |
| 201 | + |
| 202 | +GitHub Actions 실행 시에는 결과(JSON/TXT)와 실행 환경 정보(Java version, CPU 정보)를 artifact로 올려서, |
| 203 | +"어떤 머신/JDK에서 나온 숫자인지"를 추적 가능하게 했습니다. |
| 204 | + |
| 205 | +결론은 단순합니다. |
| 206 | + |
| 207 | +- 이론 복잡도만 보면 LinkedList가 좋아 보일 때가 있어도 |
| 208 | +- 일반적인 Java 애플리케이션의 대부분 시나리오에서는 |
| 209 | +- `ArrayList`(그리고 Deque라면 `ArrayDeque`)가 더 좋은 기본값입니다. |
| 210 | + |
| 211 | +--- |
| 212 | + |
| 213 | +[^arraylist-javadoc]: ArrayList Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayList.html |
| 214 | +[^linkedlist-javadoc]: LinkedList Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/LinkedList.html |
| 215 | +[^randomaccess-javadoc]: RandomAccess Javadoc, Oracle Java SE 17 — https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/RandomAccess.html |
| 216 | +[^devjava-al-vs-ll]: ArrayList vs LinkedList, dev.java (Oracle) — https://dev.java/learn/api/collections-framework/arraylist-vs-linkedlist/ |
| 217 | +[^arraydeque-javadoc]: ArrayDeque Javadoc, Oracle Java SE 25 — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayDeque.html |
| 218 | +[^jls-arrays]: JLS Chapter 10 (Arrays), multidimensional arrays are arrays of arrays — https://docs.oracle.com/javase/specs/jls/se24/html/jls-10.html |
| 219 | +[^jep401]: JEP 401: Value Classes and Objects (Preview) — https://openjdk.org/jeps/401 |
| 220 | +[^valhalla]: OpenJDK Project Valhalla — https://openjdk.org/projects/valhalla/ |
| 221 | +[^jmh]: OpenJDK JMH — https://github.com/openjdk/jmh |
0 commit comments