본문 바로가기
개발/자바

Java Atomic

by hamcheeseburger 2022. 12. 3.

Atomic 이란?

Java에서 동시성을 보장해주는 자료형이다.

Java docs에서 AtomicInteger에 대해 아래와 같이 설명하고 있다.

An int value that may be updated atomically. (생략) An AtomicInteger is used in applications such as atomically incremented counters, and cannot be used as a replacement for an Integer. However, this class does extend Number to allow uniform access by tools and utilities that deal with numerically-based classes.

원자적으로 업데이트될 수 있는 int 값입니다. AtomicInteger는 원자적으로 증가된 카운터와 같은 응용 프로그램에서 사용되며 Integer를 대체하는 데 사용할 수 없습니다. 그러나 이 클래스는 숫자 기반 클래스를 처리하는 도구 및 유틸리티에 의한 균일한 액세스를 허용하도록 숫자를 확장합니다.

Atomic에서 incrementAndGet()이라는 메서드를 제공한다. 예제를 통해서 정말 Thread Safe하게 동작하는지 확인해보겠다.

 

일단 int가 동시성을 보장하지 못한다는 것을 확인해보자.

당연히 실패한다.

 

그럼 AtomicInteger가 동시성을 보장한다는 것을 확인해보자.

테스트가 통과하는 모습이다.

 

어떻게 동시성을 보장하는가?

결론적으로 이야기 하자면 CAS(Compare And Swap) 알고리즘과 volatile의 사용으로 동시성을 보장한다.

코드를 천천히 따라가 보자.

U라는 변수의 getAndAddInt()를 호출하고 있다. U가 뭘까?

 

jdk.internal.misc.Unsafe라는 객체를 U라는 변수로 선언했다. 그 아래 volatile이라는 키워드도 보인다.

Unsafe내의 getAndInt()를 살펴보도록 하자.

 

weakCompareAndSetInt() 라는 것을 호출해서 성공할 때까지 loop를 돌고 있는 모습이다. 좀 더 들어가보자.

 

weakCompareAndSetInt()가 결국 compareAndSetInt를 호출하고 있다! 그럼 저 메서드 보면 어떤 방법으로 동시성을 보장하는지 알 수 있겠다!

 

기껏해서 들어왔는데 메서드 바디가 없다^^. 순간 추상메서든가? 해서 Unsafe의 클래스단을 확인했는데 심지어 final 키워드가 붙여져 있고 추상클래스가 아니었다.

그 때 눈에 띄는 키워드 native… 저게 대체 뭘까?

 

지금까지 모르는 키워드가 정말 많이 나왔다. Atomic을 이해하기 위해서는 알아두면 좋을 것 같다.

native, @HotSpotIntrinsicCandidate , unsafe, volatile 순서대로 알아보도록 하자.

 

native

native 키워드를 이해하려면 JNI를 이해해야 한다. JNI는 Java Native Interface의 줄임말이다.

Oracle java docs에서는 JNI를 아래와 같이 소개하고 있다.

JNI는 기본 프로그래밍 인터페이스입니다. 이를 통해 Java 가상 머신(VM) 내에서 실행되는 Java 코드는 C, C++ 및 어셈블리와 같은 다른 프로그래밍 언어로 작성된 애플리케이션 및 라이브러리와 상호 운용할 수 있습니다.

그렇다. java에서 다른 언어로 구현된 것을 사용할 수 있다. 그것을 도와주는게 native 키워드이다.

 

Java에서 JNI를 제공하는 이유를 Oracle java docs에서는 아래와 같이 설명하고 있다.

  1. 표준 Java 클래스 라이브러리는 애플리케이션에 필요한 플랫폼 종속(platform-dependent) 기능을 지원하지 않습니다.
    • 여기서 platform이란 processor와 OS 라고 이해하면 될 것 같다. java는 JVM상에서 동작하기 때문에 platform-independent 언어다.
    • (참고) C언어는 platform-dependent 언어다.
  2. 다른 언어로 작성된 라이브러리가 이미 있고 JNI를 통해 Java 코드에 액세스할 수 있도록 만들고 싶습니다.
  3. 어셈블리와 같은 저수준 언어로 시간이 중요한 코드의 작은 부분을 구현하려고 합니다.

