Java中的线程安全问题(多线程重点)

Posted bit_zhy

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java中的线程安全问题(多线程重点)相关的知识,希望对你有一定的参考价值。

JAVA中多线程的线程安全问题


引起线程安全问题的原因大概有以下五种

1.各个线程在系统中抢占式执行(根本原因)

我们之前提到过,操作系统在执行各个线程的时候是具有随机性的,我们并不能确定谁先被执行谁后被执行,这样一来就很可能引发很多bug,这是引发线程安全问题的根本原因,也是我们无可奈何的原因,因为我们不可能改变操作系统。

2.多个线程对同一个变量执行修改操作

我们这里指的是多个线程对于同一个变量/对象进行修改,如果多个线程对多个变量进行修改,那么并不一定会引发安全问题,或者多个线程对同一个变量只读不改,那么也同理

3.针对的变量/对象操作不是原子的

原子性,我们在学习数据库事务这一块曾接触过,操作的原子性大概就是指,要不就不执行,要执行就一口气的执行完,因为在修改变量时可能涉及到多个CPU的操作指令,这些指令并不被包装成一个原子性的指令,因此很可能引发一些bug

针对2,3的一个例子

我们在这里设想一个情况,如果有两个线程,同时对同一个变量实现自增操作,比方说每个线程自增1w次,那么会发生什么样的结果呢,根据推演应该是该变量从0变成了20000

    static int count = 0;
    public static void main(String[] args) throws InterruptedException 
        Thread t1 = new Thread(() -> 
            for (int i = 0; i < 10000; i++) 
                count++;
            
        );
        Thread t2 = new Thread(() -> 
            for (int i = 0; i < 10000; i++) 
                count++;
            
        );
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    


但是经过我们多次执行后,发现该变量的最终结果始终在10000-20000之间
这是为什么呢,这其实就是我们上面所提到的线程安全问题的2和3,我们向底层看去,线程在实现count++操作时,实际上是要有三个步骤
第一步:load (将变量的值从内存中加载到CPU寄存器中)
第二步:add (CPU将该寄存器中的值++)
第三步:save(将CPU寄存器中的值写入内存中)
t1和t2线程每一次对count++的时候,都要执行这三个指令,但是由于线程的抢占式执行,导致了这三个指令在两个线程中并发执行的顺序多种多样,十分不确定

如果箭头顺序是执行指令的顺序,那么可能是两个线程串行执行(无论t1线程先还是t2线程先执行,结果都是同理的),那么这个时候,我们可以想象得到,t1先加载count(load),假设这时count是初识状态0,那么加载到寄存器中的count值为0,之后执行add操作,count变为1,最后save写回内存中,此时,内存中的count值为1,那么t2执行load操作时读取到寄存器中的值就是1,执行add后为2,save入内存,如此看来,这样的串行执行的顺序其实是没有问题的,那么问题其实出现在其他顺序中

当线程执行指令的顺序如上图时,我们再想一下,t1执行load,这时t1线程所对应的寄存器加载值为0,t2执行load,t2线程所对应寄存器count值也为0,之后t1执行add,t1寄存器count值为1,t1执行save,内存中count值被修改为1,之后t2执行add时,因为t2是t1add,save之前读取的数据,因此t2读取到的值是0,那么add也是将0变为1,之后t2的save操作,实际上是又将1写入到了内存中,相当于覆盖了t1save的结果,那么内存中最终的count值仍然是1,这样一来就出现了问题,因为抢占式执行的随机性,并且修改这个操作也非原子操作,可以打乱其顺序,如此造成了bug,使得本应该为20000的结果为10000-20000之间(假设全部为串行执行,那么结果为20000,假设全部为混乱执行,那么结果为10000,因此结果一定在10000-20000之间)

解决办法:加锁(synchronized)修饰变量/对象

class Count
    public int count = 0;

    synchronized public void add()
        count++;
    

public class Test2 
    public static void main(String[] args) throws InterruptedException 
        Count count = new Count();
        Thread t1 = new Thread(() -> 
            for (int i = 0; i < 10000; i++) 
                count.add();
            
        );
        Thread t2 = new Thread(() -> 
            for (int i = 0; i < 10000; i++) 
                count.add();
            
        );
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.count);
    


