ConcurrentHashMap의 computeIfAbsent 메소드는 전체 테이블을 잠그는 대신 해시 빈 수준에서의 세분화된 잠금을 사용하여 값을 원자적이고 스레드 안전하게 계산합니다. 이 메소드에 제공된 mappingFunction이 실행 중 같은 맵 인스턴스 내에서 같은 키에 재귀적으로 접근하려고 할 때, 잠재적인 순환 종속성을 생성하여 중요한 재진입 위험이 발생합니다.
Java 8에서 이 재귀적 접근은 특정 해시 빈을 잠그는 동안 현재 스레드가 이미 보유하고 있는 동일한 잠금을 얻으려고 하여 교착 상태를 초래했습니다. Java 9 이후, 구현은 계산 중에 해당 빈에 ReservationNode 플레이스홀더를 삽입하여 "진행 중"으로 표시하여 이 재귀를 감지합니다. 동일한 스레드가 같은 키를 위해 탐색할 때 이 ReservationNode를 발견하면, 메소드는 "재귀 업데이트"라는 메시지를 가진 IllegalStateException을 던져 교착 상태를 피하고 잘못된 재귀에 대한 즉각적인 피드백을 제공합니다.
이 실패-빠른 메커니즘은 ForkJoinPool 공통 풀 및 교착 상태가 재앙적일 수 있는 기타 실행기 컨텍스트 내에서 스레드 기아 및 생명력 문제를 방지합니다. 그러나 이는 개발자가 키 간의 순환 종속성을 피하기 위해 계산 논리를 신중하게 구조화해야 하며, 종종 도메인 계층에서 명시적인 사이클 감지를 필요로 합니다.
우리는 금융 상품의 파생물 계산을 캐싱하여 중복 몬테카를로 시뮬레이션을 피하는 높은 처리량의 가격 책정 엔진에서 이 위험에 직면했습니다. 캐시는 **ConcurrentHashMap<String, CompletableFuture<BigDecimal>>**을 이용하여 같은 옵션 가격 요청이 시장 데이터 틱 당 한 번만 계산되도록 보장했습니다. 이 패턴은 비용이 많이 드는 계산이 여러 동시 요청 간에 공유되어야 하는 비동기 데이터 로딩 시나리오에서 일반적입니다.
문제는 데이터 모델링 오류로 인해 동일한 캐시 내에서 다른 파생물을 잘못 참조할 때 복잡한 파생물을 계산할 때 발생했습니다. 구체적으로, Instrument A의 가격 공식은 기본으로 Instrument B를 참조했으며, Instrument B의 공식은 예상치 못하게 Instrument A를 다시 참조하여 순환 종속성을 생성했습니다. 이로 인해 A에 대한 computeIfAbsent 호출이 값 초기화 단계에서 동일한 스레드 내에서 A에 대한 또 다른 computeIfAbsent 호출을 트리거했습니다.
우리가 처음 고려한 솔루션은 계산 중에 동시 수정을 방지하기 위해 캐시 접근을粗작용 synchronized 블록으로 감싸는 것이었습니다. 이 접근법은 교착 상태 위험을 제거했지만, 전체 맵의 모든 가격 계산을 직렬화하여 실제 거래를 위한 성능 특성을 손상시키며 단일 스레드 HashMap의 처리량으로 감소시킬 것입니다.
두 번째 접근법은 맵 작업 전에 **supplyAsync()**를 통해 생성된 사전에 계산된 CompletableFuture 인스턴스를 이용하여 putIfAbsent를 사용하는 것이었습니다. 이는 계산 중 잠금을 보유하지 않지만, 캐시 내에 키가 이미 있을 때에도 비용이 많이 드는 가격 계산을 미리 시작하게 하여 중복 계산에 대한 상당한 CPU 자원을 낭비하게 하고 캐시의 목적을 무의미하게 만듭니다.
세 번째 솔루션은 현재 스레드의 호출 스택 내에서 "현재 계산 중인 키"를 포함하는 **ThreadLocal<Set<String>>**을 유지하여 명시적인 사이클 감지를 구현했습니다. computeIfAbsent 작업을 시작하기 전에 시스템은 이 세트에서 대상 키를 확인하여 순환 참조에 대해 DomainException을 던집니다. 이렇게 하면 ConcurrentHashMap의 잠금 없는 동시성을 유지하면서 잘못된 금융 모델에 대한 의미 있는 비즈니스 맥락을 제공합니다.
우리는 세 번째 솔루션을 선택했습니다. 이는 단순히 증상을 가리는 것이 아니라 잘못된 순환 금융 모델이라는 근본 원인을 해결하면서도 ConcurrentHashMap의 동시 성능 특성을 완전히 보존했습니다. 명시적인 유효성 검사는 어떤 특정 장치가 잘못된 순환 종속성을 형성했는지를 명확하게 감사할 수 있도록 하여 데이터 팀이 데이터 소스 오류를 수정할 수 있게 했습니다.
이 구현은 생산 환경에서의 IllegalStateException 충돌을 제거하고 가격 계산의 중복을 약 40% 줄이며, 거래 플랫폼의 서브 밀리초 대기 시간 요구 사항을 유지했습니다. 명시적인 사이클 감지는 코드 내에서 조용히 처리하기보다는 소스에서 오류가 있는 장치 계층 구조의 수정을 강제하여 데이터 품질을 향상시켰습니다.
ConcurrentHashMap은 왜 null 키와 값을 거부하는 반면 HashMap은 이를 허용합니까?
ConcurrentHashMap은 내의 원자적 작업에서 "키가 존재하지 않음"과 "계산 진행 중"을 구별하기 위해 내부적으로 null을 발신자 값으로 사용합니다. computeIfAbsent 및 merge와 같은 메소드는 원자적 업데이트 중에 존재하지 않음을 명확하게 표시하기 위해 이 발신자를 사용하여 경합 조건을 만들지 않으면서 추가 조회가 필요하지 않습니다. get 메소드가 존재하지 않는 키와 null로 매핑된 키에 대해 null을 반환하기 때문에 null 값을 허용하면 동시 수정을 수행하는 동안 키가 실제로 맵에 존재하는지 여부를 결정할 수 없게 되어 조합 작업의 원자성 보장이 손상됩니다.
Java 8+의 빈 수준 잠금이 Java 7의 세그먼트 기반 동시성과 어떻게 다른가요?
Java 7은 각각 독립적인 ReentrantLock으로 보호되는 16개의 세그먼트로 구성된 고정된 배열을 사용하여 쓰기 동시성을 인위적으로 16개의 스레드로 제한했습니다. Java 8+는 이 세분화를 제거하고 각 해시 빈 수준에서의 세분화된 잠금을 사용하여 각 버킷의 첫 번째 노드에서 synchronized 블록을 사용하고 미경쟁 쓰기 및 읽기에 대해서는 잠금 없는 CAS 작업을 사용했습니다. 이 아키텍처는 수천 개의 스레드가 경쟁 없이 서로 다른 빈에 동시에 쓸 수 있게 하며, 크기 조정 작업은 volatile next-table 포인터를 사용하여 마이그레이션 중에도 읽기가 진행될 수 있도록 합니다.
computeIfAbsent가 putIfAbsent보다 선호되어야 하는 경우와 고려해야 할 잠금 함의는 무엇인가요?
비용이 많이 드는 값 생성이 필요하고 키가 없을 때만 원자적으로 발생해야 하는 경우 computeIfAbsent가 필수적입니다. 이는 필요할 때만 실행되는 Function을 수용합니다. 그러나 구현은 함수 실행 기간 동안 전체 해시 빈을 잠그므로 장기 실행 계산은 해당 빈에 해시되는 키에 대한 모든 접근을 직렬화하여 성능 병목 현상을 일으킬 수 있습니다. putIfAbsent는 호출 전에 값을 미리 계산해야 하므로, 가격 키가 존재하든 관계없이 비용이 많이 드는 생성이 발생하지만, 삽입 확인의 짧은 시간 동안만 잠금을 유지하므로 값 생성이 저렴하거나 멱등적일 때 더 선호됩니다.