Java

Java Volatile과 JMM의 메모리 가시성

Luti 2026. 9. 29. 01:44

 

아래 코드를 실행하면 어떤 일이 일어날까요

public class VisibilityProblem {
    private static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long count = 0;
            System.out.println("worker 시작. running = " + running );
            while (running) {
                count++;
            }
            System.out.println("worker 종료. count=" + count);
        });
        worker.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("main: running = false 로 변경");
    }
}

 

동작을 하나하나 살펴보면...

우선 메인 스레드와 워커 스레드가 있습니다.

워커 스레드에서는 running 값을 읽어 값이 true라면 계속해서 루프를 돌며 count를 쌓아가고 있고,

메인 스레드는 워커스레드 실행 1초 후 running값을 false로 업데이트합니다.

 

정리해 보면, running 초기값이 true이기 때문에 워커스레드는 `start()` 후 1초 동안 루프를 계속 돌면서 count를 쌓아가다가 1초 후 메인스레드가 running 값을 false로 바꾸면서 워커스레드 while문 조건에서 벗어나 프로세스가 종료될 것으로 예상됩니다. 

 

돌려봅시다.

 

 

1초가 한참 지나도 프로세스가 종료되지 않습니다.

워커스레드 while문 루프가 계속 돌고 있다는 뜻이겠죠.

그런데 메인스레드는 분명 running 변수를 false로 업데이트했습니다.

무슨 일일까요 이게 과연.

 

Java Memory Model과 메모리 가시성, 그리고 `volatile`이 어떤 역할을 하는지 알아보는 것으로 이러한 현상의 원인과 해결 방법을 이해해 보겠습니다.

 

 

 

# 00. JMM과 메모리 가시성

 

사실 Java 코드에서 여러 스레드가 같은 변수를 공유하더라도, 한 스레드가 변경한 값이 다른 스레드에 즉시 보인다고 보장할 수는 없습니다.

앞선 예제에서도 별도의 동기화가 없다면, main 스레드가 `running = false`를 실행했음에도 worker 스레드는 여전히 이전값인`true`를 관찰할 수 있다는 사실입니다.

 

코드에서는 그냥 `running` 변수 하나를 읽을 뿐이지만 실제 프로그램이 실행될 때는 JVM, JIT 컴파일러, CPU, 레지스터, CPU 캐시, 메모리 같은 여러 계층이 개입합니다.

 

따라서 우리는 "Heap에 running 값이 있으니까 매번 Heap에서 읽어오겠지"라고 이해하면 안 됩니다.

 

이때 스레드 A가 running 값을 변경했을 때 그 변경 사항을 스레드 B가 볼 수 있는지 없는지의 문제를 메모리 가시성이라고 합니다.

이는 "값이 존재하느냐", 혹은 "값을 변경할 수 있느냐"의 문제가 아니라 "그 변경을 다른 스레드가 관찰할 수 있느냐"의 문제입니다.

 

그러면 여기서 CPU마다, JVM마다 최적화가 다 다를 텐데 개발자가 그 동작을 다 알아야 하는 걸까요?

그럴 수가 없기 때문에 Java에서는 하드웨어 구현과 개발자 사이에 추상적인 계약을 둡니다.

그게 바로 JMM(Java Memory Model)입니다.

 

JLS가 정의하는 메모리 모델은 아래와 같습니다.

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program.

 

어떤 프로그램과 그 실행 결과가 주어졌을 때, 그 실행이 Java에서 허용되는 실행인지 판단하는 규칙을 말한다고 합니다.

 

 

# 01. volatile?

 

앞에서 본 문제는 main 스레드가 `running`을 변경했음에도 workker 스레드가 그 변경을 관찰하고 보장할 수 없다는 것이었습니다.

Java는 이런 경우 사용할 수 있는 수단 중 하나로 `volatile` 키워드를 제공합니다.

 

private static volatile boolean running = true;

 

`volatile`은 필드에 붙일 수 있는 modifier로, 여러 스레드가 공유하는 변수의 가시성과 메모리 접근 순서에 특별한 보장을 제공합니다.

JLS에서도 volatile 필드를 locking보다 편리하게 사용할 수 있는 별도의 동기화 메커니즘으로 정의하고 있습니다.

 

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.
A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.

 

 

이때 `volatile`의 의미를 "CPU 캐시를 사용하지 않는다." 혹은 "항상 RAM에서 직접 읽는다"라고 이해하면 안 됩니다.

`volatile`의 의미는 해당 변수의 read/write에 JMM의 특별한 메모리 가시성 및 순서 규칙을 적용하는 것에 가깝지, 단순하게 CPU 캐시를 무시하거나 RAM에서 읽는 동작만을 수행하지는 않습니다.