可以看到我们在count类的add方法加了锁,这样的原理是,加了锁的方法不可以重复加锁,相当于通过锁,将count++这个操作中的三个步骤(三个指令)包装成了原子性的,当一个线程加锁执行方法时,另一个线程进入阻塞态,因为已经有锁,必须等该锁解开,才可以再加锁,这样强行的使得操作原子化,使得两个线程一定串行执行,所以最终的结果:

因此并发性越强,越容易出bug,但是并发性越弱,串行性越强,程序执行时间就越长,这是没有办法的

4.内存可见性所引发的问题(编译器优化导致)

我们很多语言的编译器现在都很智能,会在程序员编写代码后,在不影响原来逻辑的情况下进行一系列优化,这样的优化,在针对单线程时,基本上是不会出问题的,但是我们的多线程的执行顺序不确定,导致了这一部分优化可能会改变了逻辑顺序,甚至产生bug,内存可见性所引发的问题就是由于编译器优化所产生的

例子:

我们来看一个例子,如果创建两个线程,一个线程不断的循环执行读操作,另一个线程执行对读操作线程的循环条件的修改,类似于中断线程的一个操作,我们看一下会发生什么:

    static boolean flg = true;
    public static void main(String[] args) 
        Thread t1 = new Thread(() -> 
            while(flg)

            
            System.out.println("读操作线程结束!");
        );
        t1.start();
        Scanner scanner = new Scanner(System.in);
        flg = scanner.nextBoolean();
        System.out.println("main线程结束!");
    

在这段代码中,我们自定义了一个标志位flg,在main线程中修改了它的值,如果读线程结束,则会输出“读操作线程结束!”

然而,我们发现当我们输入了false之后,我们发现main线程都结束了也并没有输出读操作线程结束,这样一来就产生了bug,产生这样bug的原因,其实是因为读操作线程中,不断地循环空语句,这样在while的判断中,读线程不断快速的访问内存中的flg的值,实际上是一个很抵消的操作,因为访问内存是一个很浪费时间的操作,这时,编译器就大胆的认为flg的值不变,所以会直接访问读线程所对应的寄存器中的flg的值,也就是执行了一次load操作之后,读操作寄存器中的值是true,之后一直直接访问寄存器中的true,以此来优化代码,但是main线程中执行修改,这一切所对应的flg的改变关系只存在于内存和main线程所对应的寄存器之间,而读线程所对应的寄存器始终没有改变,即使内存中的flg值已经改变了,读线程寄存器中的flg值仍然是之前读到的true,因此读线程没有结束。

解决方法:加锁(synchronized)/volatile

我们在遇到这种编译器优化所带来的问题时,可以采用加锁,加锁可以保证所有的问题的解决,也可以使用volatile,volatile是手动的取消了编译器的优化,但是并没有将操作原子化

当我们将flg用volatile修饰之后,可以发现


main线程和读操作线程都结束了,这说明我们的修改被读线程从内存中读取到了,这样内存可读性问题得以解决

5.指令重排序问题(编译器优化导致)

这个问题比较抽象,我们拿一个图来举例

如果妈妈让我们买菜,写的顺序是上图的茄子,鸡蛋,黄瓜,番茄,那么我们的路线就是

很明显这并不是一个最优最短线路,反而浪费了很长时间,那么这个时候编译器就会自动优化顺序为1.鸡蛋 2.黄瓜 3.茄子 4.番茄,这样一来线路就是


很明显,大大缩短了路程,但是执行这些指令的顺序发生了改变,因此也很有可能出现问题

总结原因

1.线程调度优先级不一定(抢占式执行) -----根本原因
2.多个线程对同一个变量进行修改操作(多个线程对多个变量操作不算在内,读操作也不算在内)
3.针对的变量操作不是原子的(要不不执行,要不就一口气执行完毕)
(比方说读操作,只需要一条机器指令,属于原子的操作,通过加锁,可以将不原子的操作打包成原子的)
4.内存可见性所带来的问题(编译器优化)
5.5.指令重排序(编译器优化)

以上是关于Java中的线程安全问题(多线程重点)的主要内容,如果未能解决你的问题,请参考以下文章

Java中的线程安全问题(多线程重点)

Java中的线程安全问题(多线程重点)

Java多线程编程——多线程技能

Java多线程编程——多线程技能

多线程四大经典案例及java多线程的实现

java中的线程安全随机访问循环数组?