目录
以著名的 double-checked locking 单例模式为例
共享模型之内存
Java内存模型
Java内存模型(Java Memory Model,JMM),它定义了主存,工作内存抽象概念,底层对应着cpu寄存器,缓存,硬件内存,cpu指令优化。
JVM体现在以下几个方面
原子性-保证指令不会受到上下文切换的影响
可见性-保证指令不会受cpu缓存的影响
有序性-保证指令不会受cpu指令并行优化的影响
可 见 性 - 由 J V M 缓 存 优 化 引 起
有 序 性 - 由 J V M 指 令 重 排 序 优 化 引 起
可见性
停不下的循环
@Slf4j
public class Test15 {
static boolean b =true;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (b){
//代码块
}
});
thread.start();
Thread.sleep(1000);
log.debug("将变量置为false");
b=false;
}
}
上面示例,将变量置为false之后,线程thread还一直在运行,而没有停下来。下面我们分析一下原因。
1.初始状态,t线程刚开始从主内存读取了run的值到工作内存
2.因为t线程要频繁从主内存读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率
3.一秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决办法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取到它的值,线程操作volatile变量都是直接操作主存。
它不能保证原子性,仅用在一个写线程,多个读线程的情况
有序性
JVM会在不影响正确性的前提下,可以调整语句的执行顺序,这种特性称之为指令重排,多线程情况下指令重排会影响其正确性。
为什么会有指令重排这种优化呢?
指令重排是现代处理器为了提高执行效率而采取的一种优化手段。虽然指令重排可能会导致程序的执行顺序与代码编写顺序不一致,但在不影响程序语义的前提下,可以带来性能的提升。
指令重排可以充分利用处理器的并行执行能力和流水线技术,使得处理器能够更快地执行指令,从而提高程序的执行速度。
多线程下怎么解决这种指令重排问题带来的影响呢?
对一个volatile变量的写操作 happens-before 于后续对这个volatile变量的读操作。这确保了对volatile变量的写入操作对所有线程可见,并且禁止了volatile变量之前的操作与volatile变量之后的操作重排序。所以在变量之前加上volatile关键字即可
volatile原理
volatile的底层实现原理是内存屏障
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
如何保证可见性
写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中
读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新的数据,而不是缓存中的
如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
double-checked locking 问题
以著名的 double-checked locking 单例模式为例
public final class Single {
private Single(){}
private static Single instace = null;
public static Single getInstace(){
if(instace == null){
synchronized (Single.class){
if(instace == null){
instace = new Single();
}
}
}
return instace;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外但在多线程环境下,上面的代码是有问题的
关键在于 第一个if,这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初 始化完毕的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效
double-checked locking 解决
public final class Single {
private Single(){}
private static volatile Single instace = null;
public static Single getInstace(){
if(instace == null){
//实例没有创建,才会进入内部的synchronized代码块
synchronized (Single.class){
//也许其他线程已经创建实例,所以再判断一次
if(instace == null){
instace = new Single();
}
}
}
return instace;
}
}
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),
保证下面 两点:
可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛 开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();
new Thread(()->synchronized(m){
x =10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待 它结束)
tatic int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见