Iterable
└── Collection
├── List 순서 있음, 중복 허용, 인덱스 접근
│ ├── ArrayList 동적 배열
│ ├── LinkedList 이중 연결 리스트 (Deque도 구현)
│ └── (Vector/Stack) 레거시, 쓰지 않음
├── Set 중복 없음
│ ├── HashSet 해시 기반, 순서 없음
│ ├── LinkedHashSet 삽입 순서 유지
│ └── TreeSet 정렬 순서 (SortedSet/NavigableSet)
└── Queue FIFO
├── Deque 양방향 큐
│ └── ArrayDeque 스택/큐 용도의 기본 선택
└── PriorityQueue 힙 기반 우선순위
Map (Collection과 별개 계층) 키-값 쌍, 키 중복 없음
├── HashMap 해시 기반, 순서 없음
├── LinkedHashMap 삽입 순서 (또는 접근 순서) 유지
├── TreeMap 키 정렬 순서 (SortedMap/NavigableMap)
└── (Hashtable) 레거시, 쓰지 않음Map이 Collection을 상속하지 않는 이유: Collection은 "원소의 모음"이고 Map은 "키→값 매핑"이라 add(E) 같은 메서드가 의미상 맞지 않는다. 대신 map.keySet(), map.values(), map.entrySet()으로 Collection 뷰를 얻는다.
ArrayList는 내부에 Object[] elementData 배열과 int size를 갖는다. 배열은 크기가 고정이므로 "동적"으로 보이게 하려면 꽉 찼을 때 더 큰 배열을 만들고 복사해야 한다.
| 동작 | 내부 처리 | 시간 복잡도 |
|---|---|---|
get(i) |
elementData[i] |
O(1) |
set(i, e) |
elementData[i] = e |
O(1) |
add(e) (끝에 추가) |
공간 있으면 elementData[size++] = e, 없으면 확장 후 |
상각 O(1) |
add(i, e) (중간 삽입) |
i 이후 원소를 한 칸씩 뒤로 밀기(System.arraycopy) |
O(n) |
remove(i) |
i 이후 원소를 한 칸씩 앞으로 당기기 | O(n) |
contains(e) |
처음부터 순회하며 equals |
O(n) |
확장 정책: 기본 초기 용량 10(첫 add 시점에 할당). 꽉 차면 newCapacity = oldCapacity + (oldCapacity >> 1), 즉 1.5배로 확장하고 Arrays.copyOf로 복사한다. 1.5배 확장은 드물게 일어나므로 n번 add의 총 복사 비용은 O(n), 1회당 상각 O(1)이다. 최종 크기를 알면 new ArrayList<>(expectedSize)로 확장을 없앨 수 있다.
중간 삽입이 O(n)인 이유: 배열은 연속 메모리다. 인덱스 3에 넣으려면 3 이후의 모든 원소를 한 칸씩 뒤로 옮겨야 한다. 앞쪽에 삽입할수록 옮길 원소가 많다. 그러나 System.arraycopy는 네이티브 메모리 블록 복사라 실제로는 매우 빠르며, 수천 개 수준에서는 체감이 안 된다.
LinkedList는 각 원소를 Node{item, prev, next}로 감싸 이중 연결한다. 이론적으로 "삽입/삭제 O(1)"이 장점이라고 배우지만, 그 O(1)은 삽입할 위치의 노드를 이미 알고 있을 때의 이야기다. list.add(5000, x)는 인덱스 5000까지 노드를 따라가야 하므로(랜덤 접근 불가) O(n)이다.
| 비교 | ArrayList | LinkedList |
|---|---|---|
get(i) |
O(1) | O(n) — 처음(또는 끝)부터 따라감 |
| 끝에 add | 상각 O(1) | O(1) |
| 중간 add(i) | O(n) — 이동 | O(n) — 탐색 + O(1) 연결 |
| 메모리 | 원소당 참조 1개 (8바이트) | 원소당 Node 객체 (참조 3개 + 헤더 ≈ 40바이트) |
| 캐시 친화성 | 높음 (연속 메모리) | 낮음 (노드가 힙 여기저기) |
| Iterator로 삭제 | O(n) | O(1) |
현대 CPU는 연속 메모리를 순차 접근할 때 캐시 프리페칭으로 극도로 빠르다. LinkedList는 노드마다 포인터를 따라 힙의 임의 위치로 점프하므로 캐시 미스가 반복된다.
결과적으로 거의 모든 실측에서 ArrayList가 빠르다. Iterator로 순회하면서 삭제하는 특수한 경우조차 ArrayList.removeIf가 더 빠른 경우가 많다. LinkedList의 저자인 Joshua Bloch 본인이 "쓴 적이 없다"고 말한 것은 유명하다.
큐/스택이 필요하면 ArrayDeque를 쓴다. 원형 배열 기반으로 양 끝 삽입/삭제가 O(1)이며 LinkedList보다 빠르고 메모리도 적다.
HashMap은 Node<K,V>[] table 배열(버킷 배열)을 갖는다. 각 Node는 {hash, key, value, next}다.
put(key, value) 과정:
key.hashCode()를 호출해 해시값 h를 얻는다.h ^ (h >>> 16)으로 상위 비트를 하위에 섞는다(보조 해시, 하위 비트만 쓰는 인덱스 계산에서 분포를 고르게).index = hash & (table.length - 1)로 버킷 인덱스를 계산한다(테이블 크기가 2의 거듭제곱이라 % 대신 비트 AND).hash가 같고 equals가 true인 노드를 찾는다. 있으면 값 교체, 없으면 리스트 끝에 추가.size > threshold(capacity × loadFactor)면 테이블을 2배로 확장하고 모든 노드를 재배치(rehash)한다.get(key) 은 1~3까지 같고, 버킷의 리스트를 따라가며 hash == && equals로 찾는다.
여기서 hashCode/equals 규약이 왜 필요한지 명확해진다. equals가 true인데 hashCode가 다르면 다른 버킷에 들어가서 영원히 못 찾는다. 반대로 hashCode가 항상 같은 값을 반환하면 모든 원소가 한 버킷에 몰려 O(n)이 된다.
| 파라미터 | 기본값 | 의미 |
|---|---|---|
| 초기 용량 (capacity) | 16 | 버킷 배열 크기. 항상 2의 거듭제곱 |
| 로드 팩터 (load factor) | 0.75 | size / capacity가 이 값을 넘으면 확장. 시간(충돌 적음) vs 공간(빈 버킷 많음)의 절충점 |
| 트리화 임계값 | 8 | 한 버킷의 리스트 길이가 8을 넘으면 레드-블랙 트리로 변환 (JDK 8+) |
| 트리 해제 임계값 | 6 | 트리 크기가 6 이하로 줄면 다시 리스트로 |
| 최소 트리화 용량 | 64 | 테이블이 64 미만이면 트리화 대신 확장 |
JDK 8의 트리화(treeification): 악의적이거나 나쁜 hashCode로 한 버킷에 원소가 몰리면 조회가 O(n)이 된다. 이를 완화하기 위해 버킷의 리스트가 8을 넘으면 레드-블랙 트리로 바꿔 최악을 O(log n)으로 보장한다. 단, 트리화되려면 키가 Comparable이거나, 아니면 System.identityHashCode로 순서를 정한다.
초기 용량 지정: 원소 개수 n을 안다면 new HashMap<>(n / 0.75 + 1) 정도로 잡으면 확장(rehash)이 없다. 실무에서는 HashMap.newHashMap(n)(JDK 19+)이 이 계산을 해 준다.
null: HashMap은 null 키 1개와 null 값을 허용한다(null 키는 해시 0, 버킷 0). Hashtable과 ConcurrentHashMap은 둘 다 금지.
HashSet<E>의 소스를 열면 private transient HashMap<E,Object> map;이 있고, add(e)는 map.put(e, PRESENT)(PRESENT는 더미 Object)이다. 즉 HashSet은 값이 없는 HashMap이다.
따라서 HashSet의 성능 특성, hashCode/equals 의존성, 초기 용량/로드 팩터가 전부 HashMap과 같다. LinkedHashSet은 LinkedHashMap 위에, TreeSet은 TreeMap 위에 같은 방식으로 구현되어 있다.
| 구현체 | 순서 | 내부 구조 | 조회/삽입 | 용도 |
|---|---|---|---|---|
HashMap |
없음 (해시 순, 확장 시 바뀜) | 해시 테이블 | O(1) | 기본 선택 |
LinkedHashMap |
삽입 순서 (또는 접근 순서) | 해시 테이블 + 이중 연결 리스트 | O(1) | 순서가 의미 있는 매핑, LRU 캐시 |
TreeMap |
키 정렬 순서 | 레드-블랙 트리 | O(log n) | 범위 검색, 정렬된 순회, firstKey/floorKey/subMap |
LinkedHashMap은 HashMap을 상속하고 각 노드에 before/after 참조를 추가해 삽입 순서를 연결 리스트로 유지한다. 순회는 이 리스트를 따르므로 삽입 순서가 보존된다.
생성자 new LinkedHashMap<>(cap, 0.75f, true)로 accessOrder = true를 주면 get할 때마다 그 엔트리를 리스트 끝으로 옮긴다. 이것과 removeEldestEntry() 오버라이딩을 결합하면 몇 줄로 LRU 캐시가 만들어진다.
TreeMap은 키를 Comparable(또는 생성자로 넘긴 Comparator)로 비교해 균형 이진 탐색 트리에 넣는다. 해시를 쓰지 않으므로 hashCode/equals가 아니라 compareTo가 0인지로 같은 키를 판단한다. equals와 compareTo가 일관되지 않으면 HashMap과 TreeMap에서 동작이 달라진다.
| 연산 | ArrayList | LinkedList | HashSet/HashMap | LinkedHashMap | TreeSet/TreeMap | ArrayDeque |
|---|---|---|---|---|---|---|
| 인덱스 조회 | O(1) | O(n) | — | — | — | — |
| 키 조회 (get/contains) | O(n) | O(n) | O(1) 평균 | O(1) 평균 | O(log n) | — |
| 끝 삽입 | O(1) 상각 | O(1) | O(1) 평균 | O(1) 평균 | O(log n) | O(1) 상각 |
| 앞 삽입 | O(n) | O(1) | — | — | — | O(1) 상각 |
| 중간 삽입/삭제 | O(n) | O(n) 탐색 | — | — | — | — |
| 삭제 (키) | O(n) | O(n) | O(1) 평균 | O(1) 평균 | O(log n) | — |
| 정렬 순회 | 정렬 필요 O(n log n) | 동일 | 불가 | 삽입 순 | O(n) | — |
| 최소/최대 | O(n) | O(n) | O(n) | O(n) | O(log n) | — |
"평균"은 해시 분포가 좋을 때. 최악(모두 충돌)은 JDK 8+에서 O(log n).
for (E e : collection)은 컴파일러가 Iterator로 변환한다. ArrayList, HashMap 등의 Iterator는 fail-fast다. 컬렉션 내부에 modCount(구조 변경 횟수)가 있고, Iterator는 생성 시점의 modCount를 기억한다. next()마다 현재 modCount와 비교해 다르면 ConcurrentModificationException을 던진다.
for (String s : list) {
if (s.startsWith("tmp")) list.remove(s); // modCount 변경 → 다음 next()에서 CME
}이름에 "Concurrent"가 있지만 단일 스레드에서 순회 중 수정해도 발생한다. 해결책:
| 방법 | 코드 | 비고 |
|---|---|---|
| Iterator의 remove | it.remove() |
Iterator가 자기 modCount도 갱신 |
removeIf |
list.removeIf(s -> s.startsWith("tmp")) |
JDK 8+, 가장 간결 |
| 복사본 순회 | for (String s : new ArrayList<>(list)) |
원본 수정 가능, 메모리 2배 |
| 인덱스 역순 루프 | for (int i = size-1; i >= 0; i--) |
List 전용 |
| 스트림으로 새 컬렉션 | list.stream().filter(...).toList() |
원본 불변 유지 |
Map: entrySet().removeIf |
map.entrySet().removeIf(e -> ...) |
Map 전용 |
CopyOnWriteArrayList나 ConcurrentHashMap의 Iterator는 fail-safe(스냅샷 또는 약한 일관성)라 예외가 없지만, 동시성 레슨의 주제다.
JDK 9의 List.of, Set.of, Map.of, JDK 10의 List.copyOf 등은 진짜 불변(수정 메서드 호출 시 UnsupportedOperationException) 컬렉션을 만든다. Collections.unmodifiableList(list)는 원본을 감싸는 뷰라서 원본을 바꾸면 뷰도 바뀌지만, List.of/copyOf는 복사본이라 원본과 독립적이다.
| 특징 | List.of(...) |
Collections.unmodifiableList(l) |
Arrays.asList(...) |
|---|---|---|---|
| 수정 | 불가 (예외) | 불가 (예외) | set은 가능, add/remove 불가 |
| 원본 변경 반영 | 복사본이라 무관 | 반영됨 | 배열과 연결됨 |
| null 원소 | 불가 (NPE) | 가능 | 가능 |
| 반환 타입 | List |
List |
List |
불변 컬렉션은 방어적 복사, 상수 정의, 메서드 반환값에 쓴다. "이 리스트는 바뀌지 않는다"를 타입이 아닌 런타임 예외로 보장한다는 점은 아쉽지만, 실수를 즉시 드러낸다는 점에서 유용하다.
Comparable<T> |
Comparator<T> |
|
|---|---|---|
| 위치 | 클래스 자신이 구현 (compareTo) |
별도 객체 (compare(a, b)) |
| 의미 | 자연 순서(natural ordering) 하나 | 여러 정렬 기준 |
| 사용 | Collections.sort(list), TreeMap 기본 |
list.sort(cmp), new TreeMap<>(cmp) |
| 예 | String, Integer, LocalDate |
Comparator.comparing(Order::amount) |
JDK 8+의 Comparator 조합 API가 사실상 표준이다.
orders.sort(Comparator.comparing(Order::status)
.thenComparing(Order::amount, Comparator.reverseOrder())
.thenComparing(Order::id));compareTo는 음수/0/양수를 반환하며, a.compareTo(b) == 0이면 a.equals(b)가 true인 것이 권장된다(TreeSet이 compareTo로 중복을 판단하기 때문). List.sort와 Collections.sort는 TimSort(안정 정렬, O(n log n))를 쓴다.