目录
一、可见性概念
1.1 概念
可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
二、可见性问题由来
2.1 由来分析
可见性问题是在CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,为了解决CPU加载主内存数据慢的问题,就在CPU加入了缓存寄存器,分别为L1、L2、L3三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。如下图所示:
引入这种缓存机制后,这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致主内存和缓存之间数据不一致问题。而数据不一致的问题,换据说法就是可见性问题。
为了解决CPU硬件层面的缓存一致性问题,于是就设计出了缓存一致性协议,其中比较典型的就是MESI协议,但是这个协议其实不同的CPU厂商的实现方式是有差异的,Java 层面为了屏蔽各种硬件和操作系统带来的差异,让并发编程做到真正意义上的跨平台,就设计出了JMM,即Java Memery Model, Java 内存模型来解决。
三、可见性代码例子
3.1 代码
package com.ningzhaosheng.thread.concurrency.features.visible;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:36:39
* @description 测试可见性
*/
public class TestVisible {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
3.2 执行结果
由结果可知,主线程修改了flag = false;但是并没有使t1线程里面的循环结束。
四、Java 中保证可见性的手段
4.1 volatile
4.1.1 优化代码
package com.ningzhaosheng.thread.concurrency.features.visible.volatiles;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:45:29
* @description 测试volatile
*/
public class TestVolatile {
private volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
4.1.2 测试结果
由以上测试结果可以看到,使用volatile 修饰共享变量之后,在主线程修改了flag =false 之后,线程t1读取到了最新值,并结束了循环,结束了线程。那么为什么使用了volatile之后就能解决共享变量的问题呢?要回答这个问题其实综合性考虑的内容还比较多,涉及到CPU的多级缓存、计算机缓存一致性协议和Java 内存模型等相关内容。我们接下来就分析下吧。
4.1.3 volatile原理分析
4.1.3.1 查看字节码
javap -v .\TestVolatile.class
从以上截图我们可以看到,使用volatile修饰的变量,会多一个ACC_VOLATILE 指令关键字。我们接着去hotspot 查看c++源码,分析ACC_VOLATILE做了些什么操作。
4.1.3.2 hotspot 层面
根据ACC_VOLATILE指令关键字,我们可以在hotspot 源码中,找到他的内容:
jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/utilities/accessFlags.hpp (openjdk.org)
接着,我们找下is_volatile:
从以上截图的这段代码中可以看到,会先判断tos_type(volatile变量类型),后面有不同的基础类型的调用,比如int类型就调用release_int_field_put,byte就调用release_byte_field_put等等。
判断完类型之后,我们可以看到代码后面执行的语句是:
我们可以在以下代码位置找到该源码:
jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/runtime/orderAccess.hpp (openjdk.org)
实际上storeload() 这个方法,针对不同CPU有不同的实现,它的具体实现在src/os_cpu下,我们可以去看一下:
这里我们以linux_x86架构的CPU实现为例,我们去看下storeload()方法做了些什么操作。
接着看下fence()函数:
通过这面代码可以看到lock;add1,其实这个就是内存屏障。lock;add1 $0,0(%%esp)作为cpu的一个内存屏障。
add1 $0,0(%%rsp)表示:将数值0加到rsp寄存器中,而该寄存器指向栈顶的内存单元。加上一个0,rsp寄存器的数值依然不变。即这是一条无用的汇编指令。在此利用add1指令来配合lock指令,用作cpu的内存屏障。
内存屏障:
这四个分别对应了经常在书中看到的JSR规范中的读写屏障LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
对于volatile操作而言,其操作步骤如下:
- 每个volatile写入之前,插入一个 StoreStore ,写入以后插入一个StoreLoad,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中。
- 每个volatile读取之前,插入一个 LoadLoad ,读取之后插入一个LoadStore,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量。
4.1.3.3 volatile原理总结
通过编译的字节码分析,我们可以知道,使用volatile 修饰的变量,编译后会生成ACC_VOLATILE关键字,通过关键字搜索,我们在hotspot 源码层(JVM层)查询到,is_volatile函数,这个函数的作用就是会先判断tos_type(volatile变量类型),后面有不同的基础类型的调用,比如int类型就调用release_int_field_put,byte就调用release_byte_field_put等等,还有就是调用了一个OrderAccess::storeload();函数,最终我们通过查看源码,找到storeload方法在不同CPU架构下的实现,最终基本可以得出以下结论:
- 在JVM层:volitile的底层,在JVM层其实是通过内存屏障防止了指令重排序。
- CPU层面:在x86的架构中,含有lock前缀的指令拥有两种方法实现;一种是开销很大的总线锁,它会把对应的总线直接全部锁住,如此明显是不合理的;所以后期intel引入了缓存锁以及mesi协议,如此便可以轻量化的实现内存屏障;
最终结论:volatile的底层原理,在JVM源码层次而言,内存屏障直接起到了禁止指令重排的作用,且之后与总线锁或者MESI协议配合实现了可见性;即:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中;当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量。
4.2 synchronized
4.2.1 代码优化
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:52:31
* @description 测试synchronized
*/
public class TestSynchronized {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
synchronized (TestSynchronized.class) {
//...
}
System.out.println(111);
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
4.2.2 测试结果
从测试结果可以看出,使用了synchronized同步代码块之后,在主线程中修改了flag=false 之后,线程t1也获取到最新的变量值,结束了while循环。也就是说synchronized也可以解决并发编程的可见性问题。那么synchronized是怎么保证并发编程的可见性的呢,我们接下来分析下。
4.2.3 synchronized 原理分析
4.2.3.1 synchronized 修饰方法
4.2.3.1.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/13 10:16:36
* @description synchronized 修饰方法
*/
public class TestSynchronizedMethod {
public static boolean flag = true;
public static synchronized void runwhile() {
while (flag) {
System.out.println(111);
}
System.out.println("t1线程结束");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
runwhile();
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
4.2.3.1.2 执行结果
4.2.3.1.3 编译分析
javap -v .\TestSynchronizedMethod.class
可以看见,使用synchronized修饰方法后,通过javap -v 查看编译的字节码,会生成一个ACC_SYNCHRONIZED标识符,会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。
可查看官网解析:Chapter 2. The Structure of the Java Virtual Machine (oracle.com)
该标识符的作用是使当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的方法。
获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。
注意:关于Monitor的更多底层实现原理,由于篇幅原因,这里先不分析,后续会出相关文章详细说明synchronized,这里只是就实现可见性原理做些说明。
4.2.3.2 synchronized 修饰代码块
4.2.3.2.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/13 10:48:02
* @description synchronized 修饰代码块
*/
public class TestSynchronizedCodeBlock {
public static boolean flag = true;
public static void runwhile() {
while (flag) {
synchronized (TestSynchronizedCodeBlock.class) {
System.out.println(flag);
}
System.out.println(111);
}
System.out.println("t1线程结束");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
runwhile();
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
4.2.3.2.2 执行结果
4.2.3.2.3 编译分析
javap -v .\TestSynchronizedCodeBlock.class
可以看到,使用synchronized修饰代码块后,查看编译的字节码会发现再存取操作静态共享变量时,会插入monitorenter、monitorexit原语指令,关于这两个指令的说明,可查看文档:
Chapter 6. The Java Virtual Machine Instruction Set (oracle.com)
它实现可见性的原理和上一小节说明的那样,都是:
当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的代码块。
获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。
4.3 Lock
4.3.1 优化代码
package com.ningzhaosheng.thread.concurrency.features.visible.lock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:57:24
* @description 测试Lock
*/
public class TestLock {
private static boolean flag = true;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
lock.lock();
try {
//...
} finally {
lock.unlock();
}
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
4.3.2 测试结果
4.3.3 Lock实现可见性原理分析
4.3.3.1 源码分析
通过以上截图可以看到,我们创建了一个ReentrantLock,调用了它的lock()方法,而ReentrantLock实现了Lock接口和基于AQS定义实现了锁。
4.3.3.2 总结
Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到主内存的操作。
Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。
如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。
参考4.1.3.3 volatile原理总结部分。
4.4 final
4.4.1 final 实现可见性原理分析
final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。
final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的。
final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。
好了,本次内容就分享到这,欢迎关注本博主。如果有帮助到大家,欢迎大家点赞+关注+收藏,有疑问也欢迎大家评论留言!