본문 바로 가기

[자바] AtomicInteger: incrementAndGet 구체적인 동작방식

들어가며

AtomicInteger 클래스는 원자적 연산으로 자바의 동시성 문제를 해결하는 방안 중 하나입니다. AtomicInteger 클래스의 incrementAndGet() 메소드에 대해 자세히 알아보도록 하자.

환경

- 아키텍쳐: x86
- os: linux
- JDK17

AtomicInteger

AtomicInteger 클래스에 대해 미리 설명해 둘 정보가 있다.

    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE
        = U.objectFieldOffset(AtomicInteger.class, "value");

    private volatile int value;

AtomicInteger 는 내부에 int value 값을 사용해서 값을 나타내는 것을 알 수 있다.
그리고 Unsafe 라는 객체를 참조한다.
VALUE 는 value의 메모리 주소를 참조하기 위한 offset 값이다.

 

AtomicInteger의 incrementAndGet() 메소드 내부를 보자.

    public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }

여기서 U 는 참조하고 있는 Unsafe 의 객체이다.
U.getAddInt() 메소드를 통해 값을 불러오고 그것 보다 1 큰 값을 리턴하고 있다.

 

여기서 this 와 VALUE 는 왜 필요할까?? 나중에 보겠지만 CAS 연산은 메모리 주소가 필요하다. this 를 통해 현재 객체의 주소, VALUE 를 통해 value 에대한 메모리 offset 을 전달해주는 모습이다.

Unsafe

Unsafe 의 getAndAddInt() 메소드를 확인해보자.


    @IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }

@IntrinsicCandidate 어노테이션이 붙어 있다.
이 뜻은 JVM 상에서 구현된 코드로 대체될 수도 있다는 뜻이다.

 

코드의 형태는 Compare And Swap(CAS) 형태를 띄고 있다.
v = getIntVolatile(); 을 통해 캐시에 있는 값이 아니라 메모리 가시성을 확보한 v 값을 가져오는 것을 볼 수 있다.

 

그렇다면 weakCompareAndSetInt() 메소드를 들어가 보자

    @IntrinsicCandidate
    public final boolean weakCompareAndSetInt(Object o, long offset,
                                              int expected,
                                              int x) {
        return compareAndSetInt(o, offset, expected, x);
    }

여기에는 별다른 내용이 없다. 바로 compareAndSetInt() 메소드로 가보자.

    @IntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);

메모리에 대한 직접 접근과 하드웨어의 atomic operation을 지원받아야 해서 native 코드로 작성되었다. native 코드를 좀 더 추적해보자.


앞으로 현재 값은 expected, 예상값은 x 로 통일해서 표현하겠다.
계속 추적해나가다 보면 변수명이 변경되기 때문이다.

Unsafe.cpp

https://github.com/openjdk/jdk/blob/master/src/hotspot/share/prims/unsafe.cpp
에 있는 unsafe.cpp 파일을 참고하였다.


compareAndSetInt 는 openjdk에서 아래와 같이 구현되어 있다.

UNSAFE_ENTRY_SCOPED(jboolean, Unsafe_CompareAndSetInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) {
  oop p = JNIHandles::resolve(obj);
  volatile jint* addr = (volatile jint*)index_oop_from_field_offset_long(p, offset);
  return Atomic::cmpxchg(addr, e, x) == e;
} UNSAFE_END

c++ 언어로는 위와 같이 구현되어 있다.

oop 는 간단하게 자바 heap 의 포인터라 생각하면 된다. oop p 와 offset 을 이용하여 해당 값의 메모리 주소 addr 을 구한다.


Atomic::cmpxchg 에 대해 더 추적해보자.

Atomic.hpp

Atomic.hpp 는 os, 아키텍쳐별 구현이 모두 다른다. 여기서는
https://github.com/openjdk/jdk/blob/master/src/hotspot/os_cpu/linux_x86/atomic_linux_x86.hpp
x86 기반 linux OS 의 Atomic::cmpxchg 구현을 보자.

template<>
template<typename T>
inline T Atomic::PlatformCmpxchg<4>::operator()(T volatile* dest,
                                                T compare_value,
                                                T exchange_value,
                                                atomic_memory_order /* order */) const {
  STATIC_ASSERT(4 == sizeof(T));
  __asm__ volatile ("lock cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest)
                    : "cc", "memory");
  return exchange_value;
}

machine instruction level로 작성되야 하므로, 어셈블리어로 작성되었다.
간단하게 설명하면 lock cmpxchgl %1, %3 을 실행하라는 뜻인데
여기서 %1은 exchange_value 즉 예상값 x, %3은 현재값이 있는 메모리 주소, 즉 addr 을 의미한다.

cmpxchg

인텔의 x86 아키텍처 명세서 중 cmpxchg 에대해 확인해보자.
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4 문서를 다운받고, Volume2 를 확인하면 된다.

 

CMPXCHG r/m32, r32
Compare EAX with r/m32. If equal, ZF is set and r32 is loaded
into r/m32. Else, clear ZF and load r/m32 into EAX.

 

레지스터 EAX -> 현재값 expected
r/m32 -> 메모리 주소 addr
r32 -> 예상값 x

 

즉 현재값 e 와 실제 메모리에 있는 값을 비교해서 같다면, ZF flag를 set 해주고 예상값을 메모리에 load 해준다. 아니라면 ZF flag 를 해제하고 실제 메모리에 있는 값을 현재값 expected에 옮겨준다.

 

여기서 EAX 레지스터란 x86 아키텍처 에서 사용하는 범용 32bit 레지스터 이다.

여기서 위쪽에 작성된 어셈블리어 와 약간 다른 부분이 2가지가 있다.

"lock cmpxchgl %1,(%3)"
  1. 먼저 cmpxchg 가 아니라 cmpxchgl 을 사용하였다.
    이것은 어셈블리 명령어 에서는 맨뒤에 접미사로 데이터 크기를 지정해주기 때문이다.
    'b': byte (8비트)
    'w': word (16비트)
    'l': long (32비트)
    'q': quad word (64비트)

이 경우 4byte int 연산이었기에 뒤에 접미사 l을 붙여준다.

  1. 두 operand 의 순서가 다르다.
    "lock cmpxchgl %1,(%3)"
    를 보면 예상값 x, 메모리 주소 순이다.

인텔의 명세서를 보면

CMPXCHG r/m32, r32

메모리 주소, 예상값 x 순이다.

 

이는 AT&T 와 INTEL 의 어셈블리 문법이 다르기 때문이다. 두 체계는 operand 위치를 정반대로 사용한다.

여기까지 왔으면, AtomicInteger 가 machine instruction level 에서 어떤 행동을 하는지 까지 볼 수 있었다. 이를 좀더 간략하게 정리해보자.

정리

코드를 좀더 이해하기 편리하게 추상화하고 변형하여 전체 구조를 나타내 보았다.

Reference

https://github.com/openjdk/jdk/blob/master/src/hotspot/share/prims/unsafe.cpp
https://github.com/openjdk/jdk/blob/master/src/hotspot/os_cpu/linux_x86/atomic_linux_x86.hpp
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html