자바

Java Concurrent Map: 스레드 안전성을 보장하는 Atomic 메서드 소개

알쓸개잡 2024. 6. 10.

멀티스레드 애플리케이션에서는 데이터의 일관성과 스레드 안전성을 보장하는 것이 매우 중요하다. Java는 이러한 요구를 충족시키기 위해 다양한 동시성 컬렉션 클래스를 제공한다. 

이번 포스팅에서는 Java ConcurrnetMap을 사용하여 스레드 안전성을 보장하는 방법에 대해서 소개한다.

특히 computeIfAbsent, computeIfPresent, merge, putIfAbsent와 같은 원자적(atomic) 메서드들을 중점적으로 살펴보고자 한다.

 

java.util.concurrent 패키지

java.util.concurrent 패키지는 Java5에서 도입된 패키지로, 멀티스레드 프로그래밍을 보다 쉽고 효율적으로 할 수 있도록 다양한 동시성 유틸리티를 제공한다. 이 패키지는 고성능 멀티스레드 애플리케이션을 개발하는데 필요한 핵심 클래스와 인터페이스들을 포함하고 있으며, 스레드 풀, 동기화 도구, 원자적 연산, 동시성 컬렉션 등을 제공한다.

 

ConcurrentMap을 확장 구현한 클래스는 ConcurrentHashMap과 ConcurrentSkipListMap이 있는데 클래스 구성도를 살펴보면 다음과 같다.

ConcurrentMap 클래스 다이아그램

 

ConcurrentHashMap, ConcurrentSkipListMap 모두 AbstractMap을 상속받고 ConcurrentHashMap은 ConcurrentMap을 구현하고 ConcurrentSkipListMap은 ConcurrentNavigableMap을 구현한다.

 

ConcurrentHashMap

ConcurrentHashMap은 멀티스레드 환경에서 안전하게 사용할 수 있는 고성능 해시 맵이다. 이 클래스는 여러 스레드가 동시에 데이터를 읽고 쓸 수 있도록 설계되었으며, HashMap과 달리 동시 접근을 안전하게 처리한다.

 

주요 특징

  • 스레드 안전성: ConcurrentHashMap은 여러 스레드가 동시에 맵에 접근하고 수정할 수 있도록 설계되었다. 내부적으로 세분화된 락(segmented locking) 또는 락 분할(lock striping)이라는 메커니즘을 사용하여 높은 동시성을 제공한다.
  • 고성능: 동시 접근을 처리하기 위해 전체 맵에 락을 걸지 않고, 일부 버킷 또는 특정 부분에만 락을 걸어 성능 저하를 최소화 한다.
  • 비차단 읽기: 대부분의 읽기 연산을 락을 사용하지 않으므로 매우 빠르게 수행된다.
  • 안정된 성능: 큰 데이터 셋에서도 일관된 성능을 유지하며, 동시성 수준이 높을 때도 성능이 크게 저하되지 않는다.

 

ConcurrentSkipListMap

ConcurrentSkipListMap은 스레드 안전성을 제공하는 동시에 높은 성능을 유지하는 동시성 컬렉션이다. 멀티스레드 환경에서 안전하게 사용할 수 있는 정렬된 맵(Sorted Map) 구현체다.

 

주요 특징

  • 스레드 안전성: ConcurrentSkipListMap은 여러 스레드가 동시에 안전하게 접근하고 수정할 수 있도록 설계되었다. 내부적으로는 락을 사용하지 않는 (non-blocking) 알고리즘을 통해서 고성능을 유지한다.
  • 정렬된 맵: 자연 순서(키가 Comparable 인터페이스를 구현하는 경우) 또는 명시적으로 제공된 Comparator에 따라 키를 정렬한다. 따라서 항상 정렬된 상태로 유지된다.
  • Skip List: 내부적으로는 스킵 리스트(Skip List)라는 데이터 구조를 사용하여 효율적인 검색, 삽입 및 삭제 연산을 지원한다. 스킵 리스트는 여러 레벨의 정렬된 링크드 리스트로 구성되어 있으며, 이로 인해 이진트리와 유사한 성능을 제공한다.

주요 메서드

ConcurrentSkipListMap은 NavigableMap 인터페이스를 구현하므로 다음과 같은 메서드를 제공한다.

firstKey(): 정렬된 순서에서 첫 번째 키를 반환한다.

lastKey(): 정렬된 순서에서 마지막 키를 반환한다.

headMap(K toKey): 지정된 toKey까지의 부분맵을 반환한다.

tailMap(K fromKey): 지정된 fromKey부터의 부분맵을 반환한다.

subMap(K fromKey, K, toKey): 지정된 fromKey부터 toKey까지의 부분맵을 반환한다.

 

Comparator를 제공하여 정렬 방식을 직접 구현할 수 있다.

