Search

멀티 스레드와 동시성 (2) 메모리 가시성과 Happens-Before 관계

Tags
Study
Java
Last edited time
2025/09/26 08:12
2 more properties
Search
멀티 스레드와 동시성 (3) 동기화: synchronized와 ReentrantLock
Study
Java
멀티 스레드와 동시성 (3) 동기화: synchronized와 ReentrantLock
Study
Java

1. 메모리 가시성

1.1. TL;DR

메모리 가시성 문제는 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 의미함.
이 문제는 주로 CPU의 캐시 메모리 사용 방식 때문에 발생함.
volatile을 사용하면 해당 변수의 읽기/쓰기마다 메인 메모리를 직접 이용하여 가시성을 보장함.
대신 성능은 떨어질 수 있음

1.2. 문제 발생 예제

package thread.volatile1; import static util.MyLogger.log; import static util.ThreadUtils.sleep; public class VolatileFlagMain { public static void main(String[] args) { MyTask task = new MyTask(); Thread t = new Thread(task, "work"); log("runFlag = " + task.runFlag); t.start(); sleep(1000); log("runFlag를 false로 변경 시도"); task.runFlag = false; log("runFlag = " + task.runFlag); log("main 종료"); } static class MyTask implements Runnable { boolean runFlag = true; // ❌ volatile 없음 //volatile boolean runFlag = true; @Override public void run() { log("task 시작"); while(runFlag) { // runFlag가 false로 변하면 탈출 } log("task 종료"); } } }
Java
복사
기대 되는 실행 결과
runFlag가 false로 변경되어 태스크가 종료될 것으로 기대됨
15:39:59.830 [ main] runFlag = true 15:39:59.830 [ work] task 시작 15:40:00.837 [ main] runFlag를 false로 변경 시도 15:40:00.838 [ main] runFlag = false 15:40:00.838 [ work] task 종료 15:40:00.838 [ main] main 종료
Plain Text
복사
실제 실행 결과
taks 종료가 되지 않고 프로그램이 멈추지 않음
07:53:45.438 [ main] runFlag = true 07:53:45.439 [ work] task 시작 07:53:46.453 [ main] runFlag를 false로 변경 시도 07:53:46.453 [ main] runFlag = false 07:53:46.454 [ main] main 종료
Plain Text
복사

1.3. 문제 원인 분석

일반적으로 생각하는 메모리 접근 방식
main 스레드와 work 스레드가 같은 메모리의 runFlag 변수를 공유한다고 생각
한 스레드에서 값을 변경하면 다른 스레드에서 즉시 확인 가능하다고 예상
실제 CPU 메모리 구조
1.
CPU 코어별 캐시: 각 CPU 코어는 독립적인 캐시를 가짐
2.
메모리 접근 속도: 메인 메모리보다 캐시가 훨씬 빠름
3.
캐시 일관성 문제: 각 스레드가 서로 다른 캐시에서 변수 값을 읽을 수 있음
메모리 가시성 문제 발생 과정
1.
work 스레드가 runFlag = true를 CPU 캐시에 저장
2.
main 스레드가 runFlag = false로 변경 (메인 메모리 또는 다른 캐시에 저장)
3.
work 스레드는 여전히 자신의 캐시에 있는 runFlag = true 값을 계속 읽음
4.
따라서 while 루프에서 빠져나오지 못함

1.4. volatile 를 이용한 해결

volatile을 변수에 붙이면, 해당 변수의 모든 읽기/쓰기가 메인 메모리를 통하도록 JMM이 규정함.
즉, 캐시를 경유하지 않고, 즉시 보이도록 만듦.
따라서 runFlag 같은 종료 플래그에 volatile을 붙이면 바로 끌 수 있음.
단, 캐시 우회를 하므로 성능이 떨어질 수 있음
static class MyTask implements Runnable { volatile boolean runFlag = true; // ✅ volatile 키워드 추가 @Override public void run() { log("task 시작"); while(runFlag) { // runFlag가 false로 변하면 탈출 } log("task 종료"); } }
Java
복사

1.5. volatile과 원자성(Atomicity)의 관계

volatile 키워드는 변수의 값을 메인 메모리에서 직접 읽고 쓰게 하여 가시성을 보장하지만, 연산 자체의 원자성까지 보장하지는 않음
예를 들어, value++와 같은 연산은 '값 읽기', '값 증가', '값 쓰기'의 여러 단계로 이루어져 있어 원자적이지 않음. 
volatile은 각 개별 읽기/쓰기 작업의 가시성을 보장하지만, 이 전체 연산이 다른 스레드의 간섭 없이 한 번에 이루어지는 것은 보장하지 않음.
이러한 원자성을 보장하려면 synchronized나 java.util.concurrent.atomic 패키지의 클래스(예: AtomicInteger)를 사용해야 함

2. 자바 메모리 모델 (JMM)과 Happens-Before 관계

2.1. Happens-Before 정의

Java Memory Model(JMM) 은 자바 프로그램(특히 멀티스레드)이 메모리에 어떻게 읽고/쓰고/보는지를 규정함.
그 핵심은 happens-before라는 가시성과 순서 보장 규칙이다.
“A happens-before B”라면, A의 모든 메모리 쓰기는 B에서 반드시 보인다.

2.2. Happens-Before 규칙들

프로그램 순서 규칙 (Program Order Rule)
한 스레드 내에서는 소스 순서가 happens-before 관계를 형성
public class ProgramOrderExample { private int x = 0; private int y = 0; public void singleThreadExecution() { int a = 1; // Statement 1 int b = 2; // Statement 1 happens-before Statement 2 x = a + b; // Statement 1,2 happens-before Statement 3 y = x * 2; // Statement 1,2,3 - happens-before Statement 4 // 보장: Statement 4에서 y 계산 시, x는 반드시 3 System.out.println("x = " + x + ", y = " + y); // x = 3, y = 6 } }
Java
복사
Volatile 변수 규칙 (Volatile Variable Rule)
한 스레드의 volatile 변수 쓰기는, 그 변수를 읽는 모든 스레드의 읽기보다 앞섬
public class VolatileRuleExample { private int normalVariable = 0; private volatile boolean flag = false; // Producer Thread public void producer() { normalVariable = 42; // 1. 일반 변수 수정 flag = true; // 2. volatile 쓰기 } // Consumer Thread public void consumer() { if (flag) { // 3. volatile 읽기 int value = normalVariable; // 4. 반드시 42를 읽음 System.out.println("Value: " + value); } } // (1,2) happens-before (3,4) }
Java
복사
스레드 시작 규칙 (Thread Start Rule)
t.start() 호출 전에 한 일들은 새 스레드가 시작되어 실행하는 일보다 앞섬
public class ThreadStartRuleExample { private int sharedData = 0; private String message = null; public void demonstrateThreadStartRule() { sharedData = 100; // 1. 데이터 설정 message = "Hello Thread"; // 2. 메시지 설정 Thread workerThread = new Thread(() -> { // 3. 반드시 100을 읽음 System.out.println("Shared Data: " + sharedData); // 4. 반드시 "Hello Thread"를 읽음 System.out.println("Message: " + message); }); workerThread.start(); // start() 호출 // (1,2) happens-before (3,4) } }
Java
복사
스레드 종료 규칙 (Thread Join Rule)
t.join()이 반환되면, t 내부에서 한 일은 join 이후 코드보다 앞섬
public class ThreadJoinRuleExample { private int result = 0; private String status = "processing"; public void demonstrateJoinRule() throws InterruptedException { Thread worker = new Thread(() -> { result = 500; // 1. 결과 설정 status = "completed"; // 2. 상태 업데이트 }); worker.start(); worker.join(); // Worker 스레드 완료 대기 // join() 반환 후 System.out.println("Result: " + result); // 3. 반드시 500 System.out.println("Status: " + status); // 4. 반드시 "completed" // (1,2) happens-before (3,4) } }
Java
복사
인터럽트 규칙 (Interruption Rule)
interrupt() 호출은, 인터럽트를 감지하는 시점의 동작보다 앞섬
public class InterruptRuleExample { private volatile String interruptReason = null; public void demonstrateInterruptRule() throws InterruptedException { Thread worker = new Thread(() -> { try { while (!Thread.currentThread().isInterrupted()) { Thread.sleep(100); // 3. 인터럽트 감지 지점 } } catch (InterruptedException e) { // 4. 인터럽트 감지 시 interruptReason 확인 가능 System.out.println("Interrupted: " + interruptReason); Thread.currentThread().interrupt(); } }); worker.start(); Thread.sleep(50); interruptReason = "Shutdown requested"; // 1. 인터럽트 이유 설정 worker.interrupt(); // 2. 인터럽트 호출 worker.join(); // (1,2) happens-before (3,4) } }
Java
복사
객체 생성 규칙 (Object Creation Rule)
생성자 완료 전의 값은 외부에 보이지 않음 (완전 초기화 후에만 참조됨).
public class ObjectCreationRuleExample { private final int value; private final String name; private final List<Integer> numbers; public ObjectCreationRuleExample(int value, String name) { this.value = value; // 1. 필드 초기화 this.name = name; // 2. 필드 초기화 this.numbers = new ArrayList<>(); // 3. 컬렉션 생성 this.numbers.add(1); // 4. 컬렉션 초기화 this.numbers.add(2); // 5. 컬렉션 초기화 // 생성자 완료 } public static void demonstrateRule() { // 6. 객체 생성 - 이 시점에서 모든 필드가 완전히 초기화됨 ObjectCreationRuleExample obj = new ObjectCreationRuleExample(10, "Test"); // 7. 안전한 접근 - 모든 필드 값이 보장됨 System.out.println("Value: " + obj.value); // 반드시 10 System.out.println("Name: " + obj.name); // 반드시 "Test" System.out.println("Numbers: " + obj.numbers); // 반드시 [1, 2] // (1,2,3,4,5) happens-before (6,7) } }
Java
복사
모니터 락 규칙 (Monitor Lock Rule)
synchronized 블록의 unlock은 이후에 같은 락을 lock하는 스레드보다 앞섬
public class MonitorLockRuleExample { private int sharedCounter = 0; private String sharedMessage = null; private final Object lock = new Object(); // Thread 1 public void writerThread() { synchronized (lock) { // lock 획득 sharedCounter = 100; // 1. 공유 변수 수정 sharedMessage = "Updated"; // 2. 공유 변수 수정 } // 3. unlock } // Thread 2 public void readerThread() { synchronized (lock) { // 4. lock 획득 (Thread 1의 unlock 이후) // 5. Thread 1의 모든 변경사항이 가시화됨 System.out.println("Counter: " + sharedCounter); // 반드시 100 System.out.println("Message: " + sharedMessage); // 반드시 "Updated" } } // (1,2,3) happens-before (4,5) }
Java
복사
전이 규칙 (Transitivity Rule)
A→B, B→C면 A→C (happens-before 관계의 전이성).
public class TransitivityRuleExample { private int data1 = 0; private int data2 = 0; private volatile boolean step1Complete = false; private volatile boolean step2Complete = false; // Thread 1 public void firstStep() { data1 = 10; // A1 step1Complete = true; // A2 (volatile 쓰기) } // Thread 2 public void secondStep() { if (step1Complete) { // B1 (volatile 읽기) data2 = data1 * 2; // B2 - data1은 반드시 10 step2Complete = true; // B3 (volatile 쓰기) } } // Thread 3 public void finalStep() { if (step2Complete) { // C1 (volatile 읽기) int result = data1 + data2; // C2 - data1=10, data2=20 System.out.println("Final result: " + result); // 반드시 30 } } // 전이 관계: // (A1,A2) happens-before (B1,B2,B3) - volatile 규칙 // (B1,B2,B3) happens-before (C1,C2) - volatile 규칙 // 따라서 (A1,A2) happens-before (C1,C2) - 전이 규칙 }
Java
복사