在《Java 内存模型》中我们简单介绍了Java内存结构以及Java内存模型的定义,这边文章我们将介绍Java是如何来保证可见性、有序性和原子性的。
一、可见性
可见性是缓存一致性的抽象叫法。Java内存模型通过在共享变量修改后,将值同步回主存,在变量读取前从主存刷新变量这种依赖于主存的作为传递媒介的方式来实现可见性。
我们通过一段代码来展示由于可见性导致的BUG:
下面这段代码,线程A执行load()方法,线程B执行refresh()方法,按照我们通常的理解,线程B执行完refresh()方法后,变量flag变为fasle,那么线程A执行的load()方法就会退出循环,但实际情况却是程序一直在运行,造成这个情况的原因就是由于可见性(缓存一致性)问题导致的。
线程A的工作内存中,共享变量flag的值一直没有发生变化,就导致即便线程B执行完refresh()方法,把主存中变量flag的值改了false,但对于线程A来说,却是不可见的。
public class VisibilityTest {
private boolean flag = true;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag");
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
int i = 0;
while (flag) {
i++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
// 线程threadA模拟数据加载场景
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
// 让threadA执行一会儿
Thread.sleep(1000);
// 线程threadB通过flag控制threadA的执行时间
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
为了保证可见性,Java提供了如下几种方式来保证可见性:
1.1 内存屏障
-
volatile
我们可以直接在变量flag前面加上
volatile
关键字,上面的程序就可以退出循环private volatile boolean flag = true;
-
Unsafe.storeFence()
可以通过调用Unsafe类的storeFence()方法来保证可见性
-
通过synchronized
println()方法加了synchronized关键字,在while循环里面调用println()方法,就可以保证flag的可见性
public void load() { System.out.println(Thread.currentThread().getName() + "开始执行....."); int i = 0; while (flag) { i++; System.out.println(i); } System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i); } public void println(int x) { synchronized (this) { print(x); newLine(); } }
上面这些方式都是通过内存屏障实现的,我们先看一下JVM层面的内存屏障,在JSR规范中定义了4种内存屏障:
LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作
硬件层内存屏障
硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:
-
lfence,是一种Load Barrier 读屏障
-
sfence, 是一种Store Barrier 写屏障
-
mfence, 是一种全能型的屏障,具备lfence和sfence的能力
-
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
内存屏障有两个能力:
-
阻止屏障两边的指令重排序
-
刷新处理器缓存/冲刷处理器缓存
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据;对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
在x86处理器中,通过lock前缀指令来实现JVM的内存屏障
Lock前缀指令的作用:
-
确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
-
LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
-
LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。
1.2 上下文切换
通过Lock的方式可以保证线程同步,当线程切换时,线程上下文里面的共享变量会重新从主存中获取
下面的方式也是通过上下文切换来保证的可见性
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
int i = 0;
while (flag) {
i++;
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
}
二、有序性
下面线程1和线程2内部执行的代码,如果从表面上来看,好像最终的执行结果,x和y永远不能同时为0,但实际的执行结果却是可以退出循环,之所有出现这种情况,就是由于指令重排序,线程1中,a=1和x=b这两条指令互不影响,顺序变化对线程1的执行结果没有任何变化,就会导致指令重排
JVM 存在指令重排,所以存在有序性问题
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException{
int i=0;
while (true) {
i++;
x = 0;y = 0;a = 0;b = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x==0&&y==0){
break;
}
}
}
而有序性可以通过volatile、内存屏障、synchronized、Lock手段来保证。
我们是使用单例模式时,通常使用volatiel+双重来保证线程安全,volatiel通过内存屏障保证了有序性:
private volatile static SingletonTest singleton;
public static SingletonTest getInstance(){
if (singleton == null){
synchronized (SingletonTest.class){
if (singleton == null){
singleton = new SingletonTest();
}
}
}
return singleton;
}
new关键字创建一个对象的过程分为内存分配、初始化、singleton指向内存空间地址,但初始化和singleton指向内存空间地址这两步骤之间,并没有什么关联,即使乱序指定,也不会对对象创建有什么问题。
但在高并发的情况下,如果singleton指向内存空间地址先于初始化执行,这个时候有一个线程进来,此时singleton就不为null,直接返回了,但此时对象还没有进行初始化,就会出现空指针的情况。但加了volatile之后,它可以禁止指令重排,从而保证了单例对象的安全性。
三、原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。
Java中可以通过synchronized、Lock、CAS来保证原子性。
四、volatile语义
4.1 volatile的特性
-
可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
-
原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
volatile需要对32机器的long类型数值操作的原子性
-
有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。
JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
4.2 volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
4.3 volatile可见性实现原理
JMM内存交互层面实现
volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。
硬件层面实现
通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/153638.html