这篇文章将为大家详细讲解有关 Java中Volatile关键字怎么使用,文章内容质量较高,因此小编分享给大家做个参考,希望大家阅读完这篇文章后对相关知识有一定的了解。
创新互联建站长期为上1000家客户提供的网站建设服务,团队从业经验10年,关注不同地域、不同群体,并针对不同对象提供差异化的产品和服务;打造开放共赢平台,与合作伙伴共同营造健康的互联网生态环境。为鸡西梨树企业提供专业的成都做网站、成都网站制作,鸡西梨树网站改版等技术服务。拥有十年丰富建站经验和众多成功案例,为您定制开发。
Volatile 可见性承诺
Java volatile关键字保证了跨线程更改线程间共享变量的可见性。这可能听起来有点抽象,让我们详细说明一下。
在多线程应用程序中,线程对 non-volatile 变量进行操作,出于性能原因,每个线程在处理变量时,可以将它们从主内存复制到CPU缓存中。如果你的计算机包含一个以上的CPU,每个线程可以在不同的CPU上运行。这意味着,每个线程可以将同一个变量复制到不同CPU的CPU缓存中。这就和计算机的组成和工作原理息息相关了,之所以在每一个 CPU 中都含有缓存模块是因为出于性能考虑。因为 CPU 的执行速度要比内存(这里的内存指的是 Main Memory)快很多,因为 CPU 要对数据进行读、写的操作,如果每次都和内存进行交互那么 CPU 在等待 I/O 这个过程中就消耗了大量时间,大部分时间都是在停滞等待而没有真正投入工作当中。所以为了解决这个问题就引入了CPU缓存。如下图所示:
这样就导致了一个问题同一个变量会被不同的 CPU 放在自己的缓存中,对该变量的读、写操作在缓存中进行。当然对于非共享数据来说这一点问题也没有,就比如函数内部的变量,但是对于共享数据来说就会造成多个 CPU 之间对该数据进行了操作但是别的 CPU 不知道这个数据发生了改变 ,依然使用旧的数据,最终导致程序不符合我们的预期。因为 CPU 是不知道你的程序内哪些数据是多线程共享数据,而那些数据不是,如果你不告诉 CPU 那么它默认都会认为这些数据都是不共享的,而各自在自己的缓存中随意操作。比如这个代码:
public class VolatileCase0 { public int counter = 0; }
这个代码在多线程执行的环境下是不安全的,counter 是共享变量。假设两个 CPU 共同操作同一个 VolatileCase0 对象,如下图所示:
目前这个情况下 counter 在两个 CPU 缓存中都存在,但是每个 CPU 对 counter 的操作对其他 CPU 来说是不可见的。因为此时我们并没有告知 CPU 和 CPU 缓存这个 counter 是一个共享内存变量。要解决多个 CPU 缓存之间变量写操作可见性的问题,就需要用 volatile 关键字来修饰这个 counter 。代码如下:
public class VolatileCase0 { public volatile int counter = 0; }
接下来看一个例子程序:
public class VolatileCase1 { volatile boolean running = true; public void run() { while (running) { } System.out.println(Thread.currentThread().getName() + " end of execution "); } public void stop() { running = false; System.out.println(Thread.currentThread().getName() + " thread Modified running to false"); } public static void main(String[] args) throws Exception { VolatileCase1 vc = new VolatileCase1(); Thread t1 = new Thread(vc::run , "Running-Thread"); Thread t2 = new Thread(vc::stop , "Stop-Thread"); t1.start(); TimeUnit.SECONDS.sleep(1); t2.start(); } }
如果对 running 变量不加 volatile 关键字,程序就会陷在 “Running-Thread”中一直执行而无法结束。加上了 volatile 关键字之后 “Running-Thread”会读取到被修改后的 running 值,这时就可以执行结束了。
Volatile 禁止指令重排序
首先需要解释一下什么是“指令重排序”。所谓指令重排序也就是 CPU 对程序指令进行执行的时候,会按照自己制定的顺序,并不是完全严格按照程序代码编写的顺序执行。这样做的原因也是出于性能因素考虑,CPU对一些可以执行的指令先执行可以提供总体的运行效率,而不是让CPU把时间都浪费在停滞等待上面。感兴趣的读者可以参考这篇文章:
感兴趣的读者也可以阅读 64-ia-32-architectures-software-developer-vol-3a-part-1-manual 这个开发手册。以下是该手册中对于指令重排序的一些描述:
译文:术语Memory Ordering 是指处理器通过系统总线向系统内存发出读(装入)和写(存储)的顺序。Intel 64和IA-32体系结构支持多种内存排序模型,具体取决于体系结构的实现。例如,Intel386处理器强制执行程序排序(通常称为强排序),在任何情况下,读写都是按指令流中发生的顺序在系统总线上发出的。
为了优化指令执行的性能,IA-32体系结构允许在Pentium 4、Intel Xeon和P6系列处理器中偏离称为处理器排序的强排序模型。这些处理器排序变体(在这里称为内存排序模型)允许性能增强操作,比如允许读优先于缓冲写。这些变化的目的是提高指令执行速度,同时保持内存一致性,即使在多处理器系统中也是如此。我们通过一个代码来证实CPU对指令的重排序:
public class MemoryOrderingCase1 { static int x = 0 , y = 0 , a = 0 , b = 0; public static void main(String[] args) throws Exception { while (true) { CountDownLatch latch = new CountDownLatch(2); x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(() -> { a = 1; x = b; latch.countDown(); }); Thread t2 = new Thread(() -> { b = 1; y = a; latch.countDown(); }); t1.start(); t2.start(); latch.await(); if (x == 0 && y == 0) { System.out.println("x = " + x + " , y = " + y + " , a = " + a + " , b = " + b); break; } } } }
当 x = 0 同时 y = 0 的时候说明CPU在写指令完成之前执行了读指令。
另一个例子 Java Double checking locking 单例模式,代码如下:
public class MemoryOrderingCase2 { private static volatile MemoryOrderingCase2 INSTANCE; int a; int b; private MemoryOrderingCase2() { a = 1; b = 2; } public static MemoryOrderingCase2 getInstance() { if (MemoryOrderingCase2.INSTANCE == null) { synchronized (MemoryOrderingCase2.class) { if (MemoryOrderingCase2.INSTANCE == null) { MemoryOrderingCase2.INSTANCE = new MemoryOrderingCase2(); } } } return MemoryOrderingCase2.INSTANCE; } }
在这个例子中如果 INSTANCE 取除掉 volidate 关键字就会导致问题的发生。假设有两个线程在访问 getInstance() 函数,执行序列如下:
1. 线程 1 进入 getInstance 函数 , INSTANCE 为 null ,并切当前没有线程持有锁定。
2. 线程 1 再次判断 INSTANCE 是否为 null ,结果为 true 。
3. 线程 1 执行 INSTANCE = new MemoryOrderingCase2() 。
4. 线程 1 执行 new MemoryOrderingCase2() 。
5. 线程 1 在堆内存中为对象分配了空间。
6. 线程 1 INSTANCE 指向了该对象,此时 INSTANCE 已经不为 null。
7. 线程 1 new MemoryOrderingCase2() 对象开始执行初始化过程,调用父类构造函数,给一些属性赋值等。
8. 线程 2 进入 getInstance 函数 ,判断 INSTANCE 不为 null ,将 INSTANCE 返回。
这里的问题在于 MemoryOrderingCase2 对象还没有完成全部的初始化过程,就被线程2暴漏给了外界。也就是说读操作在写操作还没有完成之前就发生了。
查看 getInstance() 函数的部分汇编代码:
0x0000000003a663f4: movabs $0x7c0060828,%rdx ; {metadata('org/blackhat/concurrent/date20200312/MemoryOrderingCase2')} 0x0000000003a663fe: mov 0x60(%r15),%rax 0x0000000003a66402: lea 0x18(%rax),%rdi 0x0000000003a66406: cmp 0x70(%r15),%rdi 0x0000000003a6640a: ja 0x0000000003a66557 0x0000000003a66410: mov %rdi,0x60(%r15) 0x0000000003a66414: mov 0xa8(%rdx),%rcx 0x0000000003a6641b: mov %rcx,(%rax) 0x0000000003a6641e: mov %rdx,%rcx 0x0000000003a66421: shr $0x3,%rcx 0x0000000003a66425: mov %ecx,0x8(%rax) 0x0000000003a66428: xor %rcx,%rcx 0x0000000003a6642b: mov %ecx,0xc(%rax) 0x0000000003a6642e: xor %rcx,%rcx 0x0000000003a66431: mov %rcx,0x10(%rax) ;*new ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2::getInstance@17 (line 24) 0x0000000003a66435: movl $0x1,0xc(%rax) ;*putfield a ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2::@6 (line 16) ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2::getInstance@21 (line 24) 0x0000000003a6643c: movl $0x2,0x10(%rax) ;*putfield b ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2:: @11 (line 17) ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2::getInstance@21 (line 24) 0x0000000003a66443: movabs $0x76b907160,%rsi ; {oop(a 'java/lang/Class' = 'org/blackhat/concurrent/date20200312/MemoryOrderingCase2')} 0x0000000003a6644d: mov %rax,%r10 0x0000000003a66450: shr $0x3,%r10 0x0000000003a66454: mov %r10d,0x68(%rsi) 0x0000000003a66458: shr $0x9,%rsi 0x0000000003a6645c: movabs $0xf6fd000,%rax 0x0000000003a66466: movb $0x0,(%rsi,%rax,1) 0x0000000003a6646a: lock addl $0x0,(%rsp) ;*putstatic INSTANCE ; - org.blackhat.concurrent.date20200312.MemoryOrderingCase2::getInstance@24 (line 24)
关于 Java中Volatile关键字怎么使用就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。
当前题目:Java中Volatile关键字怎么使用
URL链接:http://scgulin.cn/article/pssppe.html