
Java에서는 멀티 스레딩 환경에서 동기화를 보장하기 위해 여러 기법을 제공합니다. 대표적으로 synchronized, volatile, Lock, Atomic 등이 존재합니다.
왜, 동기화를 보장해야 하나요?
멀티 스레드 환경은 하나의 프로세스 내에서 여러 개의 스레드가 병렬 실행되는 구조입니다. 이에 따라, 여러 스레드가 공유 자원에 접근할 경우 동기화를 수행하지 않는다면, 데이터 정합성 문제가 발생할 수 있습니다.
synchronized는 Java에서 가장 기본적인 동기화 방법으로, 임계 영역에 하나의 스레드만 접근할 수 있도록 보장합니다.
synchronized 블록에 진입할 때, 해당 객체의 모니터를 획득// 메서드 동기화
public synchronized void increment() {
count++;
}
// 블록 동기화
public void decrement() {
synchronized (this) {
count--;
}
}
volatile 키워드는 가시성 확보와 재정렬 방지를 통해 동시성을 제어할 수 있습니다.
각 스레드는 CPU 캐시에 값을 복사하여 로컬 변수처럼 사용합니다. 하지만, volatile 키워드를 선언한 변수는 모든 스레드에 대하여 항상 메인 메모리에서 값을 읽도록 강제합니다.
컴파일러나 CPU는 성능을 위해 명령어 순서를 바꾸는 최적화를 수행합니다. 하지만, volatile 키워드를 선언한 변수는 재정렬을 방지하여 JMM의 happens-before 관계를 보장합니다. 이로 인해, 변경 사항은 이후에 해당 값을 읽는 스레드에게 반드시 반영됩니다.
volatile은 읽기/쓰기 자체를 메모리에서 수행하도록 강제하는 역할만 수행합니다. 이로 인해, 복합 연산의 원자성은 보장하지 않아 count++와 같은 연산에는 적합하지 않습니다.
다음 예시와 같이 사용할 수 있습니다.
public class VolatileExample {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) { // 다른 스레드에서 상태 변경시 바로 감지 가능
// 실행 중
}
}
}
Java는 synchronized의 단점을 보완하기 위해 Lock인터페이스를 제공하여 동기화를 지원합니다. 대표적인 구현체 ReentrantLock은 synchronized와 유사하지만 보다 정밀한 동시성 제어를 수행할 수 있습니다.
Lock 인터페스의 경우 공정락, 타임아웃 기반 락, 락 중첩 사용, 비차단 락, 조건 변수 등의 기능들을 지원합니다.
Lock의 경우 AQS라는 구현체를 기반으로 내부적으로 동작합니다. 이 구현체는 락 보유 여부인 state, 락 획득을 기다리는 대기 큐 CLH , 락 소유자 스레드 정보인 Thread를 가지고 있습니다.
acquire() 메소드가 실행되어, tryAcquire()를 통해 상태가 0인 경우 CAS 연산으로 state 1로 변경하며 락 획득lock()을 호출하면, 상태 값을 증가시키며 재진입 허용unlock() 호출release(1)를 통해 state 감소state가 0이 되면 락 소유자 해제unpark()를 통해 깨워지고, 다시 락 획득 시도다음 예시와 같이 사용할 수 있습니다
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // lock 획득
try {
count++;
} finally {
lock.unlock(); // lock 해제 (필수, 데드락 방지)
}
}
}
java.util.concurrent.atomic 패키지는 락 없이 변수의 원자적 연산을 제공하기 위한 클래스들이 존재합니다. 대표적으로, AtomicLong, AtomicInteger 등이 있습니다. 이들은 내부적으로 CAS(Compare-And-Sweep)연산을 사용하여 병렬 환경에서도 데이터 정합성을 유지합니다.
AtomicLong클래스를 예로 내부 구현을 살펴보겠습니다.
public class AtomicLong extends Number implements java.io.Serializable {
// Unsafe 객체를 활용해 low-level 수준의 CAS 연산 수행
private static final Unsafe unsafe = Unsafe.getUnsafe();
// Unsafe 연산에 필요한 메모리 오프셋 값
// -> JVM 내부에서 value 필드 위치로 접근하기 위한 값
private static final long valueOffset;
// volatile 키워드를 활용해, 메모리 가시성 확보
private volatile long value;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicLong.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex);
}
}
public final long get() {
return value;
}
public final void set(long newValue) {
value = newValue;
}
// 현재 값이 기대값과 같다면 새 값으로 교체
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
// 현재 값을 반환 후 1증가
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1);
}
// 값을 중가시킨 후, 증가된 값을 반환
public final long incrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, 1) + 1;
}
// ...
}
syncronized, volatile, Lock 인터페이스 등이 있습니다.
키워드를 선언한 블록이나 영역에 1개의 스레드만 접근할 수 있도록 합니다.
메모리 가시성 확보와 재정렬 방지를 통해 동시성을 제어합니다. 하지만, 복합 연산에 대한 원자성은 보장하기 않기 때문에 완전한 동시성 제어는 아닙니다.
volatile 변수는 이를 막음특정 자원에 대하여 1개의 스레드만 접근할 수 있도록 하며, 공정 락/비차단 락/중첩 락/타임아웃 기반 락/조건 변수 등으로 syncronized보다 정밀한 제어가 가능한 방법입니다.
tryLock() 메서드로 락 획득을 즉시 시도하고, 실패하면 기다리지 않고 실패를 반환하는 것