Java프로그래밍수석 Java 백엔드 개발자

왜 이후의 Java 메모리 모델 개정이 double-checked locking 관용구를 보장하기 위해 volatile 의미를 요구했나요?

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

질문에 대한 답변

역사

Java 5 이전에 Java 메모리 모델(JMM)은 많은 인기 있는 동시성 관용구를 안전하지 않게 만드는 약한 메모리 가시성 보장을 가지고 있었습니다. Double-Checked Locking 패턴은 지연 초기화를 위한 성능 최적화로 주장되면서 1990년대 후반에 등장했지만, 명령 재배치에 관한 치명적인 결함이 있었습니다. JSR-133은 2004년에 volatile 키워드의 의미를 재정의하여 전체 동기화의 오버헤드 없이도 이러한 가시성 문제를 해결하기 위해 획득-릴리스 메모리 순서를 제공했습니다.

문제

volatile이 없으면 JVM과 기본 CPU 아키텍처는 명령을 재배치할 수 있어, 참조를 변수에 할당하는 일이 생성자가 완료되기 전에 발생할 수 있습니다. 이로 인해 다른 스레드가 기본값이나 초기화되지 않은 값을 포함하는 객체에 대한 비어 있지 않은 참조를 관찰할 수 있는 창이 만들어져 예측할 수 없는 동작이나 NullPointerException을 초래하게 됩니다. 이 동시성 위험은 특정 타이밍 조건과 하드웨어 메모리 모델에서만 나타나기 때문에 테스트 중에 재현하기 어려운 점이 특히 교활합니다.

해결책

인스턴스 필드를 volatile로 선언하면 작성이 생성자에서 이루어질 때와 다른 스레드에서의 모든 후속 읽기 사이에 발생-이전 관계를 설정하는 메모리 장벽이 삽입됩니다. 이렇게 하면 컴파일러와 프로세서가 생성자 내의 이전 쓰기와 volatile 필드에 대한 쓰기를 재배치하는 것을 방지하여, 객체가 참조가 가시화되기 전에 완전히 구성되도록 보장합니다. 이 패턴은 스레드가 초기화 후 잠금을 사용하지 않고 참조를 확인할 수 있게 하여, 스레드 안전성과 높은 성능을 제공합니다.

