본문 바로가기

연재작/프로그래밍 언어

IntegerCache, Atomic, CAS

gpt가 영어 그리는 건 힘든가보다...

0. 서론

 

Java 관련 공부를 하다가 흥미로운 사실을 알게 됐다.

Integer i1 = 45;
Integer i2 = 45;
Integer i3 = 999999999;
Integer i4 = 999999999;

System.out.println(i1 == i2); // true
System.out.println(i3 == i4); // false

위쪽은 왜 true이고 아래쪽은 왜 false인가? 에 대한 의문이었다.

 

Java는 Stack 영역에 primitive type일 경우, 그 값을 직접 가지게 되고

reference type은 Heap이나 Static영역에 객체 주소를 가지는 것으로 알고 있는데,

 

전자와 후자 모두 동일한 결과 값을 가져야 하는 것 아닌가?

하는 생각이 들었기 때문이다.

 

 

1. Integer안의 IntegerCache

 

Integer는 기본 타입 변수(Primitive Type)인 int의 Wrapper Class이다.

그리고, Integer의 -128 부터 127까지 총 256개의 숫자들은 Integer안에 IntegerCache라는 숫자로

맨 처음 초기화 될때 미리 캐싱해놓는데, 이 방식이 참 흥미롭다.

먼저, Integer안의 내부 정적 클래스인 IntegerCache를 정의해 Singleton 객체들을 사용한다.

private static final class IntegerCache {
    static final int low = -128;
    static final int high;

    @Stable
    static final Integer[] cache;
    static Integer[] archivedCache;

    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                h = Math.max(parseInt(integerCacheHighPropValue), 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;

        // Load IntegerCache.archivedCache from archive, if possible
        CDS.initializeFromArchive(IntegerCache.class);
        int size = (high - low) + 1;

        // Use the archived cache if it exists and is large enough
        if (archivedCache == null || size > archivedCache.length) {
            Integer[] c = new Integer[size];
            int j = low;
            for(int i = 0; i < c.length; i++) {
                c[i] = new Integer(j++);
            }
            archivedCache = c;
        }
        cache = archivedCache;
        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

 

이 때 사용하는 생성 방법이 static 블록을 통한 초기화 방식이다.

캐시하고자하는 최대의 Integer가 있다면, 그 값에 대한 범위까지의 Integer를 캐싱한다.

(참고로, CDS는 로드 최적화를 진행하는 기술이다.)

 

이 방식을 사용한다면, 생성시에 생기는 동시성 문제는 없다.

그리고 읽기 수준에서의 동시성 문제도 전혀 없다.

그렇기 때문에 이전의 싱글턴 패턴과 같은 동시성 문제를 다룰 필요도 없어진다.

 

따라서 -128 부터 127까지의 Integer는 초기화 시에 정해진 IntegerCache 정적 클래스의 캐시된 Integer 객체를 사용하기 떄문에 저 범위 내에서의 Integer는 "=="를 통한 값의 비교가 가능한 것이다.

 

반면, 그 범위 이외에서는 새로운 객체가 생성되기 때문에 ==를 통한 비교가 불가능한 것이다.

 

이는 아래의 코드를 통해 알 수 있다.

@IntrinsicCandidate
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

 

만약 캐시하고 싶은 Integer를 늘리고 싶다면, JVM 실행시 아래와 같은 명령어를 추가해야 한다.

java -Djava.lang.Integer.IntegerCache.high=1000 MyApplication

 

이 범위 이외의 값의 비교를 하려면 equals()메서드를 사용할 것을 권한다.

사실 Java는 객체지향 프로그래밍이기에, 모든 값에 대해서 equals 메서드를 사용하는 것이 더 좋을 듯 하다.

 

 

 

위와 같은 방식은 적당히 주어진 Integer의 범위 내에서 읽기 전용의 Integer의 동기화 방식이었다.

 

지난번 글에 Singleton을 사용한 동시성 제어 방법 중

AtomicInteger등의 Reference class가 있다고 했는데, 이 방식에 대해 알아보자.

 

 

2. AtomicInteger

 

AtomicInteger란 Atomic, 즉 원자성을 보장하는 Integer를 의미한다.

RDBMS의 속성인 ACID중 A가 이 Atomic인데, 원자성이란 완벽하게 수행되거나, 완벽하게 수행되지 않거나

둘 중 하나의 결과를 만들어내는 성질을 갖는다. DB상에서는 commit이 되거나 rollback이 되거나 둘 중 하나라는 뜻이다.

 

AtomicInteger에서의 Atomic도 DB와 비슷한 맥락에서

동시성을 고려했을 때 생기는 문제가 있더라도 바뀌거나 바뀌지 않거나를 공유하는 Integer이다.

즉, 멀티 스레드의 접근에서 동시성을 해결할 수 있다.

 

그러면 이를 어떻게 해결하는지 한 번 알아봐야 한다.

 

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

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

    private volatile int value;
    ...
}

java 17 기준 decompile한 Atomic Integer의 코드는 위와 같다.

여기서 동시성 문제를 해결하는 첫번째 키워드는 value라는 int의 volatile 키워드이다.

volatile은 지난 번 singleton 관련 글에서 가시성을 해결하기 위한 keyword임을 언급한 바 있다.

이를 통해서 읽기 상황에서의 가시성 덕분에 동시성 문제를 어느 정도 해결할 수 있다.

 

그리고 두 번째로는 Unsafe라는 class를 사용하는 것인데,

값을 write할 때는 아래와 같은 내부 메서드를 사용한다

