Java 컬렉션 프레임워크 완전 정리
Java 컬렉션 프레임워크(Java Collections Framework, JCF)는 데이터를 저장하고 조작하기 위한 통합된 아키텍처를 제공합니다. 인터페이스, 구현체, 알고리즘으로 구성되며, 실무에서 가장 자주 사용되는 핵심 API 중 하나입니다.
1. 컬렉션 프레임워크 전체 구조
인터페이스 계층도
java.lang.Iterable
└── java.util.Collection
├── List (순서 O, 중복 O)
│ ├── ArrayList
│ ├── LinkedList
│ ├── Vector
│ └── CopyOnWriteArrayList
│
├── Set (순서 X, 중복 X)
│ ├── HashSet
│ ├── LinkedHashSet
│ ├── TreeSet (SortedSet → NavigableSet)
│ └── EnumSet
│
└── Queue (FIFO)
├── PriorityQueue
├── ArrayDeque (Deque 구현)
├── LinkedBlockingQueue
└── ArrayBlockingQueue
java.util.Map (Key-Value, 별도 계층)
├── HashMap
├── LinkedHashMap
├── TreeMap (SortedMap → NavigableMap)
├── ConcurrentHashMap
├── WeakHashMap
└── Hashtable (레거시)
핵심 인터페이스 요약
| 인터페이스 | 특징 | 대표 구현체 |
|---|---|---|
Collection |
모든 컬렉션의 루트 | - |
List |
인덱스 기반, 순서 보장, 중복 허용 | ArrayList, LinkedList |
Set |
중복 불허, 순서 미보장(구현체마다 다름) | HashSet, TreeSet |
Queue |
FIFO 큐, offer/poll/peek | PriorityQueue, ArrayDeque |
Deque |
양방향 큐 (Double Ended Queue) | ArrayDeque, LinkedList |
Map |
Key-Value 쌍, Key 중복 불허 | HashMap, TreeMap |
2. List 구현체
2-1. ArrayList
가장 많이 사용되는 List 구현체로, 내부적으로 Object 배열을 사용합니다.
내부 구조
ArrayList 내부 배열 (초기 capacity = 10)
index: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9]
data: ["A"] ["B"] ["C"] ["D"] null null null null null null
↑
size=4 (실제 원소 수)
capacity=10 (배열 크기)
동적 확장 (grow)
배열이 꽉 차면 새로운 배열을 생성하고 기존 데이터를 복사합니다.
기존 배열 (capacity=10, 가득 참)
[0][1][2][3][4][5][6][7][8][9]
새 배열 생성 (capacity = oldCapacity + oldCapacity >> 1 = 15)
[0][1][2][3][4][5][6][7][8][9][10][11][12][13][14]
← 복사 후 새 원소 추가 →
Java 소스 코드 (OpenJDK):
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth: 1.5배 */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
시간복잡도
| 연산 | 시간복잡도 | 설명 |
|---|---|---|
add(E e) |
O(1) amortized | 배열 끝에 추가. 확장 시 O(n)이지만 분할 상환 O(1) |
add(int i, E e) |
O(n) | i 이후 원소를 전부 한 칸 이동 |
get(int i) |
O(1) | 인덱스 직접 접근 |
remove(int i) |
O(n) | i 이후 원소를 한 칸 앞으로 이동 |
contains(Object o) |
O(n) | 순차 탐색 |
size() |
O(1) | 필드 참조 |
코드 예제
import java.util.ArrayList;
import java.util.List;
List<String> list = new ArrayList<>(16); // 초기 capacity 지정으로 resize 최소화
list.add("Apple");
list.add("Banana");
list.add(0, "Avocado"); // O(n): 앞 삽입은 비쌈
// 인덱스 접근 O(1)
String first = list.get(0); // "Avocado"
// 중간 삭제 O(n)
list.remove(1); // "Apple" 삭제, 이후 원소 이동
// 예측 가능한 크기라면 초기 capacity를 지정해 resize 비용 제거
List<String> optimized = new ArrayList<>(1000);
2-2. LinkedList
이중 연결 리스트(Doubly Linked List) 로 구현된 List이자 Deque입니다.
내부 구조
head tail
↓ ↓
[prev=null | "A" | next] ↔ [prev | "B" | next] ↔ [prev | "C" | next=null]
각 노드(Node)는 이전/다음 노드의 참조와 데이터를 보관합니다:
// LinkedList 내부 Node 클래스 (OpenJDK)
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
시간복잡도
| 연산 | 시간복잡도 | 설명 |
|---|---|---|
addFirst(E e) / addLast(E e) |
O(1) | head/tail 포인터만 변경 |
add(int i, E e) |
O(n) | i번째 노드까지 순차 탐색 후 삽입 |
get(int i) |
O(n) | i번째 노드까지 순차 탐색 |
remove(Object o) |
O(n) | 탐색 O(n) + 포인터 변경 O(1) |
removeFirst() / removeLast() |
O(1) | head/tail 포인터만 변경 |
ArrayList vs LinkedList 선택 기준
// LinkedList가 유리한 경우: 양 끝 삽입/삭제가 빈번할 때
Deque<String> deque = new LinkedList<>();
deque.addFirst("first"); // O(1)
deque.addLast("last"); // O(1)
deque.removeFirst(); // O(1)
// ArrayList가 유리한 경우: 랜덤 접근, 순차 읽기
List<String> list = new ArrayList<>();
String val = list.get(500); // O(1) — LinkedList라면 O(n)
실무 팁: 대부분의 경우 ArrayList가 빠릅니다. LinkedList는 캐시 지역성(cache locality)이 나쁘고, 노드마다 prev/next 포인터 오버헤드(객체 헤더 포함 약 24~32 bytes/node)가 있습니다. 큐/덱 목적이라면
ArrayDeque이 더 좋습니다.
2-3. Vector
ArrayList와 동일한 배열 기반 구조이지만, 모든 메서드에 synchronized 가 붙어 있어 스레드 안전합니다.
// Vector의 add 메서드 — 메서드 전체에 synchronized
public synchronized boolean add(E e) {
modCount++;
add(e, elementData, elementCount);
return true;
}
단점: 단일 스레드 환경에서도 락을 획득해야 하므로 ArrayList보다 느립니다. Java 1.0 시대 레거시 클래스이므로 새 코드에서는 사용을 피하세요.
2-4. CopyOnWriteArrayList
쓰기 시 배열 전체를 복사하는 스레드 안전 List입니다. java.util.concurrent 패키지에 속합니다.
초기 상태:
internal array → ["A", "B", "C"]
readers ─────────────┘
add("D") 호출 시:
1. lock 획득
2. 새 배열 생성: ["A", "B", "C", "D"]
3. 참조 교체: internal array → ["A", "B", "C", "D"]
4. lock 해제
기존 배열은 이미 참조 중인 reader가 끝날 때까지 유효
readers (동시 읽기) → 기존 배열 ["A", "B", "C"] (스냅샷)
new readers → 새 배열 ["A", "B", "C", "D"]
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("A");
// 읽기는 락 없음 — 매우 빠름
for (String s : cowList) {
// 반복 중 다른 스레드가 add해도 ConcurrentModificationException 없음
System.out.println(s);
}
| 특성 | CopyOnWriteArrayList | Collections.synchronizedList |
|---|---|---|
| 읽기 성능 | 락 없음 (매우 빠름) | 매 읽기마다 락 |
| 쓰기 성능 | 배열 전체 복사 (느림) | 락만 획득 (상대적으로 빠름) |
| 반복 안전성 | 항상 안전 (스냅샷) | 수동으로 동기화 필요 |
| 적합한 상황 | 읽기 多, 쓰기 少 | 읽기/쓰기 균형 |
3. Set 구현체
3-1. HashSet
내부적으로 HashMap을 사용합니다. 원소를 HashMap의 Key로, dummy 값(PRESENT)을 Value로 저장합니다.
// HashSet 내부 (OpenJDK)
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
equals / hashCode 계약
HashSet의 중복 판단 과정:
add("Hello") 호출
│
▼
hashCode() 계산 → 버킷 인덱스 결정
│
▼
해당 버킷에 원소 있음?
├── NO → 바로 저장 (중복 없음)
└── YES → equals() 비교
├── true → 중복! 저장 안 함
└── false → 해시 충돌, 함께 저장
계약(Contract):
a.equals(b)가true이면a.hashCode() == b.hashCode()이어야 합니다.- 역은 성립하지 않아도 됩니다 (해시 충돌 허용).
// 잘못된 예: equals만 재정의하고 hashCode를 재정의하지 않음
class BadKey {
String name;
@Override
public boolean equals(Object o) {
return ((BadKey) o).name.equals(this.name);
}
// hashCode 미재정의 → Object의 기본 hashCode(메모리 주소 기반) 사용
// equals는 같지만 hashCode가 달라 HashSet에 중복 저장됨!
}
Set<BadKey> set = new HashSet<>();
set.add(new BadKey("kim")); // hashCode = 1234
set.add(new BadKey("kim")); // hashCode = 5678 → 다른 버킷 → 중복 허용!
System.out.println(set.size()); // 2 (잘못된 결과)
// 올바른 예
class GoodKey {
String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof GoodKey)) return false;
return name.equals(((GoodKey) o).name);
}
@Override
public int hashCode() {
return Objects.hash(name); // name 기반 hashCode
}
}
해시 충돌 처리 — 체이닝 → 트리화 (Java 8+)
Java 7 이하: 체이닝 (Linked List)
버킷[3]: → [Node:"A"] → [Node:"X"] → [Node:"M"] → null
해시 충돌 시 연결 리스트로 연결
Java 8+: 충돌이 많으면 Red-Black Tree로 전환
버킷[3]: → TreeNode (TREEIFY_THRESHOLD=8 초과 시)
┌──────┴──────┐
[Node] [Node]
┌──┴──┐ ┌──┴──┐
[Node] [Node] [Node] [Node]
장점: 최악의 경우 탐색 O(n) → O(log n)으로 개선
3-2. LinkedHashSet
HashSet을 상속하며, 내부적으로 LinkedHashMap을 사용해 삽입 순서를 유지합니다.
Set<String> linked = new LinkedHashSet<>();
linked.add("Banana");
linked.add("Apple");
linked.add("Cherry");
System.out.println(linked); // [Banana, Apple, Cherry] — 삽입 순서 유지
// HashSet이라면: [Apple, Banana, Cherry] (순서 미보장)
내부적으로 이중 연결 리스트로 각 버킷의 원소들을 삽입 순서로 연결합니다.
Hash 버킷(빠른 조회):
bucket[2] → "Banana"
bucket[7] → "Apple"
bucket[4] → "Cherry"
삽입 순서 연결 리스트(순서 유지):
head ↔ [Banana] ↔ [Apple] ↔ [Cherry] ↔ tail
3-3. TreeSet
Red-Black Tree 기반의 NavigableSet 구현체입니다. 원소를 항상 정렬된 상태로 유지합니다.
Red-Black Tree 구조
[5, BLACK]
/ \
[3, RED] [7, RED]
/ \ / \
[1,BLK] [4,BLK][6,BLK] [9,BLK]
규칙:
1. 모든 노드는 RED 또는 BLACK
2. 루트는 항상 BLACK
3. RED 노드의 자식은 반드시 BLACK (RED 연속 불가)
4. 모든 경로의 BLACK 노드 수는 동일
→ 높이가 항상 O(log n) 보장
Comparable vs Comparator
// 1. Comparable 구현 (자연 순서)
class Student implements Comparable<Student> {
String name;
int score;
@Override
public int compareTo(Student other) {
return Integer.compare(this.score, other.score); // 점수 오름차순
}
}
TreeSet<Student> byScore = new TreeSet<>();
byScore.add(new Student("Kim", 90));
byScore.add(new Student("Lee", 80));
// 2. Comparator 지정 (커스텀 순서)
TreeSet<String> byLength = new TreeSet<>(
Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())
);
byLength.add("Banana");
byLength.add("Apple");
byLength.add("Fig");
System.out.println(byLength); // [Fig, Apple, Banana] — 길이 순
NavigableSet 범위 검색
TreeSet<Integer> ts = new TreeSet<>(Set.of(1, 3, 5, 7, 9, 11));
System.out.println(ts.headSet(6)); // [1, 3, 5] — 6 미만
System.out.println(ts.tailSet(6)); // [7, 9, 11] — 6 이상
System.out.println(ts.subSet(3, 9)); // [3, 5, 7] — [3, 9)
System.out.println(ts.floor(6)); // 5 — 6 이하 최대
System.out.println(ts.ceiling(6)); // 7 — 6 이상 최소
System.out.println(ts.higher(5)); // 7 — 5 초과 최소
System.out.println(ts.lower(5)); // 3 — 5 미만 최대
| 연산 | 시간복잡도 |
|---|---|
add, remove, contains |
O(log n) |
first, last |
O(log n) |
headSet, tailSet, subSet |
O(log n) (뷰 생성) |
3-4. EnumSet
Enum 타입 전용 Set으로, 내부적으로 비트 벡터(bit vector) 를 사용합니다.
왜 빠른가?
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }
bit: 0 1 2 3 4 5 6
EnumSet.of(Day.MON, Day.WED, Day.FRI)
→ long 비트마스크: 0b0010101 = 21L
add(Day.SAT) → bits |= (1L << SAT.ordinal()) → O(1)
remove(Day.MON) → bits &= ~(1L << MON.ordinal()) → O(1)
contains(Day.WED) → (bits & (1L << WED.ordinal())) != 0 → O(1)
- Enum 상수가 64개 이하 →
RegularEnumSet(long 하나) - 65개 이상 →
JumboEnumSet(long 배열)
import java.util.EnumSet;
enum Permission { READ, WRITE, EXECUTE, DELETE }
EnumSet<Permission> adminPerms = EnumSet.allOf(Permission.class);
EnumSet<Permission> readOnly = EnumSet.of(Permission.READ);
EnumSet<Permission> noDelete = EnumSet.complementOf(EnumSet.of(Permission.DELETE));
// 비트 연산 기반이라 모든 연산이 O(1)
adminPerms.containsAll(readOnly); // true
4. Map 구현체
4-1. HashMap
Java 컬렉션에서 가장 많이 사용되는 Map 구현체입니다.
내부 해시 버킷 구조
HashMap 내부 (capacity=16, loadFactor=0.75)
buckets 배열:
[0] → null
[1] → [key="Alice", val=30] → null
[2] → null
[3] → [key="Bob", val=25] → [key="Carol", val=28] → null (해시 충돌)
[4] → null
...
[15] → null
put("Dave", 35):
1. hash = hash("Dave") = 0x3a2f...
2. index = hash & (capacity - 1) = hash & 15
3. 해당 버킷에 연결 리스트/트리로 삽입
초기 용량(initialCapacity)과 로드 팩터(loadFactor)
// 기본값: capacity=16, loadFactor=0.75
HashMap<String, Integer> map = new HashMap<>();
// 리사이징 임계값: capacity × loadFactor = 16 × 0.75 = 12
// 원소가 12개를 초과하면 capacity를 2배(32)로 확장하고 rehashing
리사이징 비용 예측이 가능하다면 초기 용량을 지정하세요:
// 1000개 원소를 저장할 예정이라면:
// capacity = ceil(1000 / 0.75) + 1 = 1335 → 다음 2의 거듭제곱 = 2048
int expectedSize = 1000;
int initialCapacity = (int) (expectedSize / 0.75) + 1;
HashMap<String, Integer> optimized = new HashMap<>(initialCapacity);
Java 8 트리화 (Treeify)
// HashMap 상수
static final int TREEIFY_THRESHOLD = 8; // 버킷 원소 8개 초과 시 트리화
static final int UNTREEIFY_THRESHOLD = 6; // 원소 6개 이하로 감소 시 복원
static final int MIN_TREEIFY_CAPACITY = 64; // 전체 capacity가 64 미만이면 트리화 대신 resize
충돌 7개: LinkedList 유지
bucket[3] → N1 → N2 → N3 → N4 → N5 → N6 → N7
8번째 충돌 발생:
capacity >= 64 이면 → TreeNode로 변환
capacity < 64 이면 → capacity 2배 확장 (rehash로 분산)
트리화 후:
bucket[3] → TreeNode (Red-Black Tree)
최악의 경우 탐색: O(n) → O(log n)
시간복잡도
| 연산 | 평균 | 최악 (모든 원소 같은 버킷) |
|---|---|---|
put |
O(1) | O(log n) [Java 8+, 트리화 후] |
get |
O(1) | O(log n) |
remove |
O(1) | O(log n) |
containsKey |
O(1) | O(log n) |
Map<String, Integer> wordCount = new HashMap<>();
String[] words = {"apple", "banana", "apple", "cherry", "banana", "apple"};
for (String word : words) {
wordCount.merge(word, 1, Integer::sum); // getOrDefault + put 보다 간결
}
System.out.println(wordCount); // {apple=3, banana=2, cherry=1}
// computeIfAbsent: 키 없을 때만 계산
Map<String, List<String>> groups = new HashMap<>();
groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("apple");
groups.computeIfAbsent("fruits", k -> new ArrayList<>()).add("banana");
// groups: {fruits=[apple, banana]}
4-2. LinkedHashMap — LRU 캐시 구현
HashMap을 상속하며, 이중 연결 리스트로 삽입 순서 또는 접근 순서(accessOrder)를 유지합니다.
LinkedHashMap (accessOrder=false, 삽입 순서):
put("A") → put("B") → put("C")
순서: A ↔ B ↔ C
LinkedHashMap (accessOrder=true, 접근 순서):
put("A") → put("B") → put("C") → get("A")
순서: B ↔ C ↔ A (최근 접근이 tail로 이동)
LRU 캐시 구현
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
// accessOrder=true: get/put 시 해당 항목을 tail(최근)으로 이동
super(maxSize, 0.75f, true);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 크기 초과 시 가장 오래된(head) 항목 자동 제거
return size() > maxSize;
}
}
LRUCache<String, String> cache = new LRUCache<>(3);
cache.put("A", "val_A");
cache.put("B", "val_B");
cache.put("C", "val_C");
cache.get("A"); // A 접근 → A가 최근으로 이동: B ↔ C ↔ A
cache.put("D", "val_D"); // 용량 초과 → 가장 오래된 B 제거: C ↔ A ↔ D
System.out.println(cache.containsKey("B")); // false (evicted)
4-3. TreeMap
Red-Black Tree 기반 NavigableMap으로, Key를 항상 정렬된 상태로 유지합니다.
TreeMap<String, Integer> scores = new TreeMap<>();
scores.put("Charlie", 85);
scores.put("Alice", 92);
scores.put("Bob", 78);
System.out.println(scores); // {Alice=92, Bob=78, Charlie=85} — Key 오름차순
System.out.println(scores.firstKey()); // Alice
System.out.println(scores.lastKey()); // Charlie
System.out.println(scores.floorKey("Bz")); // Bob — "Bz" 이하 최대 Key
System.out.println(scores.ceilingKey("Bz")); // Charlie — "Bz" 이상 최소 Key
// 범위 조회
Map<String, Integer> sub = scores.subMap("Alice", "Charlie"); // [Alice, Charlie)
System.out.println(sub); // {Alice=92, Bob=78}
// 내림차순
NavigableMap<String, Integer> desc = scores.descendingMap();
System.out.println(desc); // {Charlie=85, Bob=78, Alice=92}
4-4. ConcurrentHashMap
멀티스레드 환경에서 안전한 Map입니다.
Java 7: 세그먼트 락(Segment Lock)
ConcurrentHashMap (Java 7)
segments 배열 (기본 16개):
[Segment-0] → 자체 ReentrantLock + 버킷 배열
[Segment-1] → 자체 ReentrantLock + 버킷 배열
...
[Segment-15] → 자체 ReentrantLock + 버킷 배열
서로 다른 세그먼트에 속하는 put() 연산은 동시에 수행 가능
최대 16개 스레드 동시 쓰기 가능
Java 8: CAS + synchronized (버킷 단위 락)
ConcurrentHashMap (Java 8+)
단일 Node 배열 (버킷):
[0] → null (CAS로 첫 노드 삽입)
[1] → [Node] (synchronized(Node) 로 충돌 처리)
...
1. 버킷이 비어있으면 → CAS (Compare-And-Swap) 로 락 없이 삽입
2. 버킷에 원소 있으면 → 해당 버킷 head 노드에만 synchronized
→ 다른 버킷은 완전히 독립적으로 동작
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 스레드 안전한 원자적 연산
map.put("count", 0);
map.compute("count", (k, v) -> v == null ? 1 : v + 1); // 원자적
map.merge("count", 1, Integer::sum); // 원자적
// putIfAbsent: 키 없을 때만 삽입 (원자적)
map.putIfAbsent("new_key", 100);
// 동시성 높은 집계
long total = map.reduceValues(1, v -> (long) v, Long::sum); // 병렬 집계
Hashtable vs Collections.synchronizedMap vs ConcurrentHashMap
| 특성 | Hashtable | synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| 락 범위 | 메서드 전체 | 메서드 전체 | 버킷 단위 |
| 동시 읽기 | 불가 | 불가 | 가능 (락 없음) |
| null Key/Value | 불허 | 허용 | 불허 |
| 성능 | 낮음 | 낮음 | 높음 |
| 추천 여부 | 레거시 | 비추천 | 권장 |
4-5. WeakHashMap
Key에 약한 참조(WeakReference) 를 사용하는 Map입니다.
일반 HashMap:
map.put(key, value)
key 객체를 map이 강하게 참조
→ GC가 key를 절대 수거하지 못함
WeakHashMap:
map.put(key, value)
key 객체를 WeakReference로 참조
→ key에 다른 강한 참조가 없으면 GC가 수거 가능
→ GC 수거 후 해당 Entry는 자동으로 map에서도 제거
import java.util.WeakHashMap;
WeakHashMap<Object, String> cache = new WeakHashMap<>();
Object key1 = new Object();
Object key2 = new Object();
cache.put(key1, "value1");
cache.put(key2, "value2");
System.out.println(cache.size()); // 2
key1 = null; // key1에 대한 강한 참조 제거
System.gc(); // GC 힌트 (보장은 아님)
// GC 후 key1 Entry가 자동 제거될 수 있음
System.out.println(cache.size()); // 1 (또는 2, GC 타이밍에 따라)
주요 사용 사례: 메타데이터 캐시 (객체에 부가 정보를 붙이되, 객체가 사라지면 정보도 자동 제거).
5. Queue / Deque
5-1. PriorityQueue
최소 힙(Min-Heap) 으로 구현된 우선순위 큐입니다. poll()을 호출하면 항상 가장 작은 원소가 반환됩니다.
힙 구조 (배열로 표현)
힙 배열: [1, 3, 2, 7, 4, 5, 6]
인덱스: 0 1 2 3 4 5 6
트리로 시각화:
1 (index 0)
/ \
3 2
(1) (2)
/ \ / \
7 4 5 6
(3) (4) (5) (6)
부모 인덱스: (i-1) / 2
좌측 자식: 2*i + 1
우측 자식: 2*i + 2
규칙: 부모 ≤ 자식 (Min-Heap)
주요 연산 시간복잡도
| 연산 | 시간복잡도 | 설명 |
|---|---|---|
offer(E e) |
O(log n) | 배열 끝에 삽입 후 sift-up |
poll() |
O(log n) | 루트 제거 후 sift-down |
peek() |
O(1) | 루트(최솟값) 조회만 |
contains(Object o) |
O(n) | 선형 탐색 |
remove(Object o) |
O(n) | 탐색 O(n) + sift O(log n) |
import java.util.PriorityQueue;
import java.util.Collections;
// 기본: Min-Heap (오름차순)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
System.out.println(minHeap.poll()); // 1 (최솟값)
System.out.println(minHeap.poll()); // 3
// Max-Heap (내림차순)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
maxHeap.offer(5);
maxHeap.offer(1);
maxHeap.offer(3);
System.out.println(maxHeap.poll()); // 5 (최댓값)
// 커스텀 우선순위: Task 처리 순서
record Task(String name, int priority) {}
PriorityQueue<Task> taskQueue = new PriorityQueue<>(
Comparator.comparingInt(Task::priority)
);
taskQueue.offer(new Task("저우선", 10));
taskQueue.offer(new Task("고우선", 1));
taskQueue.offer(new Task("중우선", 5));
System.out.println(taskQueue.poll().name()); // "고우선"
5-2. ArrayDeque
원형 배열(Circular Array) 기반의 Deque(Double Ended Queue)입니다. Stack과 Queue 모두 대체 가능합니다.
원형 배열 구조
capacity = 8, head = 3, tail = 6
index: [0] [1] [2] [3] [4] [5] [6] [7]
data: null null null ["A"] ["B"] ["C"] null null
↑ ↑
head tail
addFirst("Z"):
head = (head - 1 + capacity) % capacity = 2
elements[2] = "Z"
index: [0] [1] [2] [3] [4] [5] [6] [7]
data: null null ["Z"] ["A"] ["B"] ["C"] null null
↑ ↑
head tail
addLast("D"):
elements[tail] = "D"
tail = (tail + 1) % capacity = 7
import java.util.ArrayDeque;
import java.util.Deque;
// Stack으로 사용 (LIFO)
Deque<String> stack = new ArrayDeque<>();
stack.push("first"); // addFirst()
stack.push("second"); // addFirst()
System.out.println(stack.pop()); // "second" (removeFirst())
// Queue로 사용 (FIFO)
Deque<String> queue = new ArrayDeque<>();
queue.offer("first"); // addLast()
queue.offer("second"); // addLast()
System.out.println(queue.poll()); // "first" (removeFirst())
// Stack 클래스보다 ArrayDeque를 권장하는 이유:
// - Stack은 Vector 상속 → 불필요한 synchronized 오버헤드
// - ArrayDeque는 synchronized 없음 → 단일 스레드에서 더 빠름
5-3. 블로킹 큐 (BlockingQueue) — 생산자-소비자 패턴
BlockingQueue 인터페이스는 스레드 간 안전한 데이터 교환을 위한 블로킹 연산을 제공합니다.
LinkedBlockingQueue
내부적으로 연결 리스트 사용. 생산자/소비자 각각 별도 Lock(putLock/takeLock)으로 동시성을 높입니다.
import java.util.concurrent.LinkedBlockingQueue;
// 용량 제한 있는 큐
LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
// 생산자 스레드
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 200; i++) {
queue.put("item-" + i); // 큐가 가득 차면 블로킹
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 소비자 스레드
Thread consumer = new Thread(() -> {
try {
while (true) {
String item = queue.take(); // 큐가 비면 블로킹
System.out.println("처리: " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
ArrayBlockingQueue
내부적으로 고정 크기 배열 사용. 단일 Lock 사용(LinkedBlockingQueue보다 처리량이 낮을 수 있음).
import java.util.concurrent.ArrayBlockingQueue;
// 반드시 초기 용량 지정 필요 (고정 크기)
ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(50);
// offer: 즉시 반환 (타임아웃 버전도 있음)
boolean added = queue.offer(42); // 가득 차면 false
boolean addedWithTimeout = queue.offer(42, 1, TimeUnit.SECONDS); // 1초 대기
// poll: 즉시 반환 (타임아웃 버전도 있음)
Integer val = queue.poll(); // 비었으면 null
Integer valWithTimeout = queue.poll(1, TimeUnit.SECONDS); // 1초 대기
BlockingQueue 메서드 비교
| 동작 | 예외 발생 | 특수 값 반환 | 블로킹 | 타임아웃 |
|---|---|---|---|---|
| 삽입 | add(e) |
offer(e) |
put(e) |
offer(e, t, unit) |
| 제거 | remove() |
poll() |
take() |
poll(t, unit) |
| 조회 | element() |
peek() |
- | - |
6. 시간복잡도 총정리 표
List
| 구현체 | add(끝) | add(중간) | get(index) | remove(index) | contains |
|---|---|---|---|---|---|
| ArrayList | O(1)* | O(n) | O(1) | O(n) | O(n) |
| LinkedList | O(1) | O(n) | O(n) | O(n) | O(n) |
| CopyOnWriteArrayList | O(n) | O(n) | O(1) | O(n) | O(n) |
*amortized (분할 상환)
Set
| 구현체 | add | remove | contains | 순서 |
|---|---|---|---|---|
| HashSet | O(1)* | O(1)* | O(1)* | X |
| LinkedHashSet | O(1)* | O(1)* | O(1)* | 삽입 순서 |
| TreeSet | O(log n) | O(log n) | O(log n) | 정렬 순서 |
| EnumSet | O(1) | O(1) | O(1) | Enum 선언 순서 |
*평균 (최악 O(log n), Java 8+ 트리화)
Map
| 구현체 | put | get | remove | containsKey | 순서 |
|---|---|---|---|---|---|
| HashMap | O(1)* | O(1)* | O(1)* | O(1)* | X |
| LinkedHashMap | O(1)* | O(1)* | O(1)* | O(1)* | 삽입/접근 순서 |
| TreeMap | O(log n) | O(log n) | O(log n) | O(log n) | Key 정렬 |
| ConcurrentHashMap | O(1)* | O(1)* | O(1)* | O(1)* | X |
| WeakHashMap | O(1)* | O(1)* | O(1)* | O(1)* | X |
Queue / Deque
| 구현체 | offer | poll | peek | contains |
|---|---|---|---|---|
| PriorityQueue | O(log n) | O(log n) | O(1) | O(n) |
| ArrayDeque | O(1)* | O(1) | O(1) | O(n) |
| LinkedBlockingQueue | O(1) | O(1) | O(1) | O(n) |
| ArrayBlockingQueue | O(1) | O(1) | O(1) | O(n) |
7. 동시성 컬렉션 정리
Collections.synchronizedXxx vs Concurrent 계열
// 방법 1: Collections.synchronizedXxx 래퍼
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 반복 시 반드시 수동 동기화 필요!
synchronized (syncList) {
Iterator<String> it = syncList.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
// 방법 2: Concurrent 계열 (권장)
import java.util.concurrent.*;
ConcurrentHashMap<String, Integer> concMap = new ConcurrentHashMap<>();
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
ConcurrentLinkedQueue<String> clQueue = new ConcurrentLinkedQueue<>();
// Concurrent 계열은 반복 중 수동 동기화 불필요 (약한 일관성 보장)
for (String s : cowList) {
System.out.println(s); // 안전
}
동시성 컬렉션 선택 가이드
| 상황 | 권장 컬렉션 |
|---|---|
| 멀티스레드 Map | ConcurrentHashMap |
| 읽기 多, 쓰기 少 List | CopyOnWriteArrayList |
| 생산자-소비자 큐 (유계) | ArrayBlockingQueue |
| 생산자-소비자 큐 (무계) | LinkedBlockingQueue |
| 동시성 단순 큐 | ConcurrentLinkedQueue |
| 동시성 덱 | ConcurrentLinkedDeque |
| 동시성 우선순위 큐 | PriorityBlockingQueue |
8. 실무 선택 가이드
List 선택
순서가 중요한 데이터를 저장하고 싶다
│
▼
랜덤 접근(get)이 많은가?
├── YES → ArrayList (캐시 지역성 좋음, 대부분의 경우)
└── NO → 양 끝 삽입/삭제만 하는가?
├── YES → ArrayDeque (큐/덱 목적)
└── NO → ArrayList (LinkedList보다 실제로 빠른 경우 많음)
Set 선택
중복 없는 컬렉션이 필요하다
│
▼
정렬이 필요한가?
├── YES → TreeSet (정렬 + 범위 검색 필요)
└── NO → 삽입 순서 보존이 필요한가?
├── YES → LinkedHashSet
└── NO → Enum 타입인가?
├── YES → EnumSet (가장 빠름)
└── NO → HashSet (기본)
Map 선택
Key-Value 저장이 필요하다
│
▼
멀티스레드 환경인가?
├── YES → ConcurrentHashMap
└── NO → Key 정렬이 필요한가?
├── YES → TreeMap
└── NO → 순서 보존이 필요한가?
├── YES → LinkedHashMap
│ (accessOrder=true이면 LRU 캐시)
└── NO → Key가 Enum인가?
├── YES → EnumMap (비트 벡터 기반, 빠름)
└── NO → HashMap (기본)
상황별 선택 예제
// 1. 대용량 데이터 순차 처리 → ArrayList
List<Record> records = new ArrayList<>(100_000);
// 이유: 연속 메모리 → CPU 캐시 적중률 높음
// 2. 이력 순서 보존 로그 → LinkedHashMap
Map<String, String> auditLog = new LinkedHashMap<>();
auditLog.put("2026-01-01", "로그인");
auditLog.put("2026-01-02", "수정");
// 삽입 순서대로 이터레이션 보장
// 3. 멤버십 체크 (수백만 건) → HashSet
Set<Long> blockedUserIds = new HashSet<>(1_000_000);
boolean isBlocked = blockedUserIds.contains(userId); // O(1)
// 4. IP 대역 범위 검색 → TreeMap
TreeMap<String, String> ipRoutes = new TreeMap<>();
ipRoutes.put("10.0.0.0", "route_A");
ipRoutes.put("192.168.0.0", "route_B");
String route = ipRoutes.floorEntry("10.0.1.5").getValue(); // 범위 매칭
// 5. 스레드 풀 작업 큐 → ArrayBlockingQueue
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(200);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS, workQueue
);
// 6. 권한 집합 → EnumSet
EnumSet<Permission> userPerms = EnumSet.of(Permission.READ, Permission.WRITE);
if (userPerms.contains(Permission.DELETE)) { /* ... */ }
// 7. 캐시 (메모리 민감) → WeakHashMap
WeakHashMap<Object, CachedData> cache = new WeakHashMap<>();
// 키 객체가 GC되면 캐시 항목도 자동 제거
// 8. 이벤트 리스너 목록 (읽기 多) → CopyOnWriteArrayList
CopyOnWriteArrayList<EventListener> listeners = new CopyOnWriteArrayList<>();
// 리스너 등록/해제는 드물고 이벤트 발생(읽기)은 빈번
컬렉션 사용 시 자주 하는 실수
// 실수 1: for 루프 안에서 List.remove()
// → ConcurrentModificationException 발생
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (String s : list) {
if (s.equals("B")) list.remove(s); // 위험!
}
// 올바른 방법: Iterator 또는 removeIf
list.removeIf(s -> s.equals("B")); // Java 8+
// 실수 2: HashMap에 가변 객체를 Key로 사용
Map<List<Integer>, String> map = new HashMap<>();
List<Integer> key = new ArrayList<>(List.of(1, 2, 3));
map.put(key, "value");
key.add(4); // key 변경 → hashCode 변경 → map에서 찾을 수 없음!
map.get(key); // null 반환
// 실수 3: Arrays.asList() 결과에 add/remove
List<String> fixed = Arrays.asList("A", "B", "C");
fixed.add("D"); // UnsupportedOperationException!
// 올바른 방법:
List<String> mutable = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 실수 4: HashMap null 처리 누락
Map<String, Integer> map2 = new HashMap<>();
int count = map2.get("key"); // NullPointerException! (auto-unboxing)
// 올바른 방법:
int count2 = map2.getOrDefault("key", 0);
요약
Java 컬렉션 프레임워크는 인터페이스-구현체 분리 원칙을 따르므로, 변수 타입은 인터페이스(List, Map, Set)로 선언하고 구현체는 필요에 따라 교체하는 것이 좋은 설계입니다.
// 좋은 예: 인터페이스로 선언
List<String> list = new ArrayList<>();
Map<String, Integer> map = new HashMap<>();
Set<String> set = new HashSet<>();
// 나쁜 예: 구현체로 선언 (불필요한 결합도)
ArrayList<String> list2 = new ArrayList<>(); // 피하세요
성능 최적화가 필요하다면 초기 capacity 지정, 적절한 구현체 선택, 불필요한 박싱/언박싱 제거 순서로 접근하는 것을 권장합니다.