실제 JVM이 이를 CPU에서 구현하는 방법은 CPU 아키텍처에 따라 달라지며, 메모리 베리어, acquire semantics, 캐시 일관성 등 여러 복잡한 구현이 있는데, 거기까지는 알아보지 않고 저희는 JMM이 제공하는 보장을 기준으로 이해하는 것으로 하겠습니다.

 

그래서 volatile을 붙이면 기존 코드는 어떻게 달라질까요

원래 코드는

private static boolean runnnig = true;

 

위와 같았습니다.

 

이를 

private static volatile boolean running = true;

 

와 같이 바꿔보겠습니다.

 

다시 전체 코드를 보면서 흐름을 따라가 보면

public class VisibilityProblem {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long count = 0;
            System.out.println("worker 시작. running = " + running );
            while (running) {
                count++;
            }
            System.out.println("worker 종료. count=" + count);
        });
        worker.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("main: running = false 로 변경");
    }
}

 

worker 스레드에서 `running`값을 읽고 있고, 이후 main 스레드에서 `running` 값을 업데이트합니다.

이때 JMM은 `volatile` 변수인 running에 대해 write과 read 사이에 happens-before 관계를 정의합니다.

happens-before는 앞선 작업의 결과가 이후 작업에서 관찰될 수 있도록 순서와 메모리 가시성을 보장하는 JMM 규칙입니다.

따라서 `running=false`라는 변경을 worker 스레드가 이후의 volatile read에서 관찰할 수 있도록 메모리 가시성이 보장됩니다.

 

실제로 프로세스를 실행해 보겠습니다.

 

 

실행 1초 후 main 스레드가 `running` 값을 false로 업데이트 함과 동시에 worker 스레드의 while 루프가 탈출되며 실행이 종료되는 것을 확인했습니다.

 

 

# 02. volatile이 보장하는 가시성의 범위

 

새로운 예제를 준비했습니다.

지금까지 `volatile`변수의 read와 write 동작에 대해 이해하는 시간을 가져보았으니 아래 코드의 동작을 예측해 봅시다.

public class HappensBeforeExample {
    private static int data = 0;
    private static volatile boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            data = 100;
            ready = true; // volatile write
        });

        Thread reader = new Thread(() -> {
            while (!ready) {
                // spin
            }
            System.out.println("data = " + data);
        });

        reader.start();
        Thread.sleep(1000);
        writer.start();
    }
}

 

우선 int형의 변수 `data`가 초기값 0으로 세팅되어있고,`volatile` boolean 변수 `ready`가 false로 초기화되어 있습니다.

writer 스레드에서 `data`를 100으로, `ready`를 true로 업데이트합니다.

reader 스레드에서는 `ready` 값을 읽어 `ready`가 false인 동안 while 루프에 빠져있도록 하고, 루프 탈출 이후 `data` 변수에 할당된 값을 콘솔에 출력하도록 합니다.

reader 스레드가 먼저 실행되고 1초 후 writer 스레드가 실행됩니다.

 

자 먼저 `ready`의 초기값이 false이기 때문에 reader는 시작 후 1초 동안 while문 루프를 돌고 있을 것입니다.

그리고 1초 후 `ready` 값을 worker 스레드가 write 하게 되는데, `ready`는 `volatile` 변수이기 때문에 메모리 가시성이 보장이 되어 worker 스레드가 변경한 `ready`가 reader 스레드에서도 관측되는 것이 보장됩니다.

그렇다면 실행 1초 후 writer 스레드가 `ready`를 업데이트 함과 동시에 read 스레드의 while 루프는 종료되고 `data` 변수에 할당된 값이 콘솔에 출력될 것입니다.

worker 스레드는 `data` 값을 100으로 업데이트하긴 했지만 `data`는 `volatile` 변수가 아니니까 메모리 가시성이 보장되지 않아 콘솔에는 0으로 찍힐 것으로 예상이 됩니다.

 

확인해 봅시다.

 

예상이 빗나갔습니다!

worker 스레드가 변경한 `data` 변수는 `volatile` 키워드가 붙지 않았음에도 reader 스레드에서 worker 스레드가 변경한 값으로 읽었습니다.

 

이를 통해 우리는 `volatile`은 단순히 `volatile`이 붙은 변수에만 메모리 가시성을 보장하는 것이 아니라는 것을 알 수 있습니다.

실제로 Oracle의 AtomicAccess 문서에 따르면 어떤 스레드가 `volatile` 변수를 읽으면, 그 `volatile` 변수의 변경뿐 아니라 그 변경에 이르가까지 앞에서 발생한 코드의 side effect도 볼 수 있다고 설명합니다.

when a thread reads a volatile variable, it sees not just the latest change to the volatile, but also the side effects of the code that led up the change.

 

