当前位置: 首页 > news >正文

JavaEE:线程安全问题的原因和解决方案

目录

线程不安全问题出现的原因及其解决方案

1.抢占式执行

2.多个线程修改同一个变量

3、操作指令不是原子的

例子:

4、内存可见性问题

例子:

 原因:

解决方案:

5.指令重排序

例子:

原因:

解决方法:


线程安全的概念:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说

这个程序是线程安全的

即如果在多线程的随机调度下,代码出现bug,此时就认为线程不安全.

线程不安全问题出现的原因及其解决方案

1.抢占式执行

多个线程的调度执行过程,可以视为是"全随机"的(也不能视为成纯的随机,但是确实在应用层程序这里是没有规律的),即无法确定先执行哪一个线程,后执行哪一个线程(通俗的来讲就是哪一个线程先抢到机会,哪个线程就先执行),

抢占式执行被视为线程不安全的万恶之源,罪魁祸首

(内核实现的,咱们无能为力)

2.多个线程修改同一个变量

相反:

一个线程修改一个变量,没事

多个线程读同一个变量,没事

多个线程修改不同便是,没事

但当这三个同时满足时,就可能出现线程不安全问题:多个线程&&同时修改&&一个变量。

(有的时候可以调整代码,来从这里入手,规避线程安全问题,但是普适性不高!)

3、操作指令不是原子的

什么是原子性?

我们可以把一段代码想象成一个公共场所,每个线程都是要进入这个线程去上厕所,如果没有任何的机制保证,A在进入厕所之后,还没有出来;B是不是也可以进入厕所,打断A在厕所里的隐私.这个就不具备原子性了!

由于线程是在CPU上调度执行的,而CPU在执行指令时,都是以“一个指令”为单位进行执行,但是有些简单的操作本质上是多个CPU指令:

例如count++这个操作,本质上是三条CPU指令:load、add、save

(先把temp的值从内存中读取到CPU寄存器上,然后进行add操作,最后再把寄存器上的值写会到内存上),但是在多线程环境下线程操作是并发的,此时就可能出现线程不安全的问题。

例子:

创建两个线程,对count进行自加10w次后看结果,如果正常来说是10w

public class Demo {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }
}

可以看到,我一共运行了三次程序,得到是数值均没有达到10w,且每次都不相等

 这个是正常的指令运行模式:

诸如此类都是错误的:

除去第一张图片的两种排列方法之外的都是错误的!!

在形如第二张图片中的排列方法的排序下,此时的多线程自增就会存在"线程安全问题"!!

整个线程调度过程中,执行的顺序都是随机的,由于在调度过程中,出现"串行执行"两种情况的次数,和其他情况的次数不确定,

因此得到的结果就是不确定的值.

虽然结果是不确定的值,但是结果的范围是可以预测的:

考虑极端情况下:

如果两个线程之间的调度全是串行执行,结果就是:10w

如果两个线程之间的调度全是七日情况,一次串行执行都没有,结果就是5w

但是:

正常分析就是如此,但是我们在实际操作中运行代码是可以得到<5w的值的

当t1加了一次的时候,t2加了两次就会出现这种情况

解决方案:

把多个CPU指令打包成一个原子操作,使用synchronized关键字对可能出现线程不安全问题的代码进行加锁操作。了解synchronized关键字点这哦

class Count{
    public int count = 0;
    public synchronized void increase(){
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    }
}

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Count c = new Count();
        Thread t1 = new Thread(() -> {
           c.increase();
        });
        Thread t2 = new Thread(() -> {
           c.increase();
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + c.count);
    }
}

4、内存可见性问题

        程序员写代码,写好的代码编译之后,在机器上运行,但是由于程序员的水平参差不齐,  大佬写的代码非常高效,菜鸡(博主)写的代码比较低效,跑得慢;  于是写编译器的大佬们就想办法让编译器具有一定的"优化能力"!!我代码里写了一些逻辑,然后编译器把我写的代码等价转换成另外一种执行逻辑,等价转换之后,代码的逻辑不变(逻辑等价),但是效率变高了!!

        在多线程环境下,编译器优化后的代码可能就会和原来的代码逻辑有所不同,运行时出现了我们预期之外的结果,这就是内存可见性问题

例子:

t1线程中循环判断count的值是否为0,t2线程中修改count的值:

import java.util.Scanner;

public class Test {
    static class Counter{
        public int count;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.count == 0){
                
            }
            System.out.println("t1线程结束");
        });
        t1.start();
 
        Thread t2 = new Thread(() -> {
            System.out.println("修改count的值");
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();
            System.out.println("count = " + counter.count);
        });
        t2.start();
    }
}

无论我们输入什么数值,t1线程中的while循环都不会结束

 原因:

        因为t1线程中的while循环里没有什么任何操作,所以编译器这个就认为count的值是不会发生改变的,既然count不会改变,那么只需要在内存中读取一次就行了,不必在每次执行count == 0时都从去compare一次,这样太浪费时间了;

        于是编译器在优化之后,count只有在第一次执行count == 0比较的时候是从内存中读取的,之后的每次都是从CPU寄存器的缓存中读取,这样一来就节省了许多的时间。(从寄存器中读取数据的速度比从内存中读取数据的速度快了成千上万倍)

        但是编译器并没有想到我们会通过其他线程来修改count的值,所以当我们在t2线程中修改count的值后,t1线程并没有感知到,因此代码便陷入了死循环。

解决方案:

既然编译器自己的判定不准了,将不应该优化的给优化了,就可以让程序员显示的提醒编译器,这个地方不要优化,所以可以使用volatile关键字来修饰count,此时编译器就不会对count进行“只读一次内存”的优化了,所以volatile可以保证“内存可见性”问题。

