多线程编程4——线程安全问题

news2024/12/22 9:59:40

一、线程之间是并发执行的,是抢占式随机调度的。

多个线程之间是并发执行的,是随机调度的。我们只能确保同一个线程中代码是按顺序从上到下执行的,无法知道不同线程中的代码谁先执行谁后执行。

比如下面这两个代码:

代码一:

这俩红框框打印的顺序是随机的。

主线程调用start,创建一个线程,是主线程先接着往下执行代码,还是线程先执行run中的代码,这是随机的,看哪行代码先在CPU上调度,就先执行哪行代码。

代码二: 

运行结果: 

主线程先sleep休眠1秒,这时线程趁着主线程休眠,打印thread,然后也进入sleep休眠1秒

主线程和线程谁会先从sleep中醒过来是不确定的。醒来后会回到就绪队列,等待CPU调度。CPU调度是随机的,谁先被调度谁先执行下一行代码。也就是说,控制台输出的结果,是hello在前,还是thread在前,是不确定的。完全看调度器的调度结果。哪行代码先被调度执行先输出哪个结果。

观察运行结果我们发现,有时是thread hello,有时是 hello thread,谁先打印很随机,不可预期。

二、一段出现线程安全问题的代码

什么样的代码会产生线程安全问题呢?

我们来看一下。

创建两个线程,分别对同一个对象(new Counter())的属性(count)进行5w次的自增,打印这个属性的值。我们的预期结果是:属性的值是10_0000

代码如下:

运行结果如下:

 

我们发现,

结果很随机,每次都不同,但不是10_0000,说明代码出现了bug,出现了线程安全问题。

这段代码为什么会出现线程安全问题呢?

问题就出现在 count++ 这个代码上。 

