HJW's IT Blog

JAVA Volatile 키워드와 멀티쓰레드 본문

JAVA

JAVA Volatile 키워드와 멀티쓰레드

kiki1875 2025. 1. 13. 12:50

들어가며

현대 소프트웨어 개발에서 멀티스레드 프로그래밍은 빠질 수 없는 핵심 주제중 하나이다. 여러 스레드가 하나의 프로그램을 구성하여 조작할 때, 여러 이점이 있지만, 동시성 문제, 일관성 등의 문제또한 야기한다.

본격적으로 Volatile 에 대해 이야기 하기 전, 변수 가시성 에 대해 이해하고 넘어가야 한다. 변수 가시성 문제 란 하나의 스레드가 특정 변수의 값을 변경 했을 때, 다른 스레드에서 그 변경 사항을 즉각적으로 확인하지 못하는 상황을 의미한다. 다음 코드를 한번 보자

class SharedResource {
	private boolean flag = false;
	
	public void setFlagTrue(){
		this.flag = true;
	}
	
	public void waitForFlag(){
		while(!flag){
			// 대기
		}
		System.out.println("Flag is now true");
	}
}

public class VisibilityProblemDemo {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread writer = new Thread(resource::setFlagTrue);
        Thread reader = new Thread(resource::waitForFlag);

        reader.start();
        writer.start();
    }
}

 

이 코드에서 각 스레드는 자신만의 CPU cache 를 가진다. 그렇기에 flag 변수는 가시성 문제가 발생할 수 있는 변수이다.

 

 

먼저 공유 변수에 접근한 writer 스레드가 flag 를 true 로 설정하더라도, pending 중인 reader 스레드가 메인 메모리를 다시 읽지 않는다면 이 변경사항을 알지 못할 수 있다. 이는 Java Memory Model 은 스레드간 메모리 일관성을 보장하지 않기 때문에 발생하며, CPU 캐시 최적화로 인해 변수 값이 최신 상태로 동기화 되지 않는다. 이러한 동작은 개발자가 의도한 대로 프로그래밍 동작하지 않게 할 수 있다.

이러한 맥락에서 volatile 키워드는 중요한 역할을 한다. volatile 을 통해 변수의 가시성(visibility) 를 보장하고, 데이터의 일관성을 유지하도록 할 수 있다.

 

Volatile 기본 개념

Volatile 정의와 특징

Volatile 키워드는 Java에서 변수의 변경사항이 모든 스레드에 즉시 가시성이 보장되도록 한다. 즉, 특정 변수의 값을 읽는 모든 스레드가 항상 최신값을 참조함이 보장되는 것이다. 또한 volatile 로 선언되였다면, 컴파일러와 CPU가 명령을 재정렬하지 못하도록 방지한다.

 

가시성 보장

Volatile 로 선언된 변수는 메인 메모리에 기록되는데, 각 스레드는 해당 변수에 접근할 상황이 발생하면, CPU 캐시를 무시하고 직접 메인 메모리에서 읽어온다.

 

순서성 보장

Volatile 변수에 대한 READ, WRITE은 명령어 재정렬을 방지하여 항상 작성된 순서대로 실행됨이 보장된다.

 

메모리 아키텍처와 Volatile

현대의 CPU 는 성능을 극대화 하기 위해 cache를 사용한다. 이전에 사용한 다이어그램처럼, 각 CPU는 독립된 cache를 가지며, 자주 접근하는 데이터를 저장하여 메인 메모리 접근 시간을 단축시킨다. 하지만 이러한 이점은 멀티스레트 환경에서 캐시 일관성 문제 (Cache Coherence Issue) 를 발생시킨다. 서로 다른 CPU가 각자의 캐시를 사용하여 메인 메모리에 반영된 데이터를 참조하지 않는 상황이다.

 

Memory Barrier

Volatile 키워드는 메모리 페리어를 사용하여 CPU와 메모리간의 일관성을 보장한다.

쓰기 베리어 : Volatile 이 변수에 데이터를 기록할 때, 해당 값이 메인 메모리에 기록되도록 강제

