1. 개요
필요한 동기화가 없으면 컴파일러, 런타임 또는 프로세서가 모든 종류의 최적화를 적용할 수 있습니다. 이러한 최적화는 일반적으로 유익하지만 때때로 미묘한 문제를 일으킬 수 있습니다.
캐싱 및 재정렬은 동시 컨텍스트에서 우리를 놀라게 할 수 있는 최적화 중 하나입니다. Java와 JVM은 메모리 순서 를 제어하는 다양한 방법을 제공 하며 volatile 키워드가 그 중 하나입니다.
이 사용방법(예제)에서는 기본적이지만 Java 언어에서 자주 오해되는 개념인 volatile 키워드 에 중점을 둘 것입니다 . 먼저 기본 컴퓨터 아키텍처가 작동하는 방식에 대한 몇 가지 배경 지식부터 시작한 다음 Java의 메모리 순서에 익숙해질 것입니다.
2. 공유 멀티프로세서 아키텍처
프로세서는 프로그램 명령 실행을 담당합니다. 따라서 RAM에서 프로그램 명령과 필요한 데이터를 모두 검색해야 합니다.
CPU는 초당 많은 명령을 수행할 수 있으므로 RAM에서 가져오는 것은 그다지 이상적이지 않습니다. 이 상황을 개선하기 위해 프로세서는 Out of Order Execution , Branch Prediction , Speculative Execution 및 물론 캐싱과 같은 트릭을 사용하고 있습니다.
여기에서 다음과 같은 메모리 계층 구조가 작동합니다.
서로 다른 코어가 더 많은 명령을 실행하고 더 많은 데이터를 조작함에 따라 더 관련성 높은 데이터와 명령으로 캐시를 채웁니다. 이렇게 하면 캐시 일관성 문제 를 도입하는 대신 전반적인 성능이 향상됩니다 .
간단히 말해서, 하나의 스레드가 캐시된 값을 업데이트할 때 어떤 일이 발생하는지 두 번 생각해야 합니다.
3. 휘발성 을 사용하는 경우
캐시 일관성에 대해 더 자세히 알아보기 위해 Java Concurrency in Practice 라는 책에서 예제를 빌리겠습니다 .
public class TaskRunner {
private static int number;
private static boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
}
TaskRunner 클래스는 두 개의 간단한 변수를 유지합니다. 기본 메서드에서는 false 인 동안 ready 변수 에서 회전하는 다른 스레드를 만듭니다 . 변수가 true 가 되면 스레드는 단순히 숫자 변수를 인쇄합니다.
많은 사람들은 이 프로그램이 짧은 지연 후에 단순히 42를 인쇄할 것이라고 예상할 수 있습니다. 그러나 실제로는 지연이 훨씬 더 길 수 있습니다. 영원히 멈추거나 0을 인쇄할 수도 있습니다.
이러한 이상 현상의 원인은 적절한 메모리 가시성과 재정렬이 부족하기 때문입니다 . 더 자세히 평가해 봅시다.
3.1. 메모리 가시성
이 간단한 예제에는 메인 스레드와 리더 스레드라는 두 개의 애플리케이션 스레드가 있습니다. OS가 두 개의 서로 다른 CPU 코어에서 해당 스레드를 예약하는 시나리오를 상상해 봅시다.
- 메인 스레드는 코어 캐시에 ready 및 number 변수의 복사본을 가지고 있습니다.
- 독자 스레드도 사본으로 끝납니다.
- 기본 스레드는 캐시된 값을 업데이트합니다.
대부분의 최신 프로세서에서 쓰기 요청은 발행 직후에 적용되지 않습니다. 실제로 프로세서는 이러한 쓰기를 특수 쓰기 버퍼에 대기시키는 경향이 있습니다. 잠시 후 그들은 이러한 쓰기를 주 메모리에 한 번에 모두 적용합니다.
즉 , 메인 스레드가 숫자 와 준비 변수를 업데이트할 때 리더 스레드가 무엇을 볼 수 있는지에 대한 보장이 없습니다. 즉, 판독기 스레드는 업데이트된 값을 바로 볼 수 있거나 약간의 지연이 있거나 전혀 볼 수 없습니다.
이 메모리 가시성은 가시성에 의존하는 프로그램에서 활성 문제를 일으킬 수 있습니다.
3.2. 재정렬
설상가상으로 판독기 스레드는 실제 프로그램 순서가 아닌 다른 순서로 쓰기를 볼 수 있습니다 . 예를 들어 숫자 변수 를 먼저 업데이트하므로 다음과 같습니다.
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
판독기 스레드가 42를 인쇄할 것으로 예상할 수 있습니다. 그러나 실제로 인쇄된 값으로 0을 볼 수 있습니다.
재정렬은 성능 향상을 위한 최적화 기술입니다. 흥미롭게도 다른 구성 요소가 이 최적화를 적용할 수 있습니다.
- 프로세서는 프로그램 순서가 아닌 다른 순서로 쓰기 버퍼를 플러시할 수 있습니다.
- 프로세서는 비순차적 실행 기술을 적용할 수 있습니다.
- JIT 컴파일러는 재정렬을 통해 최적화할 수 있습니다.
3.3. 휘발성 메모리 순서
변수에 대한 업데이트가 다른 스레드에 예측 가능하게 전파되도록 하려면 해당 변수에 휘발성 수정자를 적용해야 합니다.
public class TaskRunner {
private volatile static int number;
private volatile static boolean ready;
// same as before
}
이러한 방식으로 우리는 휘발성 변수 와 관련된 명령을 재정렬하지 않도록 런타임 및 프로세서와 통신 합니다. 또한 프로세서는 이러한 변수에 대한 모든 업데이트를 즉시 플러시해야 한다는 것을 이해합니다.
4. 휘발성 및 스레드 동기화
다중 스레드 애플리케이션의 경우 일관된 동작을 위한 몇 가지 규칙을 확인해야 합니다.
- 상호 배제 – 한 번에 하나의 스레드만 중요한 섹션을 실행합니다.
- 가시성 – 데이터 일관성을 유지하기 위해 공유 데이터에 대한 한 스레드의 변경 사항을 다른 스레드에서 볼 수 있습니다.
동기화된 메서드 및 블록은 응용 프로그램 성능을 희생시키면서 위의 속성을 모두 제공합니다.
volatile 은 상호 배제를 제공하지 않고 데이터 변경의 가시성을 보장할 수 있기 때문에 상당히 유용한 키워드 입니다. 따라서 코드 블록을 병렬로 실행하는 여러 스레드가 괜찮지만 가시성 속성을 보장해야 하는 경우에 유용합니다.
5. Happens-주문하기 전에
휘발성 변수 의 메모리 가시성 효과는 휘발성 변수 자체 를 넘어 확장 됩니다.
문제를 더 구체적으로 설명하기 위해 스레드 A가 휘발성 변수에 쓰고 스레드 B가 동일한 휘발성 변수를 읽는다고 가정해 보겠습니다. 이러한 경우 휘발성 변수 를 쓰기 전에 A에게 표시되었던 값은 휘발성 변수를 읽은 후 B에게 표시됩니다 .
기술적으로 말하면 휘발성 필드에 대한 모든 쓰기는 동일한 필드의 모든 후속 읽기 전에 발생합니다 . 이것은 JMM (Java Memory Model)의 휘발성 변수 규칙입니다 .
5.1. 편승
발생 전 메모리 순서 지정의 강점 때문에 때때로 다른 휘발성 변수 의 가시성 속성에 편승할 수 있습니다 . 예를 들어, 특정 예에서 ready 변수를 volatile 로 표시하기만 하면 됩니다 .
public class TaskRunner {
private static int number; // not volatile
private volatile static boolean ready;
// same as before
}
ready 변수에 true 를 쓰기 전의 모든 항목은 ready 변수를 읽은 후 모든 항목에서 볼 수 있습니다 . 따라서 number 변수는 ready 변수 에 의해 강화된 메모리 가시성을 피기백 합니다. 간단히 말해서 휘발성 변수 는 아니지만 휘발성 동작을 보입니다.
이러한 의미 체계를 사용하여 클래스의 몇 가지 변수만 휘발성 으로 정의 하고 가시성 보장을 최적화할 수 있습니다.
6. 결론
이 기사에서는 Java 5부터 시작하여 volatile 키워드, 기능 및 개선 사항을 살펴보았습니다.
항상 그렇듯이 코드 예제는 GitHub 에서 찾을 수 있습니다 .