TIL

  • 메모리 구조와 write-read
  • volatile 특징
  • volatile 문제점
  • full visibility

 


 

메모리 구조와 write-read

CPU 내에는 성능 향상을 위해 L1 Cache가 내장되어 있다.

 

자바의 메모리 구조는 CPU-RAM 아키텍처 기반으로 다음과 같이 동작한다.

 

  • CPU 코어는 RAM에서 읽어온 값을 CPU Cache에 저장하고 해당 Cache에서 값을 읽어 작업한다.
    • CPU 코어가 값을 read할 때, 우선 Cache에 해당 값이 있는지 확인하고 Cache에 값이 없는 경우에만 RAM에서 읽어온다.
  • CPU 코어가 작업을 처리한 뒤, 변경된 CPU Cache 데이터를 RAM에 덮어씌운다. (RAM write 작업)

 

🚨 RAM에 저장된 변수의 값이 변경되었는데도 Cache에 저장된 값이 갱신되지 않아 두 값이 달라지는 경우가 발생한다. 

 

 

 

 

volatile이란?

  • `volatile`로 선언된 변수를 read하고 write할 때, CPU Cache가 아닌 메인 메모리에 하겠다고 명시적으로 선언하는 것.
  • `volatile`로 선언된 변수가 있는 코드는 최적화되지 않는다.

 

public class Main {
    public static void main(String[] args) {
        new ThreadTest().test();
    }
}

class ThreadTest {
    boolean running = true;

    public void test() {
        // Thread 1
        new Thread(() -> {
            int count = 0;
            while (running) {
                count++;
            }
            System.out.println("Thread 1의 count" + count);
        }).start();

        // Thread 2
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}

            System.out.println("Thread 2");
            running = false;
        }).start();
    }
}
  • 스레드1은 `running` 변수를 검사하며 `count` 변수 값을 증가시킨다.
  • 스레드2는 1초 잠들었다가 running 변수 값을 false로 변경한다.
  • 스레드1은 무한루프를 돌다가 running 변수 값이 false로 바뀐 시점에 while문을 통과하지 못하고 종료될 것이라고 예상했다. 하지만 스레드1은 종료되지 않고 프로그램은 계속 살아있다.

 

대체 무슨 이유일까? 🤔

 

스레드1running 변수를 참조할 때, 자신의 CPU Cache를 참조한다.
스레드2는 자신의 CPU Cache의 running 변수를 false로 바꾼 것이다.
따라서, 각 스레드에서 사용하는 변수(running)가 같음에도 불구하고 서로 다른 메모리 주소를 참조하게 되어 이러한 현상이 발생하는 것이다.

 

 

💡 여기서 running 변수에 volatile 키워드를 붙이면 어떻게 될까?

volatile boolean running = true;

 

// Thread 2
// Thread 1의 count 1410296245

 

스레드1이 무한루프를 빠져나오고 count 값을 출력한 뒤, 프로그램이 종료된다. 원래 예상했던 시나리오대로 동작한 것이다.
앞서 살펴봤 듯, 변수를 volatile로 선언하면 CPU Cache가 아닌 메인 메모리 영역을 참조하게 된다.
서로 다른 스레드들도 같은 메모리 주소를 참조하게 되어 위에서 살펴본 동기화 문제를 방지할 수 있는 것이다.

 

 

 

 

volatile 문제점

멀티 스레드 환경에서 여러 개의 스레드가 메인 메모리에 write하는 상황이라면 race condition을 해결할 수 없다.
(* race condition : 여러 개의 스레드가 동시에 경쟁하는 경우)

 

 

  1. 스레드1이 공유 변수 counter를 읽고 CPU 레지스터에서 1 증가시킨다.
    (이 변경된 값을 다시 메인 메모리에 쓰지 않는다.)
  2. 스레드2는 메인 메모리에서 CPU 레지스터로 counter변수(값이 0임)를 읽어온다.
  3. 스레드2counter 값을 1 증가시키고 메인 메모리에 쓰진 않는다.

 

공유 변수 counter 값이 2여야 하지만 각 스레드의 CPU 레지스터에서는 각 counter 값이 1이고 메인 메모리의 counter 값은 여전히 0이다. 결국 각 스레드들이 메인 메모리의 counter 변수에 값을 기록하더라도 그 값은 잘못된 값이 될 것이다.

이러한 경우에는 synchronized 키워드를 사용해 atomic을 보장해야 한다.
(* synchronized 블럭 안에는 그 객체의 lock을 가진 1개의 스레드만 출입할 수 있다.)

 

이렇게 volatile은 여러 스레드의 race condiiton을 해결하지 못하기 때문에 2개 이상의 쓰레드가 write 하려는 경우에는 부적합하다. 그러나 여러개의 스레드가 read하고, 하나의 쓰레드가 write하려는 상황에는 적절하다. volatile은 다른 동기화 방법처럼 lock 을 사용하지 않기 때문에 비용이 적다는 장점이 있다.

 

 

이쯤에서 mutual exclusion과 visibility 개념을 알아보자.

  • mutual exclusion : 하나의 코드 블록은 하나의 스레드 또는 프로세스만 실행할 수 있다.
  • visibility : 한 스레드가 공유 데이터를 변경하면 다른 스레드에서도 볼 수 있다.

 

volatile은 visibility만 지원하고 synchronized는 mutual exclusion, visibility 모두 지원한다.

 

 

 

 

full visibility

volatile은 volatile이 붙은 변수만 메인 메모리에 기록하는 것이 아니라 해당 스레드가 볼 수 있는 모든 변수들이 메인 메모리에 기록된다.

public class MyClass {
    private int years;
    private int months
    private volatile int days;


    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate() 메서드는 3개의 변수를 write하며 그 중 days만 volatile이다.
full volatile visibility guarantee는 days 변수에 값이 write 되면 스레드에 표시되는 모든 변수들도 메인 메모리에 기록된다는 의미이다. 즉, days에 값이 write 되면 years, month의 값도 메인 메모리에 기록된다.

 

 

 

 

📚 참고

https://jenkov.com/tutorials/java-concurrency/volatile.html

https://ttl-blog.tistory.com/238