Lazy를 공부하다보니 동시성에 관련된 내용이 나왔고 지금까지 써왔던 synchronized, AtomicInterger etc같은 것들에 대해서도 내부 구현을 한번 더 살펴보려고 한다.
특정 작업들을 전체적으로 동기화하는게 아닌 특정필드에 대해서만 원자성을 보장해서 값 변경을 하고 싶은 경우 사용된다.
A reflection-based utility that enables atomic updates to designated volatile reference fields of designated classes. This class is designed for use in atomic data structures in which several reference fields of the same node are independently subject to atomic updates
위는 Atomic 내부의 주석중 일부분인데 특정 volatile 필드를 원자적으로 업데이트하기위한 유틸리티다. 라고 소개한다.
안드로이드 공식문서를 가보면
An int value that may be updated atomically. ㅜㅜㅜㅜㅜㅜㅜSee the VarHandle specification for descriptions of the properties of atomic accesses.
와 같은 설명을 볼 수 있다. thread-safe의 주역은 VarHandle이 한다는걸 알았으니 좀 더 알아보면 compareAndSet을 통해서 쓰기작업에 대해서 원자성을 보장한다고 한다.
이름 그대로 변수를 다루는 핸들로 리플렉션이나 Unsafe 클래스의 한계를 극복하고 더 안전하며 성능이 좋은 저수준 메모리 접근을 제공한다. VarHandle은 타입 안정성 보장, JVM 최적화 지원, 메모리 가시성 제어라는 특징이 있다.
주요 장점으로는 메모리 가시성을 제어할 수 있다는 점이다.
| 접근 방식 | Memory Semantics |
| ----------------- | ---------------------------- |
| Plain | 가시성 보장 없음 |
| Opaque | 약한 가시성 (컴파일러 재배치 방지) |
| Acquire/Release | 부분 순서 보장 |
| Volatile | 완전한 순서 보장 |
기본의 Atomic들은 Unsafe api나 리플렉션을 통해서 구현되었는데 java9에서 VarHandle이 나오면서 VarHandle을 사용하도록 변경되었다.
Lookup을 통한 접근제어 준수Lookup 을 만들어서 필드에 접근하는데 이때 자바의 접근제어를 준수하다.val vh = MethodHandles.lookup()
.findVarHandle(MyClass::class.java, "x", Int::class.javaPrimitiveType)
unsafe.compareAndSwapObject(obj, offset, expected, newValue);와 같이 long Offset + Object로만 처리한다.이부분에 대해서는 Java Memory Model에 대해 먼저 이해를 하고 추가로 작성해야할 것 같아서 다음 포스트에서 정리!
저수준 접근은 메모리 주소값을 직접 사용해서 데이터에 접근하거나 변경하는 것을 말한다. 이해할 때는 C언어의 포인터를 생각했다.
Atomic 친구들은 VarHandle.compareAndSet()을 통해서 원자성을 보장하는 업데이트를 진행해준다. CAS(Compare-And-Swap)을 통해서 여러 스레드에서 갑 변경을 시도할 때 오직 하나만 성공하도록 보장한다. 이를 통해서 Race Condition을 방지할 수 있다.
이때 락이 없기에 Synchronized보다 성능이 좋다.
코틀린에서는 자바처럼 synchronized 키워드를 직접 지원하지 않아서 아래 inline함수나 @Synchronized 어노테이션을 붙여서 사용하게된다.
@InlineOnly
public inline fun <R> synchronized(lock: Any, block: () -> R): R
inline함수의 내부 구현을 살펴보면 moniter enter / monitor exit을 통해서 JVM수준에서 락이 실행된다.
모든 자바 객체는 내부적으로 Monitor라고 불리는 잠금 객체를 하나씩 가지고 있고 이 모니터는 한 번에 하나의 스레드만 소유할 수 있다.
monitorEnter은 JVM 수준에서 lock 객체의 모니터 락을 휙득하고
monitorExit는 현재 스레드가 락을 반납한다.
만약 다른 스레드에서 이미 모니터락이 걸린 객체에 접근하면 릴리즈될 때 까지 대기하게된다.
inline 함수 내부를 보면 lock 파라미터가 있는데 모니터의 이 특성을 통해서 lock 객체의 모니터를 기준으로 동기화가 이루어진다.
@kotlin.internal.InlineOnly
public inline fun <R> synchronized(lock: Any, block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
// Force the lock object into a local and use that local for monitor enter/exit.
// This ensures that the JVM can prove that locking is balanced which is a
// prerequisite for using fast locking implementations. See KT-48367 for details.
val lockLocal = lock
@Suppress("NON_PUBLIC_CALL_FROM_PUBLIC_INLINE", "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
monitorEnter(lockLocal)
try {
return block()
}
finally {
@Suppress("NON_PUBLIC_CALL_FROM_PUBLIC_INLINE", "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
monitorExit(lockLocal)
}
}
@Synchronized 어노테이션도 컴파일된 코드를 보면
public synchronized void increment() {
counter++;
}
객체 수준의 모니터 락을 걸고 동기화를 하게된다.
모니터는 OS 수준의 락이 아니라 JVM 내부에서 관리하는 Lightweight Lock, Heavyweight Lock으로 구현되는데 이 구현부를 통해서 성능을 최적화한다고 한다.
Biased Lock이란 모드도 있지만 java 17에서 완전히 제거되었기에 제외했다.
다른 스레드와 경쟁이 거의 없는 상황에서 사용
락을 획득한 스레드가 object header를 자신의 stack에 CAS연산으로 복사해서 쓰는 방식
OS 수준의 락 없이 JVM 내부의 CAS(Compare-And-Swap) 연산으로 처리
경쟁 발생 시 Heavyweight Lock으로 전환
여러 스레드가 같은 모니터 락을 얻으려고 할 때 발생
JVM은 이 상황에서 OS 수준의 mutex로 락을 전환
스레드들은 block(park/wait) 되어 context switching 발생 → 비용 큼
monitorenter와 monitorexit가 OS-level sync primitive로 동작함
이렇게만 정리하면 잘 이해가 안간다 이건 다음 편에서 마저 작성!