본문 바로가기

기초 지식/Java

[Java] HashMap에서 잠시 헷갈렸던 부분!

Java에서 Map을 종종 사용하게 됩니다. 근데 Map에 대한 로직을 보는데 헷갈리는 부분이 있더라고요. 제가 헷갈렸던 부분을 예시로 보고 정리해보았습니다.

 

HashMap

HashMap같은 경우에는 메모리 값에 기반한 해시 값을 이용해 값을 찾아오게 됩니다.

여기에 사용되는 해시값은 Object 클래스에 포함되는 hashCode() 메서드를 통해서 만들어지게 됩니다.

 

그리고 여기서 만들어지는 값으로 equals() 메서드를 통해서 Object끼리 비교하게 됩니다.

 

물론 equals와 hashCode는 모두 오버라이딩이 가능합니다. 그리고 변경 시에는 통일성을 위해 두 메서드 모두를 수정하는 것이 필수입니다.

 

사용하는 방식과 약간 헷갈렸던 사례에 대해서 예제를 통해 보겠습니다.

 

예시

class TestClass {
    String key;
    
    public TestClass(String key) {
        this.key = key;
    }
    
    public String getKey() {
   	    return this.key;
    }
    
    @Override
    public int hashCode() {
        return this.key.hashCode();
    }
    
    @Override
    public boolean equals(Object obj) {
        TestClass testClass = (TestClass)obj;
        return testClass.getKey().equals(this.key);
    }
}

 

아주 간단하게 Model 클래스를 하나 만들어 보았습니다. hashCode(), equals(Object obj)를 오버라이딩 해서 해당 클래스의 key라는 String값을 이용해 같은 객체임을 판단하도록 수정했습니다.

 

TestClass a = new TestClass("a");
TestClass b = new TestClass("b");

a.equals(b); // True

 

위에서 key값을 이용해 같은 객체임을 판단하도록 했으므로 위의 결과와 같이 나옵니다. 분명 new를 통해서 생성해서 클래스 객체 자체는 다른 메모리에 존재하는 다른 객체이지만 java 코드 내에서 판단하기에 같다는 거죠.

 

public void mapTest() {
    Map<TestClass, Integer> map = new HashMap<>();
    
    TestClass testClass = new TestClass("b");
    map.put(new TestClass("a"), 1);
    map.put(testClass, 2);
    
    System.out.println(map.get(new TestClass("a")));
    System.out.println(map.get(new TestClass("b")));
    
    testClass.key = "a";
    
    System.out.println(map.get(new TestClass("a")));
    System.out.println(map.get(new TestClass("b")));
}

 

HashMap에서 테스트해보기 위해 맵에다 TestClass를 이용해 값을 넣어봤습니다. 위에서는 총 4개의 결과가 찍히겠네요. 이 글을 읽으시는 여러분은 바로 답이 떠오르시나요?

 

간단히 생각하면 바로 보일만큼 쉽지만 처음에 제가 너무 깊게 생각했나 봅니다.

안에서 혹시나 뭔가 직접 메모리값을 가져와서 판단하지는 않을까?

 

저는 위와 같은 생각 때문에 첫 답을 틀렸습니다. 단순히 제가 설정한 equals와 hashCode를 사용할 거란 확신이 없었죠. 하지만 Map 내부에서는 제가 설정한 대로 동작하게 됩니다.

 

그래서 정답은~~! 1, 2, 1, null이 나오게 됩니다.

 

public void mapTest() {
    Map<TestClass, Integer> map = new HashMap<>();
    
    TestClass testClass = new TestClass("b");
    map.put(new TestClass("a"), 1);
    map.put(testClass, 2);
    
    System.out.println(map.get(new TestClass("a"))); // 1
    System.out.println(map.get(new TestClass("b"))); // 2
    
    testClass.key = "a";
    
    System.out.println(map.get(new TestClass("a"))); // 1
    System.out.println(map.get(new TestClass("b"))); // null
}

 

testClass의 key를 변경한 이후에 헷갈리게 되는데요. 해당 Map의 keySet을 출력해보면 아래와 같습니다.

 

