6. 메모리 가시성

임대일·2025년 5월 13일

Thread

목록 보기
6/13
post-thumbnail

1. 예상과 실제가 다른 결과: volatile 없이 발생하는 메모리 가시성 문제

실험 시나리오: runFlag로 스레드 종료 제어

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 boolean runFlag = true;

    @Override
    public void run() {
      log("task 시작");
      while (runFlag) {
        // runFlag가 false로 변하면 탈출
      }
      log("task 종료");
    }
  }
}
12:40:50.608 [     main] runFlag = true
12:40:50.612 [     work] task 시작
12:40:51.617 [     main] runFlag를 false로 변경 시도
12:40:51.618 [     main] runFlag = false
12:40:51.618 [     main] main 종료
  • main 스레드가 runFlag = false로 변경하면, work 스레드는 이를 감지해 while 문을 탈출해야 합니다.

기대한 실행 흐름

main: runFlag = false
→ work: runFlag 감지 → "task 종료"
→ 프로그램 정상 종료

실제 결과

main: runFlag = false
→ work: 여전히 true 읽음 → 반복문 무한 실행
→ "task 종료" 출력되지 않음
→ 프로그램 종료되지 않음

원인: 메모리 가시성(Memory Visibility) 문제

  • 각 CPU 코어는 독립된 캐시 메모리를 가집니다.
  • runFlag는 처음엔 메인 메모리 → 이후엔 각자의 캐시 메모리에서만 읽습니다.
  • main 스레드가 false로 바꿔도, work 스레드는 자신의 캐시된 값(true)만 참조하기 때문에 변경을 감지하지 못합니다.

핵심 그림 요약

구성설명
main 스레드runFlag = false (캐시 1에 적용)
work 스레드runFlag = true (캐시 2에서 계속 true 읽음)
메인 메모리변경이 반영되지 않거나, 동기화되지 않음
결과work 스레드는 계속 true로 인식 → 무한 루프

정리

  • boolean runFlag = true;만으로는 스레드 간 변경이 즉시 보장되지 않습니다.
  • 동기화된 접근 또는 volatile 키워드가 필요합니다.

2. volatile 키워드를 사용한 메모리 가시성 보장

해결 전략: volatile 키워드 사용

volatile boolean runFlag = true;
  • volatile을 붙이면, 해당 변수는 항상 메인 메모리로부터 직접 읽고, 직접 씁니다.
  • 즉, 캐시 메모리를 우회하여 메모리의 최신 값을 모든 스레드가 공유할 수 있습니다.

실행 흐름 비교

항목volatile 미적용volatile 적용
main 스레드에서 false로 설정캐시에만 반영메인 메모리에 즉시 반영
work 스레드에서 읽기오래된 값(true) 계속 읽음메인 메모리에서 최신 값(false) 읽음
결과무한 루프 → 종료되지 않음즉시 while 탈출 → 정상 종료

실행 결과 비교

미적용

main: runFlag = false
work: 계속 true 읽음
→ task 종료되지 않음

적용

main: runFlag = false
work: false 인식 → "task 종료"
→ 프로그램 정상 종료

요약

키워드역할
volatile값을 읽고 쓸 때 항상 메인 메모리 사용
synchronized임계 영역 보호 + 가시성 보장
ReentrantLock락 기반 동기화 + 가시성 보장

volatile간단한 플래그나 상태값 동기화에 적합, 하지만 복합 연산 보호에는 부적절합니다.

3. 실시간 예제에서 드러나는 메모리 가시성 문제 (count & flag)

실험 코드 요약 (volatile 미적용)

boolean flag = true;
long count = 0;

while (flag) {
    count++;
    if (count % 100_000_000 == 0) {
        log("flag = " + flag + ", count = " + count);
    }
}
  • main 스레드는 1초 후 flag = false 설정합니다.
  • work 스레드는 flag가 false가 되면 루프를 빠져나가야 합니다.

결과 분석 (volatile 미적용)

  • mainflag = false를 설정했지만
  • work는 한참 후에서야 flag가 false인 걸 인식
  • 종료 시점에 count는 약 12억

