[java] HashMap 관련 정리(java8에서 간결하고 효과적으로 사용하기)
1 Overview
- Java에서 HashMap 을 사용하는 방법 과 내부적으로 어떻게 작동하는지 알아볼꺼임
- HashMap 과 매우 유사한 클래스 는 Hashtable 인데, 차이점까지는 굳이 알 필요 없을듯
- 혹시나 궁금하면 아래를 보자
2 Basic Usage
- map은 key-value 매핑이다.
- 모든 키가 정확히 하나의 값에 매핑되고 키를 사용하여 맵에서 해당 값을 검색할 수 있다
- 단순히 list 에 값을 추가하지 않는 이유 중 가장 간단한 것은 성능이슈이다.
- list에서 특정 요소를 찾으려면 시간 복잡도는 O(n) 이고 목록이 정렬된 가령, 이진 검색을 사용하면 O(log n) 이 된다
- HashMap 의 장점은 값을 삽입하고 검색하는 시간 복잡도가 평균 O(1) 이다.
2.1. Setup
- 이 문서 전체에서 사용할 예제는 아래와 같다.
public class Product {
private String name;
private String description;
private List<String> tags;
// standard getters/setters/constructors
public Product addTagsOfOtherProduct(Product product) {
this.tags.addAll(product.getTags());
return this;
}
}
2.2. Put
- String 타입의 키와 Porduct 타입의 요소들로 HashMap 을 생성할수 있다.
Map<String, Product> productsByName = new HashMap<>();
- HashMap 에 Product 를 put 하자.
Product eBike = new Product("E-Bike", "A bike with a battery");
Product roadBike = new Product("Road bike", "A bike for competition");
productsByName.put(eBike.getName(), eBike);
productsByName.put(roadBike.getName(), roadBike);
2.3. Get
- 아래처럼 key 로 map에서 value를 가져올수 있다.
Product nextPurchase = productsByName.get("E-Bike");
assertEquals("A bike with a battery", nextPurchase.getDescription());
- 만약 map에 없는 key로 vlaue를 찾으려고 하면 null 값을 얻게 된다.
Product nextPurchase = productsByName.get("Car");
assertNull(nextPurchase);
- 만약 동일한 key로 2번 insert 하게 되면, map에는 마지막으로 삽입된 value로 대체된다.
Product newEBike = new Product("E-Bike", "A bike with a better battery");
productsByName.put(newEBike.getName(), newEBike);
assertEquals("A bike with a better battery", productsByName.get("E-Bike").getDescription());
2.4. Null as the Key
- HashMap은 key값으로 null을 허용한다.
Product defaultProduct = new Product("Chocolate", "At least buy chocolate");
productsByName.put(null, defaultProduct);
Product nextPurchase = productsByName.get(null);
assertEquals("At least buy chocolate", nextPurchase.getDescription());
2.5. Values with the Same Key
- 다른 key로 동일한 value를 각각 insert 할수 있다.
productsByName.put(defaultProduct.getName(), defaultProduct);
assertSame(productsByName.get(null), productsByName.get("Chocolate"));
2.6. Remove a Value
- HashMap 에서 key-value 매핑을 지울수 있다.
productsByName.remove("E-Bike");
assertNull(productsByName.get("E-Bike"));
2.7. Check If a Key or Value Exists in the Map
- map에 key가 있는지 확인하려면 containsKey() 메소드를 사용하면 된다.
productsByName.containsKey("E-Bike");
- 또는 map에 값이 있는지 확인하기 위해 containsValue() 메소드를 사용할수 있다.
productsByName.containsValue(eBike);
- 위의 두 메소드 모두 true를 반환한다. 키가 있는지 확인하는 시간복잡도는 O(1) 이지만 value를 확인하는 시간 복잡도는 O(n)이다. 성능차이가 중요하다.
2.8. Iterating Over a HashMap
-
HashpMap의 모든 key-value쌍을 순회하는 방법은 기본적으로 3가지가 있다.
- 1번째 방법
for(String key : productsByName.keySet()) { Product product = productsByName.get(key); }
- 2번째 방법
for(Map.Entry<String, Product> entry : productsByName.entrySet()) { Product product = entry.getValue(); String key = entry.getKey(); //do something with the key and value }
- 3번째 방법
List<Product> products = new ArrayList<>(productsByName.values());
3 The Key
- HashMap의 key로 어떤 class도 사용할수 있다.
- 다만, map이 잘 동작하려면 equals() 와 hashCode() 를 구현해야 한다.
- Product 를 key로 사용하고 price를 value로 하는 map을 예제로 들어본다.
HashMap<Product, Integer> priceByProduct = new HashMap<>();
priceByProduct.put(eBike, 900);
- equals() 와 hashCode() 메소드를 구현해보자.
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Product product = (Product) o;
return Objects.equals(name, product.name) &&
Objects.equals(description, product.description);
}
@Override
public int hashCode() {
return Objects.hash(name, description);
}
- Note that hashCode() and equals() need to be overridden only for classes that we want to use as map keys, not for classes that are only used as values in a map.
4 Additional Methods as of Java 8
- java 8에서 몇가지 functional 스타일의 메소드가 HashMap에 추가가 되었다.
- 이에 대해 몇가지 알아 보겠다.
4.1. forEach()
- forEach 메소드는 map의 모든 요소들을 순회하는 방법이다.
productsByName.forEach( (key, product) -> {
System.out.println("Key: " + key + " Product:" + product.getDescription());
//do something with the key and value
});
Prior to Java 8:
for(Map.Entry<String, Product> entry : productsByName.entrySet()) {
Product product = entry.getValue();
String key = entry.getKey();
//do something with the key and value
}
- 조금더 자세한 내용은 아래를 확인하자
- https://www.baeldung.com/foreach-java
4.2. getOrDefault()
- getOrDefault() 를 이용하여 map에서 값을 가져오거나 값이 없는 경우 기본 값을 반환할 수 있다.
Product chocolate = new Product("chocolate", "something sweet");
Product defaultProduct = productsByName.getOrDefault("horse carriage", chocolate);
Product bike = productsByName.getOrDefault("E-Bike", chocolate);
Prior to Java 8:
Product bike2 = productsByName.containsKey("E-Bike")
? productsByName.get("E-Bike")
: chocolate;
Product defaultProduct2 = productsByName.containsKey("horse carriage")
? productsByName.get("horse carriage")
: chocolate;
4.3. putIfAbsent()
- 지정된 key에 value가 매핑 되지 않았으면 새 매핑을 추가할수 있는 기능이다.
productsByName.putIfAbsent("E-Bike", chocolate);
Prior to Java 8:
if(productsByName.containsKey("E-Bike")) {
productsByName.put("E-Bike", chocolate);
}
- 조금더 자세한 내용은 아래를 확인하자
- https://www.baeldung.com/java-merge-maps
4.4. merge()
- merge() 를 사용ㅇ하면 매핑이 존재하는 경우 키값을 수정하거나, 그렇지 않은 경우 새 값을 추가할수 있다.
Product eBike2 = new Product("E-Bike", "A bike with a battery");
eBike2.getTags().add("sport");
productsByName.merge("E-Bike", eBike2, Product::addTagsOfOtherProduct);
Prior to Java 8:
if(productsByName.containsKey("E-Bike")) {
productsByName.get("E-Bike").addTagsOfOtherProduct(eBike2);
} else {
productsByName.put("E-Bike", eBike2);
}
4.5. compute()
- compute() 는 키에 대한 값을 계산 할 수 있다.
productsByName.compute("E-Bike", (k,v) -> {
if(v != null) {
return v.addTagsOfOtherProduct(eBike2);
} else {
return eBike2;
}
});
Prior to Java 8:
if(productsByName.containsKey("E-Bike")) {
productsByName.get("E-Bike").addTagsOfOtherProduct(eBike2);
} else {
productsByName.put("E-Bike", eBike2);
}
5 HashMap Internals
- HashMap 내부적으로 어떻게 동작하는지 알아보자
- 간단한 list대신 HashMap 의 이점도 함께 알아본다.
- HashMap 을 사용하면 put 과 get 작업에 대해 시간 복잡도 O(1)의 평균 시간 복잡도와 O(n) 의 공간 복잡도(메모리 양)를 얻을 수 있다.
5.1. The Hash Code and Equals
- 모든 값을 반복하는 대신 HashMap은 key를 기반으로 위치를 계산하려 시도한다.
- HashMap은 buckets 이라고 불리는 곳에 value를 저장하고 , 이 버킷의 수를 용량(capacity)이라고 부른다.
- map에 value를 넣을때 hashCode() 메서드는 값이 저장될 버킷을 결정하는데 사용된다.
- 값을 검색하기 위해 HashMap은 hashCode() 를 사용하여 동일한 방식으로 버킷을 계산한다. 그런 다음 해당 버킷에서 찾은 객체를 iterates 하고 equals() 메소드를 사용하여 정확히 일치하는 항목을 찾는다.
5.2. Keys’ Immutability
- 대부분의 경우 변경할수 엇ㅂ는 key를 사용해야한다.
- 맵에 값을 저장하는 키를 사용한후 키 값을 변경하면 어떻게 될까?
- 아래 예시에선 MutableKey를 생성한다.
public class MutableKey {
private String name;
// standard constructor, getter and setter
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
MutableKey that = (MutableKey) o;
return Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
- 자 , 테스트를 진행해보자.
MutableKey key = new MutableKey("initial");
Map<MutableKey, String> items = new HashMap<>();
items.put(key, "success");
key.setName("changed");
assertNull(items.get(key));
- 보시다시피 key가 변경되면 더 이상 key에 해당하는 값을 가져올수 없으며 대산 null이 반환된다.
5.3. Collisions
- 2개의 다른 키에 동일한 해시가 있는 경우는 어떻게 될까? 이 경우 해당 키에 속한 2개의 값은 동일한 버킷에 저장된다.
- 버킷 내부의 값은 list에 저장되고 모든 요소를 반복하여 검색이 된다 이것의 비용은 O(n)이 된다.
- java8 부터 한 버킷 내부의 값이 저장되는 데이터 구조는 버킷에 8개 이상의 값이 포함된 경우 list에서 balanced tree 로 변경이 되고 반대의 경우도 마찬가지이다.
- 버킷 내부의 값은 list에 저장되고 모든 요소를 반복하여 검색이 된다 이것의 비용은 O(n)이 된다.
5.4. Capacity and Load Factor
- value가 여러개인 버킷이 많은 것을 방지하기 위해 버킷의 75%가 비어있지 않는 경우 용량은 2배로 늘어남
- 기본값은 75%이고 초기 용량은 16 (둘다 생성자에서 설정할 수 있음)
5.5. Summary of put and get Operations
- map에 put 할 경우 HashMap 이 버킷을 계산함.
- 버킷에 이미 값이 포함되어이있으면 해당 버킷에 속한 list에 값을 추가함
- map에 get 할 경우
- HashMap 이 버킷을 계산하고 list나 tree에서 동일한 키 값을 가진 값을 가지고 옮
6 Conclusion
- 내부를 더 알고 싶으면 아래 자료를 더 보자
- https://www.baeldung.com/java-hashmap-advanced
7. 조금 더 알아보자
putIfAbsent(), computeIfAbsent()
- 2가지 메서드의 공통점은 key의 존재 여부에 따라서 새로운 key와 value 값을 추가하는 메소드.
putIfAbsent
default V putIfAbsent(K key, V value)
- key : Map의 key 값
- value : value 값
- 반환 값
- key 값이 존재하는 경우
- Map의 value 값을 반환한다
- key 값이 존재하지 않는 경우
- key와 value를 Map에 저장하고 null을 반환하다
@Test
public void putIfAbsent() {
Map<String, Integer> map = new HashMap<>();
map.put("John", 5);
assertThat(map.putIfAbsent("John", 10)).isEqualTo(5); //존재하는 경우, value값을 반환한다
assertThat(map.size()).isEqualTo(1);
assertThat(map.putIfAbsent("John2", 10)).isNull(); //없는 경우, null로 반환하고 map에 저장함
assertThat(map.size()).isEqualTo(2);
}
computeIfAbsent
default V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)
- key : Map의 키 값
- mappingFunction
- mappingFunction 람다 함수는 key 값이 존재하지 않을 때만 실행된다
- 반환값
- key 값이 존재하는 경우
- map안에 있는 value을 반환한다
- key 값이 존재하지 않는 경우
- Map에 새로운 key와 value(mappingFunction 람다 함수를 실행한 결과) 값을 저장한다
@Test
public void computeIfAbsent() {
Map<String, Integer> map = new HashMap<>();
map.put("John", 5);
assertThat(map.computeIfAbsent("John", key -> key.length())).isEqualTo(5); //존재하면 value값을 반환함
assertThat(map.size()).isEqualTo(1);
//없으면 2번째 인자 함수를 실행한 결과를 반환하고 map에도 추가가 된다
assertThat(map.computeIfAbsent("John2", key -> key.length())).isEqualTo("John2".length());
assertThat(map.get("John2")).isNotNull();
assertThat(map.size()).isEqualTo(2);
assertThat(map.computeIfAbsent("John3", key -> null)).isNull();
assertThat(map.size()).isEqualTo(2);
}
compute(), computeIfPresent(), merge()
- 3개의 메서드들은 모두 Map의 value 값을 업데이트할 때 사용
compute
* compute는 key와 remappingFunction을 인자로 받고 key가 존재해야, value값을 인자로 넘겨준 remappingFunction 람다 함수의 결과로 업데이트가 됩니다. key 값이 존재하지 않는 경우에는 NullPointerException이 발생합니다. ```java default V compute(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) ```
@Test
public void compute() {
Map<String, Integer> map = new HashMap<>();
map.put("john", 20);
map.put("paul", 30);
map.put("peter", 40);
map.compute("peter", (k, v) -> v + 50);
assertThat(map.get("peter")).isEqualTo(40 + 50);
}
computeIfPresent
default V compute(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction)
- 결과 값
- key 값이 존재하는 경우
- remappingFunction 람다 함수 실행 결과로 value 값이 업데이트가 된다
- key가 존재하지 않는 경우
- null을 반환한다
- key 값이 존재하는 경우
@Test
public void computeIfPresent() {
Map<String, Integer> map = new HashMap<>();
map.put("john", 20);
map.put("paul", 30);
map.put("peter", 40);
map.computeIfPresent("kelly", (k, v) -> v + 10);
assertThat(map.get("kelly")).isNull();
map.computeIfPresent("peter", (k, v) -> v + 10);
assertThat(map.get("peter")).isEqualTo(40 + 10);
}
merge
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction)
- 결과 값
- key 값이 존재하는 경우
- Case 1 : remappingFunction 람다 함수의 결과가 null 아니면
- remappingFunction 람다 함수 실행 결과로 value 값이 업데이트가 된다
- Case 2 : remappingFunction 람다 함수의 결과가 null 이면
- map에서 해당 key를 삭제한다
- Case 1 : remappingFunction 람다 함수의 결과가 null 아니면
- key가 존재하지 않는 경우
- Map에 key, value값이 추가된다
@Test
public void merge() {
Map<String, Integer> map = new HashMap<>();
map.put("john", 20);
map.put("paul", 30);
map.put("peter", 40);
//key값이 존재를 하면, 해당 key의 값을 remapping 함수의 결과 값으로 바꾼다
map.merge("peter", 50, (k, v) -> map.get("john") + 10);
assertThat(map.get("peter")).isEqualTo(30);
//key가 존재하고 remapping 함수의 결과가 null이면 map에서 해당 key를 삭제한다
map.merge("peter", 30, (k, v) -> map.get("nancy"));
assertThat(map.get("peter")).isNull();
assertThat(map.size()).isEqualTo(3);
//key가 존재하지 않으면 key, value값을 추가함
map.merge("kelly", 50, (k, v) -> map.get("john") + 10);
assertThat(map.get("kelly")).isEqualTo(50);
assertThat(map.size()).isEqualTo(4);
}
getOrDefault()
- getOrDefault 가 반환하는 값은 아래와 같습니다.
default V getOrDefault(Object key, V defaultValue)
- key 값이 존재하는 경우
- Map의 value값을 반환한다
- key 값이 존재하지 않는 경우
- defaultValue을 반환한다
@Test
public void getOrDefault() {
String str = "aagbssdf";
Map<Character, Integer> map1 = new HashMap<>();
Map<Character, Integer> map2 = new HashMap<>();
//getOrDefault 사용하지 않는 경우
for (char c : str.toCharArray()) {
if (map2.containsKey(c)) {
map2.put(c, map2.get(c) + 1);
} else {
map2.put(c, 1);
}
}
//getOrDefault 사용하는 경우
for (char c : str.toCharArray()) {
map1.put(c, map1.getOrDefault(c, 0) + 1);
}
assertThat(map1).isEqualTo(map2);
}