Java线程安全原理深度解析

本文探讨了Java多线程环境下,共享变量的线程安全性问题。通过实例代码展示了JIT编译器对线程可见性的影响,解释了在JIT优化前后,线程如何处理共享变量的更新,以及如何在没有使用`volatile`关键字的情况下,导致线程无法感知到其他线程对共享变量的修改。通过调整JVM执行模式,验证了JIT编译器的优化可能导致的线程间不可见性现象。
摘要由CSDN通过智能技术生成

        对一些初学或者老的java程序员,都知道在多线程下访问一个共享变量是非线程安全,所谓的非线程安全,意思是线程a对 共享变量 的修改 对于线程b可能是不可见的(这句话听不懂没关系,看完全文,回头再来看,就会发现这句话豁然明朗)。直接上例子

// 建议可以代码截图,然后配合文字一起看
public class Test {
    static long a= 0; //共享变量
    //  volatile static long a= 0; 
    public static void main(String[] args) throws Exception {
        Thread work = new Thread(() -> {
            while (a== 0) {
            }
//            System.out.println("work finish");
        });
        work.start(); //工作线程开启
        Thread.sleep(100);
        a= 7;
        work.join();
    }
}

        这个例子我没有用到 System.out.println(); 去打印东西,我注解掉了这个打印语句,为什么呢?因为有些同学可能 学过一点多线程的东西,知道 println里面的有同步代码块,当却不知道同步代码块的 细节,只知道同步代码块内能读取到 最新的共享变量的信息,或者同步代码块能加读写屏障,为了不考虑这些细节,我去掉了打印语句。当运行程序发现,程序一直停不下来,分析程序:main这个主线程是肯定停止了,那么肯定就是work线程无法停下来,为什么在我修改了sum的值后,work线程在循环判断的时候不结束这个死循环?有的同学可能给共享变量添加了volatile关键字发现了程序可以停止,但是本文在这里先不提这个关键字,而只是分析这个程序停止不下来的原理.

        在把main线程这个睡眠时间设定为 1或0 毫秒,发现程序能正常运行结束

  • sleep(1)    程序能停,    work线程   感受到main线程对sum的更改
  • sleep(100)程序不能停,work线程不能感受到main线程对sum的更改

        

  • sleep(1)     JVM对work线程里执行的代码块还没正式开启 JIT优化
  • sleep(100) JVM对work线程里执行的代码块       正式开启 JIT优化

        “哎呀,你是不是在乱讲啊!你怎么知道这个优化开启没有?”有的同学可能要这样问了。通过添加虚拟机参数 运行程序 -Xint  ,设置JVM执行引擎为 纯解释执行,这个时候发现即使是 main 睡眠10000秒,程序照样能正常结束,意思就是 在 JVM执行引擎为 纯解释执行时 , 共享变量在多线程之间是可见的,即main线程对 共享变量的更改对于 work线程来说是可见的,所以这个时候work线程读取到sum为 7,不满足循环的判断条件,work线程跳出循环,程序结束。

        在JVM执行引擎 有3种模式调节——纯解释,纯编译,混合模式,默认在64为主机是混合模式。所以刚刚出现 睡1毫秒能停止程序,是work线程执行的代码还没被 JIT正式的解释执行,而睡100秒,work线程执行的循环代码块已经完全被 JIT解释执行(由于JIT有c1和c2两种执行引擎,而且在默认的分层编译的 模式下,一段代码块会被以多种方式编译,在这里我们假设JIT的编译是原子性的,要不就没有对代码编译,要不就是开启了对代码的编译并且执行)。

        结论共享变量在线程之间的 不可见性 是JIT编译器开启后 带来的副作用。

        如果有兴趣了解更深入的原理,可以往下看~

按照JMM的抽象,首先共享变量 a 得在主存有一份,然后在work线程的工作内存和主线程的工作内存有共享变量a的副本,每个线程只能访问自己的工作内存。模型抽象为下图,建议snipaste贴图后,观看下文的文章描述

  • main :main线程 简写 (work同理)
  • 内存1:工作内存1的简写 (内存2同理)
  • a1     :副本a1的简写

        (在没有JIT优化的前提下)例如:work读取 a1,完成更改操作,返还值给 内存1, 内存1 接收到work返还的值完成对 a1的更改,并且把 a1的值刷新给 a。主存再把刷新后的 a 刷给 内存2的 a2,所以main就感受到了其他线程对 共享变量a的修改。

public class Test {
    static long a = 0;//共享变量
    public static void main(String[] args) throws Exception {
        Thread work = new Thread(() ->a++);work.start();
        a++;
    }
}

        所以上面两个线程在给一个共享变量各a++,a的最后结果可能是1,也可能是2。但是2这个结果,在上面的代码手动去运行出来,机率太小,所以我设置了测试程序如下:

