[Java] 모니터락, volatile, Atomic class

나른한 개발자·2025년 12월 30일

f-lab

목록 보기
5/44

모니터락

OS 레벨의 동기화 기법을 JVM 층위에서 사용할 수 있도록 구현한 것이다.

자바의 모든 객체는 모니터를 가지며, 객체의 락을 활용하여 스레드 동기화를 제공한다. 모든 객체가 갖고 있으니 고유 락(intrinsic lock), 모니터처럼 동작한다고 하여 모니터 락(monitor lock) 또는 모니터(monitor)라고도 부르기도 한다.

이 모니터락은 상호배제, 협력 두 가지의 핵심 요소를 관리한다.

상호 배제

여러 스레드가 동시에 공유 자원에 접근하지 못하도록 제한하여 데이터 일관성을 보장하는 동기화 메커니즘이다.

협력

스레드 간 협력을 통해 특정 조건을 만족할 때 까지 실행을 제어하기 위한 동기화 메커니즘이다. 모니터 조건 변수를 활용하며 Object 클래스의 native로 구현되어있는 wait(), notify(), notifyAll() 메서드를 활용한다.

  • wait(): 특정 조건이 만족되지 않으면 현재 스레드는 락을 해제하고 대기 셋(WaitSet)에서 대기
  • notify(): 조건이 충족되었을 때, 대기 중인 스레드 중 하나를 깨워 다시 실행
  • notifyAll(): 대기 중인 모든 스레드를 깨워 실행할 기회를 부여

Monitor의 동작

자바의 모니터는 EntrySet(진입셋) 과 WaitSet(대기셋) 이라는 대기 자료구조를 가진다.

EntrySet

  • 모니터의 락을 얻기 위해 대기 중인 스레드의 집합이다.
  • 특정 스레드가 락을 사용 중이라면, 다른 스레드는 EntrySet에 들어가 대기한다.
  • 락이 해제되면 EntrySet 중 하나의 스레드가 락을 획득하고 실행됨

WaitSet

  • 조건 변수를 사용하여 특정 조건이 충족될 때까지 대기하는 스레드의 집합
  • 모니터를 소유하고 있는 쓰레드가 wait() 메서드를 호출하면 락을 해제한 후, WaitSet에서 대기
  • notify() 또는 notifyAll()이 호출되면 WaitSet에서 깨어난 스레드는 EntrySet으로 이동하여 락을 다시 획득하기 위해 경쟁

동작 흐름

1. 스레드가 synchronized 블록 진입 시도
   → EntrySet에서 대기
   
2. 락 획득 성공
   → 임계 영역 실행
   
3. wait() 호출 시
   → 락 반납하고 WaitSet으로 이동
   
4. notify() 호출 시
   → WaitSet의 스레드 → EntrySet으로 이동
   
5. 다시 락 획득 경쟁

실전 예제 코드

public class Producer {
    private Queue<String> queue = new LinkedList<>();
    private final int MAX_SIZE = 5;
    
    public synchronized void produce(String item) {
        // 큐가 가득 차 있으면 대기
        while (queue.size() == MAX_SIZE) {
            wait(); // WaitSet으로 이동
        }
        
        queue.add(item);
        System.out.println("생산: " + item);
        
        notify(); // WaitSet의 소비자 스레드를 깨움
    }
    
    public synchronized String consume() {
        // 큐가 비어있으면 대기
        while (queue.isEmpty()) {
            wait(); // WaitSet으로 이동
        }
        
        String item = queue.remove();
        System.out.println("소비: " + item);
        
        notify(); // WaitSet의 생산자 스레드를 깨움
        return item;
    }
}

volatile

멀티 스레드 환경에서 각 스레드는 메인 메모리에서 값을 복사해 CPU 캐시에 저장하여 작업한다. CPU가 2개 이상이라면 각 스레드는 서로 다른 CPU에서 동작하고 있으며 이는 각 스레드가 변수에 대해 읽기, 쓰기 동작을 수행할 시 각자의 cpu캐시에 메인 메모리 값과 다른 값을 갖고 있을 경우가 생긴다.

