AtomicReferenceFieldUpdater 사용하기

Armeria 내부 구현을 보면 아래와 같은 코드를 심심찮게 볼 수 있다.

public class DefaultStreamMessage<T> extends AbstractStreamMessageAndWriter<T> {

    private static final AtomicReferenceFieldUpdater<DefaultStreamMessage, SubscriptionImpl>
            subscriptionUpdater = AtomicReferenceFieldUpdater.newUpdater(
            DefaultStreamMessage.class, SubscriptionImpl.class, "subscription");

    private static final AtomicReferenceFieldUpdater<DefaultStreamMessage, State> stateUpdater =
            AtomicReferenceFieldUpdater.newUpdater(DefaultStreamMessage.class, State.class, "state");

    private volatile SubscriptionImpl subscription;

    private volatile State state = State.OPEN;

AtomicReferenceFieldUpdater 이름을 비추어 보았을 때, 멀티 쓰레드 환경에서 발생할 수 있는 race condition 상황에서 특정 필드를 atomic하게 업데이트하기 위한 목적으로 보인다.
그러한 목적이라면 AtomicReference 만을 사용해도 충분할텐데, 왜 굳이 복잡하게 volatile 변수와 함께 AtomicReferenceFieldUpdater 을 사용하는 걸까?

아래 예제를 보며 한번 그 이유를 살펴보도록 하자.


예제

아래는 race condition이 발생하는 간단한 예제이다.
총 10개의 쓰레드에서 동시에 boyFriend.makeGirlFriend(new GirlFriend())을 실행한다. 만약 BoyFriend 객체의 girFriend 객체 필드가 이미 세팅된 적이 있다면, 함수가 호출되더라도 최초 생성된 그 GirlFriend 객체를 그대로 사용하기를 기대하고 있다.

public class AtomicOperationTest {

    public static void main(String[] args) throws Exception {
        int n = 10;
        ExecutorService executorService = Executors.newFixedThreadPool(n);
        BoyFriend boyFriend = new BoyFriend();

        Runnable runnable = () -> {
            GirlFriend girlFriend = boyFriend.makeGirlFriend(new GirlFriend());
            System.out.println(girlFriend);
        };

        for (int i = 0; i < n; i++) {
            executorService.submit(runnable);
        }

        executorService.shutdown();
    }
}
public class BoyFriend {

    private volatile GirlFriend girlFriend;

    public BoyFriend() { }

    public GirlFriend makeGirlFriend(GirlFriend girlFriend) {
        if (this.girlFriend == null) {
            this.girlFriend = girlFriend;
            return girlFriend;
        }
        return this.girlFriend;
    }

    static class GirlFriend { }
}

main 함수를 실행해보면,

> Task :AtomicOperationTest.main()
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@610add8a  
atomic.BoyFriend$GirlFriend@21f189d9  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@6c68f530  
atomic.BoyFriend$GirlFriend@4552d520  

아래와 같이 각 쓰레드 실행 후의 GirlFriend 객체가 모두 일치하지 않음을 확인할 수 있다. 쓰레드 간의 동기화 처리가 전혀 안 되어있기 때문에 각 쓰레드들에서 this.girlFriend == null 조건문을 동시에 true 로 통과하게 된다면, 이러한 결과는 충분히 발생할 수 있다.


volatile 사용하기

먼저 girlFriend 변수를 volatile 타입으로 선언해보았다.

public class BoyFriend {

    private volatile GirlFriend girlFriend;

    // ...
}

다시 main 함수를 돌려보면,

> Task :AtomicOperationTest.main()
atomic.BoyFriend$GirlFriend@610add8a  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@50bd2adc  
atomic.BoyFriend$GirlFriend@4552d520  
atomic.BoyFriend$GirlFriend@133eafb9  
atomic.BoyFriend$GirlFriend@50bd2adc  

결과는 크게 다르지 않음을 볼 수 있다.

volatile 의 역할을 이해하면 어쩌면 당연한 결과인데, volatile이 해주는 것은 단지 CPU 캐시가 아닌, 메인 메모리에 저장된 변수에 직접 접근하여 읽거나 업데이트하는 것 뿐이다. 이를 이용하면 멀티 쓰레드 환경에서 하나의 쓰레드에서 변경한 변수를 다른 쓰레드들에서도 바로 최신 값을 읽을 수 있다는 이점을 얻을 수 잇다.
하지만 위의 예제에서처럼, 하나의 쓰레드에서 변경한 변수(여기서는 girlFriend) 가 아직 메인 메모리에 업데이트되기 전이라면, 앞에서와 마찬가지로 다른 쓰레드에서는 girlFriendnull 로 판단해 저마다의 객체를 세팅하게 된다는 한계점이 여전히 존재한다.


AtomicReference 사용하기

위에서 발생한 문제들의 근본적인 원인은 변수의 값을 읽고(null인지 체크), 업데이트(필드를 새로 세팅) 하는 연산들이 atomic하지 않았기 때문에, 이 두 연산 사이에 변수의 값이 변경되는 문제가 발생할 수 있다는 것이었다.
이러한 문제를 해결하기 위하여 AtomicReference 을 사용할 수 있다. Atomic* 클래스의 연산들은 CPU low level에서 atomic하게 수행되기 때문에, 따로 lock을 걸지 않고도 연산들의 원자성(atomicity)을 보장할 수 있다.
compareAndSet(null, girlFriend) 함수를 이용하여 null 체크와 새로운 필드를 세팅하는 연산 2개를 atomic하게 실행하고 있음에 주목하자.

public class BoyFriend {

