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
) 가 아직 메인 메모리에 업데이트되기 전이라면, 앞에서와 마찬가지로 다른 쓰레드에서는 girlFriend
을 null
로 판단해 저마다의 객체를 세팅하게 된다는 한계점이 여전히 존재한다.
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개씩 생성되는 상황이라고 가정해보자.
그렇게 되면
(
GirlFriend
을AtomicReference
객체로 N개 생성하는 메모리 오버헤드) * M
만큼의 메모리가 추가적으로 요구된다.
이 때 volatile
과 AtomicReferenceFieldUpdate
을 함께 사용하면 이러한 추가적인 비용을 막을 수 있다.
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
을 사용하였을 때 메모리 사용 측면에서 이점을 얻을 수 있다.