"ArrayList랑 LinkedList 뭐가 달라요?", "HashMap이 왜 빠른가요?", "ConcurrentHashMap은 왜 Thread-safe한가요?" — 이 세 가지가 면접에서 가장 자주 나오는 Collections 질문입니다. 사용법은 다 알지만 "내부에서 어떻게 동작하는지"를 묻는 거라서, 원리만 이해하면 쉽게 답할 수 있습니다.
결론부터: 대부분의 상황에서 ArrayList가 더 빠릅니다. 왜인지 이해하려면 내부 구조를 보면 됩니다.
ArrayList는 이름에 List가 붙어 있지만 내부는 배열(Object[])입니다. 인덱스로 접근하는 건 배열 주소 계산 한 번이라 O(1)으로 매우 빠릅니다.
// [A, B, C, D, E] 에서 인덱스 1 위치에 "X" 삽입
list.add(1, "X");
// 내부에서 일어나는 일:
// B, C, D, E를 전부 오른쪽으로 한 칸씩 밀어야 함
// → 요소가 100만 개면 100만 번 이동
// → O(n) 작업
// [A, X, B, C, D, E] ← 결과
LinkedList의 각 요소는 "값 + 앞 노드 주소 + 뒤 노드 주소"를 담은 노드입니다. 메모리에 흩어져 있고 체인처럼 연결됩니다.
// B와 C 사이에 "X" 삽입
// → B의 next를 X로, C의 prev를 X로 바꾸면 끝
// → 요소가 100만 개여도 포인터 2개만 바꿈
// → O(1) (단, 삽입할 위치를 이미 알고 있을 때)
// 문제는 "위치를 찾는 것"이 O(n)
// list.get(500_000) → 앞에서부터 50만 번 이동 → O(n)
이론상으로는 맞습니다. 하지만 실무에서 LinkedList가 더 빠른 경우는 드뭅니다. 이유는 두 가지입니다.
첫째, 삽입할 위치를 찾는 탐색(O(n))이 먼저입니다. 삽입 자체가 O(1)이어도 위치 탐색이 O(n)이면 전체가 O(n)입니다.
둘째, 메모리 지역성(cache locality)입니다. ArrayList는 배열이라 데이터가 메모리에 연속으로 붙어 있습니다.
CPU 캐시가 한 번에 여러 요소를 미리 가져오므로 실제 속도가 훨씬 빠릅니다.
LinkedList는 각 노드가 메모리에 흩어져 있어 CPU 캐시 미스가 자주 납니다.
벤치마크 결과 대부분의 삽입/삭제 작업에서도 ArrayList가 더 빠른 경우가 많습니다.
LinkedList를 선택하는 경우: 맨 앞/맨 뒤 삽입이 매우 빈번하고 인덱스 접근이 없는 큐/덱으로 쓸 때.
그 외에는 ArrayList를 기본으로 씁니다.
| 연산 | ArrayList | LinkedList |
|---|---|---|
| 인덱스 접근 (get) | O(1) — 배열 직접 접근 | O(n) — 앞에서부터 순회 |
| 맨 뒤 추가 (add) | O(1) amortized — 배열 끝에 삽입 | O(1) — tail 노드 포인터 변경 |
| 중간 삽입/삭제 | O(n) — 뒤 요소 전부 이동 | O(n) — 위치 탐색이 O(n) |
| 메모리 | 연속 배열 — 캐시 효율 좋음 | 노드마다 앞/뒤 포인터 16바이트 추가 |
HashMap은 내부에 배열(버킷 배열)을 갖고 있습니다.
put("key", value)를 하면 key로 해시값을 계산하고,
그 해시값으로 배열의 어떤 칸(버킷)에 넣을지 결정합니다.
나중에 get("key")를 하면 같은 방식으로 해시값 계산 → 배열 해당 칸 바로 접근 → O(1).
key마다 해시값이 다르지만, 배열 크기가 16이면 결국 0~15 중 하나에 들어가야 합니다.
서로 다른 key가 같은 칸으로 배정될 수 있습니다 — 이게 해시 충돌입니다.
충돌 시 같은 칸에 LinkedList로 연결해서 관리합니다(체이닝).
충돌이 많아질수록 체인이 길어지고 탐색 시간도 O(1)에서 멀어집니다.
Java 8부터 체인이 8개 이상이 되면 자동으로 Red-Black Tree로 변환해서
최악의 경우를 O(n) → O(log n)으로 개선합니다.
(Red-Black Tree의 세부 구조는 몰라도 됩니다 — "균형 이진 탐색 트리" 정도로만 알면 충분합니다.)
Resize는 모든 항목을 다시 배치하는 O(n) 작업입니다.
넣을 항목 수를 미리 알면 new HashMap<>(1000)처럼 초기 크기를 지정해서 Resize를 막을 수 있습니다.
멀티스레드 환경에서 두 스레드가 동시에 같은 HashMap에 put하면
한쪽 데이터가 유실되거나, Resize 중에 무한루프가 걸릴 수 있습니다.
이를 해결하는 게 ConcurrentHashMap입니다.
Collections.synchronizedMap(map)은 모든 읽기/쓰기를 하나의 락으로 막습니다.
한 스레드가 작업 중이면 다른 스레드는 전부 대기 → 병목.
ConcurrentHashMap은 버킷(칸) 단위로 잠급니다.
버킷 0을 수정 중이어도 버킷 1, 2, 3은 다른 스레드가 동시에 접근 가능합니다.
읽기는 락도 없습니다 — volatile 읽기로 항상 최신 값을 봅니다.
| 특성 | HashMap | synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| Thread-safe | ❌ | ✅ | ✅ |
| 락 방식 | 없음 | 전체 메서드에 락 (느림) | 버킷 단위 락 (빠름) |
| 읽기 | 빠름 | 락 필요 → 느림 | 락 없음 → 빠름 |
| null 키/값 | 키 null 1개, 값 null 허용 | 동일 | null 불허 |
// 내부: HashMap + 이중 연결 리스트
// 삽입한 순서대로 꺼낼 수 있음
Map<String, Integer> map =
new LinkedHashMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 삽입 순서대로: C → A → B
map.forEach((k, v) ->
System.out.println(k)); // C, A, B
// accessOrder=true: 최근 접근 순서 유지
// → LRU 캐시 구현에 활용
// 내부: Red-Black Tree
// 키가 항상 정렬된 순서로 유지됨
// get/put: O(log n)
Map<String, Integer> map = new TreeMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 알파벳 오름차순: A → B → C
map.forEach((k, v) ->
System.out.println(k)); // A, B, C
// 범위 조회도 가능
map.subMap("A", "C"); // A이상 C미만
map.headMap("B"); // B 미만
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
// accessOrder=true → 접근할 때마다 해당 항목이 맨 뒤로 이동
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // 가득 차면 가장 오래된 항목 자동 제거
}
}
// 사용
LRUCache<Long, Product> cache = new LRUCache<>(100);
cache.put(1L, product);
cache.get(1L); // 접근 → 이 항목이 "최근"으로 이동
// ⚠️ 멀티스레드 환경에서는 Caffeine Cache 사용 권장
| 상황 | 선택 | 이유 |
|---|---|---|
| 리스트, 인덱스 접근이 필요 | ArrayList |
배열 기반 → get O(1), 캐시 효율 좋음 |
| 리스트, 맨 앞/뒤 삽입이 매우 빈번 | ArrayDeque |
LinkedList보다 더 빠른 덱 구현체 |
| 일반 Map (단일 스레드) | HashMap |
가장 빠름. 오버헤드 없음 |
| Map (멀티스레드) | ConcurrentHashMap |
버킷 단위 락 → 읽기는 락 없음 |
| 삽입 순서 유지 Map | LinkedHashMap |
이중 연결 리스트로 순서 보장 |
| 정렬된 키 Map / 범위 조회 | TreeMap |
Red-Black Tree, O(log n) |
| LRU 캐시 (멀티스레드, 실무) | Caffeine Cache |
Thread-safe + TTL + 크기 제한 + 통계 |
내부 구조가 다릅니다. ArrayList는 내부적으로 배열입니다. 인덱스로 바로 접근해서 O(1)이고, 메모리가 연속으로 배치되어 CPU 캐시 효율이 좋습니다. 중간 삽입/삭제는 뒤 요소를 전부 밀어야 해서 O(n)입니다. LinkedList는 각 요소가 앞뒤 노드 주소를 담은 노드의 체인입니다. 삽입/삭제는 포인터만 바꾸면 되지만, 위치를 찾는 탐색이 O(n)이고 메모리가 흩어져 캐시 미스가 많습니다. 결론적으로 대부분의 상황에서 ArrayList가 더 빠르고, 인덱스 접근이 있다면 반드시 ArrayList를 씁니다.
key의 해시값으로 배열의 위치(버킷)를 계산하고 직접 접근하므로 평균 O(1)입니다. 하지만 여러 key가 같은 버킷으로 몰리는 해시 충돌이 발생하면 같은 버킷 안에서 탐색해야 합니다. 최악의 경우 모든 key가 한 버킷에 몰리면 O(n)이 됩니다. Java 8부터는 충돌이 8개 이상이면 LinkedList를 Red-Black Tree로 자동 전환해서 최악을 O(log n)으로 개선합니다.
두 스레드가 동시에 같은 HashMap에 put하면 데이터가 유실될 수 있습니다.
특히 Resize 중에 같은 버킷을 동시에 수정하면 데이터 덮어쓰기가 발생합니다.
Collections.synchronizedMap()은 모든 메서드에 락을 걸어 Thread-safe하지만,
한 스레드가 작업하면 다른 스레드는 전부 대기해야 해서 병목이 생깁니다.
ConcurrentHashMap은 버킷 단위로만 잠그고 읽기는 락 없이 처리해서
동시 처리 성능이 훨씬 좋습니다. 멀티스레드에서는 ConcurrentHashMap을 씁니다.
List는 거의 항상 ArrayList(배열 기반, 캐시 효율), Map은 단일스레드 → HashMap / 멀티스레드 → ConcurrentHashMap / 순서 → LinkedHashMap / 정렬 → TreeMap.