읽기 베이러 : Volatile 이 변수를 읽을때 캐시가 아닌 메인 메모리에서 읽도록 강제

 

캐시 일관성 프로토콜의 이해

이러한 동작원리를 이해하기 위해 캐시 프로토콜을 이해하고 넘어가는것이 좋다. 캐시 프로토콜은 캐시에 저장된 테이터를 다음과 같은 4가지 형태로 구분한다.

  • Modified : 해당 데이터는 캐시에서 변경되었으며, 메인 메모리에 반영되지 않음
  • Exclusive : 데이터가 로컬 캐시에만 있지만 메모리와 동일하다
  • Shared : 여러 캐시에 해당 데이터가 존재하며, 메모리와 동일하다
  • Invalid : 데이터가 유효하지 않은 상태

Volatile 변수가 포함된 메모리 주소를 두개의 CPU 가 동시에 다룬다고 가정해 보자. 이때 MESI 프로토콜이 작동하는 방식은 다음과 같다.

  1. CPU 1, CPU 2 는 동일한 메모리 주소를 포함하는 데이터를 각자의 캐시에 저장 : Shared
  2. CPU 1 에서 write 발생 → 다른 모든 캐시에서 해당 데이터를 invalid 상태로 만든다 → CPU 1 에서 해당 데이터는 modified 상태가 된다
  3. CPU 2 에서 read 발생 → invalid 상태이기에 캐시가 아닌 메인 메모리에서 로드 된다 (이때 cpu 1에서의 변경사항은 commit 되었다 가정)

Volatile 의 활용 예시

상태 플레그 관리

Volatile 키워드는 간단한 상태 플래그를 관리하는데에 사용될 수 있다. 다음은 그 예시이다.

class SharedResource {
	private volatile boolean flag = false;
	
	public void setFlagTrue(){
		this.flag = true;
	}
	
	public void waitForFlag(){
		while(!flag){
			// 대기
		}
		System.out.println("Flag is now true");
	}
}

 

volatile 로 선언 된 flag 는 변경 즉시 변경사항이 다른 스레드에서 변경된 값을 참조할 수 있다.

Synchronized, Double-checked locking

싱글톤 구현에서, 인스턴스를 안전하게 하기 위해 한번에 하나의 스레드만 생성 과정을 거치기 위해 synchronized 키워드를 통한 클래스 자체를 monitor lock 으로 삼아 동기화 한다.

Synchronized 키워드를 사용하게 되면, 기본적으로 특정 코드 블록이나 메서드가 동시에 하나의 스레드에 의해서만 실행됨이 보장된다. 이를 통해 공유 자원에 대한 race condition 을 방지할 수 있다.

다음 코드를 생각해 보자

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }
}

 

둘 이상의 스레드가 해당 increment 를 실행한다면, count 의 값이 의도와 다를 수 있다.

public synchronized void increment() {
    count++;
}

 

를 통해 한번에 하나의 스레드만 접근할 수 있음이 보장된다.

Synchronized 는 monitor lock 메커니즘을 기반으로 동작한다. 객체에는 monitor 라는 lock 이 있는데, synchronized 블록에 접근하기 위해선 해당 monitor 를 획득해야 한다. 즉 해당 monitor 가 없는 스레드는 synchronized 블록을 실행할 수 없는 것이며 pending 상태가 된다.

Double-Checked Locking

위의 synchronized 키워드는 안정성은 증가하지만 비용이 큰 연산이다. 이러한 문제를 해결하기 위해 싱글톤 인스턴스를 생성할 때 double-checked locking 패턴을 사용한다. 이로써, 초기화 비용이 큰 객체를 필요할 때만 생성하면서, 동시에 동기화로 인한 성능 저하를 최소화 할 수 있다.

 

싱글톤의 핵심은 전역적으로 단 하나의 인스턴스만 생성되어야 하며, 이를 모든 스레드가 공유하는 것이다. 하지만 별도의 동기화 없이 구현하게 되면, 멀티 스레드 환경에서 race condition 이 발생하여 여러 인스턴스가 생성될 수 있다. 그렇다면 synchronized 를 통해 한번에 하나의 스레드만 이 코드를 실행함을 보장시키면 어떨까?

