멀티 스레딩과 동기화

parkrootseok·2025년 2월 6일

자바

목록 보기
14/19
post-thumbnail

synchronized, volatile, Lock, Atomic

Java에서는 멀티 스레딩 환경에서 동기화를 보장하기 위해 여러 기법을 제공합니다. 대표적으로 synchronized, volatile, Lock, Atomic 등이 존재합니다.

왜, 동기화를 보장해야 하나요?
멀티 스레드 환경은 하나의 프로세스 내에서 여러 개의 스레드가 병렬 실행되는 구조입니다. 이에 따라, 여러 스레드가 공유 자원에 접근할 경우 동기화를 수행하지 않는다면, 데이터 정합성 문제가 발생할 수 있습니다.

synchronized

synchronized는 Java에서 가장 기본적인 동기화 방법으로, 임계 영역에 하나의 스레드만 접근할 수 있도록 보장합니다.

내부 동작

1. 모니터 획득 시도

  • 객체는 내부적으로 모니터란느 구조를 가지고 있음
  • synchronized 블록에 진입할 때, 해당 객체의 모니터를 획득
  • 모니터는 Object Header에 포함

2. 다른 스레드가 락을 점유 중이면 대기

  • 다른 스레드가 이미 모니터를 점유하고 있다면, 해당 스레드는 대기 상태로 전환
  • 모니터를 획득할 때까지 OS레벨의 Context Switching 발생 가능

3. 락 획득 성공 후 블록 리행

  • 모니터 락을 얻은 스레드는 임계 영역을 실행

4. 블록 종료 후 락 해제

  • 블록이 끝나거나 예외 발생 시 자동으로 락 해제

예시

// 메서드 동기화
public synchronized void increment() {
    count++;
}

// 블록 동기화
public void decrement() {
    synchronized (this) {
        count--;
    }
}

volatile

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) { // 다른 스레드에서 상태 변경시 바로 감지 가능
            // 실행 중
        }
        
    }
    
}

Lock

Java는 synchronized의 단점을 보완하기 위해 Lock인터페이스를 제공하여 동기화를 지원합니다. 대표적인 구현체 ReentrantLocksynchronized와 유사하지만 보다 정밀한 동시성 제어를 수행할 수 있습니다.

제공하는 기능

Lock 인터페스의 경우 공정락, 타임아웃 기반 락, 락 중첩 사용, 비차단 락, 조건 변수 등의 기능들을 지원합니다.

  • 공정 락
    • 오래 기다린 스레드부터 락을 획득하도록 보장하는 것
  • 타임아웃 기반 락
    • 일정 시간동안 락 획득을 시도하고, 획득하지 못하면 실패를 반환하는 것
  • 비차단 락
    • 락 즉시 획득 시도를 하고, 획득하지 못하면 실패를 반환하는 것
  • 중첩 락
    • 동일한 스레드가 락을 다시 획득할 수 있도록 하는 것
  • 조건 변수
    • 특정 조건에 따라 락을 유동적으로 사용할 수 있도록 하는 것
      • 조건 만족 시 스레드 대기(await()) 수행 및 스레드 재실행(signal()/signalAll()) 수행

내부 동작

Lock의 경우 AQS라는 구현체를 기반으로 내부적으로 동작합니다. 이 구현체는 락 보유 여부인 state, 락 획득을 기다리는 대기 큐 CLH , 락 소유자 스레드 정보인 Thread를 가지고 있습니다.

1. 락 획득

  • CAS 시도
    • 내부적으로 acquire() 메소드가 실행되어, tryAcquire()를 통해 상태가 0인 경우 CAS 연산으로 state 1로 변경하며 락 획득
      • CAS가 실패하면 CLH큐에 보관
    • 만약, 이미 락을 보유한 스레드가 다시 lock()을 호출하면, 상태 값을 증가시키며 재진입 허용

2. 락 해제

  • 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

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 인터페이스 등이 있습니다.

각 기능들은 어떻게 동시성을 보장하나요?

syncronized

키워드를 선언한 블록이나 영역에 1개의 스레드만 접근할 수 있도록 합니다.

  • 모니터 락 획득
    • 객체는 Header에 모니터를 가지고 있음
    • 이를 보유한 스레드만 접근 가능
  • 모니터 락 반납
    • 모든 로직을 수행 후, 모니터 락을 반납

volatile

메모리 가시성 확보와 재정렬 방지를 통해 동시성을 제어합니다. 하지만, 복합 연산에 대한 원자성은 보장하기 않기 때문에 완전한 동시성 제어는 아닙니다.

  • 가시성 확보
    • CPU 캐시에 값을 복사하여 조작하지 않고, 메모리에 직접 읽고 쓸 수 있도록 강제
  • 재정렬 방지
    • 컴파일러나 CPU가 성능 향상을 위해 명령어 재배치를 수행하는 경우가 있는데 volatile 변수는 이를 막음

Lock

특정 자원에 대하여 1개의 스레드만 접근할 수 있도록 하며, 공정 락/비차단 락/중첩 락/타임아웃 기반 락/조건 변수 등으로 syncronized보다 정밀한 제어가 가능한 방법입니다.

  • 공정 락
    • 오래 기다린 스레드부터 락을 획득할 수 있도록 하는 것
  • 비차단 락
    • tryLock() 메서드로 락 획득을 즉시 시도하고, 실패하면 기다리지 않고 실패를 반환하는 것
  • 타임아웃 기반 락
    • 일정 시간 락을 획득 하지 못할 경우 포기할 수 있도록 하는 것
  • 중첩 락
    • 같은 스레드가 연속해서 락을 획득할 수 있는 것
  • 조건 변수 지원
    • 조건에 따라 락을 유동적으로 사용하는 것
profile
동료들의 시간과 노력을 더욱 빛내줄 수 있는 개발자가 되고자 노력합니다.

0개의 댓글