Dev/Java

[Java] HashMap

syuare 2025. 4. 29. 19:15

 

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();
}