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

时间:2022-09-21 10:45:13

-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;
}
}

案例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
}
}
...
}

答案:在上面的例子里面,线程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;
}
}

案例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;
}

上述的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;
}
}

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?
}
}
}

答案是: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的剖析中,会详细阐述

final 数组

private final Object[] a = new Object[100];  //a是final的,只能一次赋值。意味着a数组是固定长度

a[0] = new Object(); //但a[x]并不是final的,可以多次赋值
a[0] = b

指令重排序/happen before

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

为什么要指令重排序呢?

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

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

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

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