public void mapTest() {
    Map<TestClass, Integer> map = new HashMap<>();
    
    TestClass testClass = new TestClass("b");
    map.put(new TestClass("a"), 1);
    map.put(testClass, 2);
    
    testClass.key = "a";
    
    for (TestClass i : map.keySet()) {
        System.out.println(i);
    }
    
    // 결과
    ...$TestClass@61
    ...$TestClass@61
}

 

TestClass 객체의 key 필드 값만 바꾼 건데 같은 메모리값을 참조하는 객체로 변경된 것 같이 보입니다. 맵에는 중복이 허용되지 않는데 이렇게 되는 것도 참 신기하네요.

 

만약 그렇다면 저희가 할당해놨던 new TestClass("b")의 값은 사라진 걸까요?

 

public void mapTest() {
    Map<TestClass, Integer> map = new HashMap<>();

	TestClass testClass = new TestClass("b");
	map.put(new TestClass("a"), 1);
	map.put(testClass, 2);
    
	System.out.println(map.get(new TestClass("a")));
	System.out.println(map.get(new TestClass("b")));
	for (TestClass i : map.keySet()) {
		System.out.println(i);
	}
    
	testClass.key = "a";
    
	System.out.println(map.get(new TestClass("a")));
	System.out.println(map.get(new TestClass("b")));
	for (TestClass i : map.keySet()) {
		System.out.println(i);
	}
    
	testClass.key = "b";
    
	System.out.println(map.get(new TestClass("a")));
	System.out.println(map.get(new TestClass("b")));
	for (TestClass i : map.keySet()) {
		System.out.println(i);
	}
    
    // 결과
    1
    2
    ...$TestClass@61
    ...$TestClass@62
    1
    null
    ...$TestClass@61
    ...$TestClass@61
    1
    2
    ...$TestClass@61
    ...$TestClass@62

}

 

정말 신기하게도 다시 값을 찾아왔습니다. 객체가 참조하는 메모리값을 다시 찾아서 간 것 같네요.

 

이렇게 되는 이유에 대해서 저의 생각을 정리해보겠습니다.

  • 리터럴로 선언된 String은 String Pool내에서 값을 저장하고 있다.
  • String값으로 객체를 비교하도록 했으니 "b" -> "a"로 변경하면 String Pool 내에서 "a"값에 대한 주소 값을 변경한 객체에 할당하게 된다.
  • 변경된 "a"에 대한 주소 값으로 hashCode를 만들어 map에서 찾게 된다. 그래서 keySet을 찍었을 때 동일한 객체를 참조한다. (이때 실제 저장된 TestClass 객체는 메모리상에서 다른 객체이기 때문에 Map에 동시에 존재 가능한 듯)
  • 기존의 "b"로 저장된 값은 메모리상 어딘가에 저장이 되어 있고 메서드가 끝나기 전까지 해제가 되지 않는다. 하지만 현재는 Map을 통해서는 접근할 수 없는 즉, 참조하는 객체가 없는 값이 된다. (그럼 garbageCollection의 대상이 될까?)
  • 이때 다시 "b" -> "a" -> "b"로 변경, String Pool에 있는 "b"값에 대한 주소 값으로 세팅한다. 처음에 할당했던 것과 동일한 HashCode가 만들어지고 그 코드를 이용해 map이 주소 값을 찾게 된다.
  • 그러면 메모리에 남아있단 "b"에 대한 값(2)이 찾아지게 된다.

이것이 맞다는 보장은 없습니다! 단순히 제가 추론해본 내용입니다. 이 내용에 대해 정확히 아시는 분은 댓글로 도움을 주시면 감사하겠습니다 :)

반응형

'기초 지식 > Java' 카테고리의 다른 글

[Java] HashTable, ConcurrentHashMap에 대해서  (0) 2021.09.28
[Java] this 키워드에 관해서  (0) 2021.09.14
[Java 15 ~] Record  (2) 2021.08.01
JVM에 관하여  (0) 2021.07.24
[Java] Checked Exception, Unchecked Exception  (0) 2021.05.18