学习笔记-从并发到volatile

前言:

  在多线程并发编程的过程中,synchronized和volatile都扮演着重要的角色。其实呢,我们可以把volatile看做是一个轻量级的synchronized。volatile可以保证在多处理器开发中保证了共享变量的“可见性”。当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。既然是是线程切换就一定涉及线程状态的保存和恢复,包括寄存器、栈等私有数据。。另外,线程的调度是需要内核级别的权限的(操作CPU和内存),也就是说线程的调度工作是在内核态完成的,因此会有一个从用户态到内核态的切换。而且,不管是线程本身的切换还是特权模式的切换,都要进行CPU的上下文切换。本质上都是从“一段程序”切换到了“另一段程序”,都要设置相应的CPU上下文。每个任务运行前,CPU都需要知道任务从哪里加载、此时的状态、从哪里开始运行,也就是说,需要系统事先帮它设置好CPU寄存器和程序计数器,这些内容就是CPU上下文,这就是cup的开销,也就是为什么说我们要避免cpu的上下文切换。

volatile的定义与实现原理:
  定义:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。(摘自:Java语言规范第3版)
  我们在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事情。
  Java代码如下。
  volatile Singleton instance = new Singleton();  
  转变成汇编代码,如下。
0x01a3de1d: movb $0×0,0×1104800(%esi);

0x01a3de24: lock addl $0×0,(%esp);

  有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发了两件事情:1)将当前处理器缓存行的数据写回到系统内存。2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。我们都知道java内存模型中,为了提高处理数据,处理器处理数据不是直接和内存通信的,而是把内存中的数据读取到内部缓存中,然后在对其进行操作的,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。于是乎,就有了第二件事,通过缓存一致性协议,通知到其他线程检查自己的缓存是否失效。这个时候如果失效了就再去内存读取。

  我们记得之前的文章有一篇将到单例模式的双重检查的单例中,为什么instance要用volatile修饰。在这里在重新回顾一下。

 1 public class DoubleCheckedLocking {                     // 1 
 2     private static Instance instance;                   // 2 
 3     public static Instance getInstance() {              // 3 
 4         if (instance == null) {                         // 4:第一次检查 
 5             synchronized (DoubleCheckedLocking.class) { // 5:加锁
 6                 if (instance == null)                   // 6:第二次检查
 7                     instance = new Instance();          // 7:问题的根源出在这里
 8             }                                           // 8
 9         }                                               // 9 
10         return instance;                                // 10 
11     }                                                   // 11
12 }

如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。但是我们为什么又说如果不用volatile的时候,有什么问题呢?问题的根源在哪儿呢?

  前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate(); // 1:分配对象的内存空间 
ctorInstance(memory); // 2:初始化对象 
instance = memory; // 3:设置instance指向刚分配的内存地址
上面3行伪代码中的2和3之间,可能会被重排序。2和3之间重排序之后的执行时序如下:
memory = allocate();    // 1:分配对象的内存空间 
instance = memory;      // 3:设置instance指向刚分配的内存地址 
                        // 注意,此时对象还没有被初始化! 
ctorInstance(memory);   // 2:初始化对象

当然,如果在同一个线程里面,这样的执行顺序是没有一点儿问题的,那么我们来看下如果是有两个线程的时候,他们的执行顺序会带来什么问题,这里我用一个简单的图表来标识

由于单线程内要遵守intra-thread semantics(语义就是,保证重排序不会改变单线程内的程序执行结果),从而能保证A线程的执行结果不会被改变。但是,当线程A和B按上图的时序执行时,B线程将看到一个还没有被初始化的对象。回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance=new Singleton();)如果 发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将 访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!下表是这个场景的 具体执行时序。

这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。 在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。1)不允许2和3重排序。2)允许2和3重排序,但不允许其他线程“看到”这个重排序。 当声明对象的引用为volatile后,A2和AA3之间的重排序,在多线程环境中将会被禁止。这样线程B就可以正常的访问到对象了。  

volatile的特性
  理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。
 1 class VolatileFeaturesExample {
 2     volatile long vl = 0L; // 使用volatile声明64位的long型变量
 3     public void set(long l) {
 4         vl = l; // 单个volatile变量的写
 5     }
 6     public void getAndIncrement() {
 7         vl++; // 复合(多个)volatile变量的读/写
 8     }
 9     public long get() {
10         return vl; // 单个volatile变量的读
11     }
12 }
假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。
 1 class VolatileFeaturesExample {
 2     long vl = 0L; // 64位的long型普通变量
 3     public synchronized void set(long l) {
 4         // 对单个的普通变量的写用同一个锁同步 
 5         vl = l;
 6     }
 7     public void getAndIncrement() {
 8         // 普通方法调用 
 9         long temp = get();
10         // 调用已同步的读方法
11         temp += 1L;
12         // 普通写操作
13         set(temp);
14         // 调用已同步的写方法 
15     }
16     public synchronized long get() {
17         // 对单个的普通变量的读用同一个锁同步 
18         return vl;
19     }
20 }
如上面示例程序所示,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。(这里顺便提一下与程序员密切相关的happens-before规则基本。有三条。第一个程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。第二个监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。第四个volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。另外一个是特性是传递性:如果A happens-before B,且B happens-before C,那么A happens-before C)
简而言之,volatile变量自身具有两大特性:1,可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。2,·原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
 
posted @ 2018-05-17 17:25  小杨ABC  阅读(329)  评论(0编辑  收藏  举报