List
Search
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
복사