[Java] HashMap
HashMap
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
Map 인터페이스를 구현한 자료구조
- 키(key) - 값(value) 구조로 데이터를 저장한다.
- 자물쇠가 있는 사물함 느낌 (키가 있어야 값을 꺼낼 수 있다.)
- 내부적으로 해시 테이블(hash table)을 사용한다
- 해시 함수로 “키” → “숫자 인덱스” 계산
- 그 숫자를 배열의 슬롯으로 사용
- 충돌 시 연결 구조로 관리
- 빠른 검색, 삽입, 삭제 성능을 제공한다. (get(), put() )
- 평균 O(1) 성능에 가까운 성능 제공
O(1) 성능(시간 복잡도)
데이터 크기가 아무리 커져도(대용량 데이터) 연산이 걸리는 시간이 거의 변하지 않는다는 뜻
시간 복잡도(Big-O)란?
알고리즘이 입력 크기 N에 따라 어떻게 수행 시간이 늘어나는지를 수학적으로 표현하는 기호
(대표적인 예시)
- O(1): 상수 시간 – N에 무관하게 일정
- O(n): 선형 시간 – N에 비례
- O(n²): 제곱 시간 – N²에 비례
O(1)의 의미
상수 시간(constant time): 처리할 데이터가 10개든 1,000만 개든, “언제나 대략 같은” 단계 수를 수행한다.
- 단, 해시 충돌이 심하면 최악의 경우 O(n)으로 느려질 수 있지만,
- Java 8 이후 버킷이 트리화되어 대부분 O(1)에 가깝게 동작한다.
예시
// 배열 인덱스 접근
int[] arr = {10, 20, 30, 40, 50};
int x = arr[3]; // arr[3]에 바로 접근 → O(1)
// HashMap 조회/삽입
Map<String, Integer> map = new HashMap<>();
map.put("A", 1); // 키 “A” 해시 → 곧장 버킷에서 저장 → O(1) 평균
Integer v = map.get("A"); // 동일하게 바로 꺼내기 → O(1) 평균
왜 일정 시간인가?
- 해시 함수가 키를 ‘숫자 인덱스’로 변환
- 내부 배열(버킷) 중 정확히 그 위치에 바로 접근
- 반복문·검색 절차 없이 바로 저장·조회 가능
HashMap 탄생
Java 1.2 때 도입된 Collections 프레임워크로 도입되었다.
- 과거 Hashtable(동기화된 해시 테이블)을 대체하여
- 성능 향상 + 유연한 확장성을 제공한다
Java8 때 잦은 충돌이 발생해떤 버킷을
트리(Tree) 구조로 전환함에 따라 시간복잡도를 개선하여 성능 안정성 개선하였다.
HashMap 장점
- 빠른 조회, 삽입: 해시 함수를 통해 인덱스를 바로 계산
- 유연한 API: getOrDefault, computeIfAbsent, merge 등 편리한 메서드 활용
- null 허용: key, value 값에 null을 사용할 수 있음 > 간단한 자료구조로 활용 가능
- null을 많이 사용하면 버그 발생 위험 있음.
해시 함수(Hash Function)
임의의 길이 데이터를 일정한 길이의 숫자(해시값)로 변환하는 함수
역할 / 동작 방식
- 키 → 숫자
- "apple" 이라는 문자열 키를 넣으면 해시 함수는 이를 내부적으로 (예시) 93084631 같은 정수로 변환한다.
- 버킷(bucket) 위치 계산
- 변환된 숫자를 배열 크기로 나눈 나머지(hash%capacity)를 구해서, 실제 데이터를 저장할 배열 인덱스로 사용한다.
- 예: 93084631 % 16 = 7 → 7번 슬롯에 저장한다
*버킷(bucket): 해시밗으로 결정된 내부 배열(table)의 슬롯
String key = "apple";
int h = key.hashCode(); // .hashCode()가 해시 함수를 호출
System.out.println("key.hashcode(): " + h); // 93029210
int capacity = 16; // HashMap의 기본 초기 버킷 수
int index = (h & 0x7FFFFFFF) % capacity;
// 음수 방지(최상위 비트 제거) 후 capacity로 모듈러(나머지 구하는 연산)
System.out.println("index = " + index); // 10

