java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java多线程的原子性,可见性,有序性

Java多线程的原子性,可见性,有序性你都了解吗

作者:小小茶花女

这篇文章主要为大家详细介绍了Java多线程的原子性,可见性,有序性,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下,希望能够给你带来帮助

问题:

1.什么是原子性、可见性、有序性?

1. 原子性问题

原子性、可见性、有序性是并发编程所面临的三大问题。

所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。

例如对于 i++ 而言,实际会产生如下的 JVM 字节码指令:

getstatic i  // 获取静态变量i的值(内存取值)
iconst_1     // 准备常量1
iadd         // 自增 (寄存器增加1)
putstatic i  // 将修改后的值存入静态变量i(存值到内存)

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

在这里插入图片描述

但多线程下这 8 行代码可能交错运行:

出现负数的情况:

在这里插入图片描述

出现正数的情况:

在这里插入图片描述

一个自增运算符是一个复合操作,“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。

因为这4个操作之间是可以发生线程切换的,或者说是可以被其他线程中断的。所以,++操作不是原子操作,在并行场景会发生原子性问题。

2. 可见性问题

一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。

谈到内存可见性,要先引出Java内存模型的概念。JMM规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作内存(私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。

如果两个线程同时操作一个共享变量,就可能发生可见性问题:

(1) 主存中有变量sum,初始值sum=0;

(2) 线程A计划将sum加1,先将sum=0复制到自己的私有内存中,然后更新sum的值,线程A操作完成之后其私有内存中sum=1,然而线程A将更新后的sum值回刷到主存的时间是不固定的;

(3) 在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1;

线程A和线程B并发操作sum发生内存可见性问题:

在这里插入图片描述

要想解决多线程的内存可见性问题,所有线程都必须将共享变量刷新到主存,一种简单的方案是:使用Java提供的关键字volatile修饰共享变量。

为什么Java局部变量、方法参数不存在内存可见性问题?

在Java中,所有的局部变量、方法定义参数都不会在线程之间共享,所以也就不会有内存可见性问题。所有的Object实例、Class实例和数组元素都存储在JVM堆内存中,堆内存在线程之间共享,所以存在可见性问题。

3. 有序性问题

所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。

@Slf4j
public class Test3 {
    private static volatile int x=0,y=0;
    private static int a=0,b=0;

    public static void main(String[] args) throws InterruptedException {
        for(int i=0;;i++){
            a=0;
            b=0;
            x=0;
            y=0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            // 假如t1线程先执行,t2线程后执行,则结果为a=1,x=0,b=1,y=1  (0,1)
            // 假如t2线程先执行,t1线程后执行,则结果为b=1,y=0,a=1,x=1  (1,0)
            // 假如t1线程和t2线程的指令是同时或交替执行的,则结果为a=1,b=1,x=1,y=1 (1,1)
            // 但是不可能出现(0,0)
            if(x==0 && y==0){
                log.debug("x:{}, y:{}",x,y);
            }
        }
    }
}

由于并发执行的无序性,赋值之后x、y的值可能为(1,0)、(0,1)或(1,1)。为什么呢?因为线程t1可能在线程t2开始之前就执行完了,也可能线程t2在线程t1开始之前就执行完了,甚至有可能二者的指令是同时或交替执行的。

然而,执行以上代码时,出乎意料的事情发生了:这段代码的执行结果也可能是(0,0),部分结果如下:

19:37:32.113 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:33.041 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:34.501 [main] DEBUG com.example.test.Test3 - x:0, y:0
19:37:41.825 [main] DEBUG com.example.test.Test3 - x:0, y:0

于以上程序来说,(0,0)结果是错误的,意味着已经发生了并发的有序性问题。为什么会出现(0,0)结果呢?可能在程序的执行过程中发生了指令重排序。对于线程t1来说,可能a=1和x=b这两个语句的赋值操作顺序被颠倒了,对于线程t2来说,可能b=1和y=a这两个语句的赋值操作顺序被颠倒了,从而出现了(x,y)值为(0,0)的错误结果。

什么是指令重排序?

一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。

重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,因此会导致工作线程似乎表现出了随机行为。指令重排序不会影响单个线程的执行,但是会影响多个线程并发执行的正确性。

事实上,输出了乱序的结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出。但是,指令重排序也是导致乱序的原因之一。

总之,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有得到保证,就有可能会导致程序运行不正确。

总结

本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!     

您可能感兴趣的文章:
阅读全文