// 添加虚拟机参数 ,关闭JIT,为了实验的严谨性,
// VM参数: -Xint
public class Test {
    static long a = 0;
    public static void main(String[] args) throws Exception {
        while (true){
            Thread work = new Thread(() ->a++);work.start();
            a++; if (a==2) break;
            work.join();a = 0; //重置
        }
        System.out.println("结束");
    }
}

       在由JIT优化时,JIT对代码进行优化(反复执行的热点代码转化为 机器码),而且重点是,对这段代码内用到的共享变量,进行优化。代码的优化好理解,如字节码编译存储为机器码等,机器码肯定是比字节码要运行得更快的。对共享变量的优化 举个例子(下面2张图,建议先把图贴在屏幕,好理解文字),JIT给 work线程优化了一段代码(这里指的就是while循环块),代码内引用到了共享变量a,这个共享变量在work线程对应的工作内存肯定是有个副本值a1,本来主存 可以给这个 副本更新,只要main线程更改a2,然后同步到 主存a,主存就可以同步更新a1。但是,在work线程 被JIT开启了优化,在这个时间点开始,主存不能更新内存1 的副本a1(但是反过来 内存1 还是能更新主存的共享变量a),如下图

// 就是上面第一次使用的代码,为了在这方便看
public class Test {
    static long a= 0; //共享变量
    public static void main(String[] args) throws Exception {
        Thread work = new Thread(() -> {
            while (a== 0) {
            }
        });
        work.start(); //工作线程开启
        Thread.sleep(100);
        a= 7;
        work.join();
    }
}

 

  再来做个实验来验证这个结论,代码如下

//先把代码截图,然后配合下面的文字一起看
// VM参数: 将vm参数清空,这里我们需要测试JIT,所以必须清空
public class Test {
    static long a = 0; //共享变量
    static long h = 0; //共享变量

    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            while (a <= 0x7ffffffffl) {h = a;} }).start();
        Thread work = new Thread(() -> {
            while (a <= 0x7ffffffffl) {a++;}});
        work.start();

        Thread.sleep(1000);
        //睡眠一秒可以确保2个线程都已经被 JIT激进优化
        //一般这种循环体 几毫秒 JIT就可以开始优化
        System.out.println(h);//127914
        System.out.println(a);//1551985624
        Thread.sleep(100);
        System.out.println(h);//127914
        System.out.println(a);//1689629292

        work.join(); // work 结束要大概10几秒,所以在这等待
        System.out.println("work线程结束");
    }
}

首先,将vm参数清空,虚拟机以混合编译模式启动(对热点代码会先解释执行再优化为JIT编译执行),这样JIT才会 优化代码。

        匿名的线程和主线程,在一秒后,都被JIT优化执行,此时,work线程一直在更新自己工作内存的 副本a,然后这个副本a,会同步到主存 a,主存a 同步给没有被JIT优化的 main线程的副本a,所以在main线程能打印出不同的a值。

        匿名线程在1秒内的某个时刻就被JIT优化,在这个时刻,主存的共享变量a,就不能同步到这个匿名线程,在这个时刻匿名线程的副本a就不会再变化 ,所以给自己工作内存的副本h的赋值,一直都是 自己工作内存里那个不变的副本a。所以打印的h值也就是在匿名线程被JIT优化的那个时刻,最后一次与主存里的a值保持同步的值。

        这个程序是不能正常结束的,因为匿名线程的副本a,没办法被自己更新,也无法被主存同步,work线程的副本a也是不能被主存同步,但是他每次循环都会更改自己的副本a值,在循环一定次数,就会跳出循环。

在来看一个实验

//先把代码截图,然后配合下面的文字一起看
// VM参数: 将vm参数清空,这里我们需要测试JIT,所以必须清空
public class Test {
    static long a= 0; //共享变量
    static long h= 0; //共享变量
    public static void main(String[] args) throws Exception {
        Thread work = new Thread(() -> {
            while (a <= 0x7ffffffffl) {a++;}
        }); work.start();
        Thread.sleep(1000); // JIT激进优化开启

        new Thread(() -> {
            long now = a;
            long now1 = a;
            while (now == now1) { //确定 a的更新在这个新开启的线程是可见的
                now = a;
                now1 = a;
            }
            System.out.println("内线程结束");
        }).start();
        
        long var = a;
        long var1 = a;
        while (var == var1) {//确定 a的更新在main线程是可见的
            var = a;
            var1 = a;
        }
        System.out.println("对外可见");

        work.join();
        System.out.println("结束!");

        用刚刚的逻辑,1秒后,work线程被JIT优化,work线程是时刻在 给自己工作内存的副本a 进行更新,而且更新后的工作内存会把更新的副本a 同步给主存里的a(只是主存不能再给 work工作内存的副本a 赋值),这个时候启动一个匿名的线程,由于刚刚启动,还没被JIT优化,所以匿名线程的副本a 是和主存保持同步,所以在2次读取自己工作内存的副本a时,2次读取结果可能不同,应为work线程一直在给 主存的a更新,而匿名线程的副本a是和主存保持同步,所以2次读取结果是可能不一样,匿名线程能正常结束

        而main线程虽然是早就创建,但是这段代码块是需要循环执行很多次才能被优化,没有被JIT优化,main线程的副本a就是和主存保持同步,所以也能这个循环能停止。

        而work线程虽然它的副本a是不能被主存赋值,但是它自己是能更新自己的,所以在一定时间后,(我是在10几秒后),work也结束,整个程序终止!

       

        看到下面这幅图,

 这里没有提到缓存一致性,JIT如何分层编译,读写锁的内容等一些底层的内容。我绝对没有必要,而且现在的结论已经够用了。

如果还有不理解,可以尽量提,可以互相交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值