分析线程不安全的原因与其解决方法

文章目录

  • 线程不安全的原因
  • 如何解决线程不安全

线程不安全的原因

首先我们来看一下这样一个代码:

public class ThreadJoin {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 5000;i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 5000;i++) {
                count++;
            }
        });

        t1.start();
        t2.start();

        Thread.sleep(8000);

        System.out.println(count);
    }
}

分析线程不安全的原因与其解决方法_第1张图片
8秒的时间足够两个线程执行5000次++操作,我们的预期结果是两个线程同时对变量count进行++5000次,最后得到的结果为10000,但我们运行之后发现结果并不是我们想要的结果,这就是线程不安全的所导致的,如何导致的呢我们进行分析。
首先++操作我们可以分解为三个CPU指令:

1、load 把内存中的数据读取到CPU寄存器中
2、add 就是把寄存器中的值进行 +1 操作
3、save 把寄存器中的值写回内存当中

那么在多线程操作时,就有可能出现下面的情况,此时线程1从内存中读取数据到CPU寄存器中后,没有等到add、saved等操作执行完,线程2就又从内存中向CPU寄存器中读取了一次数据。
分析线程不安全的原因与其解决方法_第2张图片

此时的这样的操作虽然造成了执行两次++操作,但是实际上内存中的count变量相当于只增加了一次,最终导致了最后的结果与预期的不相同,这就是线程的不安全性。
还有一些线程不安全的情况,例如下面的几个例子:

import java.util.Scanner;

public class ThreadDemo {

    public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {

            }

            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
           flag = scanner.nextInt();
            System.out.println("flag已更改");
        });

        t1.start();
        t2.start();
    }
}

分析线程不安全的原因与其解决方法_第3张图片
上述代码的逻辑当我们在t2线程中输入值更改flag的值,此时t1线程应该结束循环并且线程执行完毕,但实际上我们更改了flag的值后,t1线程并没有结束,这是为什么呢?这是内存可见性的问题,判断flag == 0时会有两个操作:

1、load 从内存读取数据到CPU寄存器
2、cmp 比较寄存器里的值是否为 0

我们都知道,内存的读写速度比硬盘快几千倍,而寄存器的读写速度又比内存快几千倍,所以load操作消耗的时间远远超过了cmp的操作,并且编译器发现了,每次load的结果都一样,此时编译器做了一个大胆的决定,优化了load,只进行一次load剩下的循环都只cmp,不进行load。
因为内存可见性这个原因,虽然我们修改了内存中flag的值,但是在进行判断时并没有重现从内存中拿到修改过的flag所以造成了BUG的出现。

我们分析一下上述情况造成线程不安全的原因有哪些:

1、首先就是线程的调度是无序的,是随机的,我们成为抢占式执行。
2、使用多个线程修改同一个变量。
3、修改的操作不是原子的,原子就是不可再分的,向上述的++操作,还可以分为load add save等CPU指令,单个的指令对于CPU来说就是原子的。
4、内存可见性:多线程环境下,编译器对于代码的优化产生了误判,从而引起了一些BUG。
5、指令重排序,也是编译器优化的一种策略,调整代码的顺序,保证最后结果不变的情况下,让程序更高效。

如何解决线程不安全

知道了造成线程不安全的原因,我们就需要解决,我们可以直接使用join()方法,让程序最后获得正确的结果,这样只是让两个线程完整的进行串行,我们的目标是并发的完成任务,这个时候我们就可以使用锁synchronized

public class ThreadJoin {
    public static int count = 0;


    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 5000;i++) {
               synchronized (locker) {
                   count++;
               }
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 5000;i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();

        Thread.sleep(8000);

        System.out.println(count);
    }
}

分析线程不安全的原因与其解决方法_第4张图片
为什么加锁就可以解决线程不安全问题呢?因为加锁可以让++操作变为原子的。这个操作就像上厕所,++操作就是上厕所的人,只有一个厕所必须等上一个人上完打开锁,第二个人才能进去。此时给++操作上锁,t1抢到了锁,那么t2就必须等着,等t1的++操作的三个CPU指令load add save执行完毕,t2才能有机会抢到锁,执行++操作,这样就解决了问题。
需要有几点注意:
在这里插入图片描述
锁对象必须是一个Object对象,锁对象就好像是厕所,当很多个人需要上厕所,但是只有一个厕所时才会发生竞争,同理,多个线程中只有锁对象相同才会发生锁竞争。
至于内存可见性和指令重排序造成的多线程不安全的问题,我们可以使用volatile 解决问题。

import java.util.Scanner;

public class ThreadDemo {

    volatile public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {

            }

            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
           flag = scanner.nextInt();
            System.out.println("flag已更改");
        });

        t1.start();
        t2.start();
    }
}

分析线程不安全的原因与其解决方法_第5张图片
volatile关键字修饰的变量,此时编译器会禁止优化的操作,保证每次都是从内存中重新读取数据,就解决了问题。

你可能感兴趣的:(JavaEE初阶,java,jvm,开发语言)