하지만 자바에서 어떤 변수에 대해 volatile 키워드를 붙이면 해당 변수는 읽기와 쓰기 작업이 CPU 캐시가 아닌 메인 메모리에서 이루어지게 되어 해당 변수에 대해 가시성을 보장할 수 있다.

synchronized가 가시성을 보장하는 원리

synchronized에서는 자바 메모리 모델(JMM)의 Happens-Before 관계를 강조하여, 꼭 volatile 키워드를 사용하지 않아도 가시성을 확보할 수 있다.

synchronized 블록에 진입하고 빠져나올 때, JVM은 다음과 같은 작업을 수행한다.

  • Unlock (락 해제 시): 스레드가 작업을 마치고 락을 해제하기 직전, 로컬 캐시(CPU 캐시)에서 변경된 모든 내용을 메인 메모리에 즉시 반영(Flush)한다.

  • Lock (락 획득 시): 다른 스레드가 동일한 모니터 락을 획득하면, 자신의 로컬 캐시를 무효화하고 메인 메모리에서 최신 값을 다시 읽어온다(Refresh).

따라서 synchronized 블록 안에서 수정한 값은, 해당 블록이 끝나는 시점에 메모리에 저장되고, 다음에 락을 잡는 스레드는 무조건 그 최신값을 보게 된다.

synchronized 와 volatile

상황에 따라 synchronizedvolatile을 같이 써야하는 경우도 존재한다.

  • 동기화되지 않은 읽기: 어떤 스레드는 synchronized 메서드를 통해 값을 수정하는데 다른 스레드는 일반 메소드(비동기)로 값을 읽으려고 하는 경우이다. 읽는 쪽에서는 최신 값을 못볼수 있으므로 읽는 메서드도 synchronized를 걸거나 해당 변수를 volatile로 선언해야한다.
  • Double-Checked Locking (DCL): 싱글톤 패턴 등을 구현할 때 락을 걸기 전 체크 로직에서 가시성 문제 때문에 volatile이 필요한 특수 사례가 있다.

synchronized는 '원자성 + 가시성'을 모두 갖춘 만능 도구이고, volatile은 '가시성'만 책임지는 경량 도구이다. 둘을 같이 쓰는 것은 대게 중복이지만, 성능 최적화를 위해 읽기/쓰기 전략을 다르게 가져갈 때만 선택적으로 조합한다.

Atomic Class

Atomic Class는 멀티 스레드 환경에서 안전하게 값을 변경할수 있도록 하는 클래스이다. synchronized, Reentrantrock 은 비관적락 기반이었다면 Atomic Class 는 낙관적 락의 원리로 동작한다.

CAS(Compare-And-Swap)

Atomic Class는 다른 lock 기반 방식과는 달리 non-blocking 방식으로 동작한다. 즉 다른 스레드가 작업중이라면 락을 획득할때까지 잠드는 것이 아닌 계속 Runnable 상태로 CPU를 점유하며 시도한다.

대신 CAS 알고리즘을 사용하여 내가 가진 값이 예상값과 같으면 업데이트하고 그렇지 않으면 다시 시도하는 방식으로 동작합니다.

AtomicInteger 같은 클래스의 내부를 보면 하드웨어(CPU)가 지원하는 CAS 명령어를 사용한다.

  • Read: 현재 메모리의 값(V)을 읽어온다.
  • Compare: 내가 알고 있던 기존 값(A)과 현재 메모리 값(V)이 같은지 비교한다.
  • Swap: 같다면 새로운 값(B)으로 교체합니다. 다르다면(그 사이 다른 스레드가 가로챘다면) 처음부터 다시 반복한다.