    public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
    }

 

Unsafe에 compareAndSetInt 메서드를 통해서 write를 진행한다.

 

그런데 Unsafe의 compareAndSetInt는 아래와 같이 native keyword가 달려있다.

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

 

성능을 위해서 JVM상에서 JNI를 통해 C++ 이나 C의 메서드를 사용하는 듯 하다.

 

 

3. JNI에서 사용하는 Unsafe.cpp를 분석

찾아본 결과 JDK 17, java 17에서는 Unsafe.cpp (C++의 클래스)를 사용하는 것을 확인했다.

그리고 소스코드에 다음과 같이 CompareAndSetInt 함수가 정의되어 있었다.

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSetInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) {
  oop p = JNIHandles::resolve(obj);
  if (p == NULL) {
    volatile jint* addr = (volatile jint*)index_oop_from_field_offset_long(p, offset);
    return RawAccess<>::atomic_cmpxchg(addr, e, x) == e;
  } else {
    assert_field_offset_sane(p, offset);
    return HeapAccess<>::atomic_cmpxchg_at(p, (ptrdiff_t)offset, e, x) == e;
  }
} UNSAFE_END

 

C++의 코드와 JNI에서 사용하기 위한 코드가 어떻게 되는지를 본 적이 없어서

이 부분은 어쩔수 없이 GPT의 도움을 받아야 했다.

요약: 코드의 동작 방식

  1. Java 객체를 JVM 내부에서 참조 가능한 C++ 포인터(oop : ordinary object pointer)로 변환합니다.
  2. 객체가 NULL인지 확인합니다.
    • NULL이면 필드 오프셋을 계산하여 메모리 주소를 찾고 atomic_cmpxchg로 CAS 연산을 수행합니다.
    • NULL이 아니면 Heap 상의 필드에 대해 CAS 연산을 수행합니다.
  3. CAS(Compare-And-Swap) 연산이 성공하면 true, 실패하면 false를 반환합니다.

이렇게 보니까 감이 조금 잡히는 것 같다.

추가적으로 알아야 할 내용이라면 

  • assert_field_offset_sane : 필드 오프셋이 유효한지 확인
  • index_oop_from_field_offset_long : 특정 필드의 오프셋을 사용해 메모리 주소를 계
  • RawAccess : 하드웨어 수준에서
  • HeapAccess : Heap 상의 특정 필드에서
  • atomic_cmpxchg : CAS 알고리즘을 사용해 원자적으로 연산하는 함수

만 알면 될 것 같다.

 

Offset은 C++에서 기준이 되는 주소로부터 얼마나 떨어져 있는지를 나타내는 값이라고 한다.

Java에서는 객체 내 특정 필드의 위치를 나타내는 값으로 사용된다고 한다.

객체의 시작 주소를 기준으로 해당 필드의 상대적 위치를 나타내는 방식으로 말이다.

 

아래에는 C++의 assert_field_offset_sane, index_oop_from_field_offset_long 의 구현이다.

static inline void assert_field_offset_sane(oop p, jlong field_offset) {
#ifdef ASSERT
  jlong byte_offset = field_offset_to_byte_offset(field_offset);

  if (p != NULL) {
    assert(byte_offset >= 0 && byte_offset <= (jlong)MAX_OBJECT_SIZE, "sane offset");
    if (byte_offset == (jint)byte_offset) {
      void* ptr_plus_disp = cast_from_oop<address>(p) + byte_offset;
      assert(p->field_addr((jint)byte_offset) == ptr_plus_disp,
             "raw [ptr+disp] must be consistent with oop::field_addr");
    }
    jlong p_size = HeapWordSize * (jlong)(p->size());
    assert(byte_offset < p_size, "Unsafe access: offset " INT64_FORMAT " > object's size " INT64_FORMAT, (int64_t)byte_offset, (int64_t)p_size);
  }
#endif
}

static inline void* index_oop_from_field_offset_long(oop p, jlong field_offset) {
  assert_field_offset_sane(p, field_offset);
  jlong byte_offset = field_offset_to_byte_offset(field_offset);

  if (sizeof(char*) == sizeof(jint)) {   // (this constant folds!)
    return cast_from_oop<address>(p) + (jint) byte_offset;
  } else {
    return cast_from_oop<address>(p) +        byte_offset;
  }
}

 

