多线程之线程安全 - javaee
创始人
2025-06-01 17:43:13

前言

本篇通过了解线程不安全产生的原因,解决线程不安全的方式,一般出现线程不安全的情况的问题,如有错误,请在评论区指正,让我们一起交流,共同进步!


文章目录

  • 前言
  • 1. 多线程执行产生的线程不安全问题
  • 2. 线程安全
      • 线程的抢占式执行 <=> 根本原因
      • 多个线程修改同一个变量
      • 线程的修改,不是原子性的
      • 内存可见性问题
      • 指令重排序问题
  • 总结

本文开始

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,创建的就会禁止指令重排序;


总结

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

相关内容

热门资讯

【实验报告】实验一 图像的... 实验目的熟悉Matlab图像运算的基础——矩阵运算;熟悉图像矩阵的显示方法࿰...
MATLAB | 全网最详细网... 一篇超超超长,超超超全面网络图绘制教程,本篇基本能讲清楚所有绘制要点&#...
大模型落地比趋势更重要,NLP... 全球很多人都开始相信,以ChatGPT为代表的大模型,将带来一场NLP领...
Linux学习之端口、网络协议... 端口:设备与外界通讯交流的出口 网络协议:   网络协议是指计算机通信网...
kuernetes 资源对象分... 文章目录1. pod 状态1.1 容器启动错误类型1.2 ImagePullBackOff 错误1....
STM32实战项目-数码管 程序实现功能: 1、上电后,数码管间隔50ms计数; 2、...
TM1638和TM1639差异... TM1638和TM1639差异说明 ✨本文不涉及具体的单片机代码驱动内容,值针对芯...
Qt+MySql开发笔记:Qt... 若该文为原创文章,转载请注明原文出处 本文章博客地址:https://h...
Java内存模型中的happe... 第29讲 | Java内存模型中的happen-before是什么? Java 语言...
《扬帆优配》算力概念股大爆发,... 3月22日,9股封单金额超亿元,工业富联、鸿博股份、鹏鼎控股分别为3.0...
CF1763D Valid B... CF1763D Valid Bitonic Permutations 题目大意 拱形排列࿰...
SQL语法 DDL、DML、D... 文章目录1 SQL通用语法2 SQL分类3 DDL 数据定义语言3.1 数据库操作3.2 表操作3....
文心一言 VS ChatGPT... 3月16号,百度正式发布了『文心一言』,这是国内公司第一次发布类Chat...
CentOS8提高篇5:磁盘分...        首先需要在虚拟机中模拟添加一块新的硬盘设备,然后进行分区、格式化、挂载等...
Linux防火墙——SNAT、... 目录 NAT 一、SNAT策略及作用 1、概述 SNAT应用环境 SNAT原理 SNAT转换前提条...
部署+使用集群的算力跑CPU密... 我先在开头做一个总结,表达我最终要做的事情和最终环境是如何的,然后我会一...
Uploadifive 批量文... Uploadifive 批量文件上传_uploadifive 多个上传按钮_asing1elife的...
C++入门语法基础 文章目录:1. 什么是C++2. 命名空间2.1 域的概念2.2 命名...
2023年全国DAMA-CDG... DAMA认证为数据管理专业人士提供职业目标晋升规划,彰显了职业发展里程碑及发展阶梯定义...
php实现助记词转TRX,ET... TRX助记词转地址网上都是Java,js或其他语言开发的示例,一个简单的...
【分割数据集操作集锦】毕设记录 1. 按要求将CSV文件转成json文件 有时候一些网络模型的源码会有data.json这样的文件里...
Postman接口测试之断言 如果你看文字部分还是不太理解的话,可以看看这个视频,详细介绍postma...
前端学习第三阶段-第4章 jQ... 4-1 jQuery介绍及常用API导读 01-jQuery入门导读 02-JavaScri...
4、linux初级——Linu... 目录 一、用CRT连接开发板 1、安装CRT调试工具 2、连接开发板 3、开机后ctrl+c...
Urban Radiance ... Urban Radiance Fields:城市辐射场 摘要:这项工作的目标是根据扫描...
天干地支(Java) 题目描述 古代中国使用天干地支来记录当前的年份。 天干一共有十个,分别为:...
SpringBoot雪花ID长... Long类型精度丢失 最近项目中使用雪花ID作为主键,雪花ID是19位Long类型数...
对JSP文件的理解 JSP是java程序。(JSP本质还是一个Servlet) JSP是&#...
【03173】2021年4月高... 一、单向填空题1、大量应用软件开发工具,开始于A、20世纪70年代B、20世纪 80年...
LeetCode5.最长回文子... 目录题目链接题目分析解题思路暴力中心向两边拓展搜索 题目链接 链接 题目分析 简单来说࿰...