多线程 - 线程安全问题的产生 与 解决 - javaee

本文探讨了多线程环境下线程不安全的问题,主要源于线程的抢占式执行、非原子性操作、内存可见性和指令重排序。通过示例解释了++操作在多线程中的潜在风险,并提出了解决方案,如使用`synchronized`关键字确保原子性以及`volatile`关键字解决内存可见性问题。此外,还介绍了指令重排序可能导致的问题及其影响。
摘要由CSDN通过智能技术生成

前言

本篇通过了解线程不安全产生的原因,解决线程不安全的方式,线程不安全的情况例如线程抢占式执行,多线程操作统一变量,非原子性修改,内存可见性问题与指令重排序问题,如有错误,请在评论区指正,让我们一起交流,共同进步!



本文开始

1. 多线程执行产生的线程不安全问题

问题产生:
在多线程情况下,线程的无序调度,会产出bug, 称之为线程不安全问题;

原因产生:
通过下面例子认识一下 !
两个线程通过调用同一个类同一个方法add, 一起计算变量count的值,每个线程调用方法一次,使变量count自增一次,每个线程都调用方法add 10000 次,最后想要得到结果是20000;但是结果确与我们想的不一样,通过代码来看一下吧!
两个线程调用同一个方法代码实现(有线程安全问题):

class Sum{
    private int count = 0;
    public void add() {
            count++;
    }
    public int getCount(){
        return count;
    }
}
public class ThreadDemo2 {
    public static void main(String[] args) throws InterruptedException {
        Sum s = new Sum();
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                s.add();
            }
        });
        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                s.add();
            }
        });
        //启动线程
        t1.start();
        t2.start();
        //线程等待
        t1.join();
        t2.join();
        //获取计算结果
        System.out.println(s.getCount());
    }
}

结果如下:

在这里插入图片描述

上述结果只是一次执行的结果,可以多次执行,通过执行结果可以发现,每次执行的结果都会小于20000,这就是线程安全产生的问题;

为什么会产生上述结果的原因?
实际在执行调用add方法时,进行的自增++操作,在寄存器上是分三部执行的load,add,save, 这三种执行的顺序是无法确定的,所以可能产生寄存器增加一次,或者多次,但最后结果只显示增加1了次,产生的计算结果小于20000的这种情况;

【注】寄存器进行++操作本质:
load: 把内存数据读取到cpu寄存器中
add: 把寄存器中的值,进行+1 操作 =》增长1
save: 再把寄存器中的值写会到内存中

只是语言描述可能有点抽象,通过画图来进一步理解一下!!!

寄存器完整的自增操作在这里插入图片描述

上述是严格按照寄存器1先执行三部操作,寄存器2再执行三部操作,得到的结果才是2,如果三部操作前后执行顺序有交叉部分,可能就出现线程安全情况,从下图了解情况;

在这里插入图片描述

由上述图可得,自增两次,结果确只有一次,一次结果被覆盖的情况,这只是其中一种情况,中间可能有两次,甚至可多次自增情况被覆盖,造成虽然自增了很多次,但结果只有少数次. 这是因为多线程的调度是无序的,所以这三步的指令执行顺序也是不确定,这就产生了bug,导致结果会小于20000;

由此,我们再认识一下常见的线程不安全的原因!

2. 线程安全

为什么会出现线程安全?
在多线程情况下,线程的无序调度会造成线程安全 又称 线程抢占式执行

出现线程安全的原因:

线程的抢占式执行 <=> 根本原因

多个线程执行操作,不能确定操作的执行的顺序,这就是现线程的抢占式执行;

多个线程修改同一个变量

计算一个数字,定义一个变量,count计算,使用两个线程或多个线程对这个count变量进行++操作,由于++操作分为三部load, add, save这三部分执行的顺序不能确定,结果就可能产生某一次或两次操作被覆盖的情况,导致最后的结果是小于20000的;这就产生了bug;

完整代码参考最开始代码在这里插入图片描述

线程的修改,不是原子性的

什么是原子性?
原子:不能分割的最小单位,将一些操作看成整体,不能分开;
例如:++操作,它对应的CPU指令可以看作三部分,load,add,save, 如果分开执行就认为不是原子的了;必须将这3部分看作一个整体,再执行就可以看作是一个原子操作;