@Test
@DisplayName( "ConcurrnetSkipListMap example" )
void test() {
    ConcurrentSkipListMap<String, String> map = new ConcurrentSkipListMap<>(
            Comparator.comparing(k -> k, Comparator.reverseOrder() )
    );
    String value = map.putIfAbsent( "key1", "value1" );
    Assertions.assertNull( value );

    value = map.putIfAbsent( "key1", "value2" );
    Assertions.assertEquals( value, "value1" );

    value = map.get( "key1" );
    Assertions.assertEquals( value, "value1" );

    map.put("key3", "value3");
    map.put( "key2", "value2" );
    map.put( "abcd", "abcd" );
    System.out.println(map );
}

ConcurrentSkipListMap 생성자에 Comparator 함수형 인터페이스를 전달하여 내림차순으로 정렬하도록 하였다.

결과

{key3=value3, key2=value2, key1=value1, abcd=abcd}

 

 

원자적(atomic) 메서드 알아보기

ConcurrentMap 인터페이스에 정의된 원자적(atomic) 메서드를 알아보자.

ConcurrentHashMap, ConcurrentSkipListMap에 모두 ConcurrentMap 인터페이스를 구현하므로 두 클래스 모두 사용할 수 있다.

다음에 소개할 메서드들은 모두 원자적(atomic)으로 실행되는 메서드들이다. 여러 스레드가 동시에 접근하더라도 데이터의 일관성을 보장한다. 따라서 스레드 안전성이 보장된다.

putIfAbsent

메서드 정의는 다음과 같다.

V putIfAbsent(K key, V value);
  • 값이 없을 때만 삽입한다.
  • 지정된 키가 이미 존재하는 경우 새 값으로 덮어쓰지 않고 기존값을 그대로 유지한다.
  • 리턴값은 키가 존재하지 않는 경우 null을 리턴하며 키가 존재하는 경우 기존 값을 리턴한다.
@Test
@DisplayName( "putIfAbsentSample" )
void putIfAbsent_test() {
    ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
    String value = map.putIfAbsent( "key1", "value1" );
    Assertions.assertNull( value );

    value = map.putIfAbsent( "key1", "value2" );
    Assertions.assertEquals( value, "value1" );

    value = map.get( "key1" );
    Assertions.assertEquals( value, "value1" );
    System.out.println(map);
}

 

결과

{key1=value1}

 

computeIfAbsent

메서드 정의는 다음과 같다.

V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction);

 

computeIfAbsent 메서드는 지정된 키가 존재하지 않는 경우에만 해당 키와 연결된 값을 계산하고 업데이트한다. 이 메서드는 키가 없을 때만 연산을 수행한다.

 

  • 키가 존재하면 아무 동작도 하지 않는다.
  • 키가 존재하지 않으면 mappingFunction 람다식이 호출되어 값을 계산하고, 그 값을 맵에 추가한다.
  • mappingFunction 람다식이 null을 반환하면 맵에 아무것도 추가되지 않는다.
  • 리턴값은 mappingFunction 람다식이 리턴되는 값이 된다.
@Test
@DisplayName( "computeIfAbsentSample" )
void computeIfAbsent_test() {
    ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
    //putIfAbsent와 다른 점은 putIfAbsent는 null을 리턴하는 반면,
    //computeIfAbsent는 function lambda 식의 리턴되는 값을 리턴한다.
    String value = map.computeIfAbsent( "key1", k -> "value1" );
    Assertions.assertEquals( value, "value1" );

    value = map.computeIfAbsent( "key2", k -> {
        String newValue = "newValue";
        System.out.println( "inserted value is " + newValue );
        return newValue;
    } );

    Assertions.assertEquals( value, "newValue" );

    //이미 키가 존재하는 경우에는 lambda 식은 수행되지 않고 기존 값이 리턴된다.
    value = map.computeIfAbsent( "key1", k -> "value2" );
    Assertions.assertEquals( value, "value1" );
    System.out.println(map);
}

 

결과

{key1=value1, key2=newValue}

 

computeIfPresent

메서드 정의는 다음과 같다.

V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction);

mappingFunction은 key와 value를 파라미터를 받는 BiFunction 함수형 인터페이스다. 즉 mappingFunction 에는 지정된 키와 해당 키의 값이 파라미터로 전달된다.

 

computeIfPresent 메서드는 지정된 키가 존재하는 경우에만 해당 키와 연결된 값을 계산하고 업데이트 한다. 이 메서드는 키가 이미 존재할 때만 연산을 수행한다.

  • 키가 존재하는 경우에만 값을 계산하고 업데이트 한다.
  • 키가 존재하지 않으면 아무 동작도 하지 않는다.
  • mappingFunction 람다식이 null을 반환하면 해당 element는 맵에서 제거된다.
  • 리턴값은 mappingFunction 람다식이 리턴되는 값이 된다.
