Java 메모리 모델 (JMM)은 생성자가 완료되면 final 필드에 대한 쓰기가 객체 참조를 읽는 모든 스레드에 가시적으로 되는 것을 보장합니다. 단, 해당 참조가 생성 중에 누출되지 않아야 합니다. 만약 this 참조가 생성자가 반환되기 전에 다른 스레드에 전달되거나 정적 컬렉션에 저장된다면, 생성자에서 final 필드에 대한 쓰기와 다른 스레드의 읽기 간의 happens-before 관계는 끊어집니다. 그 결과, 관찰하는 스레드는 구성된 값이 아닌 기본값(0, false 또는 null)을 목격하며, 이는 명백한 불변성을 깨뜨리게 됩니다. 안전한 공지는 생성이 종료될 때까지 생성 중인 객체에 대한 참조가 누출되지 않도록 보장해야 하며, 이는 final 필드에 대한 동결 작업이 어떤 스레드가 참조를 불러오기 전에 발생하도록 합니다.
우리는 Service 인스턴스가 생성자 중에 글로벌 ConcurrentHashMap에 자신을 등록하여 조회를 용이하게 하는 고주파 거래 시스템에서 이 문제를 겪었습니다. 클래스는 생성자 매개변수에서 초기화된 final **long instrumentId**를 정의했지만, 모니터링 스레드는 생성 직후 레지스트리를 조회할 때 가끔 0을 읽었습니다.
한 가지 제안된 해결책은 **instrumentId**를 final 대신 volatile로 선언하여 코어 간의 즉각적인 가시성을 강요하는 것이었습니다. 이 접근 방식은 원자성과 가시성을 보장했지만 불변성 계약을 포기하고 매 읽기마다 전체 메모리 장벽 비용이 발생하여, 불변으로 유지되는 값의 처리량을 불필요하게 저하시켰고 객체 상태에 대한 추론을 복잡하게 만들었습니다.
또 다른 제안은 레지스트리에 대한 모든 접근을 생성자 로직을 감싸는 synchronized 블록으로 동기화하여 잠금이 메모리 캐시를 플러시할 것이라고 이론화하는 것이었습니다. 이는 경쟁 조건을 방지했지만 글로벌 레지스트리 잠금에서 심한 경쟁을 야기하여, 동시 구조를 직렬 병목으로 전환하고 시장 데이터 수집에 대한 엄격한 지연 요구 사항을 위반하게 했습니다.
우리는 등록과 인스턴스를 분리하는 팩토리 패턴을 선택했습니다. 생성자는 비공개로 유지되고, 팩토리 메서드는 **new Service(id)**를 완전히 호출한 다음 완전하게 형성된 참조를 ConcurrentHashMap에 게시했습니다. 이렇게 함으로써 instrumentId가 검색 즉시 가시적이도록 하여 JMM의 final 필드 동결 의미를 활용하면서 동기화 오버헤드 없이 보장되었습니다.
이 변경은 0 노출 이상을 제거하고 서비스 조회에 대한 예상 마이크로초 단위 지연을 복원하면서 불변 설계 의도를 유지했습니다.
왜 final이 ConcurrentHashMap과 같은 스레드 안전한 컬렉션을 통해 참조를 간단히 게시하면 가시성을 보장하지 못하나요?
ConcurrentHashMap의 put 및 get 연산에 의해 제공되는 happens-before 관계는 맵의 내부 상태 변화 간의 순서를 설정하며, 생성자의 쓰기와 맵의 게시 간의 순서를 설정하지 않습니다. 생성 중에 **this**가 누출될 경우, final 필드에 대한 쓰기는 한 스레드에서 발생하고 맵의 게시가 동시에 발생하여, 명령 재정렬을 방지할 수 있는 happens-before 관계가 없습니다. 따라서 읽기 스레드는 생성자의 쓰기가 메인 메모리에 플러시되기 전에 맵을 통해 참조를 관찰하여 기본값을 확인할 수 있습니다.
레지스트리 필드를 객체의 필드 대신 volatile로 만드는 것으로 이 문제를 해결할 수 있을까요?
레지스트리 참조를 volatile로 표시하면 레지스트리 변수 자체의 변경 사항은 가시성이 보장되지만, 그 안에 포함된 객체의 내부 상태는 보장되지 않습니다. 문제는 객체의 필드 쓰기가 참조가 가시적으로 되는 것과 관련된 타이밍이기 때문에, 컨테이너의 volatile는 생성자와 객체 소비자 간에 필요한 순서를 설정하지 못합니다. 여전히 부분적으로 생성된 인스턴스가 관찰될 수 있습니다.
생성자 내부에서 synchronized를 사용하면 안전하지 않은 게시를 방지할 수 있을까요?
생성자에 synchronized를 배치하거나 등록 자체를 보호하기 위해 이를 사용하면 다른 스레드가 크리티컬 섹션에 동시 접근하는 것을 방지하지만, 등록 메서드가 그 잠금을 벗어난 스레드에 참조를 누출하면 this 참조의 누출을 방지하지 못합니다. JMM은 final 필드 의미가 유지되기 위해서는 생성자가 완료되기 전까지 객체에 대한 참조가 누출되어서는 안 된다고 명시하고 있습니다. 적절한 게시 순서 없이 동기화는 그 보장을 회복할 수 없습니다.