Map 을 구현하는 대표적인 클래스.
Key를 저장할 때 hash function을 사용하므로 HashMap이라 한다. Value가 저장될 때 key 값이 hashing 처리되고, 처리된 hash code가 compressed되어 index를 생성한다. 해당 index에 entry가 저장된다. 특징을 정리해보면 다음과 같다.
TL;DR
- Hashing function을 사용하는 Map이다.
- 단순 데이터 탐색, iteration을 할 경우 ArrayList보다 훨씬 빠르다.
- Key 중복이 안된다.
HashMap을 비롯한 Map 컬렉션을 구현하는 클래스들은 대부분 아래와 같은 개념으로 이루어져 있다.
HashMap은 앞서 말한 것처럼 배열 기반의 Map이며, 각 array 내의 node를 bucket이라고 한다. entry, 저장된 요소를 bucket이라고 보면 되겠다.
저장할 수 있는 bucket 의 수. HashMap의 용량 정도라고 생각하면 되겠다. HashMap은 기본 생성자로 생성할 경우 기본 capacity가 16이다. HashMap을 생성할 때에 생성자의 인자로 capacity를 설정해줄 수 있다
역치. HashMap에 저장 공간이 커져야 되는 시점이다. load factor * capacity로 계산할 수 있다. load factor는 언제 값이 늘어나야 하는지 정해주는 단위이다. 기본 설정은 0.75f 로, 이는 capacity의 75%를 말한다.
예를 들어 기본 생성자로 HashMap을 생성했다고 하면, capacity는 16, load factor는 0.75f로 설정되어 있어, threshold는 16 * 0.75 = 12 이다. 즉 해당 HashMap은 16개의 entry를 저장할 수 있으며, map의 size가 12개에 도달했을 때 HashMap의 크기가 커지게 된다. 크기는 두 배 가까이로 커지게 된다. 따라서 16이었던 크기는 32로 커지게 된다.
HashMap이 threshold에 도달해 크기가 커질 때 내부적으로 모든 bucket에 대해 rehashing이 발생한다. 구조적인 변경이 발생한다는 의미이다. load factor를 낮게 설정해서 complexity를 낮출 수는 있지만, 그만큼 rehashing이 자주 일어날 수 있다. rehashing 자체가 자원 소모가 큰 편이므로 HashMap에 저장될 데이터가 예측 가능하다면 capacity를 적절히 높이 설정하여 이런 문제를 어느 정도 해결 할 수 있겠다.
생성자는 기본 생성자와 더불어 위에서 언급한 capacity, load factor를 파라미터로 하는 생성자와, Map 구현 클래스를 파라미터로 받는 생성자가 있다.
HashMap<String, String> bandNameAndCountry = new HashMap<>;
bandName.put("radiohead", "Britain");
bandName.put("interpol", "America");
HashMap<String, String> bandNameAndCountry2 = new HashMap<>(bandNameAndCountry);
System.out.println(bandNameAndCountry.equals(bandNameAndCountry2)); // true
Map의 메서드를 모두 구현하고 있다. put, get, remove, containValues 등을 모두 사용할 수 있다.
JDK 1.8부터 추가된 기능. Key를 통해 값을 얻어오지만, Key 가 존재하지 않거나 Key에 매핑된 Vaule가 없는 경우 default 값을 지정해서 리턴시킬 수 있다.
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("hungry", "yes");
System.out.println(hashMap.getOrDefault("thirsty", "yeah")); // yeah
후술할 entrySet()과 keySet(), values() 등을 통해 HashMap을 iterate시킬 수 있다.
HashMap의 entry를 다양한 방법으로 출력할 수 있다.
HashMap<String, String> hashMap = new HashMap<String, String>();
hashMap.put("food1", "hamburger");
hashMap.put("food2", "pizza");
hashMap.put("food3", "ramen");
hashMap.put("food4", "ramen");
Map의 entry를 Set view로 만든 뒤 iterate 시키는 방법이다.
for(Map.Entry<String, String> entries : hashMap.entrySet()) {
System.out.println("key :"+ entries.getKey() + " value : "+entries.getValue());
}
//key :food1 value : hamburger
//key :food3 value : ramen
//key :food2 value : pizza
//key :food4 value : ramen
앞서 설명했듯 HashMap에서는 데이터의 저장 순서가 중요하지 않기 때문에, iterate 시켰을 때 순서가 다르게 나올 수도 있다.
for(String s : hashMap.keySet()) {
System.Out.println("key + "+s + " value : "+hashMap.get(s));
}
//key :food1 value : hamburger
//key :food3 value : ramen
//key :food2 value : pizza
//key :food4 value : ramen
Map을 iterate 시킬 때 사용하는 Iterator를 활용할 수 있다.
Iterator<Entry<String, String>> entries = hashMap.entrySet().iterator();
while(entries.hasNext()) {
Entry<String, String> entry = entries.next();
System.out.println(entry.getKey() + entry.getValue());
}
keySet() 를 통해 위 예제와 같이 get(key)로 iterate 시켜도 된다.
두 개의 HashMap을 비교하고자 한다면 equals()를 통해 비교할 수 있다. 기본적으로 equals()가 객체에서 사용되었을 때에는 두 객체가 같은 인스턴스를 참조하고 있는지 확인하지만, HashMap에 equals()를 사용했을 때에는 두 개의 HashMap이 가지고 있는 entry들이 논리적으로 동등한지에 대해서만 확인한다. 앞에서 언급했듯 entry의 순서는 상관이 없다. 예를 들어서
HashMap<Integer, String> map1 = new HashMap<>();
map1.put(1, "A");
map1.put(2, "B");
map1.put(3, "C");
//Same as map1
HashMap<Integer, String> map2 = new HashMap<>();
map2.put(3, "C");
map2.put(1, "A");
map2.put(2, "B");
//Different from map1
HashMap<Integer, String> map3 = new HashMap<>();
map3.put(1, "A");
map3.put(2, "B");
map3.put(3, "C");
map3.put(3, "D");
System.out.println(map1.equals(map2)); //true
System.out.println(map1.equals(map3)); //false
위와 같을 경우 map1과 map2가 모두 new 키워드를 통해 생성된 객체이지만 equals() 비교 시 true가 리턴된다.
HashMap에서 POJO를 Key로 사용할 때에는 equals()와 hashCode()를 오버라이딩해서 논리적으로 동등한(객체의 데이터가 같은) 비교를 할 수 있도록 해야 한다. Key가 hashCode()의 값으로 저장되는데, 값을 가져올 때에 hashCode()로 bucket의 위치를 찾은 다음, 저장되어있는 hash code와 일치하는지 equals()로 판단한다. 따라서 hashCode()의 결과값이 같더라도 equals()에서 다른 값이라고 판단되면 다른 키로 인식하여 원하지 않는 결과가 나올 수 있다.
다음의 예를 보면
public class MutableKey{
private String name;
@Override
public boolean equals(Object o){
// 같은 인스턴스를 참조하고 있으면
if(this == o){
return true;
}
// 파라미터로 받은 객체가 null을 참조하거나 둘의 인스턴스가 다르면
if(o == null || this.getClass() !=o.getClass()){
return false;
}
MutableKey that = (MutableKey) o;
//두 데이터의 값 일치를 비교해서 결과 리턴
return Objects.equals(name, that.name);
}
@Override
public int hashCode(){
// name이 같을 경우 같은 hashcode 리턴
return Objects.hash(name);
}
}
이렇게 Key로 사용할 POJO 내에서 equals()와 hashCode()를 overriding 했을 경우, MutableKey 클래스의 name 필드를 따로 변경하지 않는 이상 HashMap의 키로 사용해도 항상 같은 hashcode를 리턴할 것이다.
HashMap<MutableKey, String> hashMap = new HashMap<MutableKey, String>();
hashMap.put(new MutableKey("name"), "John Doe");
String value = hashMap.get(new MutableKey("name"));
System.out.println(value); // John Doe
하지만 name field를 변경한 뒤 HashMap에서 값을 얻어오려고 하면, 변경된 변수에 대한 hash code를 가진 Key에 접근하려 하는 것과 같으므로 저장된 Value가 없어 null을 리턴하게 된다.
MutableKey key = new MutableKey("John Doe");
hashMap.put(key, "happy");
System.out.println(hashMap.get(key)); // happy
key.setName("punch");
System.out.println(hashMap.get(key)); // null
따라서 이런 점을 예상해서 코드를 짜거나, 되도록이면 immutable한 값을 키로 활용하는 것이 좋을 것 같다.
이번 포스트에서는 HashMap에 대해서 알아보았다.
references:
https://www.baeldung.com/java-hashmap-load-factor
https://www.baeldung.com/java-hashmap
https://d2.naver.com/helloworld/831311
https://stackoverflow.com/questions/55333952/is-there-an-array-based-map-implementation
https://stackoverflow.com/questions/37959941/what-exactly-is-bucket-in-hashmap