이 시점에 콘솔 출력이 있어서 컨텍스트 스위칭이 발생 → 캐시 갱신 → flag = false 감지

문제 핵심

  • work 스레드는 자신의 캐시 메모리에 있는 flag 값(true)을 계속 읽습니다.
  • main이 바꾼 flag = false캐시에 반영되지 않습니다.
  • 캐시 동기화는 예측 불가: 언제, 혹은 영원히 안 될 수도 있습니다.

해결: volatile 적용

volatile boolean flag = true;
volatile long count = 0;
  • flagcount를 모두 메인 메모리에서 읽고 씁니다.
  • main이 설정한 값이 즉시 work에서 보입니다.

결과 분석 (volatile 적용)

  • flag 변경 후 즉시 감지됩니다.
  • count약 2.2억에서 종료됨 → 미적용보다 5배 이상 빠른 종료

실행 성능 차이 비교

항목volatile 미적용volatile 적용
count 종료 시점약 12억약 2.2억
flag 변경 감지 시점느림 (랜덤)빠름 (즉시)
종료 반응성매우 낮음높음

단점: volatile 사용 시 성능은 약간 저하됩니다. (항상 메인 메모리를 사용해야 하므로)

결론

  • 실시간 반응이 중요한 상태 동기화에는 volatile이 매우 효과적입니다.
  • 하지만 복합 연산(ex: count++)이 원자적이지 않으므로, 동기화가 필요한 경우엔 synchronizedAtomicLong을 사용해야 합니다.

4. 자바 메모리 모델 (Java Memory Model, JMM)과 happens-before 규칙

1) 메모리 가시성의 정의

Memory Visibility (메모리 가시성): 하나의 스레드가 변경한 메모리 값이 다른 스레드에게 언제 보이는지를 다루는 문제입니다.
멀티스레드 환경에서는 각 스레드가 자신만의 CPU 캐시를 사용하므로, 값을 변경해도 다른 스레드가 즉시 그 변화를 감지하지 못할 수 있습니다.

2) Java Memory Model (JMM)이란?

  • 자바 메모리 모델은 멀티스레드 환경에서 일관성 있는 동작을 보장하기 위한 명세입니다.
  • 핵심 개념: happens-before 관계

3) happens-before란?

“A 작업이 B 작업보다 먼저 발생한 것이 보장되면, A는 B보다 happens-before 관계에 있다.”

  • A에서의 메모리 변경 내용은 B가 반드시 볼 수 있습니다.
  • 즉, A 작업이 완료된 후 B가 실행되며, 변경 내용을 인지 가능합니다.

happens-before 관계 예시

상황설명
동일 스레드 내 코드 순서순차적으로 실행된 코드들은 앞선 코드가 뒤의 코드보다 happens-before
volatile 변수 쓰기 → 읽기volatile로 선언된 변수에 쓰기 → 다른 스레드에서 읽기: happens-before
Thread.start() 호출 → 새로운 스레드의 실행start() 호출 이전 작업 → 새 스레드의 run() 내용보다 먼저 발생
Thread.join() 호출 → join 이후 코드대상 스레드의 모든 작업 → join() 반환 후의 작업보다 먼저 발생
synchronized 블록 종료 → 다음 스레드의 진입락을 해제한 후 → 그 락을 획득한 스레드의 작업보다 먼저 발생
ReentrantLock.unlock()lock() 획득 후 작업락 해제 후 락을 획득한 스레드의 작업보다 먼저 발생

4) happens-before의 전이 규칙

  • 만약 A → B, B → C가 happens-before 관계라면, A → C 역시 happens-before가 성립합니다.

5) 요약 정리

🔐 volatile, synchronized, ReentrantLock 같은 동기화 도구를 사용하면 happens-before 관계가 성립되고, 메모리 가시성 문제가 발생하지 않습니다.

5. happens-before 관계 예시 상세 설명

동일 스레드 내 코드 순서

상황: 순차적으로 실행된 코드들은 앞선 코드가 뒤의 코드보다 happens-before