public class ConnectionPool { private static volatile ConnectionPool instance; private ConnectionPool() { // 무거운 초기화 } public static ConnectionPool getInstance() { if (instance == null) { synchronized (ConnectionPool.class) { if (instance == null) { instance = new ConnectionPool(); } } } return instance; } }

실제 상황

결제가 이루어지는 고처리량 마이크로서비스는 PostgreSQL 클러스터에 대한 JDBC 연결을 관리하기 위해 싱글턴 ConnectionPool이 필요했습니다. 피크 트래픽 동안, 수천 개의 스레드가 서비스가 처음 시작될 때 동시에 getInstance()를 호출해야 했으므로, 잠금 경합을 최소화한 스레드 안전 초기화 전략이 필요했습니다. 초기화 시퀀스에는 TCP 소켓을 설정하고, 직접 바이트 버퍼를 할당하며, 스키마 검증 쿼리를 실행하는 과정이 포함되어 있었으므로, 자동 확장 시나리오에서 지연 초기화가 비상비용이 발생했습니다.

지연 초기화

지연 초기화는 정적 초기화 블록에서 풀을 만드는 것을 포함했습니다. 이 접근 방식은 클래스 로딩 메커니즘을 통해 스레드 안전성을 보장하고 synchronized 블록의 필요성을 완전히 제거했습니다. 그러나, 연결 설치에는 3초의 TCP 핸드쉐이크 및 자격 증명 교환이 필요하여, 자동 확장 이벤트 동안 콜드 스타트 시간의 서비스 수준 계약을 위반했습니다.

동기화된 메서드

동기화된 메서드getInstance() 메서드를 synchronized 키워드로 감쌌습니다. 이는 모든 접근을 직렬화하여 경쟁 조건을 수정했지만, 부하 상태에서 심각한 성능 저하를 초래했습니다. 프로파일링 결과, 초기화 후 스레드가 불변의 완전히 구성된 풀의 모니터 잠금을 획득하는 데 쓸데없는 사이클이 소모되어 호출당 약 18밀리초의 대기 시간이 추가되었습니다.

volatile과 함께한 double-checked locking

volatile과 함께한 double-checked locking이 최적의 접근 방식으로 선택되었습니다. 이 솔루션은 null을 확인하기 위한 비동기 퀵 경로를 사용한 다음, 필수 구간에 대한 synchronized 블록을 사용하고, 여러 인스턴화 방지를 위해 내부에 두 번째 null 확인을 추가했습니다. volatile 수정자는 완전히 초기화된 풀 상태가 게시 즉시 모든 CPU 코어에 가시화되도록 보장하여, 시작 후 잠금 오버헤드 없이 지연 초기화를 균형 있게 제공합니다.

선택한 솔루션은 블로킹 없이 성공적인 지연 초기화를 초래하여, 초기 풀 생성 후 매우 낮은 밀리초 응답 시간으로 50,000 요청을 초당 처리할 수 있게 했습니다. 구현은 시작 중의 경쟁 조건을 제거하면서 정상 상태 운영 중에는 잠금 없는 접근이 가능하도록 하여, 이전에 높은 동시성 시나리오에서 발생했던 NullPointerException 인스턴스를 방지했습니다. 모니터링을 통해 JVM이 싱글턴이 설정된 후 별도의 동기화 없이 모든 64 코어에 대한 메모리 가시성을 올바르게 처리했음을 확인했습니다.

후보자들이 자주 놓치는 점

왜 double-checked locking 패턴이 단일 동기화 확인이 아닌 두 개의 개별 null 검사가 필요합니까?

첫 번째 체크는 인스턴스가 이미 존재하는 일반 케이스를 위한 빠르고 잠금 없는 경로를 제공하기 위해 synchronized 블록 외부에서 작동합니다. 두 번째 체크는 인스턴스가 아직 초기화되지 않았을 때 여러 스레드가 동시에 첫 번째 null 확인을 통과할 수 있기 때문에 필수적입니다. 이 두 번째 검증이 없다면 각 스레드는 잠금을 순차적으로 획득하고 별도의 인스턴스를 생성하여, 싱글턴 속성을 위반하게 됩니다. 내부 확인은 비판적 구역에 첫 번째로 들어간 스레드만 생성 작업을 수행하도록 보장하고, 이후 스레드는 인스턴스가 이미 초기화된 것을 확인하고 생성을 건너뜁니다.

Java 메모리 모델은 volatile 쓰기와 synchronized 블록 종료의 가시성 보장을 어떻게 구분합니까?

두 구조 모두 발생-이전 관계를 설정하지만, 서로 다른 세부 사항과 성능 특성에 기반하여 작동합니다. synchronized 블록 종료는 스레드의 작업 메모리에서 모든 수정 변수를 메인 메모리로 플러시하여 글로벌 메모리 장벽으로 작용합니다. 반대로, volatile 쓰기는 특정 변수가 주변 명령과 재배치되는 것을 방지하고 쓰기가 즉시 가시화되도록 보장합니다. Java 5 이전에 volatile은 이러한 보장을 결여하여 안전한 게시를 위해 충분하지 않았습니다. 현대 JMMvolatile 쓰기를 C++ 릴리스 작업과 유사하게 처리하며, 완전한 모니터 잠금 비용 없이 목표 가시성을 제공합니다.

불변 객체가 double-checked locking 패턴에서 volatile의 필요성을 없앨 수 있습니까?

아니오, 왜냐하면 final 필드는 참조 게시 중이 아니라 생성이 완료된 후에만 불변성을 보장하기 때문입니다. volatile이 없으면 명령 재배치로 인해 참조가 생성자가 실행되는 것을 마치기 전에 메인 메모리에 기록될 수 있어, 다른 스레드가 부분적으로 구성된 객체에 대한 비어 있지 않은 참조를 관찰할 수 있습니다. final 필드는 값이 생성 후 변경될 수 없음을 보장하지만, 참조가 일찍 탈출하면 기본값이나 초기화되지 않은 값의 가시성을 방지하지는 않습니다. 안전한 게시를 위해서는 생성과 가시성 간의 발생-이전 관계를 보장하기 위해 volatile 또는 synchronized가 필요하며, 객체의 내부 불변성과 관계없이 그렇습니다.