이 글에서 얻는 것
ArrayList/LinkedList/HashMap/ConcurrentHashMap을 “외워서”가 아니라, 왜 빠른지/왜 느려지는지로 설명할 수 있습니다.- 리사이즈/박싱/캐시 미스 같은 비용이 실제 성능에 미치는 영향을 이해하고, 튜닝 포인트를 잡을 수 있습니다.
- 멀티스레드 환경에서
ConcurrentHashMap을 안전하게 쓰는 기준이 생깁니다.
1) ArrayList: 기본은 맞는데, 리사이즈와 이동 비용을 기억하자
ArrayList는 내부가 “배열”입니다. 그래서 랜덤 접근이 빠르고, 순회가 캐시 친화적입니다.
핵심 비용 2가지:
- 리사이즈: 용량이 부족해지면 더 큰 배열을 만들고 복사합니다(스파이크).
- 중간 삽입/삭제: 뒤의 원소를 한 칸씩 밀거나 당깁니다(O(n)).
튜닝 포인트: 초기 용량
대략 크기를 알면 용량을 먼저 잡아두는 게 가장 큰 효과를 줍니다.
List<Order> orders = new ArrayList<>(expectedSize);
2) LinkedList: “중간 삽입 O(1)”을 믿고 쓰면 망하는 이유
LinkedList는 노드(객체)들이 포인터로 연결되어 있습니다.
- 중간 삽입/삭제가 O(1)인 건 “그 노드를 이미 알고 있을 때”입니다.
- 대부분의 실무 코드는 먼저 위치를 찾기 위해 O(n)을 수행합니다.
- 노드 객체가 많아지면 메모리 오버헤드/GC 부담/캐시 미스가 커집니다.
큐/스택이 목적이라면 보통 ArrayDeque 가 더 좋은 선택입니다.
3) HashMap: 평균 O(1) 뒤에 숨어있는 리사이즈/충돌 비용
HashMap의 성능을 좌우하는 건 보통 이 3가지입니다.
- 키의 hashCode/equals 비용
- 충돌 빈도(키 분포)
- 리사이즈 빈도(용량/부하율)
3-1) 초기 용량: 리사이즈 스파이크 줄이기
HashMap은 (대략) capacity * loadFactor를 넘으면 리사이즈합니다(기본 loadFactor 0.75).
대략 크기가 있으면 다음처럼 잡는 것이 흔합니다.
int expected = 100_000;
Map<String, User> map = new HashMap<>((int) (expected / 0.75f) + 1);
3-2) 키는 “불변”이어야 한다
키로 쓰이는 객체의 equals/hashCode에 사용되는 필드가 변경되면 조회가 깨질 수 있습니다.
키로는 불변(immutable) 타입을 선호합니다.
4) ConcurrentHashMap: 멀티스레드에서 HashMap 대신 쓰는 ‘기본’
ConcurrentHashMap(CHM)은 멀티스레드에서 안전하게 Map을 쓰기 위한 표준 도구입니다.
자바 8 이후는 세그먼트 방식이 아니라, 버킷(bin) 단위로 CAS/락을 섞어 구현됩니다.
실무에서 중요한 포인트:
- 읽기는 대부분 락 없이 동작합니다.
- 쓰기는 상황에 따라 CAS/동기화를 사용합니다(경합이 심하면 느려질 수 있음).
- “카운터” 용도는
LongAdder와 조합하는 것이 흔합니다.
ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
counters.computeIfAbsent(key, k -> new LongAdder()).increment();
주의할 점:
computeIfAbsent의 mapping 함수에서 “다시 같은 맵을 건드리는” 복잡한 로직을 넣으면 예상치 못한 병목이 생길 수 있습니다.- 경합이 심한 단일 키 카운터는
LongAdder가AtomicLong보다 유리할 때가 많습니다.
5) 박싱/언박싱: ‘생각보다’ 비싼 비용
Map<Long, Long> 같은 구조는 오토박싱으로 객체 할당이 늘고, GC 압박이 커질 수 있습니다.
정말 병목일 때는 primitive 컬렉션(예: fastutil/HPPC)을 고려하지만, 의존성/복잡도를 함께 감안해야 합니다.
6) 성능을 확인할 때의 최소 원칙
- “내 코드에서 실제로 느린 지점”을 먼저 찾기(프로파일링) → 컬렉션을 바꾸는 건 마지막
- 비교가 필요하면 JMH 같은 도구로 워밍업/JIT 영향을 줄이기
- 시간뿐 아니라 할당량/GC 도 같이 보기
연습(추천)
- 같은 기능을
LinkedList/ArrayDeque로 구현해보고, 원소 수가 커질 때 어떤 차이가 나는지 관찰해보기 - HashMap에
withCapacity를 넣었을 때 리사이즈가 줄어드는지 확인해보기(로그/프로파일링) - 멀티스레드 카운터를
AtomicLongvsLongAdder로 만들어 경합이 커질 때 차이를 비교해보기
💬 댓글