阅读Java并发编程:volatile关键字解析一文后有感。
1.并发编程中的三个概念
- 原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 - 可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值 - 有序性
即程序执行的顺序按照代码的先后顺序执行
2.Java 内存模型
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM
)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。
也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存
当中(类似于前面说的物理内存),每个线程都有自己的工作内存
(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存
中进行,而不能直接对主存
进行操作。并且每个线程不能访问其他线程的工作内存
。
那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?
- 原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。 - 可见性
对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile
修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。 - 有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile
关键字来保证一定的“有序性”。另外可以通过synchronized
和Lock
来保证有序性,很显然,synchronized
和Lock
保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
happens-before原则
3.volatile的作用&特点
- 保证内存可见性
使用该变量必须重新去主内存读取,修改了该变量必须立刻刷新主内存。 - 防止指令重排序
通过插入内存屏障(内存栅栏) - 并不保证操作原子性
volatile 关键字对于基本类型的修改可以在随后对多个线程的读保持一致,但是对于引用类型: 如数组,实体Bean,仅仅保证引用的可见性,但并不保证引用内容的可见性。
数组用 volatile 修饰主要是保证在数组扩容的时候保证可见性。
禁止进行指令重排序。
4.volatile
常用场景
- 状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
- double check
class Singleton{
// 使用了volatile关键字后,重排序被禁止,所有的写(write)操作都将发生在读(read)操作之前。
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
4.1 双重检查模式
引用类型使用双重检查锁会失效,是因为指令重排造成的。直接原因也就是 初始化一个对象并使一个引用指向他 这个过程不是原子的。导致了可能会出现引用指向了对象并未初始化好的那块堆内存,使用volatile
修饰对象引用,防止重排序即可解决。对于 long 和 double 的基本类型,双重检查模式是适用的。
5. 总结
5.1 volatile
- 1.可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
- 2.原子性:对任意单个 volatile 变量的读/写具有原子性(long, double 这 2 个 8 字节的除外),但类似于 volatile++ 这种复合操作不具有原子性。
- 3.volatile 修饰的变量如果是对象或数组之类的,其含义是对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性。
5.2 volatile 修饰对象或数组
修饰的变量如果是对象或数组之类的,其含义是对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性。
- 1) 用 volatile 修饰数组和对象不是不可以,要注意一点:修改操作要从 volatile 变量逐级引用,去找到要修改的变量,保证修改是刷新到主存中的值对应的变量;
读取操作,也要以 volatile 变量为根,逐级去定位,这样保证修改即使刷新到主存中 volatile 变量指向的堆内存,读取能够每次从主存的 volatile 变量指向的堆内存去读,保证数据的一致性。 - 2) 在保证了 1) 的前提下,因为大家读取修改的都是同一块内存,所以变相的符合 happen-before 规则中的程序顺序规则,具有 happen-before 性。
5.3 volatile 写-读建立的 happens-before 关系
对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。
5.4 happen-before 规则
JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在 happens-before 关系,尽管a操作和b操作在不同的线程中执行,但 JMM 向程序员保证a操作将对b操作可见)。
具体的定义为:
- 1) 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 2) 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。
具体的规则:
- 1.程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 2.监视器锁规则:对一个锁(监视器)的解锁,happens-before 于随后对这个锁(监视器)的加锁。
- 3.volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 4.传递性:如果 A happens-before B,且 B happens-before C,那么 A happens- before C。
- 5.start()规则:如果线程 A 执行操作 ThreadB.start()(启动线程B),那么 A 线程的ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。
- 6.join()规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。
- 7.程序中断规则:对线程 interrupted() 方法的调用先行于被中断线程的代码检测到中断时间的发生。
- 8.对象 finalize 规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的 finalize() 方法的开始。
如果 A,B 操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许 A,B 两个操作重排序,即happens-before关系并不代表了最终的执行顺序。
6.Java开发手册(阿里巴巴)对 volatile 的要求
volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。
说明:如果是 count++操作,使用如下类实现:
AtomicInteger count = new AtomicInteger(); count.addAndGet(1);
如果是 JDK8,推荐使用LongAdder
对象,比 AtomicLong 性能更好(减少乐观 锁的重试次数)。
评论区