public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}
  • 이 방법은 thread-safe 하지만 메서드를 호출할 때 마다 동기화를 수행하기에 성능 저하가 일어난다.

이 문제를 해결하기 위해 Double-Checked Locking 패턴이 설계 되었다. 성능 최적화와 thread-safe, 두마리의 토끼를 모두 잡기 위해 말이다.

class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

if (instance == null) 을 통해 만약 인스턴스가 있다면 동기화 블록을 생략하고 바로 반환할 수 있다. 이후 synchronized 를 통해 배타적 접근을 보장한다. 두번째 if(instance==null) 에선 다중 스레드 환경에서 인스턴스가 중복 생성 되는 것을 방지한다.

  • 아까 synchronized 는 한번에 한 스레드 만이 접근할 수 있다 말했다. 그렇다면 초기화 과정에서 두 스레드가 첫 if 조건을 통과하여 두 스레드중 하나만이 monitor lock 을 획득하여 코드를 수행하고 나면, 대기 상태이던 두번째 스레드는 synchronized 의 블록 내부에서 if(instance ==null) 을 통해 조건문 바깥으로 빠져나오게 된다.

Volatile 은 여기서 빛을 발한다. Volatile 은 초기화 과정에서 발생할 수 있는 reordering 과정을 방지한다. JVM 은 최적화를 위해 명령 순서를 reorder 하는 경우가 있는데, 이로 인해 스레드 A 가 초기화를 완료하기 전, 다른 스레드가 instance 를 읽을 수도 있는 일이 발생하게 된다. volatile 은 이러한 문제를 방지해 준다.

Volatile 의 한계

원자성의 부족

volatile int count = 0;

public void increment(){
	count++;
}

 

위 연산은 간단한 연산이지만 사실 뜯어서 살펴보면 변수 읽기 -> 값 증가 -> 변수 쓰기 의 과정을 거친다. Volatile 키워드는 다른 스레드와 동기화 된다는 점에선 효과적이지만, 여러 단계로 이루어진 복합 연산은 동기화 하지 못한다.

 

복잡한 상태관리 부적합

volatile List<String> sharedList = new ArrayList<>();

public void addItem(String item) {
    sharedList.add(item);
}

 

위와 같은 경우, volatile 변수로 참조 가시성은 보장 받을 수 있지만, ArrayList 그 자체는 thread-safe 하지 않기 때문에 동기화된 접근 방식이 필요하다

 

성능 비용

 

Volatile 은 메인 메모리와의 동기화를 강제 하기 때문에 일반 변수에 비해 느리다.

결론

Volatile 키워드는 멀티스레드 프로그래밍에서 변수의 가시성과 순서성을 보장하는 간단하고 효과적인 도구다. 그러나 Volatile은 모든 동시성 문제를 해결할 수 있는 만능 키는 아니다. 원자성이 보장되지 않기 때문에 복잡한 연산이나 데이터 구조를 다룰 때는 synchronized 블록이나 다른 동기화 메커니즘이 필요하다.

Volatile은 다음과 같은 상황에서 적합하다:

  • 간단한 플래그 상태 관리.
  • Singleton 패턴 구현에서 Double-Checked Locking 사용.
  • 읽기/쓰기 작업이 독립적이고 복잡하지 않은 경우.

그러나 다음과 같은 상황에서는 적합하지 않다:

  • 원자성이 필요한 복합 연산.
  • 데이터 구조 전체의 동기화가 필요한 경우.

결론적으로 Volatile은 멀티스레드 프로그래밍에서 꼭 알아야 할 도구지만, 사용 시 그 한계를 이해하고 적절히 활용하는 것이 중요하다. 동시성 문제를 완전히 해결하기 위해서는 Volatile 외에도 synchronized, Lock, Atomic 변수 등 다양한 도구를 상황에 맞게 조합하는 것이 필요하다.