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

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缓存,后续会详细阐述,此处只是提及)。如图所示: 
《Java多线程 -- JUC包源码分析3-- volatile/final语义》

其对应的JVM的抽象内存模型JMM,如下图所示: 
《Java多线程 -- JUC包源码分析3-- volatile/final语义》

线程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的深入阐释,且看下回分解。

    原文作者:JUC
    原文地址: https://blog.csdn.net/ychenfeng/article/details/75005522
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