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의 도움을 받아야 했다.
요약: 코드의 동작 방식
- Java 객체를 JVM 내부에서 참조 가능한 C++ 포인터(oop : ordinary object pointer)로 변환합니다.
- 객체가 NULL인지 확인합니다.
- NULL이면 필드 오프셋을 계산하여 메모리 주소를 찾고 atomic_cmpxchg로 CAS 연산을 수행합니다.
- NULL이 아니면 Heap 상의 필드에 대해 CAS 연산을 수행합니다.
- 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
https://saltyzun.tistory.com/37
https://aaronryu.github.io/2021/03/14/unboxing-when-use-equal-operator-on-wrapper-class/
https://younghwannam.blogspot.com/2018/11/javathreadunsafe-sunmiscunsafe-1.html
https://junhyunny.github.io/information/java/java-atomic/
'연재작 > 프로그래밍 언어' 카테고리의 다른 글
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 |