Java多线程之间的【可见性】问题和多条指令执行时的【有序性】问题

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受到CPU缓存的影响
  • 有序性:保证指令不会受到CPU并行优化的影响

可见性问题(缓存)

import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "test")
public class Test {
//    volatile static boolean flag = true;
    static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
            }
        },"t1").start();

        Thread.sleep(1000);
        log.info("stop");
        flag = false;
    }
}

主线程对flag变量的修改对于t1线程来说不可见,导致t1线程无法停止。

原因:

  • 初始时,t1线程从主线程读取flag到自己的工作内存
  • 由于t1线程需要频繁的从主线程中读取flag,JIT即时编译器会将flag的值缓存在t1的工作内存的高速缓存区
  • 1秒后,主线程对flag值进行了修改,但是t1线程读取的仍然是自己工作内存中的flag的旧值。

解决办法:使用volatile修饰flag。

volatile可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 修饰的变量都是直接操作主存。

有序性问题(指令重排序)

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。

static int i;
static int j;

i = ...;
j = ...;

在执行上述代码时,先执行i或先执行j,对最终的结果没有影响。所以在真正执行时,
既可以是:

i = ...;
j = ...;

也可以是:

j = ...;
i = ...;

这种特性称为指令重排。多线程下的指令重排会影响正确性。

既然如此为什么要有指令重排这项优化呢?

指令重排优化

指令可以再划分成一个个更小的阶段,例如每条指令都可以分为:取指令-指令译码-执行指令-内存访问-数据写回 这五个阶段。

现代CPU支持多级指令流水线。例如支持同时执行取指令-指令译码-执行指令-内存访问-数据写回的处理器可以称为五级指令流水线。这时CPU可以在一个时钟周期内,同时执行五条指令的不同阶段(相当于执行一条完整的指令)。本质上不能缩短单条指令的执行时间,但变相提高了指令的吞吐量。

使用volatile修饰变量,禁用指令重排序。

指令重排禁用(使用volatile)——volatile原理

volatile的底层实现原理是内存屏障,Memory Barrier

  • 对volatile变量的读指令前会加入读屏障
  • 对volatile变量的写指令后会加入写屏障

volatile保证可见性

写屏障(sfence)保证在该屏障之前的对共享变量的改动,都同步到主存中。

public void actor2(I_Result r){
	num = 2;
	ready = true; 	//ready由volatile修饰,带写屏障
	//=======================写屏障
}

读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新的数据,而不是缓存的数据

public void actor1(I_Result r){
	//================读屏障
	if(ready) {
		r.r1 = num +num;
	}else{
		r.r1 = 1;
	}
}

volatile保证有序性

写屏障确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

public void actor2(I_Result r){
	num = 2;
	ready = true; 	//ready由volatile修饰,带写屏障
	//=======================写屏障
	//num = 2;不会出现在这里
}

读屏障确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

public void actor1(I_Result r){
	//================读屏障
	if(ready) {
		r.r1 = num +num;
	}else{
		r.r1 = 1;
	}
}

volatile不能解决指令交错问题。

  • 写屏障仅仅保证之后的读能够读到最新的结果,但不能保证其他线程的读在写屏障之前读
  • 有序性只是保证了本线程内的相关代码不被重排序

double-checked locking问题

双重验证的单例模式:

public final class Singleton {
    private Singleton(){};

    private static Singleton INSTANCE = null;

    public static Singleton getINSTANCE() {
        //多线程首次访问时可能会同步,之后的使用没有进入synchronized
        if (INSTANCE == null){
            synchronized (Singleton.class){
                if (INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

以上实现的特点:

  • 懒加载实例化
  • 首次使用getINSTANCE会使用synchronized加锁,之后使用时无需加锁
  • 隐含的关键一点:第一个if使用INSTANCE变量,是在同步代码块之外

在多线程环境下,上述代码是有问题的:
image

  • 17行表示创建Singleton对象,将对象引用入栈
  • 20行表示复制一份对象引用
  • 21行表示消耗栈中的一份对象引用,调用构造方法
  • 24行表示消耗栈中的一份对象引用,赋值给INSTANCE

JVM可能会指令重排,进行优化:先执行24,再执行21.
如果t1线程先执行了24,在还未来得及执行21时,t2线程执行了该方法,0行getstatic拿到了INSTANCE实例,但此时的INSTANCE实例是没有执行构造方法的,所以t2在使用INSTANCE实例时会出现空指针异常。

错误在于:0行getstatic代码在monitor控制之外,可以越过monitor直接读取INSTANCE变量的值。

对INSTANCE使用volatile修饰,禁用指令重排可以解决该错误。

public final class Singleton {
    private Singleton(){};

    private static volatile Singleton INSTANCE = null;

    public static Singleton getINSTANCE() {
        //多线程首次访问时可能会同步,之后的使用没有进入synchronized
        if (INSTANCE == null){
            synchronized (Singleton.class){
                if (INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

字节码层面没有发生改变,看不出来volatile指令的效果。

//-------------------getstatic读操作之前,加入INSTANCE变量的读屏障
 0 getstatic #7 <memory/Singleton.INSTANCE : Lmemory/Singleton;>
 3 ifnonnull 37 (+34)
 6 ldc #8 <memory/Singleton>
 8 dup
 9 astore_0
10 monitorenter
11 getstatic #7 <memory/Singleton.INSTANCE : Lmemory/Singleton;>
14 ifnonnull 27 (+13)
17 new #8 <memory/Singleton>
20 dup
21 invokespecial #13 <memory/Singleton.<init> : ()V>
24 putstatic #7 <memory/Singleton.INSTANCE : Lmemory/Singleton;>
//-------------------putstatic写操作之后,加入写屏障
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #7 <memory/Singleton.INSTANCE : Lmemory/Singleton;>
40 areturn

21行构造方法不可能重排序到写屏障下面,也就不可能出现空指针异常。

posted @ 2024-01-24 15:01  ︶ㄣ演戲ㄣ  阅读(26)  评论(0)    收藏  举报