import java.util.Scanner;

public class Test {
    static class Counter{
        public volatile int count;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.count == 0){
                
            }
            System.out.println("t1线程结束");
        });
        t1.start();
 
        Thread t2 = new Thread(() -> {
            System.out.println("修改count的值");
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();
            System.out.println("count = " + counter.count);
        });
        t2.start();
    }
}

5.指令重排序

指令重排序也是编译器优化所带来的问题,有些单个的操作可以分为多个CPU指令(例如count++,就分为三个CPU指令:load,add,save),经过编译器优化后,这些指令的顺序可能会发生改变,在多线程环境下,就可能出现bug,即带来线程不安全问题。

例子:

单例模式的"懒汉模式中"存在指令重排序的问题:

class Singleton{
    private static Singleton instance = null;
 
    //封装构造方法
    private Singleton(){
 
    }
 
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

原因:

在new一个对象的时候,我们大致分为三个步骤:

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

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

3)把内存的首地址赋值非instance(即new的对象)使用

此时,编译器可能会进行指令重排序的优化,因为在单线程角度下,第二步和第三步的执行顺序是可以调换的,先执行哪一步后执行哪一步,最终结果是一样的。

然而,在多线程角度下,就可能会出现问题:

假设代码在经过编译器优化后出现了指令重排序的问题,并且按照1、3、2的顺序来执行new操作。如果t1线程执行完第一步和第三步后,此时的instance对象是一个不完全的对象,只是有内存,但是内存上的数据无效;当t1在执行第二步之前,t2线程调用了getInstance()方法,那么它就会认为(instance == null)的条件为假,直接返回当前这个不完全的instance对象,那么bug就出现了

就相当于老板叫你写一段代码后天要用,然后你玩着玩着忘记了,到了后天老板问你要,你想着老板应该不会那么急,你就说你已经写好了,但是老板很开心说到:"好,马上传给我"

解决方法:

使用volatile关键字,就可以禁止编译器进行指令重排序的优化。

所以一般我们在编写多线程代码时,volatile能写就写,可以最大程度的避免内存可见性问题/指令重排序问题!!

相关文章:

  • Linux/CentOS 安装 flutter 与 jenkins 构建 (踩坑)
  • (八)光盘的挂载与解挂、挂载CentOS镜像、rpm安装软件详细学习笔记
  • 随想录一期 day4 [24. 两两交换链表中的节点|19. 删除链表的倒数第 N 个结点|面试题 02.07. 链表相交|142. 环形链表 II]
  • iOS动画相关
  • LeetCode往完全二叉树添加节点
  • Linux、docker、kubernetes、MySql、Shell运维快餐
  • 基数(桶)排序算法详解之C语言版
  • 生成模型的中Attention Mask说明
  • java毕业设计企业固定资产管理系统源码+lw文档+mybatis+系统+mysql数据库+调试
  • Java---Java Web---JSP
  • opencv 机器学习-人脸识别
  • JavaScript的函数
  • java基于springboot+vue基本微信小程序的乒乓球课程管理系统 uniapp小程序
  • 安装数据库中间件——Mycat
  • 爬虫之Scrapy框架
  • [数据结构]链表的实现在PHP中
  • Angular数据绑定机制
  • css布局,左右固定中间自适应实现
  • JavaScript类型识别
  • JS函数式编程 数组部分风格 ES6版
  • Just for fun——迅速写完快速排序
  • Spring Security中异常上抛机制及对于转型处理的一些感悟
  • 不发不行!Netty集成文字图片聊天室外加TCP/IP软硬件通信
  • 从地狱到天堂,Node 回调向 async/await 转变
  • 道格拉斯-普克 抽稀算法 附javascript实现
  • 将回调地狱按在地上摩擦的Promise
  • 利用DataURL技术在网页上显示图片
  • 实现简单的正则表达式引擎
  • 为什么要用IPython/Jupyter?
  • 详解移动APP与web APP的区别
  • 转载:[译] 内容加速黑科技趣谈
  • HanLP分词命名实体提取详解
  • LIGO、Virgo第三轮探测告捷,同时探测到一对黑洞合并产生的引力波事件 ...
  • Mac 上flink的安装与启动
  • 从如何停掉 Promise 链说起
  • 蚂蚁金服CTO程立:真正的技术革命才刚刚开始
  • #git 撤消对文件的更改
  • $.ajax中的eval及dataType
  • (01)ORB-SLAM2源码无死角解析-(56) 闭环线程→计算Sim3:理论推导(1)求解s,t
  • (11)MSP430F5529 定时器B
  • (delphi11最新学习资料) Object Pascal 学习笔记---第2章第五节(日期和时间)
  • (MonoGame从入门到放弃-1) MonoGame环境搭建
  • (二)hibernate配置管理
  • (二)windows配置JDK环境
  • (附程序)AD采集中的10种经典软件滤波程序优缺点分析
  • (附源码)ssm高校志愿者服务系统 毕业设计 011648
  • (原創) 系統分析和系統設計有什麼差別? (OO)
  • (转)用.Net的File控件上传文件的解决方案
  • .NET Core 和 .NET Framework 中的 MEF2
  • .NET/C# 避免调试器不小心提前计算本应延迟计算的值
  • .NET框架设计—常被忽视的C#设计技巧
  • .Net下使用 Geb.Video.FFMPEG 操作视频文件
  • .sh
  • .sh文件怎么运行_创建优化的Go镜像文件以及踩过的坑
  • /etc/apt/sources.list 和 /etc/apt/sources.list.d