assert를 사용해 디버깅일때 체크하는 식으로 사용한다.
(#ifdef ASSERT ~ #endif를 통해 매크로가 정의된 경우에만 assert가 활성화 된다고 한다.)

 

 

4. CAS : Compare And Swap Algorithm

CAS는 원자적으로 비교한 뒤 값이 같을 경우에만 write를 사용하는 알고리즘으로, 

위의 atomic_cmpxchg 함수가 이를 구현한 함수이다.

  • cmpxchg(addr, e, x) 는 addr, 즉 pointer가 가리키는 주소에서
    그 값이 e와 같으면 x로 변경하는 것이고, 그렇지 않으면 아무것도 하지 않는 방식의 알고리즘이다.
  • cmpxchg(p, (ptrdiff_t)offset, e, x) 은 Heap영역의 p라는 객체의 위치 기준
    offset만큼 떨어진 곳의 메모리의 값을 기준으로
    이 값이 e와 같으면 x로 변경, 그렇지 않으면 아무것도 하지 않는 것이다. (overloading)

 

보통 Java는 Reflection API와 같은 라이브러리를 통해 객체와 필드 접근을 한다면

CAS는 메모리에 직접적으로 접근해 JNI 를 사용해 C++의 소스코드를 사용한 성능 향상을 위한 방식으로 쓰인다.

 

 

5. 결론

AtomicInteger가 이런식으로 메모리 접근을 통해 동시성을 제어한다는 것을 꼼꼼하게 공부해낸 것 같다.

 

사실 아주 예전 Java 버전은 compareAndSwap이라는 메서드가 구현되어 있었고, JNI 또한 사용하지 않아서

해당 버전의 내용을 베껴 쓰고 싶었는데,

현재 버전에 맞춰서 보니 구현이 달라져 있어서 공부하는 김에 deep dive를 하게 되었다.

 

C++ 이 개발자의 메모리 직접 접근을 하는 식으로 성능을 극대화한다는 얘기를 들었는데,

실제로 이러한 구현 방식을 보니 상당히 재밌는 것 같다.

 

 

 

 

https://github.com/openjdk/jdk17/blob/master/src/hotspot/share/prims/unsafe.cpp

 

jdk17/src/hotspot/share/prims/unsafe.cpp at master · openjdk/jdk17

https://openjdk.org/projects/jdk/17 released 2021-09-14 - openjdk/jdk17

github.com

 

 

https://saltyzun.tistory.com/37

 

[JAVA] Atomic 변수와 동시성 제어 (AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference)

얼마 전 코드에서 AtomicInteger 타입으로 선언된 변수를 발견했고, 관련 내용들을 찾아보게 되었다. 이번 글에서는 synchronized에 비해 적은 비용으로 동시성을 제어할 수 있는 Atomic 변수의 특성에 대

saltyzun.tistory.com

 

https://aaronryu.github.io/2021/03/14/unboxing-when-use-equal-operator-on-wrapper-class/

 

Wrapper Class Caching: Integer(Wrapper Class) == 사용시 이슈

얼마전부터 서버에서 Integer 객체를 == (항등 연산자)를 사용한 코드때문에 간간히 에러 로그가 남는것을 확인했습니다. 신기한건 해당 API 가 매우 자주사용되는데, 간헐적으로 발생한다는 것이

aaronryu.github.io

 

https://younghwannam.blogspot.com/2018/11/javathreadunsafe-sunmiscunsafe-1.html

 

[java][thread][unsafe] 자바가 숨겨놓은 악마의 열매 sun.misc.Unsafe - 1

sun.misc.Unsafe 이름부터 강력함을 뽐내는 이 클래스는 아무나 쓸 수 없는 녀석이다. 사실 이 강력함은 우리를 다치게 할 수 있다. 그 어떤 강력한 것도 항상 제물이 필요하다. 열정을 더 불태우기

younghwannam.blogspot.com

https://stackoverflow.com/questions/16135199/what%C2%B4s-the-difference-between-atomicreferenceinteger-vs-atomicinteger

 

What´s the difference between AtomicReference<Integer> vs. AtomicInteger?

I don´t get the difference between these two: AtomicReference<Integer> atomicReference = new AtomicReference<>(1); vs. AtomicInteger atomicInteger = new AtomicInteger(1); Can someone

stackoverflow.com

https://junhyunny.github.io/information/java/java-atomic/

 

Atomic Classes in Java

<br /><br />

junhyunny.github.io

 

'연재작 > 프로그래밍 언어' 카테고리의 다른 글

Aspect Oriented Programming  (0) 2024.11.24
Java Casting, Generic 응용  (0) 2024.11.12
Java - Optional 뽀개기 (2)  (0) 2024.10.11
Java - Optional 뽀개기 (1)  (0) 2024.10.11
Java - Stream 뽀개기 (2)  (2) 2024.10.06