一个线程具体要执行,需要先编译成很多的CPU指令。可以这样理解,一个线程是完成某个任务,这个任务可以拆分成一个一个的小步骤,每个小步骤就是一个指令。CPU调度时只认得指令,它不认识count++这个操作。(指令:可以视为机器语言,由0和1组成。 load,add,save这些相当于汇编指令,比机器指令好记,和机器指令是一一对应的,通过编译器可以将汇编指令转换成机器指令。不同的CPU会支持不同的机器指令,load,add,save这三个操作是CPU中已经支持的指令

count++操作本质上要分成3步:

1、先把内存中的值,读取到CPU的寄存器中(load)

2、把CPU寄存器里的数值进行 +1 运算(add)

3、把得到的结果写回内存中(save)

load,add,save这三个操作,就是CPU上执行的3条指令。

就拿我们写的其中的一部分代码举例,

上面这个代码要执行就需要编译成很多CPU指令:

 本质上是 cmp指令

方法调用,是 call指令

load、add、save这三条指令

针对 i 的load、add、save这三条指令

那为什么说count++操作本质上要分成三条指令,就导致了线程安全问题呢?

我们已经知道,count++最终落实到CPU上执行,会分成这三个指令来去执行(load、add、save),那么,如果是两个线程并发的执行count++,此时就相当于两组 load add save 进行执行。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都有可能被调度走,CPU让别的线程来执行,正是这样的切换,导致会出现不同的线程调度顺序,出现线程安全问题。(哪个线程中的count++先执行是随机的。调度一次执行到了这个count++哪个指令也是随机的。)

调度顺序会出现无数种可能性,下面列出几种:

(1)load、add、save 是一起的,都执行完才被调度走

(2)执行到任意一个指令,线程都有可能被调度走,CPU去执行别的线程,过一会又回来接着执行这个线程了

(3)左:两个CPU某一时刻同时调度到了两个线程的相同指令;右:一个线程才执行完load,另一个线程已经执行好几轮load、add、save操作了 

这些不同的调度顺序,产生了不同的结果。

比如说, ,如果是这个顺序,我们来分析一下是怎么执行的。

为了方便画图,我们假设线程t1 只会被左边那个CPU调度,线程t2 只会被右边那个CPU调度(真正在你的电脑上跑这串代码,肯定是可以被你电脑上任意CPU调度的。)

第一步:load —— 把内存中的值加载到CPU的寄存器中(CPU里有个重要的组成部分,寄存器。CPU里进行的运算都是针对寄存器里的数据进行的,也就是说,CPU不能直接操作内存)

于是,第一个CPU寄存器中存了个 0

第二步:add —— CPU寄存器里的数值进行+1运算

于是,第一个CPU寄存器里的值变为1

第三步:save —— 得到的结果写回内存中

于是,内存中的值变为1

第四步:load —— 把内存中的值加载到CPU的寄存器中

于是,第二个CPU寄存器中存了个1

第五步:add —— CPU寄存器里的数值进行+1运算

于是,第二个CPU寄存器里的值变为2

第六步:save —— 得到的结果写回内存中

于是,内存中的值变为2

count自增2次,结果为2,符合预期。

我们再来看另一种可能的调度顺序,,如果是这个顺序,我们来分析一下是怎么执行的。

第一步:load —— 把内存中的值加载到CPU的寄存器中

于是,第二个CPU寄存器中存了个0

第二步:load —— 把内存中的值加载到CPU的寄存器中

于是,第一个CPU寄存器中也存了个 0

第三步:add —— CPU寄存器里的数值进行+1运算

于是,第二个CPU寄存器里的值变为1

第四步:save —— 得到的结果写回内存中

于是,内存中的值变为1

第五步:add —— CPU寄存器里的数值进行+1运算

于是,第一个CPU寄存器里的值变为1

第六步:save —— 得到的结果写回内存中

于是,内存中的值变为1

count自增2次,结果为1,不符合预期。 线程t1 load的值,是线程t2修改之前的值,t1 读到了 t2 还没save 的数据。于是出现问题。

也就是说,只要其中一个线程读到了另一个线程还没save 的数据,结果就会出问题。

这么多种调度顺序,只有这两种情况满足不会读到还没save 的数据的所以,只有两个线程每次的调度顺序都是这两种,才会打印10_0000。这种概率非常非常小。输出结果基本上都是小于10_0000。

那么结果一定会大于5_0000吗?

不一定。也有可能出现一个线程才执行完load,另一个线程已经执行好几轮load、add、save操作了  这种情况。这就是,count自增好几次,结果为1了。

所以,这段代码一定会出现线程安全问题,且结果很随机。 

三、出现线程安全问题的原因

为什么会产生线程安全问题呢?

1、根本原因:操作系统是抢占式执行,随机调度的。

2、代码结构:多个线程同时修改同一个变量。(上述代码中,两个线程修改同一个变量count。出现线程安全问题的概率非常大。) 

  • 一个线程,修改一个变量,没事
  • 多个线程,同时读取同一个变量,没事
  • 多个线程,同时修改不同的变量,也没事

 3、原子性:修改操作不是原子性的。如果修改的操作是原子的,出现线程安全问题的概率比较小。如果是非原子的,出现线程安全问题的概率就非常大了。(原子:不可拆分的基本单位。单个指令就是原子的。上述代码中,count++可以拆分成load、add、save这三个指令,所以count++不是原子的,出现线程安全问题的概率非常大。)

4、内存可见性问题:如果是一个线程读取,一个线程修改,也可能出现线程安全问题,读取的结果不符合预期。

5、指令重排序:编译器对你写的代码进行优化,保证逻辑不变的情况下,进行调整,来加快程序的执行效率。调整时,可能会调整代码的执行顺序,从而可能会出现线程安全问题。

上述是5个典型的线程安全问题的原因,但并不是全部。

一个代码究竟线程安全不安全,要具体问题具体分析。如果一个代码踩中了上面的原因,也可能线程安全。如果一个代码没踩中上面的原因,也可能线程不安全。

要结合原因,结合需求,具体问题具体分析。多线程运行代码,只要不出bug,就是线程安全的。

四、如何解决线程安全问题?

根据 出现线程安全问题的原因,从中入手解决线程安全问题。

我们可以通过调节代码结构来规避线程安全问题。

但是代码结构也是来源于需求的,不一定就能调整。是方案没错,但普适性不是特别强。

最主要的解决线程安全问题的方法就是从原子性入手。把非原子的操作变成 “原子” 的

就比如上面那段出现线程安全问题的代码,

正是因为 count++操作不是原子的,本质上要分成load、add、save这三条指令,当两个线程并发的执行count++时,就相当于两组 load add save 进行执行。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都有可能被调度走,CPU让别的线程来执行,正是这样的切换,导致会出现不同的线程调度顺序,后一个线程 读到了 还没save 的数据,出现线程安全问题

我们从原子性入手,就非常好解决了。只要 把count++这个非原子操作,变成 “原子” 的,保证线程的load、add、save 3个操作都执行完才会被调度走,让后一个线程读到的是 save过 的数据,问题就解决了。​​​​​​​

如何把非原子的操作变成 “原子” 的呢?

通过 synchronized关键字 加锁。

synchronized

synchronized 是一个关键字,表示加锁。要明确锁对象,是对 对象进行加锁!!!

synchronized 的使用方法:

1、修饰方法:(1)修饰普通方法(2)修饰静态方法 

2、修饰代码块

虽然 synchronized 修饰方法和代码块,但锁不是加到方法或其他上面,是线程对对象加锁加锁是给对象加锁一定要明确锁对象是哪个!!!

  1. 修饰普通方法:锁对象是this
  2. 修饰静态方法:锁对象是类对象(Counter.class)
  3. 修饰代码块:手动指定锁对象

加锁的规则: (就3条,很简单,不要自己加戏)

1、如果两个线程针对同一个对象加锁,会出现锁冲突/锁竞争,一个线程能够获取到锁(先到先得),另一个线程只能阻塞等待(BLOCKED),一直阻塞到上一个线程释放锁(解锁),它才能获取锁(加锁)成功。【注:另一个线程对这个对象加锁不成功,线程阻塞。但不代表这个锁对象不能用,还是可以获取锁对象的属性,调用锁对象的没加锁的方法的。只能说线程阻塞了,不能说对象不能用了。这两个概念不一样。】

2、如果两个线程针对不同的对象加锁,不会出现锁冲突/锁竞争。这俩线程都能获取到各自的锁,不会有阻塞等待了。

3、如果两个线程,一个线程加锁,一个线程不加锁,也不会出现锁冲突/锁竞争。

对 synchronized 的使用方法进行分别介绍​​​​​​​

1、修饰普通方法

进入方法就加锁,出了方法就解锁。锁对象是this,谁调用这个方法谁就是this

比如通过 synchronized关键字 修饰 add方法,进入add方法就加锁,出了add方法就解锁。以此来把 方法中count++变成 “原子的” 。

synchronized关键字 可以放在public前,也可以放在public后,下面两种都可以。


加了 synchronized关键字 后,我们再来看一下这个调度顺序,,会不会发生改变呢?

肯定会的。

如上,

(1)首先,加了 synchronized关键字 后,load前面和save后面会多个lock(加锁) 和 unlock(解锁)操作。

(2)线程t2 进入add方法,对counter 进行加锁(counter调用add方法,counter就是this,就是锁对象),接着往下执行load,此时 t1也想去加锁,不好意思,加锁不能成功,只能阻塞等待。

(3)一直等到 t2 unlock 之后,t1才可能lock成功。

t1 加锁的阻塞等待,就把 t1的load 推迟到 t2的save之后,t1读到的就是t2 save过 的数据。这就避免了线程安全问题,输出的结果就是符合预期的 10_0000啦。

加锁,保证了 “原子性” ,避免了线程安全问题。当然,这不是说,load、add、save这三个操作就必须一次性完成。执行到任意一个指令的时候,线程都有可能被调度走,过一会再被调度回来继续往下执行。虽然线程这会儿没在CPU上执行,但只要这个线程没有释放锁,其他线程想要获取锁就只能阻塞等待。

加锁之后,代码的执行速度会变慢,(因为多了加锁和解锁的操作),但还是要比单线程要快的。比如,上述代码,线程t1和线程t2 只有调用add方法时,才会加锁,只能串行。其余for循环这里的比较和自增操作,又不需要加锁,是可以并发执行的。一个任务中,一部分并发,一部分串行,仍然比所有代码都串行要快。

为啥红框框部分可以并发执行,没有线程安全问题?

因为这里的 i 是局部变量,t1 和 t2线程中各自都有,t1 和 t2线程各自修改各自的局部变量,完全不会出现问题。

2、修饰静态方法

进入方法就加锁,出了方法就解锁。锁对象是类对象

 

上述例子的锁对象是 Counter.class 

3、修饰代码块

进入代码块就加锁,出了代码块就解锁。手动指定锁对象。

用修饰代码块这个使用方法,来举个

如果两个线程,一个线程加锁,一个线程不加锁,也不会出现锁冲突/锁竞争

的例子:

 

synchronized(this){  } 在小括号里 手动指定锁对象

这里指定的锁对象是this。 t1线程对 counter 加锁,t2线程 没对 counter 加锁。就不会出现锁竞争,不会阻塞等待。运行结果不符合预期。

如果给 t2线程也给 counter加锁,那么就属于两个线程针对同一个对象counter加锁,就会出现锁竞争,只有一个线程能获取到锁,另一个线程只能阻塞等待(BLOCKED)。​​​​​​​运行结果符合预期。

 

synchronized是可重入锁

一个线程针对同一个对象连续加锁多次,没问题就是可重入的。有问题,就是不可重入。 

 

如上代码,可以看出 synchronized是可重入的。

具体的解释: 

锁对象是this,只要有线程调用add,进入add方法的时候,就会先加锁,假如此时能够加锁成功,接着代码往下执行,会遇到代码块,再次尝试加锁。

站在锁对象的视角,它认为自己已经被线程给占用了。那会不会让第二个线程阻塞等待呢?

synchronized 能检查出两个线程是同一个,允许加锁。可重入。

如果不允许加锁,会阻塞等待,那就是不可重入。

总结:

无论这个对象是个啥样的对象,原则就一条,看锁对象相不相同

锁对象相同,就会产生锁竞争(产生阻塞等待);

锁对象不同,就不会产生锁竞争(不会产生阻塞等待);​​​​​​​

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1425257.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

接口性能优化常见12式

目录 1.批处理 2.异步处理 3.空间换时间 4.预处理 5.池化思想 6.串行改并行 7.索引 8.避免大事务 9.优化程序结构 10.深分页问题 11.SQL优化 12.锁粒度避免过粗 1.批处理 批量思想:批量操作数据库,这个很好理解,我们在循环插入场…

GSM-TRIAL-21.04.9-VMware-Workstation.OVA安装教程,GreenBone虚拟机安装教程

将GSM-TRIAL-21.04.9-VMware-Workstation.ova用VMware打开 先设置好网络和内存: 1、打开虚拟机,显示:你的GSM还不能完全正常工作。您想现在完成设置吗? 点击yes 2、创建用户,一会儿登录网页要用,点击yes 3、创建用户…

提升网站性能的秘诀:为什么Nginx是高效服务器的代名词?

在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。 一、Nginx是什么 Nginx&#x…

Zoho Mail 2023:回顾过去,展望未来:不断进化的企业级邮箱解决方案

当我们告别又一个非凡的一年时,我们想回顾一下Zoho Mail如何融合传统与创新。我们迎来了成立15周年,这是一个由客户、合作伙伴和我们的敬业团队共同庆祝的里程碑。与我们一起回顾这段旅程,探索定义Zoho Mail历史篇章的敏捷性、精确性和创新性…

分布式搜索引擎_学习笔记_3

分布式搜索引擎03 0.学习目标 1.数据聚合 **聚合(aggregations)**可以让我们极其方便的实现对数据的统计、分析、运算。例如: 什么品牌的手机最受欢迎?这些手机的平均价格、最高价格、最低价格?这些手机每月的销售…

各品牌主板快速启动热键对照表及CMOS进入方法

各品牌主板快速启动热键对照表 主板品牌 启动按键 笔记本品牌 启动按键 主机品牌 启动按键 华硕主板 F8 联想笔记本 F12 联想台式机 F12 技嘉主板 F12 宏碁笔记本 F12 惠普台式机 F12 微星主板 F11 华硕笔记本 ESC 宏碁台式机 F12 梅捷主板 F9 惠普笔…

光伏设计系统都具备哪些功能?

随着可再生能源的日益重要,光伏能源已成为我们能源结构中的重要组成部分。而光伏设计系统作为实现光伏能源高效利用的关键,其功能也日益丰富和多样化。本文将探讨光伏设计系统所具备的主要功能。 1.数据分析与模拟 光伏设计系统能够对大量的数据进行分…

iOS 文件分割保存加密

demo只是验证想法,没有做很多异常处理 默认文件是大于1KB的,对于小于1KB的没有做异常处理demo中文件只能分割成2个,可以做成可配置的N个文件分割拼接还可以使用固定的二进制数据,拼接文件开头或结尾 不论哪种拼法,目的…

InfluxDB数据的导入导出

Background influxdb支持将时序数据导出到文件,然后再将文件导入到数据库中,以此实现数据的迁移。 1、数据导出 语法: 示例: influx_inspect export -datadir "/var/lib/influxdb/data" -waldir "/var/lib/influ…

Django中的模板

目录 一:基本概念 二:模板继承 在Django中,模板是用于呈现动态内容的HTML文件。它们允许你将动态数据与静态模板结合起来,生成最终的HTML页面。 Django模板使用特定的语法和标签来插入动态内容。你可以在模板中使用变量、过滤器和标签来控…

Java技术栈 —— Hadoop入门(二)实战

Java技术栈 —— Hadoop入门(二) 一、用MapReduce对统计单词个数1.1 项目流程1.2 可能遇到的问题1.3 代码勘误1.4 总结 一、用MapReduce对统计单词个数 1.1 项目流程 (1) 上传jar包。 (2) 上传words.txt文件。 (3) 用hadoop执行jar包的代码,…

【Leetcode 514】自由之路 —— 动态规划

514. 自由之路 电子游戏“辐射4”中,任务 “通向自由” 要求玩家到达名为 “Freedom Trail Ring” 的金属表盘,并使用表盘拼写特定关键词才能开门。 给定一个字符串ring,表示刻在外环上的编码;给定另一个字符串key,表…

Linux 驱动开发基础知识——总线设备驱动模型(八)

个人名片: 🦁作者简介:学生 🐯个人主页:妄北y 🐧个人QQ:2061314755 🐻个人邮箱:2061314755qq.com 🦉个人WeChat:Vir2021GKBS 🐼本文由…

unity WebGL发布游戏生成WebGL

1.unty Hub中安装WEBGL支持 2.项目平台的切换 color space需要根据项目选择 ColorSpace,是指玩家设置的颜色空间。 伽马颜色空间是历史悠久的标准格式,但线性颜色空间渲染可提供更精确的结果。 具体区别:ColorSpace 3.由于没有自己服务器…

Zookeeper分布式锁实战

目录 什么是分布式锁? 基于数据库设计思路 基于Zookeeper设计思路一(非公平锁) 基于Zookeeper设计思路二(公平锁) Curator可重入分布式锁 Curator可重入分布式锁工作流程 什么是分布式锁? 在单体的应…

无参考图像质量客观评估指标

文章目录 前言一、基于感知的图像质量评估器(Perception based Image Quality Evaluator,PIQE)二、盲/无参考图像空间质量评估器(Blind/Referenceless Image Spatial Quality Evaluator,BRISQUE)三、自然图…

C语言实战项目<贪吃蛇>

我们这篇会使用C语言在Windows环境的控制台中模拟实现经典小游戏贪吃蛇 实现基本的功能: 结果如下: 1.一些Win32 API知识 本次实现呢我们会用到一些Win32 API的知识(WIN32 API也就是Microsoft Windows 32位平台的应用程序编程接口): 1)控制窗口大小 我们可以使用…

【算法】Partitioning the Array(数论)

题目 Allen has an array a1,a2,…,an. For every positive integer k that is a divisor of n, Allen does the following: He partitions the array into n/k disjoint subarrays of length k. In other words, he partitions the array into the following subarrays: [a1,…

如何将Mac连接到以太网?这里有详细步骤

在Wi-Fi成为最流行、最简单的互联网连接方式之前,每台Mac和电脑都使用以太网电缆连接。这是Mac可用端口的标准功能。 如何将Mac连接到以太网 如果你的Mac有以太网端口,则需要以太网电缆: 1、将电缆一端接入互联网端口(可以在墙…

A+CLUB管理人支持计划第十一期 | 巨量均衡

免责声明 本文内容仅对合格投资者开放! 私募基金的合格投资者是指具备相应风险识别能力和风险承担能力,投资于单只私募基金的金额不低于100 万元且符合下列相关标准的单位和个人: (一)净资产不低于1000 万元的单位&a…