Java프로그래밍선임 자바 개발자

**String.hashCode()**가 내부 해시 캐시에 대한 **volatile** 한정자를 안전하게 제외할 수 있는 특정 원자성 보장은 무엇입니까? 여러 스레드에 의한 동시 채움에도 불구하고?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

질문의 역사

JSR 133 사양(자바 5) 이전에, Java Memory Model은 공식적인 발생-이전 규칙이 없어 무해한 데이터 레이스가 위험해졌습니다. String은 항상 성능에 중요한 불변 클래스이며, HashMap 작업에서 많이 사용됩니다. 초기 JDK 버전에서는 큰 문자열에 대해 해시를 반복해서 다시 계산하지 않기 위해 지연 해시 캐싱이 도입되었습니다. hash 필드에서 volatile를 생략하기로 한 결정은 현대 동시성 원시 기능 이전의 고의적인 최적화였으며, 계산의 자명한 특성과 자바 5에서 JLS에 추가된 특정 원자성 보장에 의존하고 있습니다.

문제

여러 스레드가 새로 생성된 String에 대해 **hashCode()**를 동시에 호출하면, 모두 hash 필드에서 기본 값 0을 관찰할 수 있습니다. 동기화가 없으면 여러 스레드가 동시에 해시 값을 계산하고 이를 다시 쓰려는 데이터 레이스가 발생하게 됩니다. 필요한 것은 어느 스레드도 부분적으로 작성된(찢긴) 해시 값이나 일관되지 않은 상태를 관찰하지 않도록 보장하는 것이며, 모든 hashCode() 호출에서 volatile 읽기 및 쓰기에 따른 메모리 장벽의 부담스러운 비용을 피하는 것입니다.

해결책

해결책은 두 가지 기본 JMM 속성에 의존합니다. 첫째, Java 언어 사양(§17.7)은 32비트 원시 값(int)에 대한 쓰기가 원자성을 보장하여 워드 찢기(torn)를 방지합니다. 둘째, String 생성자는 최종 value 필드를 통해 발생-이전 관계를 설정하여 백업 배열이 참조를 받는 어떤 스레드에게도 완전히 가시적이도록 보장합니다. 해시 계산은 이 불변이고 안전하게 공개된 데이터의 순수한 함수이므로 캐시를 채우기 위한 경쟁은 무해합니다. 만약 스레드가 오래된 0을 읽으면 동일한 값을 재계산하고, 캐시된 값을 읽으면 그것을 사용합니다. 원자적 쓰기는 값이 완전히 관찰되거나 관찰되지 않으며, 절대 손상되지 않도록 보장합니다.

public int hashCode() { int h = hash; // 비-volatile 읽기: 0 또는 캐시된 값 볼 수 있음 if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // 원자적 쓰기: 32비트 할당은 나눌 수 없음 } return h; }

실제 상황

우리는 매 초 수백만 개의 CSV 레코드를 처리하는 고처리량 수집 서비스를 설계하고 있었습니다. 각 레코드는 ConcurrentHashMap 캐시를 위한 여러 개의 String 키를 생성했습니다. 프로파일링에서는 큰 문자열 키로 인해 hashCode() 계산이 CPU 시간의 15%를 소비한다는 결과가 나타났습니다.

솔루션 A: volatile 해시 필드. 우리는 사용자 정의 String 래퍼에서 hash 필드에 volatile를 추가하는 것을 고려했습니다. 장점으로는 모든 코어에서 즉각적인 가시성과 엄격한 순차적 일관성이 포함되었습니다. 그러나 단점은 심각했습니다: JMH 벤치마크에서는 캐시 일관성 트래픽과 각 맵 작업에서의 메모리 장벽 비용으로 인해 400%의 처리량 저하가 발생했습니다.

솔루션 B: synchronized 해시코드(). 우리는 계산을 동기화하는 테스트를 했습니다. 장점은 단순성과 절대적인 정확성이었습니다. 단점은 재앙적인 경합이었으며; 32 스레드에서 지연 시간이 2 나노초에서 800 나노초로 급증했습니다.

솔루션 C: 무해한 경쟁 (현재 구현). 우리는 동기화되지 않은 불변 캐싱을 유지했습니다. 장점으로는 제로 동기화 오버헤드와 코어 수에 따라 완벽한 확장성이 포함되었습니다. 단점은 이론적인 것으로, 스레드가 최초 접근 중에 경합할 경우 가끔 중복 계산이 발생할 수 있었습니다. 우리는 재계산(캐시 미스)의 비용이 캐시 일관성 프로토콜(volatile) 또는 경합(synchronized)의 비용에 비해 무시할 수 있을 정도로 작기 때문에 솔루션 C를 선택했습니다.

결과: 시스템은 **hashCode()**가 상위 100개 핫 메서드에 나타나지 않으면서 초당 코어당 250만 작업을 처리할 수 있었고, 무해한 데이터 레이스가 이 불변 데이터 구조에 대한 올바른 아키텍처 트레이드오프임을 검증했습니다.

후보자들이 자주 놓치는 것

volatile의 부재가 String을 생성하는 스레드와 해시를 계산하는 스레드 간의 발생-이전 관계를 위반하지 않는 이유는 무엇입니까?

발생-이전 관계는 실제로 hash 필드가 아니라 String 객체 자체의 안전한 출판에 의해 설정됩니다. String이 생성되면, final value 필드는 백업 배열의 내용이 참조를 받는 어떤 스레드에도 가시적으로 보장합니다. hash 필드는 단지 캐시에 불과하며, 기본값 0을 관찰하는 것은 단순히 계산을 트리거하는 유효한 프로그램 상태입니다. JMM은 불변의 value 배열이 일관성이 있도록 보장하며, 해시는 이 가시적인 데이터에서 순수하게 파생되기 때문에 어떤 스레드가 수행하든 계산 결과는 동일합니다.

이 같은 최적화를 64비트 long 해시 값에 적용할 수 있을까요?

아니오. JMM은 모든 아키텍처에서 32비트 원시 값(int, float)에 대해서만 원자성을 보장합니다. 64비트 원시 값(long, double)의 경우, 사양은 volatile 또는 동기화 없이 32비트 JVM이나 특정 아키텍처에서 워드 찢기를 허용합니다. 이론적으로 스레드는 계산된 해시의 높은 32 비트를 관찰하고 다른 해시의 낮은 32 비트를 관찰하여 완전히 잘못된 비제로 해시 값을 초래할 수 있으며, 이는 HashMap 버킷 배치에 손상을 가져올 것입니다. 따라서 64비트 해시 캐싱에는 volatile 또는 AtomicLong이 필요합니다.

이것이 싱글톤 초기화를 위한 깨진 "이중 확인 잠금" 관용구와 어떻게 다른가요?

중요한 구별점은 안전한 공개와 자명성에 있습니다. 깨진 이중 확인 잠금에서는 생성기가 완료되지 않은 객체에 대한 널이 아닌 참조를 관찰하는 문제입니다(참조 할당과 생성자 실행의 재배치). **String.hashCode()**에서는 String 객체 자체가 이미 안전하게 게시되고 완전히 구성되어 있으며, hash 필드는 단지 순수 데이터의 게으르게 초기화된 캐시일 뿐입니다. 0(초기화되지 않음)을 보는 것은 부분적인 구성의 문제가 아니라 유효한 초기 상태입니다. 더욱이, 이 작업은 자명한 것으로—여러 스레드가 동일한 계산된 값을 작성하는 것은 한 스레드와 동일한 결과를 가져오며, DCL은 정확히 하나의 인스턴스 생성을 요구합니다.