Java多线程 -- JUC包源码分析3-- volatile/final语义

来源:互联网 发布:天诚网络 编辑:程序博客网 时间:2024/04/29 01:18

volatile应用1 – 内存可见性 – JMM内存模型 
-volatile应用2 – 原子性 
-volatile应用3 – 构造函数逸出/DCL问题(Double Checking Locking) 
-final应用1 – 避免构造函数重排序 
-final应用2 – CopyOnWrite 
-atomic数组/volatile数组/final数组
-指令重排序,happen before语义


volatile应用1 – 内存可见性

在讲述抽象的理论之前,先看2个案例: 
案例1:

public class Example1{  private int a = 0;  public void set(int a)     //线程A调用set(100)  {     this.a = a;  }  public int get()         //线程B调用get(),返回值是不是一定是100?  {     return this.a;  }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

案例2:

public class Example2{  private boolean flag = true;  public void stop()          //线程A调用stop()  {     flag = false;  }  public void run()         //线程B调用stop()之后,线程2是否一定会停止?  {     while(flag)     {        //do something     }  }  ...}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

答案:在上面的例子里面,线程B未必能读到线程A写入的值。案例2有可能死循环。

这要从现代多CPU说起: 在现代的CPU架构中,每个CPU都会有自己的缓存(L1缓存,L2缓存。关于CPU缓存,后续会详细阐述,此处只是提及)。如图所示: 
这里写图片描述

其对应的JVM的抽象内存模型JMM,如下图所示: 
这里写图片描述

线程A,线程B有各自的local内存。在把变量从主内存读到自己的工作内存,修改之后,不一定会立即写入主存,因此另一个线程不可见。

要保证上述案例可以完全正确执行,需要在变量前加volatile。

volatile变量可以保证:每次对该变量的写,必定刷回到主存;每次对该变量的读,必定从主存读取。从而可以保证,一个线程对共享变量的写,对其他线程可见。

volatile应用2 – 原子性

案例3:

public class Example1{  private long a = 0;  public void set(long a)     //线程A调用set(100)  {     this.a = a;  }  public long get()         //线程B调用get(),返回值是不是一定是100?  {     return this.a;  }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

案例3和案例1相比,只是int换成了long。

由于JMM并不要求对一个64位的long/double型的变量写入具有原子性,在32位的机器上,对一个long型变量的写入,可能会分成高32位,低32位2次写入。此时,另一个线程去读取时,可能读到“写了一半”的无效值!

要解决上述问题,可以加锁,也可以加volatile关键字。

可见,在对单个变量的读写中,volatile变量起到了锁同样的作用。

也正因为如此,在AtomicInteger/AtomicLong中,其get()/set()函数,都未加锁,却是线程安全的!!

volatile应用3 – 避免构造函数逸出/DCL问题

线程安全的单例模式中,有一种经典写法,即DCL(Doule Checking Locking),如下所示:

public class Sington{private static Sington instance;public static Sington getInstance(){  if(instance == null)                //DCL  {    synchronized(Sington.class)           {      if(instance == null)         instance = new Instance();   //有问题的代码!!!    }  }  return instance;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

上述的new Instance(),底层可以分为3个操作: 分配内存,在内存上初始化成员变量,把instance指向内存。

这3个操作,可能重排序,即先把instance指向内存,再初始化成员变量。

此时,另外一个线程就会拿到一个未完全初始化的对象。这时直接访问里面的成员变量,就可能出错。而这就是典型的“构造函数溢出”问题。

要解决此问题,只要在instance前加volatile就可以了!

当然,还有另外1种经典的线程安全的单例模式 – 基于类加载器的方案

public class Instance{  private static class InstanceHolder  {    public static Instance instance = new Instance();  }  public static Instance getInstance()  {      return InstanceHolder.instance;  }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

final应用1 – 避免构造函数重排序

案例4

public class Example4{   private int i;   private int j;   private static Example4 obj;   public Example4()   {      i=1;      j=2;   }   public static void write()   //线程A先执行write()   {     obj = new Example4()   }   public static void read()   //线程B再执行read()   {     if(obj!=null)     {         int a = obj.i;         int b = obj.j;     //请问,a, b是否一定等于1,2?     }   }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

答案是:a, b 未必一定等于1,2。因为这里的i, j都是非volatile变量,线程A的重排序,可能使得i, j的赋值,在构造函数之后执行!!也就是说,线程B拿到obj的时候,obj的i, j变量可能赋值还未完成!

解决办法是:给i, j 加上final

final的语义: 保证final变量的初始化,一定在构造函数返回之前完成!

final应用2 – CopyOnWrite

在上1篇 NumberRange例子中,我们看到lower, power都是final类型,这也确保了lower, power只可能被赋值1次。后续要想再改变值,只能拷贝一份出来改!

所以,通常应用CopyOnWrite的地方,也会相应的使用final!

atomic数组/volatile数组/final数组

关于atomic,volatile, final的数组类型,很容易存在着如下误解:

AtomicIntegerArray, AtomicLongArray

只是说里面的每个元素是原子的,而不是整个数组是原子的!比如说,你一个for循环,set每1个值,这整个for循环,并不是原子的。

volatile数组

private volatile Object[] a;a = new Object[100];    //a是原子的,对a的修改,立即对其他线程可见。在ConcurrentHashMap里面,rehash的时候,会用到这个特性,后面会详细阐述。a = new Object[200];    a[0] = new Object();   //但a[x]并不是原子的,对a[x]的修改,并不会对其他线程可见。此问题,在后续ConcurrentHashMap的剖析中,会详细阐述
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

final 数组

private final Object[] a = new Object[100];  //a是final的,只能一次赋值。意味着a数组是固定长度a[0] = new Object();  //但a[x]并不是final的,可以多次赋值a[0] = b
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

指令重排序/happen before

从上述各种案例可以看出,问题主要出在“指令重排序”上。

为什么要指令重排序呢?

从程序员角度来讲,最好是不要有任何的指令重排,这样程序最容易理解;但从CPU和编译器角度,希望在不改变单线程程序语义的情况下,尽可能的重排序,最大程度的提高执行效率。

而对于多线程程序,因为重排序导致的线程之间的不同步,则由程序员自己处理!

volatile和final的底层原理,就是一定程度上禁止重排序,从而实现多线程程序的同步。

关于重排序和happen before的深入阐释,且看下回分解。

阅读全文
1 0