@Test
@DisplayName( "computeIfPresentSample" )
void computeIfPresent_test() {
    ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
    //키가 존재하지 않는다면 람다식은 실행되지 않고 null을 반환한다.
    String value = map.computeIfPresent( "key1", ( k, v ) -> "value1" );
    Assertions.assertNull( value );

    map.put( "key1", "value1" );
    map.put( "key2", "value2" );

    BiFunction<String, String, String> remapping = ( k, v ) -> {
        if ( k.equals( "key1" ) ) {
            return "special " + v;
        }

        return "normal " + v;
    };

    /*
    다음과 같이 사용 가능하다.
    map.computeIfPresent( "key1", (k , v) -> {
        if ( k.equals( "key1" ) ) {
            return "special " + v;
        }

        return "normal " + v;
    } );
    */

    value = map.computeIfPresent( "key1", remapping );
    Assertions.assertEquals( value, "special value1" );

    value = map.computeIfPresent( "key2", remapping );
    Assertions.assertEquals( value, "normal value2" );

    System.out.println( map );
}

 

결과

{key1=special value1, key2=normal value2}

 

compute

메서드 정의는 다음과 같다.

V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction);

 

  • compute메서드는 computeIfAbsent와 computeIfPresent 메서드를 모두 커버하는 역할을 한다. 
  • 키가 존재하든 존재하지 않은 모두 처리할 수 있기 때문에 조금 더 일반적이고 유연한 처리가 가능하다.
  • mappingFunction 람다식에서는 기존 키와 값을 모두 파라미터로 전달하는데 키가 존재하지 않는 경우에 값에는 null이 전달되고 키가 존재하는 경우에는 해당 키와 매핑된 값이 전달된다.
  • mappingFunction 람다식에서 null을 반환하는 경우 해당 element는 제거된다.

 

@Test
@DisplayName( "computeSample" )
void compute_test() {
    ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

    AtomicInteger valueIndex = new AtomicInteger( 1 );
    BiFunction<String, String, String> remapping = ( k, v ) -> {
        if ( v == null ) {
            return "value" + valueIndex.getAndIncrement();
        }
        else {
            if ( k.equals( "key1" ) ) {
                return "special " + v;
            }

            return "normal " + v;
        }
    };

    String value = map.compute( "key1", remapping );
    Assertions.assertEquals( value, "value1" );

    value = map.compute( "key1", remapping );
    Assertions.assertEquals( value, "special value1" );

    value = map.compute( "key2", remapping );
    Assertions.assertEquals( value, "value2" );

    value = map.compute( "key2", remapping );
    Assertions.assertEquals( value, "normal value2" );

    //null을 반환하면 해당 element는 제거한다.
    map.put( "key3", "value3" );
    value = map.compute( "key3", ( k, v ) -> null );
    Assertions.assertNull( value );

    System.out.println(map);
}

 

결과

{key1=special value1, key2=normal value2}

 

merge

merge 메서드 정의는 다음과 같다.

V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction);

 

merge 메서드는 지정된 키에 대해 병합 연산을 수행한다. 이 메서드는 주로 맵에 존재하는 경우 기존 값과 새로운 값을 병합하는 데 사용된다.

메서드 파라미터는 다음과 같다.

  • key : 병합할 키
  • value : 병합할 새로운 값
  • remappingFunction : 두 값을 받아서 병합된 값을 반환하는 함수. 기존 값과 새로운 값이 이 함수의 인자로 전달된다.

동작 방식은 다음과 같다.

  • 키가 존재하지 않으면 새로운 값을 추가한다. 즉, remappingFunction 람다식이 호출되지 않는다.
  • 키가 존재하면 기존 값과 새로운 값을 remappingFunction 람다식을 통해서 병합하여 결과를 저장한다.
  • remappingFunction 람다식이 null을 반환하면 해당 element는 맵에서 제거된다.
@Test
@DisplayName( "mergeSample" )
void merge_test() {
    ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

    map.put( "key1", "value1" );
    map.put( "key2", "value2" );

    String mergedValue = map.merge( "key1", "merged value1", ( oldValue, newValue ) -> {
        System.out.println( "old value: " + oldValue + ", new value: " + newValue );
        return oldValue + "::" + newValue;
    } );
    Assertions.assertEquals( mergedValue, "value1::merged value1" );


    mergedValue = map.merge( "key3", "value3", (oldValue, newValue) -> {
        System.out.println( "old value: " + oldValue + ", new value: " + newValue );
        return oldValue + "::" + newValue;
    } );
    Assertions.assertEquals( mergedValue, "value3" );

    System.out.println(map);
}

 

결과

{key1=value1::merged value1, key2=value2, key3=value3}

 

 

끝.

 

댓글

💲 추천 글