int a = 1;         // A
int b = a + 1;     // B
  • AB보다 먼저 실행됨이 명확히 보장됩니다.
  • 따라서 A의 메모리 변경 사항은 B에서 반드시 볼 수 있습니다.
  • 단일 스레드 내부에서는 컴파일러나 CPU도 명령 순서를 바꾸지 않습니다.

결론: 보장됩니다.

volatile 변수 쓰기 → 읽기

상황: 한 스레드에서 volatile 변수에 쓰기 → 다른 스레드에서 읽기: happens-before

volatile boolean flag = false;

// 스레드 A
flag = true; // 쓰기

// 스레드 B
if (flag) { // 읽기
    // 여기는 반드시 true가 보장됨
}
  • volatile은 메모리 가시성을 보장하기 때문에 A 스레드가 true로 바꾸면, B 스레드는 반드시 변경된 값을 볼 수 있습니다.
  • 여기서 flag = trueif (flag)보다 happens-before

결론: 가시성과 순서 보장됩니다.

Thread.start() 호출 → 새로운 스레드의 실행

상황: start() 호출 이전 작업 → 새 스레드의 run()보다 happens-before

int a = 0;

Thread t = new Thread(() -> {
    System.out.println(a); // 이 시점엔 a의 값이 반드시 42
});

a = 42;
t.start(); // t.run()은 이후 실행됨
  • a = 42t.start() 이전 작업입니다.
  • 따라서 t의 run() 내부에서는 a == 42가 보장됩니다.

결론: 스레드 시작 전 작업 → run() 내부 코드보다 먼저 발생합니다.

Thread.join() 호출 → join 이후 코드

상황: 대상 스레드의 모든 작업 → join() 반환 후의 작업보다 happens-before

Thread t = new Thread(() -> {
    result = 10; // 스레드 t의 작업
});
t.start();
t.join(); // 대기

System.out.println(result); // result == 10 이 보장됨
  • t.join()이 반환되면, 스레드 t의 작업은 모두 완료된 상태입니다.
  • result = 10System.out.println(result)happens-before 관계

결론: join 이후의 코드는 안전하게 결과를 읽을 수 있습니다.

synchronized 블록 종료 → 다음 스레드의 진입

상황: 락을 해제한 후 → 그 락을 획득한 스레드의 작업보다 happens-before

synchronized(lock) {
    sharedValue = 5;
} // A 스레드 락 해제

// B 스레드
synchronized(lock) {
    System.out.println(sharedValue); // 반드시 5 출력
}
  • A 스레드가 락을 해제한 후 → B 스레드가 같은 락을 획득하면
  • A의 synchronized 블록 내의 작업은 B의 블록 내 작업보다 먼저 발생합니다.

결론: 락 해제 → 다음 스레드 진입 시점 사이에 happens-before가 성립됨

ReentrantLock.unlock()lock() 획득 후 작업

상황: 락 해제 후 락을 획득한 스레드의 작업보다 happens-before |

ReentrantLock lock = new ReentrantLock();

// A 스레드
lock.lock();
sharedValue = 100;
lock.unlock(); // A의 작업 종료

// B 스레드
lock.lock();
System.out.println(sharedValue); // 100 보장
lock.unlock();
  • ReentrantLock도 락 기반 동기화이므로 synchronized와 동일한 happens-before 관계를 가집니다.
  • 락을 해제한 후, 다른 스레드가 같은 락을 획득하면 그 전에 실행된 내용이 가시성 있게 보장됩니다.

결론: lock/unlock을 사용한 명시적 동기화도 순서 보장

정리

동기화 방식happens-before 보장 여부
동일 스레드 내 순차 실행✅ 보장됨
volatile 읽기/쓰기✅ 보장됨
Thread.start(), join()✅ 보장됨
synchronized, ReentrantLock✅ 보장됨

핵심 요약: 동기화 도구를 사용하면 happens-before 관계가 생기고, 메모리 가시성 문제가 사라집니다.

profile
🤔오늘의 복습은❓

0개의 댓글