问题又来了,怎么给线程变成原子性呢? =》加锁
认识锁的两个操作:
① 加锁:当线程加锁后,其他线程必须等待此线程执行结束
② 解锁:线程解锁后,其他线程才能继续竞争这个锁;

加锁操作需要使用关键字:synchronized;
使用关键字修饰代码块,将线程中需要加锁的代码,都放入代码块中,这就实现了原子性;

加锁操作的目的:
加锁,就是让两个线程的部分代码串行化,大部分代码是并发的;
这就要与join区分一下了,join 让两个线程完整的进行串行化,而不是部分;
部分串行化例如:上述代码两个线程,调用一个方法add, add之前会创建循环变量i, 循环条件的判断, 调add之后count会++,给count加锁后,count之前的操作认为是并发的,线程1调用count执行完后,线程2再调用count这是串行化,count后面的执行返回,变量i++等操作也是并发的;

对上述2代码进行修改,对count进行加锁操作:最后得到的count结果就是20000

class Sum{
    private int count = 0;
    public void add() {
        synchronized (this) {
            count++;
        }
    }
    public int getCount(){
        return count;
    }
}

不同的加锁方式:

在这里插入图片描述

加锁操作中()括号:里面是加锁对象,不能是基本数据类型;
静态类加锁()括号中是类对象,如上图;
【注】类对象:表示.class文件的内容(方法,属性等)

在这里插入图片描述

内存可见性问题

通过一段代码,发现内存可见性问题:

    public static int flag = 0;//控制循环条件
    public static void main(String[] args) {
        Thread t = new Thread( () -> {
            while (flag == 0) {

            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread( () -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个整数:");
            flag = scanner.nextInt();
        });
        t.start();
        t2.start();
    }

上述代码本来是,t线程执行循环为死循环,等t2线程执行输入操作更改flag的值,这样flag!=0, 条件为假循环结束;但是循环却没有结束,这个原因其实就是内存可见性问题;

判断while( falg == 0) 这个条件指令有两步
1.load: 从 内存中读取数据到cup寄存器上
2.cmp:比较寄存器中的值是否是0
【注】读取速度:寄存器 > 内存 > 硬盘
内存可见性问题的产生:
根据读取速度,发现load读取内存开销较大,因为是死循环,没有修改load的时候结果都是一样的,编译器就会做出优化操作,优化掉load; 这样只有第一次执行load, 后面操作只执行cmp比较操作;
这样就是t2线程更改了flag的值,但是寄存器不再读取,只用修改前的值,操作就会死循环,发生线程安全问题;

内存可见性: 在多线程环境下,编译器对代码进行优化,产生了误判(像上述代码认为flag的值没改),从而引起了Bug, 导致代码出错;
【注】编译器优化:智能调整代码执行逻辑,在保证程序结果不变的前提下,通过加减语句,语句变换,等一系列操作,让代码执行效率提升;
处理内存可见性问题:
使用volatile关键字:被volatile修饰的变量,编译器会禁止代码优化,从而保证每次都是从内存中重读取数据;
代码修改:

  volatile public static int flag = 0;//控制循环条件
  //volatile: 保证内存可见性

在这里插入图片描述

【注】volatile : 1.不保证原子性,适用场景一个线程读,一个线程写的情况;synchronized: 多个线程写;
2.volatile 禁止指令重排序

指令重排序问题

问题:由于一些代码操作的执行的顺序不同,可能会产生bug;
指令重排序: 编译器优化,保证整体逻辑不变,调整代码的执行顺序,让程序更高效;

例如new 对象操作,认为分为3步:
1.先申请内存空间
2.调用构造方法(初始化内存数据)
3.对象的引用赋值(内存地址的赋值)
线程1先执行1,3操作,2操作执行顺序并不确定,在此期间其他线程使用对象,调用其方法属性,虽然对象不为空,但是没有初始化,就可能会产生bug;
解决方式:给代码加volatile,创建的就会禁止指令重排序;


总结

✨✨✨各位读友,本篇分享到内容如果对你有帮助给个👍赞鼓励一下吧!!
感谢每一位一起走到这的伙伴,我们可以一起交流进步!!!一起加油吧!!!

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值