Atomic같은 경우는 세번째 이유 때문에 사용하는게 아닐까 싶다.

그렇다면 native가 적용된 메서드는 다른 언어로 작성이 되었다는 것인데, 그렇게 작성된 코드는 어디에 있는걸까?

@HotSpotIntrinsicCandidate

위의 궁금증의 해답은 JVM에 있다. java libarary에서 native로 구현되어 메서드는 JVM 내장함수(JVM Intrinsics)로 존재하게 된다.

Java9 이전에는 내장함수로 대체될 수 있는 메서드를 식별할 수 있는 방법이 없었다. 그러나 Java9부터 Hotspot JVM을 사용하는 경우 @HotSpotIntrinsicCandidate가 내장 함수로 교체될 수 있는 모든 메서드에 적용된다.

교체가 될 수 있다고 해서 무조건 Hotspot JVM이 메서드를 대체하는 것은 아니다. Hotspot JVM이 해당 구현부를 가지고 있으면 Java 메서드 본문이 무시된다. 하지만 구현부를 가지고 있지 않으면 Java 메서드 본문이 일반적으로 사용된다.

 

*Hotspot JVM란?

JVM 종류 중 하나다. Sun사에 의해 개발이 되었다가 지금은 Oracle의 소유가 되었다. 다른 JVM 종류로는 JRockit, IBM J9 등이 있다.

C11의 atomic_compare_exchange_strong

여기까지 Unsafe 클래스의 compareAndSetInt()가 JVM 내장함수를 이용한다는 것을 알게 되었다.

그런데 나는 JVM 내장함수고 뭐고 compareAndSetInt()이 대체 어떤 방식으로 동시성을 보장하는지 눈으로 확인하고 싶었던 것이다.

본론으로 돌아와서 compareAndSetInt()에 대한 설명을 다시 살펴보면 C11의 atomic_compare_exchange_strong와 관련이 있다고 명시되어 있다.

이게 해답이 될 수 있을 것 같다.

C11의 atomic_compare_exchange_strong은 무엇일까? IBM 공식문서에는 아래와 같이 설명하고 있다.

This function atomically compares the value pointed to by object for equality with the value pointed to by expected. If the comparison result is true, this function replaces the value pointed to by object with desired. If the comparison result is false, this function updates the value pointed to by expected with the value pointed to by object.

이 함수는 동일성을 위해 개체가 가리키는 값과 예상되는 값을 원자적으로 비교합니다. 비교 결과가 참이면 이 함수는 개체가 가리키는 값을 원하는 값으로 대체합니다. 비교 결과가 거짓일 경우, 이 함수는 개체가 가리키는 값으로 업데이트합니다.

어떻게 코드가 구현되어있는지는 모르겠으나, 내가 알고있는 객체의 값과 실제 객체의 값을 비교를 해서 같으면 업데이트하고 같지 않으면 업데이트를 안한다는 뜻이다.

이를 CAS(Compare And Swap) 알고리즘라고 칭한다.

 

CAS(Compare And Swap)

말로 설명하면 감이 안오니 그림으로 살펴보자. 아래는 Unsafe 클래스의 getAndAddInt()를 호출했을 때 발생할 수 있는 상황이다.

(1) Thread1에서 AtomicInteger에서 값을 가져온다. 이 때 얻은 값은 0이다.

(2) Thread2에서 AtomicInteger에서 값을 가져온다. 이 때 얻은 값은 0이다.

(3) Thread1에서 AtomicInteger의 값을 변경하려고 한다. 내가 알고 있는 AtomicInteger의 값은 0이다. 실제 AtomicInteger에 저장된 값은 0이다. 일치하므로 값을 1로 변경한다.

(4) Thread2에서 AtomicInteger의 값을 변경하려고 한다. 내가 알고 있는 AtomicInteger의 값은 0이다. 실제 AtomicInteger에 저장된 값은 1이다. 불일치하므로 값을 변경하지 않는다. 값 변경에 성공할 때 까지 getIntVolatile() 부터 다시 수행한다.

 

Unsafe

그렇다면 U라고 선언되어있던 Unsafe의 정체는 무엇일까?