즉, `volatile` 변수는 단순히 자기 자신의 변경 사항만 다른 스레드에 보여주는 것이 아닙니다.

한 스레드에서 `volatile` 변수에 값을 쓰기 전에 수행한 변경 사항들은, 다른 스레드가 해당 `volatile` 변수의 변경을 읽은 이후에도 함께 관찰할 수 있도록 메모리 가시성이 보장됩니다.

 

앞선 예제에서는 writer 스레드가 `ready = true`를 수행하기 전에 `data = 100`을 먼저 수행했습니다.

그리고 reader 스레드는 `ready`가 true로 변경된 것을 확인한 이후 `data`를 읽습니다.

따라서 `data`에는 `volatile` 키워드가 붙어 있지 않지만, `ready`를 통해 writer 스레드에서 먼저 수행한 `data = 100`이라는 변경 역시 reader 스레드에서 관찰할 수 있게 된 것입니다.

 

 

# 03. volatile과 원자성

 

새로운 예제를 보고 다시 한번 동작을 예측해 봅시다.

public class AtomicityProblem {
    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 100;
        int perThread = 1000;
        Thread[] threads = new Thread[threadCount];

        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < perThread; j++) {
                    count++; // read-modify-write, race 발생
                }
            });
        }
        for (Thread t : threads) t.start();
        for (Thread t : threads) t.join();

        System.out.println("expected = " + (threadCount * perThread));
        System.out.println("actual   = " + count);
    }
}

 

`count` 변수에 `volatile` 키워드가 붙어있습니다.

여러 스레드에서의 메모리 가시성을 보장받겠군요.

메인 스레드에서는 스레드를 100개 생성하고 스레드당 1000회씩 `count++`를 수행합니다.

`count`는 메모리 가시성을 보장받는 `volatile` 변수이기 때문에 모든 100개의 스레드에서 분명 정확한 값을 읽고 업데이트할 것입니다.

 

그래서 정답은

expected 10만 (100개 스레드 * 스레드당 1천 회 반복)

actual count도 10만!!!

 

 

아 틀렸습니다...

아니 `volatile`은 메모리 가시성을 보장한다며

근데 왜 actual 값이 10만이 아닌 건데!

 

사실 `volatile` 변수가 메모리 가시성을 보장함에는 변함없습니다.

분명 100 개의 스레드가 정확한 `count` 변수를 읽었습니다.

하지만 문제는 복합연산!

`count++` 동작은 원자적이지 않죠

이 코드는 개념적으로 

1. 값을 읽는다.

2. 1을 더한다.

3. 값을 쓴다.

이와 같은 3단계의 복합 연산의 간결한 표현입니다.

 

예를 들어 스레드 A와 스레드 B가 둘 다 `count` 값 10을 읽어 각각 1을 더해

스레드 A와 B 모두 11을 업데이트해 버리는 일종의 레이스 컨디션이 발생하는 거죠.

 

Oracle에서도 `count++` 같은 연산은 원자적이지 않다고 명시합니다.

an increment expression, such as c++, does not describe an atomic action.

 

물론 volatile read와 volatile write 동작 자체는 원자성이 보장됩니다.

하지만, 연산이 문제였던 거죠 연산이.

 

그렇기 때문에 복합 연산이 들어가는 상황에서는 `volatile`로 메모리 가시성만 보장할 것 대신, cas 기반의 Atomic 객체들을 사용하는 것이 좋은 방법이 될 수 있습니다.

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicFix {
    private static final AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 100;
        int perThread = 1000;
        Thread[] threads = new Thread[threadCount];

        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < perThread; j++) {
                    count.incrementAndGet(); // CAS 기반, 원자적
                }
            });
        }
        for (Thread t : threads) t.start();
        for (Thread t : threads) t.join();

        System.out.println("expected = " + (threadCount * perThread));
        System.out.println("actual   = " + count.get());
    }
}

 

이 코드의 실행 결과는 

 

정확하게 10만! 레이스 컨디션 없이 스레드세이프하게 값이 모두 업데이트되었습니다!

 

Atomic 클래스들은 대체로 JMM 기반의 메모리 가시성 보장 + 특정 연산의 원자성을 보장하기 때문에, 코드에서 업데이트하고자 하는 값의 연산을 확인하고 적절한 방법으로 적용하시면 되겠습니다.

 

 


 

다들 명절 잘 보내셨나요.

명절을 가족과 보내기 위해 본가에 내려가는 KTX를 얘매하는데 오전 7시부터 대기 인원이 80만 가까이 되더라고요.

그런 대기열 시스템을 구축하는게 대단하기도 하고 호기심도 생겼는데,

조만간 레디스 기반의 대기열 시스템 미니프로젝트를 진행해볼까 생각중입니다.

시간과 체력과 의욕이 된다면 ...