volatile, 자바의 메모리 가시성

서버란·2024년 9월 19일

자바 궁금증

목록 보기
26/35

자바에서 멀티스레딩이 어떻게 메모리를 처리하고, 어떻게 메모리 간 동기화를 보장하는지에 대한 내용을 다루고 있습니다.

1. 메모리 가시성과 캐시 메모리

(1) 메인 메모리와 CPU 캐시 메모리

자바에서 스레드는 메인 메모리(heap 영역)와 CPU 캐시 메모리를 사용합니다. 각 스레드는 실행 중에 자신의 작업 속도를 높이기 위해 메인 메모리의 값을 캐시 메모리에 저장하고, 이 캐시된 값을 반복적으로 사용합니다.

  • 메인 메모리: 자바 프로그램이 사용하는 전체 메모리 공간(힙 메모리), 모든 스레드가 공통으로 접근할 수 있습니다.
  • CPU 캐시 메모리: 각 CPU 코어마다 존재하는 로컬 메모리로, 스레드가 자주 사용하는 데이터를 메인 메모리보다 더 빠르게 접근할 수 있도록 합니다.

(2) 스레드 캐시 메모리와 메모리 가시성 문제

각 스레드가 자신의 캐시 메모리에 데이터를 저장하고 사용할 수 있기 때문에, 다른 스레드에서 메모리에 반영된 변경 사항을 즉시 볼 수 없는 문제가 발생할 수 있습니다. 이를 메모리 가시성 문제라고 합니다.

예를 들어, 스레드 A가 변수 count를 변경했지만, 이 변경된 값이 메인 메모리에 즉시 반영되지 않고 스레드 A의 캐시 메모리에만 존재할 경우, 스레드 B는 메인 메모리에서 읽어온 기존 값을 계속 사용할 수 있습니다. 따라서 변경한 값이 다른 스레드에게 보이지 않는 문제가 발생합니다.

2. volatile 키워드와 메모리 가시성 보장

(1) volatile의 역할

volatile 키워드는 자바에서 변수의 메모리 가시성을 보장하는 데 사용됩니다. volatile로 선언된 변수는 캐시 메모리가 아닌 메인 메모리에서 직접 읽고 쓰기를 수행하도록 보장합니다.

즉, volatile을 선언하면:

  • 변경된 값이 즉시 메인 메모리에 반영되며,
  • 다른 스레드들은 메인 메모리에서 최신 값을 읽어올 수 있게 됩니다.

이로 인해 메모리 가시성 문제를 해결할 수 있습니다.

(2) volatile의 특징

  • 캐시 메모리를 무시: volatile 변수는 캐시 메모리에 저장되지 않고, 항상 메인 메모리에서 읽고 쓰기가 이루어집니다.
  • 변경 사항의 즉각 반영: 한 스레드가 volatile 변수의 값을 변경하면, 그 변경 사항은 즉시 메인 메모리에 반영되어 다른 스레드에서 즉각 확인할 수 있습니다.

예시:

class MyRunnable implements Runnable {
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            // 작업을 수행
        }
    }

    public void stop() {
        running = false;  // 다른 스레드에서 이 값을 즉시 볼 수 있음
    }
}

위 예시에서 running 변수는 volatile로 선언되어 있습니다. 스레드가 running = false로 설정하면, 다른 스레드들은 즉시 이 변경 사항을 인식하게 됩니다.

3. 컨텍스트 스위칭과 캐시 메모리

(1) 컨텍스트 스위칭
컨텍스트 스위칭은 하나의 스레드가 CPU에서 실행을 멈추고, 다른 스레드로 전환될 때 발생하는 과정을 말합니다. 이때, 현재 스레드의 상태(레지스터, 프로그램 카운터, 스택 등)가 저장되며, 새로운 스레드의 상태가 복구됩니다.

(2) 컨텍스트 스위칭 시 캐시 메모리 갱신
일반적으로, 컨텍스트 스위칭이 발생하면, 캐시 메모리도 함께 갱신됩니다. 즉, 스레드 A가 실행을 멈추고, 스레드 B가 실행될 때, 스레드 A의 캐시 메모리는 무효화되고, 스레드 B는 자신의 캐시 메모리를 사용하게 됩니다. 하지만, 이 캐시 메모리 사용이 메인 메모리의 최신 값과 항상 동기화되는 것은 아닙니다. 이는 메모리 가시성 문제를 야기할 수 있습니다.