한줄요약을 하자면 low-level의 안전하지 않은 작업을 수행하기 위한 메서드 모음이다.

아래는 Java docs에서 명시한 내용이다.

A collection of methods for performing low-level, unsafe operations. Although the class and all methods are public, use of this class is limited because only trusted code can obtain instances of it. Note: It is the resposibility of the caller to make sure arguments are checked before methods of this class are called. While some rudimentary checks are performed on the input, the checks are best effort and when performance is an overriding priority, as when methods of this class are optimized by the runtime compiler, some or all checks (if any) may be elided. Hence, the caller must not rely on the checks and corresponding exceptions!

low-level의 안전하지 않은 작업을 수행하기 위한 메서드 모음입니다. 클래스와 모든 메서드가 공개되어 있지만 신뢰할 수 있는 코드만 해당 클래스의 인스턴스를 가져올 수 있기 때문에 이 클래스의 사용은 제한됩니다. 참고: 이 클래스의 메서드를 호출하기 전에 인수를 확인하는 것은 호출자의 책임입니다. 입력값에 대한 일부 기본적인 검사는 수행되지만, 검사는 최선의 노력이며 성능이 최우선일 때는 런타임 컴파일러에 의해 이 클래스의 메서드가 최적화될 때 일부 또는 모든 검사(있는 경우)가 생략될 수 있다. 따라서 호출자는 검사 및 해당 예외에 의존해서는 안 됩니다!

해당 클래스의 메서드들은 매우 low-level이고 소수의 하드웨어 명령어가 포함되어 있다고 한다. 즉, 하드웨어의 메모리 주소에서 읽고 쓰는 작업을 하기 때문에 호출자가 Unsafe 객체에 대해 신중히 보호해야 한다고 한다.

신뢰할 수 없는 코드에 해당 Unsafe 객체를 전달하면 안된다고 권고하고 있다.

 

volatile

volatile은 선언된 변수가 장치 레지스터(메인 메모리)를 참조해야 할 때 사용하는 키워드이다. 만약 volatile을 사용하지 않으면 컴파일 타임에 옵티마이저가 변수에 대한 접근 방식을 최적화 할 수 있다. 즉, volatile을 쓰지 않으면 장치 레지스터에서 변수값을 가져오려고 하지 않을 수 있으며 이는 결과값에서 추적하기 어려운 버그를 유발할 수 있다.

volatile은 멀티 쓰레드 환경에서 Thread-Safe한 환경을 구성하기 위해 쓰이는 요소 중 하나다.

Processor가 변수값을 빠르게 접근하기 위해서 Main Memory에 저장되는 변수를 CPU cache에 저장하는데, 이 때문에 멀티 쓰레드 환경에서 문제가 발생할 수 있다.

참고 :  https://www.geeksforgeeks.org/volatile-keyword-in-java/

위의 그림처럼 서로 두 개의 쓰레드가 다른 processor에서 동작하게 된다면 데이터 정합성 문제가 발생하게 된다.

이러한 문제를 해결하기 위해서 Thread Safe가 보장되어야 하는 변수는 Main Memory에서 읽고 쓰일 수 있도록 보장되어야 한다. 이를 volatile을 통해서 보장할 수 있는 것이다.

여기서 오해할 수도 있는 것이, volatile을 쓰면 무조건 Thread Safe해지는 것이 아니다. Thread Safe를 보장하기 위한 방법 중 volatile이 쓰이는 것이지 반대의 경우로 이해하면 안된다.

여러 쓰레드가 Main Memory에 동시에 접근할 수 있고, 접근 시 해당 변수에 대해 잠금(Lock)을 걸지 않으므로 volatile은 ‘동시성’을 해결해 주는 요소는 아니다.

volatile은 CPU cache로 인한 데이터 정합성 문제를 해결하고 데이터의 가시성(Visibility)을 보장해주는 요소로 이해하는 것이 맞다.

 

요약

  • Atomic은 CAS(Compare And Swap) 알고리즘을 low-level 언어로 사용하고 있다.
    • 성능적으로 빠르다는 것을 보장할 수 있다.
  • CAS 알고리즘과 volatile을 모두 사용함으로써 Thread Safe함을 보장한다.

참고자료

이전 댓글