【java多线程】线程不安全原因及解决办法总结

1.什么是线程不安全?

可以简单理解为:使用多线程从而引发的运行bug或者逻辑bug。运行bug就是运行报错。逻辑bug就是结果跟我们预期的不一样,例如我们希望使用多线程实现变量a累加到10000,而最终a的值却小于10000。

2.线程不安全的原因

  • 多个线程同时修改共享数据
  • 操作不是原子的
  • 指令重排序 
  • 内存可见性

2.1 操作不是原子的

     我们来看一个例子,假设我们有一个Counter类的计数器,它包含一个成员变量count初始值为0,有一个公共方法increase,每次调用increase方法就让count加1。

     然后在main方法中创建Counter实例,并新开一个线程t调用increase方法5000次,主线程也调用increase5000次。最后再主线程中打印counter对象中count的值。这里需要注意的是,因为打印函数是在主线程中完成的,而系统对线程的调用是随机的,而如果此时主线程的for循环由于系统调度提前完成,进入主线程的打印函数,但是此时t线程还在for循环,而打印函数就被执行了,相当于我们代码还没有结束,t线程还在对count进行加的操作,打印就已经结束了,这明显不符合我们的预期,所以我们得在主线程的打印函数之前加上 t.join(),等待线程t执行结束,再打印count的值。

代码如下:

public class Test {
   static class Counter{
        public int count;

        public void increase(){
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t.start();
        for (int i = 0; i < 5000; i++) {
            counter.increase();
        }

        t.join();
        System.out.println(counter.count);
    }
}

 但是!!!这样的结果与我们的预期仍然不符合!运行结果如下图:

 结果是小于10000的,按照代码逻辑来讲我们使用两个线程并发执行for循环的increase,应该让count加了10000次才对。正确结果应该是10000。而造成这样错误的原因,就是因为increase函数中 count++,这一行代码对于cpu来说操作并不是原子的。换句话说,就是不是单纯的一条指令,而是有三条指令。

第一条指令就是load(从cpu寄存器上加载数据),第二条指令才是add(将读到的数据进行加操作),第三条指令是save(将更新的数据返回到cpu寄存器中)。

那么这样造成线程不安全的例子就很多了,如下图就是一种例子:

 图上表示,t线程和main线程同时进行了这三个指令,首先cpu的寄存器上会保存count的初始值0,然后t线程和main线程同时从cpu寄存器中读取count的值,再同时进行add加操作,此时两个线程count的值都变为1,最后save将值返回给寄存器,从这里我们就可以看出问题,两个线程加起来进行了两次加的操作,但是返回给寄存器的值却是一次的。这是因为它们完全并行操作了,数据不能及时更新共享。

下图这种情况,也会因为更新不及时,从而导致线程不安全。

 2.1.1 解决方法

使用 synchronized关键字给方法加锁。这一步相当于把代码打包成一个原子操作,假设线程t先调用了这个加锁的方法的时候,就会加锁,加锁后,main线程想调用这个方法,就会进入阻塞等待,直到线程t中该方法执行完毕,才会释放锁,这时候t线程重新进入队列与main线程竞争锁。此时谁能重新竞争到锁是不确定的,全凭cpu的资源调度。

以本例来讲,就是如果t先调用了increase方法,那么线程main就必须等待,t线程的increase方法执行完毕,也就是load,add,save等操作完成之后,才有可能竞争到锁调用increase方法。

加了synchronized关键字后,调用increase方法的操作流程如下图:

 我们都可以从图中看到,这三种指令被打包到了一块,如果其中任意一条没有执行完,无论是哪个线程都不会进入下一个加操作的循环。

2.1.2 代码展示

import java.util.concurrent.atomic.AtomicInteger;

/**
 * Time : 2023/7/18 17:24
 * Author : 王雅欣
 * File : Test.java
 * Description:
 * Software : IntelliJ IDEA
 */
public class Test {
   static class Counter{
        public int count;

        public synchronized void increase(){
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t.start();
        for (int i = 0; i < 5000; i++) {
            counter.increase();
        }

        t.join();
        System.out.println(counter.count);
    }
}

运行结果:

 2.2 多个线程同时修改共享数据

若多个线程都同时修改了这个共享数据,势必会出现数据更新不及时的情况,我们也没有办法确定哪个线程限制性,哪个线程后执行,因为cpu对线程的调度是不确定的,所以我们也没办法控制结果,导致线程安全问题。

2.2.1 解决办法

这种情况也可以使用 synchronized关键字解决。

2.3 内存可见性问题

举一个例子,代码如下:

public class Test2 {
    static class Counter{
        public int count;
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t = new Thread(() -> {
            while (counter.count == 0){

            }
            System.out.println("循环结束");
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("main");
        counter.count = 1;
        System.out.println(counter.count);
    }
}

我们希望当count的值被设置为1的时候,循环能够跳出去。但是实际运行情况如下图:

 此时代码进入了死循环,但是我们打印count的值又确实为1。这就是因为内存可见性引起的问题。

实际上count的值是被改变了的,但是为什么会不生效呢?原因是因为编译器的优化机制,当编译器察觉不到count的值会被改变的时候,并且几次循环下来count的值都为0,那么编译器就会偷懒,直接在线程的工作内存当中读取数据,而并非主内存中读取,所以即使main线程中改变了count的值并更新到了主内存中,但是t线程中并没有从主内存中实时更新,而是一直读取的工作内存,而count在t线程的工作内存中依然为0。这时候就会存在内存可见性引发的线程安全问题。

 2.3.1 解决方法

加volatile关键字。这样我们就可以保证每次在读取数据的时候,都会从主内存读取数据更新到工作内存中,不会省略掉这个步骤,防止了编译器优化。

代码在写入 volatile 修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候:

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

 2.3.2 代码实现

在count变量前用volatile修饰即可

public class Test2 {
    static class Counter{
        public volatile int count;
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t = new Thread(() -> {
            while (counter.count == 0){

            }
            System.out.println("循环结束");
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("main");
        counter.count = 1;
        System.out.println(counter.count);
    }
}

运行结果:

 2.4 指令重排序

举个列子:在new Counter对象的时候,可粗分为三条指令:

1.申请内存,得到内存首地址

2.调用构造方法,来初始化实例

3.把内存首地址赋值给counter引用

在这样的场景下,编译器可能会进行“指令重排序”的优化,会把顺序变为132。但是这种优化在多线程是会引发安全问题的。如果我们在线程1创建了对象,并且编译器是按照132执行的指令,但是在线程1执行完1,3指令之后,执行2指令之前,线程2已经拿到线程1创建的对象的引用并且,准备解引用(使用对象的属性/方法),这时候解引用的数据就会出问题,因为线程1还没有对对象进行初始化操作。相当于这是个不完整的对象,但是却被调用了。

2.4.1 解决办法

使用volatile关键字,禁止指令重排序

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值