    private AtomicReference<GirlFriend> girlFriendRef;

    public BoyFriend() {
        girlFriendRef = new AtomicReference<>();
    }

    public GirlFriend makeGirlFriend(GirlFriend girlFriend) {
        if (!girlFriendRef.compareAndSet(null, girlFriend)) {
            return girlFriendRef.get();
        }
        return girlFriend;
    }

    static class GirlFriend { }
}

main 함수를 돌려보면, 각 쓰레드 실행 후의 GirlFriend 객체가 모두 동일함을 확인할 수 있다.

> Task :AtomicOperationTest.main()
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  
atomic.BoyFriend$GirlFriend@29f29261  

AtomicReferenceFieldUpdater 사용하기

사실 위의 AtomicReference 을 사용한 방식으로 구현을 끝내더라도 문제없이 잘 돌아가는 코드이다. 다만 최적화 관점에서, 여기서 AtomicReferenceFieldUpdater을 사용하면 조금 더 메모리 사용률을 줄일 수 있다는 장점이 존재한다.

만약 예제의 BoyFriend 객체가 N개의 GirlFriend를 필드를 가진다고 가정해보자. BoyFriend 객체는 각 GirlFriend 필드를 atomic하게 관리하게 위해 N개의 AtomicReference 필드를 선언하여야 한다.

public class BoyFriend {

    private AtomicReference<GirlFriend> girlFriendRef1;
    private AtomicReference<GirlFriend> girlFriendRef2;
    private AtomicReference<GirlFriend> girlFriendRef3;
    // ...
    private AtomicReference<GirlFriend> girlFriendRefN;
}

또 위의 예제의 main 함수에서는 BoyFriend 객체가 1개 생성되었었지만, 만약 BoyFriend 객체가 M개씩 생성되는 상황이라고 가정해보자.

그렇게 되면

(GirlFriendAtomicReference 객체로 N개 생성하는 메모리 오버헤드) * M

만큼의 메모리가 추가적으로 요구된다.

이 때 volatileAtomicReferenceFieldUpdate 을 함께 사용하면 이러한 추가적인 비용을 막을 수 있다.

A reflection-based utility that enables atomic updates to designated {@code volatile} reference fields of designated classes.

AtomicReferenceFieldUpdater정의를 보면 알 수 있듯, volatile 필드를 atomic하게 업데이트하기 위해 만들어졌기 때문에 항상 volatile 필드와 짝을 이루어 사용하여야 한다.

아래는 AtomicReferenceFieldUpdater를 적용한 코드이다.

public class BoyFriend {

    private volatile GirlFriend girlFriend;

    private static final AtomicReferenceFieldUpdater<BoyFriend, GirlFriend>
            girlFriendUpdater = AtomicReferenceFieldUpdater.newUpdater(
            BoyFriend.class, GirlFriend.class, "girlFriend");

    public BoyFriend() { }

    public GirlFriend makeGirlFriend(GirlFriend girlFriend) {
        if (!girlFriendUpdater.compareAndSet(this, null, girlFriend)) {
            return this.girlFriend;
        }
        return girlFriend;
    }

    static class GirlFriend { }
}

AtomicReference<GirlFriend>가 아닌 GirlFriend 타입의 변수로 그대로 선언할 수 있기 때문에 AtomicReference로 인해 생성되는 추가적인 메모리 오버헤드를 줄일 수 있다.
또 추가된 AtomicReferenceFieldUpdater 필드는 static 변수이기 때문에 인스턴스 생성시의 메모리 사용량에도 영향을 미치지 않는다.


정리

정리하면,

  • 하나의 클래스가 AtomicReferenceField 필드를 많이 가질수록
  • 그 클래스의 인스턴스가 많이 생성될수록

AtomicReferenceFieldUpdater을 사용하였을 때 메모리 사용 측면에서 이점을 얻을 수 있다.


참고