따라서 volatile을 사용하면, 캐시 메모리를 무시하고 항상 메인 메모리에서 직접 값을 읽고 쓰기 때문에 메모리 가시성 문제를 해결할 수 있습니다.

4. Happens-Before 관계

Happens-Before 관계는 멀티스레딩에서 실행 순서와 메모리 가시성을 보장하기 위한 규칙입니다. 자바 메모리 모델은 volatile과 동기화 기법(synchronization)을 통해 Happens-Before 관계를 설정하여 스레드 간의 작업 순서를 보장합니다.

(1) Happens-Before 규칙
Happens-Before는 하나의 작업이 다른 작업보다 앞서 실행되었다고 보장하는 관계입니다. 만약 A 작업이 B 작업보다 Happens-Before 관계에 있다면, A 작업에서 변경된 값은 B 작업에서 반드시 볼 수 있습니다.

(2) volatile과 Happens-Before 관계
volatile 변수에 대한 쓰기 연산은 그 변수에 대한 이후 읽기 연산보다 먼저 발생한다고 보장됩니다. 즉, 한 스레드가 volatile 변수를 수정하면, 다른 스레드는 그 이후 해당 값을 읽을 때 항상 최신 값을 확인할 수 있습니다.

예시:

class MyRunnable implements Runnable {
    private volatile boolean flag = false;

    @Override
    public void run() {
        while (!flag) {
            // 대기
        }
        // flag가 true가 되었을 때 작업 수행
    }

    public void setFlag() {
        flag = true;  // Happens-Before 보장: 다른 스레드는 이 변경을 볼 수 있음
    }
}

위 예시에서 flag 변수는 volatile로 선언되어 있습니다. setFlag() 메서드에서 flag를 true로 변경하면, 다른 스레드에서 항상 이 변경 사항을 반영하여 최신 값을 볼 수 있게 됩니다. 이는 Happens-Before 규칙에 의해 보장됩니다.

5. 동기화 기법과 메모리 가시성

volatile 외에도 동기화 기법을 사용하면 메모리 가시성 문제를 해결할 수 있습니다. synchronized 블록은 한 스레드가 공유 자원에 접근하는 동안 다른 스레드가 접근하지 못하게 잠금(lock)을 설정합니다. 또한, 잠금 해제 전까지 발생한 모든 변경 사항이 메인 메모리에 반영되므로 메모리 가시성이 보장됩니다.

synchronized와 메모리 가시성:

  • synchronized 블록에 들어갈 때, 다른 스레드가 변경한 모든 변수 값을 메인 메모리에서 읽어옵니다.
  • synchronized 블록을 빠져나갈 때, 현재 스레드가 변경한 모든 변수 값을 메인 메모리에 기록합니다.

따라서 volatile이나 synchronized를 사용하면 메모리 가시성 문제가 발생하지 않고, 스레드 간의 데이터 일관성을 유지할 수 있습니다.

요약:

  • 메모리 가시성 문제: 스레드가 자신의 캐시 메모리에서 데이터를 사용함으로써, 다른 스레드의 변경 사항을 인식하지 못하는 문제.
  • volatile: 변수를 항상 메인 메모리에서 읽고 쓰도록 보장하여 메모리 가시성 문제를 해결합니다.
    컨텍스트 스위칭: 스레드가 교체될 때, 캐시 메모리도 갱신되지만, 항상 최신 값을 사용하는 것은 보장되지 않음.
  • Happens-Before 관계: 메모리 동기화 순서를 보장하는 규칙으로, volatile과 synchronized를 사용하면 스레드 간의 메모리 가시성이 보장됩니다.

Q1. volatile과 synchronized는 어떻게 다르고, 언제 각각을 사용하는 것이 좋나요?

volatile와 synchronized는 모두 스레드 간의 데이터 동기화를 위한 수단이지만, 사용하는 방식과 그 목적에서 차이가 있습니다.

volatile:

volatile은 변수에 대한 메모리 가시성을 보장해줍니다. 즉, 한 스레드가 volatile 변수를 변경하면, 그 변경 사항이 즉시 다른 스레드에게도 보입니다. 그러나 동기화는 제공하지 않습니다. 즉, 여러 스레드가 동시에 해당 변수를 수정하는 경우에는 안전하지 않습니다.
사용 시점: 공유 자원이 읽기-쓰기 간 경쟁 상태가 발생하지 않는 경우에 사용합니다. 주로 플래그(flag)나 간단한 상태 변수 같은 경우에 적합합니다.

synchronized:

synchronized는 동기화와 메모리 가시성을 모두 보장합니다. synchronized 블록 안에서 실행되는 코드는 한 번에 하나의 스레드만 접근할 수 있으므로, 동시에 여러 스레드가 접근할 때의 경쟁 상태를 해결할 수 있습니다. 또한 synchronized 블록에서 빠져나가는 시점에 메모리 가시성도 보장됩니다.
사용 시점: 여러 스레드가 동시에 공유 자원에 접근할 가능성이 있을 때 사용합니다. 특히 복잡한 연산이나 데이터 구조에 대한 동시 접근이 있을 때 사용해야 합니다.

차이점 요약:

  • volatile은 변수에만 사용 가능하지만, synchronized는 메서드나 블록에 적용됩니다.
  • volatile은 경쟁 상태(race condition)를 해결할 수 없지만, synchronized는 해결할 수 있습니다.
  • volatile은 더 가벼운 메커니즘이고, synchronized는 상대적으로 비용이 큽니다(성능 측면에서).

Q2. Happens-Before 관계에서 volatile 변수와 동기화 블록이 어떻게 작동하는지 더 구체적으로 설명할 수 있나요?

Happens-Before 관계는 자바 메모리 모델(JMM, Java Memory Model)에서 쓰레드 간의 상호작용을 설명하는 중요한 개념입니다. 두 스레드 간의 특정 연산 순서를 보장함으로써 데이터의 일관성을 확보하는 메커니즘입니다.

  1. volatile 변수에서의 Happens-Before:
    volatile 변수에 대한 쓰기 연산은 해당 변수에 대한 읽기 연산보다 Happens-Before 관계를 가집니다. 즉, 한 스레드가 volatile 변수에 값을 쓰고, 다른 스레드가 그 값을 읽을 때, 쓰기 연산이 반드시 읽기 연산보다 먼저 발생하게 됩니다. 이는 volatile 변수의 메모리 가시성을 보장하는 핵심 원리입니다.

예시:

class Example {
    private volatile boolean flag = false;

    public void writer() {
        flag = true; // 쓰기 (Happens-Before)
    }

    public void reader() {
        if (flag) { // 읽기 (Happens-After)
            // flag가 true임을 보장
        }
    }
}

여기서 flag = true 쓰기 연산은 if (flag) 읽기 연산보다 먼저 일어나므로, 두 스레드가 서로 다른 시점에서 작업을 하더라도 데이터 일관성이 보장됩니다.

  1. synchronized에서의 Happens-Before:
    synchronized 블록을 사용할 경우, 동일한 락을 사용하는 블록 내에서의 모든 연산들은 Happens-Before 관계를 가집니다. 즉, 한 스레드가 synchronized 블록을 빠져나가기 전에 수행한 모든 작업은, 다른 스레드가 그 락을 획득한 후에 보장됩니다.

예시:

class Example {
    private int value = 0;

    public synchronized void writer() {
        value = 42; // 쓰기
    }

    public synchronized int reader() {
        return value; // 읽기 (Happens-After)
    }
}

여기서 value = 42라는 쓰기 연산이 일어난 이후에만 다른 스레드가 return value를 통해 값을 읽을 수 있습니다. synchronized는 단순히 락을 거는 것뿐만 아니라, 메모리 가시성도 함께 보장합니다.

정리:

  • volatile은 변수 수준에서 Happens-Before 관계를 보장하고, 주로 간단한 플래그나 상태 변수를 다룰 때 유용합니다.

  • synchronized는 더 강력한 동기화 방법으로, 메서드 또는 블록 전체에서 Happens-Before 관계를 보장합니다. 주로 공유 자원에 여러 스레드가 동시에 접근할 때 사용됩니다.

profile
백엔드에서 서버엔지니어가 된 사람

0개의 댓글