1. 휘발성의 역할
"Java 동시 프로그래밍: 핵심 이론" 기사에서 이미 가시성, 순서 및 원자성 문제를 언급했지만 일반적으로 동기화 키워드를 통해 이러한 문제를 해결할 수 있습니다. 동기화의 원리를 이해했다면 동기화가 상대적으로 무거운 작업이고 시스템 성능에 상대적으로 큰 영향을 미친다는 것을 알아야 합니다. 따라서 다른 솔루션이 있는 경우 일반적으로 문제를 해결하기 위해 동기화를 사용하지 않습니다. 휘발성 키워드는 가시성과 정렬 문제를 해결하기 위해 Java에서 제공되는 또 다른 솔루션입니다. 원자성과 관련하여 한 가지 강조해야 할 점이 있는데, 오해하기 쉬운 점이기도 합니다. 휘발성 변수에 대한 단일 읽기/쓰기 작업은 long 및 double 유형 변수와 같은 원자성을 보장할 수 있지만 원자성을 보장하지는 않습니다. i++와 같은 작업의 기본적으로 i++는 읽기와 쓰기의 두 번 작업이기 때문입니다.
2. 휘발성의 사용
휘발성의 사용과 관련하여 몇 가지 예를 사용하여 사용법과 시나리오를 설명할 수 있습니다.
1. 재정렬 방지
가장 고전적인 예를 들어 재정렬 문제를 분석하겠습니다. 모두가 싱글톤 패턴 구현에 익숙해야 합니다. 동시 환경에서 싱글톤을 구현하려면 일반적으로 DCL(이중 확인 잠금)을 사용할 수 있습니다. 소스 코드는 다음과 같습니다.
1 package com.paddx.test.concurrent; 2 3 public class Singleton { 4 public static volatile Singleton singleton; 5 6 /** 7 * 构造函数私有,禁止外部实例化 8 */ 9 private Singleton() {}; 10 11 public static Singleton getInstance() { 12 if (singleton == null) { 13 synchronized (singleton) { 14 if (singleton == null) { 15 singleton = new Singleton(); 16 } 17 } 18 } 19 return singleton; 20 } 21 }
이제 싱글톤 변수 사이에 휘발성 키워드가 추가된 이유를 분석해 보겠습니다. 이 문제를 이해하려면 먼저 객체 생성 프로세스를 이해해야 합니다. 객체 인스턴스화는 실제로 세 단계로 나눌 수 있습니다.
(1) 메모리 공간 할당.
(2) 객체를 초기화합니다.
(3) 해당 참조에 메모리 공간의 주소를 할당합니다.
그러나 운영 체제에서는 명령을 다시 정렬할 수 있으므로 위 프로세스는 다음 프로세스가 될 수도 있습니다.
(1) 메모리 공간을 할당합니다.
(2) 해당 참조에 메모리 공간의 주소를 할당합니다.
(3) 객체 초기화
이 과정을 거치면 멀티 스레드 환경에서 초기화되지 않은 객체 참조가 노출되어 예측할 수 없는 결과가 발생할 수 있습니다. 따라서 이 프로세스의 순서가 바뀌는 것을 방지하려면 변수를 휘발성 유형 변수로 설정해야 합니다.
2. 가시성 구현
가시성 문제는 주로 한 스레드가 공유 변수 값을 수정하지만 다른 스레드는 이를 볼 수 없음을 의미합니다. 가시성 문제의 주된 이유는 각 스레드가 자체 캐시 영역 스레드 작업 메모리를 갖고 있기 때문입니다. 휘발성 키워드를 사용하면 이 문제를 효과적으로 해결할 수 있습니다.
1 package com.paddx.test.concurrent; 2 3 public class VolatileTest { 4 int a = 1; 5 int b = 2; 6 7 public void change(){ 8 a = 3; 9 b = a; 10 } 11 12 public void print(){ 13 System.out.println("b="+b+";a="+a); 14 } 15 16 public static void main(String[] args) { 17 while (true){ 18 final VolatileTest test = new VolatileTest(); 19 new Thread(new Runnable() { 20 @Override 21 public void run() { 22 try { 23 Thread.sleep(10); 24 } catch (InterruptedException e) { 25 e.printStackTrace(); 26 } 27 test.change(); 28 } 29 }).start(); 30 31 new Thread(new Runnable() { 32 @Override 33 public void run() { 34 try { 35 Thread.sleep(10); 36 } catch (InterruptedException e) { 37 e.printStackTrace(); 38 } 39 test.print(); 40 } 41 }).start(); 42 43 } 44 } 45 }
直观上说,这段代码的结果只可能有两种:b=3;a=3 或 b=2;a=1。不过运行上面的代码(可能时间上要长一点),你会发现除了上两种结果之外,还出现了第三种结果:
......
b=2;a=1
b=2;a=1
b=3;a=3
b=3;a=3
b=3;a=1
b=3;a=3
b=2;a=1
b=3;a=3
b=3;a=3
......
为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
3、保证原子性
关于原子性的问题,上面已经解释过。volatile只能保证对单次读/写的原子性。这个问题可以看下JLS中的描述:
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
这段话的内容跟我前面的描述内容大致类似。因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。
关于volatile变量对原子性保证,有一个问题容易被误解。现在我们就通过下列程序来演示一下这个问题:
1 package com.paddx.test.concurrent; 2 3 public class VolatileTest01 { 4 volatile int i; 5 6 public void addI(){ 7 i++; 8 } 9 10 public static void main(String[] args) throws InterruptedException { 11 final VolatileTest01 test01 = new VolatileTest01(); 12 for (int n = 0; n <div class="cnblogs_code_toolbar"><span class="cnblogs_code_copy"><img src="https://img.php.cn/upload/article/000/000/001/0313942818f37f12b8713dd37831b872-5.gif" alt="휘발성의 사용에 대한 자세한 설명과 원리 분석"></span></div>
변수 i에 휘발성 키워드를 추가하면 이 프로그램이 스레드로부터 안전하다고 잘못 생각할 수도 있습니다. 위의 프로그램을 실행해 볼 수 있습니다. 다음은 저의 로컬 작업 결과입니다.
사람마다 작업 결과가 다를 수 있습니다. 그러나 휘발성은 원자성을 보장할 수 없습니다(그렇지 않으면 결과는 1000이어야 합니다). 이유도 매우 간단합니다. i++는 실제로 세 단계를 포함하는 복합 연산입니다.
(1) i의 값을 읽습니다.
(2) i에 1을 더합니다.
(3) i의 값을 메모리에 다시 씁니다.
Volatile은 이 세 가지 작업이 원자적임을 보장할 수 없습니다. AtomicInteger 또는 동기화를 사용하여 +1 작업의 원자성을 보장할 수 있습니다.
참고: Thread.sleep() 메서드는 위 코드의 여러 위치에서 실행됩니다. 목적은 동시성 문제의 가능성을 높이는 것이며 다른 효과는 없습니다.
3. 휘발성의 원리
위의 예를 통해 우리는 기본적으로 휘발성이 무엇인지, 어떻게 사용하는지 알아야 합니다. 이제 휘발성의 최하위 레이어가 어떻게 구현되는지 살펴보겠습니다.
1. 가시성 구현:
이전 글에서 언급했듯이 스레드 자체는 데이터에 대한 메인 메모리와 직접 상호 작용하지 않고 스레드의 작업 메모리를 통해 해당 작업을 완료합니다. 이는 스레드 간의 데이터가 보이지 않는 근본적인 이유이기도 합니다. 따라서 휘발성 변수의 가시성을 얻으려면 이 측면부터 시작하십시오. 휘발성 변수에 쓰기 작업과 일반 변수에 대한 쓰기 작업에는 두 가지 주요 차이점이 있습니다.
(1) 휘발성 변수가 수정되면 수정된 값이 메인 메모리에서 강제로 새로 고쳐집니다.
(2) 휘발성 변수를 수정하면 다른 스레드의 작업 메모리에 있는 해당 변수 값이 무효화됩니다. 따라서 변수 값을 읽을 때에는 다시 메인 메모리에 있는 값을 읽어야 합니다.
이 두 가지 연산을 통해 휘발성 변수의 가시성 문제를 해결할 수 있습니다.
2. 순서가 지정된 구현:
이 문제를 설명하기 전에 먼저 Java의 Happen-Before가 JSR 133에서 다음과 같이 정의되어 있다는 것을 이해해 보겠습니다. 관계. 한 작업이 다른 작업보다 먼저 발생하면 첫 번째 작업이 표시되고 두 번째 작업보다 먼저 순서가 지정됩니다.
평신도의 관점에서, a가 b보다 먼저 발생하면 a가 수행한 모든 작업은 b에 표시됩니다. ('happen-before'라는 단어는 시간 이전과 이후의 의미로 오해되기 쉽기 때문에 모두가 이것을 기억해야 합니다). JSR 133에 정의된 사전 발생 규칙을 살펴보겠습니다.
• 스레드의 각 작업은 해당 스레드의 모든 후속 작업 이전에 발생합니다.
• 모니터의 잠금 해제는 해당 모니터의 모든 후속 잠금이 발생하기 전에 발생합니다. • 휘발성 필드에 대한 쓰기는 해당 휘발성의 모든 후속 읽기 전에 발생합니다.• 스레드에서 start()에 대한 호출은 시작된 스레드의 작업보다 먼저 발생합니다.
• 스레드의 모든 작업은 다른 스레드가 성공적으로 반환되기 전에 발생합니다. 해당 스레드의 Join()에서
• 작업 a가 작업 b보다 먼저 발생하고 b가 작업 c보다 먼저 발생하면 a가 c보다 먼저 발생합니다.
다음과 같이 번역됨:
Can Reorder | 2nd operation | |||
1st operation | Normal Load Normal Store |
Volatile Load | Volatile Store | |
Normal Load Normal Store |
No | |||
Volatile Load | No | No | No | |
Volatile store | No | No |
3、内存屏障
为了实现volatile可见性和happen-befor的语义。JVM底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。下面是完成上述规则所要求的内存屏障:
Required barriers | 2nd operation | |||
1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store | StoreLoad | StoreStore |
(1)LoadLoad 屏障
执行顺序:Load1—>Loadload—>Load2
确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。
(2)StoreStore 屏障
执行顺序:Store1—>StoreStore—>Store2
确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。
(3)LoadStore 屏障
执行顺序: Load1—>LoadStore—>Store2
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。
(4)StoreLoad 屏障
执行顺序: Store1—> StoreLoad—>Load2
确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。
最后我可以通过一个实例来说明一下JVM中是如何插入内存屏障的:
1 package com.paddx.test.concurrent; 2 3 public class MemoryBarrier { 4 int a, b; 5 volatile int v, u; 6 7 void f() { 8 int i, j; 9 10 i = a; 11 j = b; 12 i = v; 13 //LoadLoad 14 j = u; 15 //LoadStore 16 a = i; 17 b = j; 18 //StoreStore 19 v = i; 20 //StoreStore 21 u = j; 22 //StoreLoad 23 i = u; 24 //LoadLoad 25 //LoadStore 26 j = b; 27 a = i; 28 } 29 }
四、总结
总体上来说volatile的理解还是比较困难的,如果不是特别理解,也不用急,完全理解需要一个过程,在后续的文章中也还会多次看到volatile的使用场景。这里暂且对volatile的基础知识和原来有一个基本的了解。总体来说,volatile是并发编程中的一种优化,在某些场景下可以代替Synchronized。但是,volatile的不能完全取代Synchronized的位置,只有在一些特殊的场景下,才能适用volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安全:
(1)对变量的写操作不依赖于当前值。
(2)该变量没有包含在具有其他变量的不变式中。
위 내용은 휘발성의 사용에 대한 자세한 설명과 원리 분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!