HashMap 단점
순서를 보장하지 않음
- 입력 순서와 상관 없이 내부 해시 위치에 따라 순서가 달라진다.
- 순서가 중요하다면 LinkedHashMap 사용하면..
동기화되지 않음
- 멀티스레드에서 동시에 수정하면 위험하다!
- 예시: 두 스레드가 같은 키에 대한 put/get을 번갈아가며 실행할 경우
- get()과 put() 사이에 다른 스레드가 끼어들어 덮어쓰기가 발생함에 따라 누락될 수 있다.
// HashMap의 멀티스레드에서 동시에 수정할 경우 (get, put)
Map<String, Integer> map2 = new HashMap<>();
Runnable incr = () -> {
for (int i = 0; i < 1000; i++) {
// null 대신 0을 기본으로 쓰기 때문에 NPE가 사라집니다.
int old = map2.getOrDefault("count", 0);
map2.put("count", old + 1);
}
};
Thread t1 = new Thread(incr);
Thread t2 = new Thread(incr);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("map2 count: " + map2.get("count"));
// 2000이 아니라 예상보다 작은 값이 나올 수 있음

- 만약 멀티스레드 환경이라면 Collections.synchronizedMap / ConcurrentHashMap 중 하나 선택해서 사용
Collections.synchronizedMap
// synchronize 블록 묶기
// Collections.synchronizedMap(new HashMap<>())
Map<String, Integer> map3 = new HashMap<>();
Runnable incr2 = () -> {
for (int i = 0; i < 1000; i++) {
synchronized (map3) { // synchronized(map)로 묶어 두 스레드가 동시에 블록 내부로 진입하지 못하게 막는다.
int old = map3.getOrDefault("count",0);
map3.put("count", old + 1);
}
}
};
Thread t3 = new Thread(incr2);
Thread t4 = new Thread(incr2);
t3.start(); t4.start();
try {
t3.join();
t4.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("synchronized count: " + map3.get("count")); // 항상 2000

ConcurrentHashMap
ConcurrentHashMap<String, Integer> map4 = new ConcurrentHashMap<>();
map.put("count", 0);
Runnable incr4 = () -> {
for (int i = 0; i < 1000; i++) {
// key가 없으면 1, 있으면 old + 1
map4.merge("count", 1, Integer::sum);
}
};
Thread t7 = new Thread(incr4);
Thread t8 = new Thread(incr4);
t7.start(); t8.start();
try {
t7.join();
t8.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("ConcurrentHashMap count: " + map4.get("count")); // 항상 2000

메모리 오버헤드 (실제 저장 데이터 크기보다 더 많은 메모리를 사용)
- 버킷 배열 + 노드 객체 +@(트리 노드)
- 버킷 배열: 배열(기본 크기 16x8byte), 배열 참조당 8byte
- 노드 객체: 객체 헤더(16byte), int hash(4byte), K key 참조(8byte), V value(8byte), Node(8byte) → 대략 44byte+@
- 작은 자료에는 과도할 수 있다. → 단순 배열이나 ArrayList 등이 보다 가벼운 대안으로 고려할 수 있다.
해시 충돌 시 성능 저하 가능
- 커스텀 객체의 hashCode, equals 구현에 주의
- 충돌이 잦아질 경우 한 버킷에 몰린 엔트리들을 순차 탐색하는 과정에서 성능이 저하된다.
- 잘못된 hashcode()는 충돌을 유발할 수 있다. → 항상 상수이거나 일부 필드만 사용하는 경우
- 직접 만든 키 클래스일 경우 두 메서드를 잘 오버라이드해야 충돌없이 동작한다.
- 커스텀 객체를 키로 쓸 때, hashCode와 equals를 꼼꼼히 구현해야 한다.
예시1 과 같이
- hashCode()가 항상 같은 값(42)를 반환하면,
- 모든 Person 인스턴스가 같은 버킷에 들어가게 됨
- 그 버킷이 커지면 탐색 비용이 선형(O(n))으로 증가
// 예시1
class Person {
String name;
int age;
// 잘못된 hashCode: 모든 Person이 같은 해시값을 가짐
@Override
public int hashCode() {
return 42;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Person)) return false;
Person p = (Person) o;
return name.equals(p.name) && age == p.age;
}
}
좋은 hashcode() / equals() 구현 방법
- 서로 다른 객체는 가급적 다른 해시값을 내도록 한다.
- equals() 에 쓰인 모든 필드를 hashCode() 에도 반영해야 일관성 유지된다.
// ↓↓↓↓↓↓↓↓↓↓↓↓ 좋은 hachcode(), equals 구현 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
// 예시2
class Person {
String name;
int age;
@Override
public int hashCode() {
// name과 age를 모두 이용해 해시값 생성
return Objects.hash(name, age);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person p = (Person) o;
return age == p.age && name.equals(p.name);
}
}
- Objects.hash(...) 는 내부적으로 여러 필드를 섞어 좋은 분포의 정수를 만들고,
- equals() 와 hashCode() 의 계약(Contract) 을 지켜야,
- a.equals(b) 이면 반드시 a.hashCode() == b.hashCode() 이어야 하고
- 반대로 같지 않은 객체들이 되도록 다른 해시값을 가져야 충돌이 줄어든다.
resize 비용
- HashMap의 기본 capacity = 16, loadFactor = 0.75 → threshold = 12
- 13번째 put() 이 들어오면 자동 확장(resize) 발생(새로운 Node[32] 배열을 만듬
- 기존 16슬롯에 있던 노드들을 전체 순회하면서 해시값으로 새 배열에 재삽입을 하게 된다. →
- 즉, 자동 확장 시 많은 엔트리 이동 발생한다.
- 만약 대용량 데이터를 사용하는 경우라면 초기 용량을 넉넉히 잡아두면 resize 횟수를 줄일 수 있다.
*threshold: HashMap이 버킷 배열을 확장(resize)할지 결정하는 엔트리 개수 기준
HashMap 메서드
기본 CRUD
메서드 | 설명 | 예시 코드 |
V put(K key, V value) | 키가 없으면 새로 추가, 있으면 값을 대체 후 이전 값을 반환 |
map.put("A", 1); // null<br>map.put("A", 2); // 1 |
V get(Object key) | 키에 대응하는 값을 반환, 없으면 null | map.get("A"); // 2 |
V remove(Object key) | 키-값 쌍을 제거하고, 제거된 값을 반환 | map.remove("A"); // 2 |
boolean containsKey(Object key) | 특정 키가 있는지 검사 | map.containsKey("A"); // false |
boolean containsValue(Object value) | 특정 값이 있는지 검사 | map.containsValue(2); // false |
int size() | 엔트리(키-값 쌍) 개수 반환 | map.size(); // 0 |
boolean isEmpty() | 비어 있으면 true | map.isEmpty(); // true |
조회 / 순회
V getOrDefault(K key, V defaultValue): 키가 없으면 defaultValue 반환
map.getOrDefault("B", 0); // 키 "B" 없으면 0 반환
Set<K> keySet(): 모든 키의 집합 반환
for (String k : map.keySet()) { … }
Collection<V> values(): 모든 값의 컬렉션 반환
for (Integer v : map.values()) { … }
Set<Map.Entry<K,V>> entrySet(): 키·값 쌍 전체를 순회할 때 사용
for (var e : map.entrySet()) {
System.out.println(e.getKey() + " → " + e.getValue());
}
일괄 작업/초기화
void putAll(Map<? extends K,? extends V> m): 다른 맵의 모든 엔트리를 한 번에 추가
Map<String,Integer> other = Map.of("X", 10, "Y", 20);
map.putAll(other);
void clear(): 모든 엔트리 삭제 (맵 비우기)
map.clear();
유용한 디폴트 메서드 (Java 8+)
메서드 | 설명 | 예시 코드 |
V computeIfAbsent(K key, Function<? super K,? extends V> f) | 키가 없으면 f.apply(key)로 값 생성·저장 | <pre>map.computeIfAbsent("C", k -> 100);</pre> |
V computeIfPresent(K key, BiFunction<? super K,? super V,? extends V> f) | 키가 있을 때만 f.apply(key, oldValue) → 새 값 저장 |
<pre>map.computeIfPresent("C", (k,v)-> v+1);</pre> |
V compute(K key, BiFunction<? super K,? super V,? extends V> f) | 항상 f.apply(key, oldValue) 로 새 값 계산 후 저장 | <pre>map.compute("C", (k,v)-> (v==null?1:v+1));</pre> |
V merge(K key, V value, BiFunction<? super V,? super V,? extends V> f) | 값이 없으면 value 저장, 있으면 f.apply(oldValue, value) 적용 | <pre>map.merge("C", 10, (oldV,newV)-> oldV+newV);</pre> |
boolean replace(K key, V oldValue, V newValue) | 키가 있고, 기존 값이 oldValue 일 때만 newValue 로 교체 | <pre>map.replace("C", 101, 200);</pre> |
V replace(K key, V value) | 키가 있으면 값을 무조건 value 로 교체 | <pre>map.replace("C", 300);</pre> |
void replaceAll(BiFunction<? super K,? super V,? extends V> f) | 모든 엔트리에 f.apply(key, value) 적용 | <pre>map.replaceAll((k,v)-> v*2);</pre> |
void forEach(BiConsumer<? super K,? super V> action) | 모든 엔트리에 action.accept(key, value) 수행 | <pre>map.forEach((k,v)-> System.out.println(k+":"+v));</pre> |
실습
여러 개의 List로 데이터 관리 방법
public class ItemList {
private List<Item> items;
private List<Item> fruits = new ArrayList<>();
private List<Item> vegetables = new ArrayList<>();
private List<Item> dairy = new ArrayList<>();
public ItemList(List<Item> items) {
this.items = items;
categorize();
}
private void categorize() {
for (Item item : items) {
switch (item.getCategory()) {
case "fruit":
fruits.add(item);
break;
case "vegetable":
vegetables.add(item);
break;
case "dairy":
dairy.add(item);
break;
}
}
}
}
public static void main(String[] args) {
// 과일 데이터 추가
List<Item> items = Arrays.asList(
new Item("사과", "fruit"),
new Item("바나나", "fruit"),
new Item("당근", "vegetable"),
new Item("브로콜리", "vegetable"),
new Item("우유", "dairy")
);
// List 방식으로 분류 및 출력
ItemList listGrouping = new ItemList(items);
listGrouping.printCategories();
}
HashMap으로 데이터 관리 방법
public class ItemHashmap {
private Map<String, List<Item>> grouped = new HashMap<>();
public ItemHashmap(List<Item> items) {
for (Item item : items) {
grouped
.computeIfAbsent(item.getCategory(), k -> new ArrayList<>())
.add(item);
}
}
}
public static void main(String[] args) {
// 과일 데이터 추가
List<Item> items = Arrays.asList(
new Item("사과", "fruit"),
new Item("바나나", "fruit"),
new Item("당근", "vegetable"),
new Item("브로콜리", "vegetable"),
new Item("우유", "dairy")
);
// HashMap 방식으로 분류 및 출력
ItemHashmap mapGrouping = new ItemHashmap(items);
mapGrouping.printCategories();
}