이 과정이 CPU의 단일 명령어(Atomic Instruction)로 처리되기 때문에 중간에 다른 스레드가 끼어들 수 없어 동시성이 보장되는 것이다.

Atomic 클래스는 스레드를 '차단(Block)'하지 않고 CAS 알고리즘을 통해 계속 시도하게 만든다. 덕분에 OS가 스레드를 잠재우고 깨울 때 발생하는 문맥 교환(Context-switching) 비용을 아낄 수 있어 가벼운 연산에서 압도적인 성능을 발휘한다.

단점

  • 충돌이 잦은 경우: 여러 스레드가 동시에 한 변수를 수정하려고 격렬하게 싸우면, Atomic은 계속 루프를 돌며 CPU 점유율만 높이고 정작 일은 못 하는 'Busy Waiting' 상태에 빠질 수 있다.
  • 작업이 긴 경우: 작업 내용이 복잡하고 길다면 차라리 락을 걸고 스레드를 잠재우는 게 CPU 효율면에서 나을 수 있다.

대표적인 Atomic Class 종류

주요 메서드 예시 (AtomicInteger 기준)

  • get() : 메모리에서 값을 가져온다.
  • set() : 메모리에 값을 저장한다(쓰기).
  • lazySet() : 결국 메모리에 값을 쓰고, 이어지는 관련된 메모리 연산을 통해 순서를 바꿀 수 있다.
  • compareAndSet() : 앞에서 살펴본 CAS 연산과 같다. 값을 바꾸는 데 성공하면 true를 실패하면 false를 리턴한다.
public class SafeCounterWithoutLock {
    private final AtomicInteger counter = new AtomicInteger(0);
    
    public int getValue() {
        return counter.get();
    }
    public void increment() {
        while(true) {
            int existingValue = getValue();
            int newValue = existingValue + 1;
            if(counter.compareAndSet(existingValue, newValue)) {
                return;
            }
        }
    }
}

위 예시에서는 compareAndSet() 연산이 성공할 때까지 그 과정을 반복함으로써 increment() 메서드가 항상 counter의 값을 1씩 올려줄 수 있도록 보장하고 있다.

volatile은 자바에서 가시성을 보장해주는 키워드입니다. 멀티 스레드 환경에서 각 스레드는 메인 메모리에서 값을 복사해 cpu 캐시에 저장해놓고 사용하는데, 각 스레드가 변수에 대해서 읽기/쓰기 작업을 하면서 메인 메모리 값과 캐시에 있는 값이 일치하지 않는 경우가 생길수 있다. 이때 변수에 volatile 키워드를 붙이면 읽기/쓰기 작업이 모두 cpu 캐시가 아닌 메인 메모리에서 일어나게 되어 가시성을 보장하게 된다.
Atomic Class는 멀티스레드 환경에서 안전하게 값을 수정할 수 있도록 하는 클래스이다. lock과는 달리 non-blocking 방식을 사용하여 스레드가 runnable 상태로 cpu를 계속 점유한다. 대신 CAS 알고리즘을 사용하여 동시성을 제어한다. (CAS란 멀티쓰레드 환경에서 동시성을 보장하기 위한 컴퓨터 명령어(instruction)이다.) 메모리에 위치한 값과 예상 값을 비교하고 두 값이 일치할 때만 업데이트를 한다. 이 과정이 단일 명령어로 수행되기 때문에 원자성 때문에 동시성을 보장할 수 있는 것이다. Atomic 클래스는 다른 스레드를 blocking 하지 않고 CAS 알고리즘을 통해 계속 시도하게 만든다. 때문에 OS가 스레드를 잠들고 깨울때 발생하는 context-switching 이 발생하는 것을 피할 수 있다. 다만 작업이 너무 길거나 충돌이 자주 발생하는 경우는 cpu효율이 떨어질 수 있어 가벼운 연산에만 적용하는 것이 좋다.

참고링크

profile
Start fast to fail fast

0개의 댓글