多线程读写共享变量引发的问题原因分析

2022/4/10 6:15:35

本文主要是介绍多线程读写共享变量引发的问题原因分析,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

澄清两个事实:你写的代码未必是实际运行的代码;代码的编写顺序,未必是实际执行顺序。

1 问题演示

1.1 共享变量可见性

public class Demo1 {
    private static boolean flag = false;
    public static void main(String[] args) {
        new Thread(()->{
            try {
                Thread.sleep(1000);
                flag = true;
                System.out.println("T1 stop");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"T1").start();

        m1();
    }

    private static void m1() {
        while (true){
            if(flag){
                break;
            }
        }
        System.out.println("main stop");
    }
}

运行结果

通过运行上面的代码发现T1线程修改flag值,main线程不可见,所以导致死循环。

可以通过-XX:+PrintCompilation查看即时编译情况

运行结果

说明

  • made not entrant :不要进入m1方法。因为JIT编译器已经用新的代码替换了m1方法,后续的执行是使用的优化后的代码。
  • 带有“%”的输出说明JIT使用的栈上替换编译(OSR:On-Stack-Replacement)。

JIT编译器(Just In Time)。目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机就会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时,完成这个任务的后端编译器被称为即时编译器。——《深入理解Java虚拟机》P389页

通过-Xint 参数强制使用解释执行

运行结果

1.2 共享变量原子性

public class Demo2 {
    private static int account = 10;

    //加余额
    public static void add() {
        account += 5;
    }
    //减余额
    public static void sub() {
        account -= 5;
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(Demo2::add);
        Thread t2 = new Thread(Demo2::sub);
        List<Thread> threads = Arrays.asList(t1,t2);
        threads.forEach(Thread::start);
        for (Thread thread : threads){
            try {
                //等待所有线程执行完成
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(account);
    }
}

通过查看字节码,add()和sub()关键代码及说明如下

public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         // 读取account变量值
         0: getstatic     #2                  // Field account:I
         // 准备一个常量5
         3: iconst_5
         // 执行int类型的加法
         4: iadd
         // 将计算后的结果存回account变量中
         5: putstatic     #2                  // Field account:I
         // 返回
         8: return
      LineNumberTable:
        line 18: 0
        line 19: 8
        
public static void sub();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
        // 读取account变量值
         0: getstatic     #2                  // Field account:I
         // 准备一个常量5
         3: iconst_5
        // 执行int类型的减法
         4: isub
         // 将计算后的结果写回account变量中
         5: putstatic     #2                  // Field account:I
         // 返回
         8: return
      LineNumberTable:
        line 22: 0
        line 23: 8

通过字节码我们可以发现 account += 5account -= 5实际并不是原子操作的,在字节码层面把这个过程拆解成了4步:

  1. account变量值;
  2. 准备一个常量5;
  3. 执行加法或减法计算;
  4. 将计算后的结果写回account变量中

通过上面的步骤,可以分析线程t1和t2实际操作:

int num = account;
num += 5; 或 num -= 5;
account = num ;

1.3 共享变量有序性

public class Demo3 {
    //默认值都是 0 
    int a, b, x, y;

    @Test
    public void test1() throws Exception {
            new Thread(() -> {
                b = 1;
                y = a;
            }, "T1").start();

            new Thread(() -> {
                a = 2;
                x = b;
            }, "T2").start();
    }
}

上面这段代码在多线程情况下指令的执行顺序是可能存在交错的,也就是根据t1和t2操作,分析出可能存在的顺序及x和y结果:
情况一:x=0;y=2

情况二:x=1;y=0

情况三:x=1;y=2

情况四:x=0;y=0

1.4 小结

为什么会出现上面的4种情况?原因主要有2点:

  • 如果让一个线程总是占用CPU资源是不合理的,任务调度器会让线程分时使用CPU;
  • 编译器以及硬件层面会做层层优化,为了提升性能
    • Compiler/JIT优化
    • Processor 处理器流水线优化
    • Cache优化

2 编译器及硬件优化

2.1 编译器优化

Java虚拟机设计团队把对性能的优化全部集中到运行时期的即时编译器中,Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升。
例 1:

//优化前
x=1
y="universe"
x=2
//优化后
y="universe"
x=2

优化前对“x”变量进行了两次复制,最终生效的是第二次,所以Java虚拟机默认去掉了第一次复制。

例2:

//z是共享变量
//优化前
for(i=0;i<max;i++){
    z += a[i]
}
//优化后
//t是局部变量
t = z
for(i=0;i<max;i++){
    t += a[i]
}
z = t

优化前,共享变量存放在内存中,当循环次数足够大的时候,每次都要读取共享变量“z”;优化后,将共享变量“z”赋值给局部变量“t”,针对循环中的累加计算直接使用局部变量,计算完成后再赋值给共享变量“z”,这样减少了内存的访问。
例3:

//优化前
if(x>=0){ // x==0
	y = 1;
	// ...
}
//优化后
y = 1;
if(x>=0){
	// ...
}

当优化器发现任何时候变量“x”值都大于等于0,满足判断条件,就会将“y = 1” 放在判断前,提前执行。

小结:编译器优化是在不影响最终执行结果的情况下才会优化,通常在单线程环境中是没有问题的。但是,在多线程的环境下,编译器优化的结果可能就是错误的。

2.2 Processor处理器优化

非流水线优化
加入有3条指令,CPU按指令依次执行。

流水线优化
将3条执行分别交给不同的CPU执行,增加一些寄存器,缓存每一个阶段的结果。目的是尽可能的使用CPU性能。

乱序执行(Execute Out of Order)

  • 在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞;
x = 1;
// 如果x未执行,下面这步将停滞
y = x+1;
  • 如果采用乱序执行,就可以跳到下一个非依赖指令并发布它。这样,执行单元就可以总是处于工作状态,把时间浪费降到最低。
x = 1;
// 如果x为执行,下面这步将停滞
y = x+1;
//下面这步可以提到 y=x+1前面执行
z = 2 ;

2.3 缓存优化

CPU在读取数据的时候,首先会去CPU缓存中查找,如果没有再去内存中读取,从内存中读取后将数据存放一份到CPU缓存中。后续再次读取的时候,直接从缓存中读取,避免频繁读取内存。

当CPU写入数据时,也会直接操作自己的CPU缓存,数据从CPU缓存中弹出(比如CPU缓存满了)时才会往内存中同步。

当存在多个CPU同时将数据读取后再修改的情况时,会遵循MESI缓存一致性协议,已修改数据的CPU会通过消息总线通知其他CPU将数据设为无效。其他CPU在收到通知后会回复ack相应。如果后续其他CPU要使用改数据会直接从最新数据CPU处读取。如下图

MESI协议
引入缓存的副作用在于同一份数据可能保存了副本一致性如何保证?

  • Modifired:要向其他CPU发送cache line无效消息,并等待ack;
  • Exclusive:独占、即将执行修改;
  • Shared:共享、一般读取时的初始状态;
  • Invalid:一旦发现数据无效,需要重新加载数据;

2.4 再次分析1.3情况

基于一致性缓存协议,再次分析1.3共享变量有序性中x=0;y=0CPU执行情况,如下图:

流程说明

  1. CPU-1、CPU-0分别执行b=1,a=2;
  2. CPU-1将b=1写入storebuffer缓存,CPU-0将a=2写入storebuffer缓存;
  3. CPU-1读取a值,CPU-0读取b值;
  4. CPU-1先去自己缓存中查找a;
  5. CPU-1自己缓存中没有a,然后转向内存中读取;
  6. 将内存中a的默认值0读取到自己缓存中;
  7. 最终读取到的a的值为0;
  8. CPU-1通过消息总线通知其他CPU将b的值作废;
  9. 其他CPU收到消息后返回ack;
  10. CPU-1收到ack后将b的值写入到缓存中;

由于交替执行,CPU-0也在执行相关操作,但CPU-1发送消息通知b值作废时,可能CPU-0已经从内存中读取了b=0的值,所以最终结果x=b=0;y=a=0;



这篇关于多线程读写共享变量引发的问题原因分析的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程