자바에서 volatile을 사용하면 데이터의 일관성과 가시성을 보장하는 역할을 한다.
우선 volatile 개념을 알기 전에 CPU에서 메모리에 어떤 식으로 접근하고 사용하는지 알아야 한다.
CPU에서의 캐시 메모리와 메인 메모리
CPU는 여러 개의 코어로 구성이 되어 있고, 각 코어마다 L1, L2 캐시 메모리를 가지고 있다.
CPU는 하나의 L3 캐시 메모리를 가지고 있다.
CPU가 어떤 특정 값을 조회한다고 가정하면 L1, L2 캐시 메모리를 먼저 확인한다. 이후 L3 캐시 메모리를 확인하고 여기에도 없으면 메인 메모리에 접근해서 데이터를 가져오게 된다.
데이터를 가져오면 역방향으로 L3, L2, L1 캐시 메모리에 저장하면서 데이터를 가져온다.
만약에 L2 캐시 메모리에서 캐시 히트가 발생했다면 이 데이터를 L1 캐시 메모리에 저장하면서 가져오게 된다.
(이거는 정책이나 설정에 따라 다른 것 같습니다.)
싱글 스레드라면 메인 메모리에 접근해서 데이터를 다루는 것에 문제가 발생하지 않는다.
하지만 멀티 스레드라면? 메인 메모리에 동시에 접근이 가능하기 때문에 이런 동시 접근을 제어해 주는 것이 중요해지게 된다. 동시성 말고도 가시성에 대해서도 크게 신경써야 하는데 이 가시성을 보장해 주는 것이 volatile 키워드인 것이다.
volatile
가시성
- 멀티 스레드 환경에서 공유 변수의 변경 내용이 다른 스레드에서는 어떻게 보이는지에 대한 개념이다.
- 멀티 스레드 환경에서는 동시에 접근, 수정이 가능하기 때문에 일관된 데이터가 보일 수 있도로 가시성 확보가 필요하다.
사진을 보면 CORE 1에서 메인 메모리의 a 데이터를 조회한다. 이때 캐시 메모리에 a = 15를 저장해 두고 이 값을 20으로 변경하는 과정이다.
여기서 캐시 메모리에 갱신하고 바로 메인 메모리로 데이터 갱신을 진행하지 않는다.
(그 이유는 캐시의 쓰기 정책때문에 그렇다.)
캐시의 쓰기 정책에는 Write-Back과 Write-Through 방식이 존재한다.
Write-Back 방식은 데이터를 변경할 때 캐시에만 우선 저장해두고 추후에 메인 메모리에 기록하는 방식이다.
(실시간 반영이 아니라서 데이터 일관성을 유지하는 것이 중요하다.)
Write-Through 방식은 데이터를 변경할 때 모든 계층의 캐시 메모리와 메인 메모리에 동시에 기록하는 방식이다.
메인 메모리에 기록하는 시간이 오래 걸리는 문제가 있어서 현대 CPU는 대부분 Write-Back 정책을 사용하는 것으로 알고 있다.
(더 자세한 내용은 다른 공식 문서나 블로그에 많이 나와있습니다.)
이후에 CORE 2가 a 데이터에 접근해서 값을 수정한다고 가정해 보자.
원래라면 CORE 1이 수정했던 a = 20을 불러와서 30으로 변경해야 하지만 메인 메모리의 값이 최신 상태가 아니기 때문에 a = 15라는 값을 읽어서 a = 30으로 변경하게 된다.
즉, 이렇게 다른 코어에서 변경한 내용에 대한 가시성이 확보되지 않아서 이런 문제가 발생하게 되는 것이다.
그러면 가시성을 확보하려면 어떻게 해야 할까?
가시성이 필요한 데이터만 메인 메모리에서 직접 접근하도록 만들면 되는 것이다.
이런 기능을 제공하는 것이 volatile 키워드이다.
a라는 변수에 volatile 키워드를 적용하면 아래처럼 프로세스가 변경된다.
CORE에서 데이터를 접근할 때 캐시 메모리를 바라보는 것이 아니라, 메인 메모리를 직접 바라보게 된다.
즉, 메인 메모리에 접근하기 때문에 다른 코어에서도 해당 변수의 변경 사항을 바로바로 확인할 수 있는 것이다.
그런데 가장 중요한 점은 volatile 키워드를 사용한다고 동시성을 보장하지는 않는다.
CORE 1이랑 CORE 2에서 동시에 a = 15에 접근이 가능해진다는 것이다. 이런 동시성 문제를 제어하기 위해서는 synchronized와 같은 키워드를 통해 보장해주어야 한다.
아래는 volatile 테스트를 위한 코드다.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
public class Main {
private static boolean running = true;
public static void main(String[] args) throws IOException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while(running){
}
System.out.println("스레드 동작 중지");
}
});
thread.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("메인 스레드 동작 멈춤");
running = false;
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
while문을 통해 running 상태를 계속 확인하는데 이때 캐시 메모리에 저장해 두고 계속 바라보고 있게 된다.
원래라면 2초 뒤에 running의 상태라 false로 변경되면서 thread는 동작을 중지해야 한다.
그러나 계속 캐시 메모리의 running을 바라보고 있어서 true로 유지돼서 변수의 값 변경을 감지하지 못하게 된다.
(이게 가시성을 확보하지 못해서 발생한 문제다.)
여기서 running 변수에 volatile 키워드를 붙여서 다시 테스트를 진행해 보자.
private static volatile boolean running = true;
이 상태에서는 running이라는 값을 메인 메모리에서 계속 확인하기 때문에 running = false로 변경되는 순간 감지해서 스레드를 정상적으로 종료하게 된다.
참고 자료
https://johngrib.github.io/wiki/java/volatile/
Java volatile
johngrib.github.io
https://jenkov.com/tutorials/java-concurrency/volatile.html
Java Volatile Keyword
The Java volatile keyword guarantees variable visibility across threads, meaning reads and writes are visible across threads.
jenkov.com
'CS지식' 카테고리의 다른 글
스프링 시큐리티에서 세션 중복을 검사하는 방법 (0) | 2024.08.15 |
---|---|
자바 8과 11에서 String 연산을 처리하는 방법 (0) | 2024.08.10 |
CSRF 공격을 막기 위한 CSRF 토큰 (0) | 2024.07.31 |
spring-oauth-client 라이브러리의 동작 흐름 정리 (0) | 2024.06.27 |