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

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

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

比如下面这两个代码:

代码一:

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

主线程调用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 能检查出两个线程是同一个,允许加锁。可重入。

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

总结:

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

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

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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

自定义一个线程安全的生产者-消费者模型(大厂java面试题)

生产者-消费者模型的核心思想是通过阻塞队列和线程的等待和通知机制实现生产者和消费者之间的协作,确保生产者不会向满队列中添加消息,消费者不会从空队列中获取消息,从而有效地解决了多线程间的同步问题。 需要实现两个方法。方法1向队列中…

Aigtek高压功率放大器主要功能是什么

高压功率放大器是一种用于将低电压信号放大到高电压水平的电子设备。它在许多领域中发挥着重要的作用,具有以下主要功能: 信号放大:高压功率放大器的主要功能之一是将低电压信号放大到高电压水平。它能够以较高的增益放大输入信号&#xff0c…

【云原生之kubernetes系列】--污点与容忍

污点与容忍 污点(taints):用于node节点排斥Pod调度,与亲和效果相反,即taint的node排斥Pod的创建容忍(toleration):用于Pod容忍Node节点的污点信息,即node节点有污点,也将新的pod创建…

​亚马逊测评礼品卡撸C采退如何搬砖?

亚马逊测评礼品卡搬砖、撸C是什么? 拿亚马逊礼品卡搬砖来讲,除了汇率差还有佣金。因为盈利的是美刀,因此比我们国内礼品卡的利润更多。比如亚马逊礼品卡,它的折损率比较低,很容易出手,所以是硬通货的存在。…

SD-WAN与MPLS没有取代之说,合适的才最重要

随着企业网络需求的不断增长和变化,SD-WAN(软件定义广域网)和MPLS(多协议标签交换)成为企业网络架构中备受关注的两种技术。然而,值得注意的是,并不存在SD-WAN完全取代MPLS或相反的情况。本文将…

SpringMVC实现对网页的访问,在请求控制器中创建处理请求的方法

目录 测试HelloWorld RequestMapping注解 RequestMapping注解的位置 RequestMapping注解的value属性 RequestMapping注解的method属性 SpringMVC支持路径中的占位符(重点) SpringMVC获取请求参数 1、通过ServletAPI获取 2、通过控制器方法的形参…

Git系列---标签管理

📙 作者简介 :RO-BERRY 📗 学习方向:致力于C、C、数据结构、TCP/IP、数据库等等一系列知识 📒 日后方向 : 偏向于CPP开发以及大数据方向,欢迎各位关注,谢谢各位的支持 目录 1.理解标签2.创建标签…

ThreadX_note:创建线程

ThreadX 创建线程 ThreadX 是一款实时操作系统 (RTOS),它提供了一套全面的 API,可以用于创建和管理线程。 创建线程 在 ThreadX 中,我们可以使用 tx_thread_create 函数来创建线程。 exam: #include "tx_api.h"/*…

Ansible自动化运维实战

一、abstract简介 ansible是新出现的自动化运维工具,基于Python开发,集合了众多运维工具(puppet、cfengine、chef、func、fabric) 的优点,实现了批量系统配置、批量程序部署、批量运行命令等功能.无客户端。我们要学一些Ansible的安装和一些基…

AI论文指南|ChatGPT在论文讨论部分能起到什么作用?

点击下方▼▼▼▼链接直达AIPaperPass ! AIPaperPass - AI论文写作指导平台 公众号原文▼▼▼▼: AI论文指南|ChatGPT在论文讨论部分能起到什么作用? 讨论部分是一篇论文的精华所在,也是写作中最难的部分。讨论部分主要是将文章…

单例模式有几种写法?请谈谈你的理解?

为什么有单例模式? 单例模式(Singleton),也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个全局对象,这样有利…

coreldraw怎么添加箭头?

使用coreldraw的时候知道箭头在哪里添加吗?下面小编就给大家带来coreldraw箭头添加教程,有需要的小伙伴不要错过哦。 coreldraw添加箭头方法 1、首先选择桌面Coreldraw格式图片。 2、然后点击文件夹按钮打开文件。 3、最后点击上方工具横线&#xff0c…

免费的ChatGPT网站 ( 7个 )

ChatGPT的核心功能是基于用户在输入时的语言或文本生成相应的回复或继续内容。此外,它还能够完成多种任务,如撰写邮件、视频脚本、文案、翻译、代码编写以及撰写论文等。 博主归纳总结了7个国内非常好用,而且免费的chatGPT网站,AI…

qt学习:http+访问百度智能云api实现车牌识别

目录 登录到百度智能云,找到文字识别 完成操作指引 开通 查看车牌识别的api文档 ​编辑​编辑 查看自己应用的api key 查看回应的数据格式 编程步骤 ui界面编辑 添加模块,头文件和定义变量 新建两个类,一个图像Image类&#xff0c…

使用Docker部署WBO白板并结合内网穿透实现远程访问WBO白板

本文主要是如何使用Docker部署WBO白板并实现公网地址远程访问的文章,如果有什么需要改进的地方还请大佬指出⛺️ 🎬作者简介:大家好,我是青衿🥇 ☁️博客首页:CSDN主页放风讲故事 🌄每日一句&am…

leetcode刷题(剑指offer)54.螺旋矩阵

54.螺旋矩阵 给你一个 m 行 n 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。 示例 1: 输入:matrix [[1,2,3],[4,5,6],[7,8,9]] 输出:[1,2,3,6,9,8,7,4,5]示例 2: 输入:ma…

编译和链接哪个才是最“猴急”的呢???

本篇会加入个人的所谓‘鱼式疯言’ ❤️❤️❤️鱼式疯言:❤️❤️❤️此疯言非彼疯言 而是理解过并总结出来通俗易懂的大白话, 我会尽可能的在每个概念后插入鱼式疯言,帮助大家理解的. 🤭🤭🤭可能说的不是那么严谨.但小编初心是能让更多人能…

Windows Server 2003 DNS服务器搭建

系列文章目录 目录 系列文章目录 文章目录 前言 一、DNS服务器是什么? 二、配置服务器 1.实验环境搭建 2.服务器搭建 3)安装Web服务器和DNS服务器 4)查看安装是否成功 5)这里直接配置DNS服务器了,Web服务器如何配置我已经发布过了 文章目录 Windows Serve…

从困惑到精通:私域运营领域10大问题全解析!

一、到底什么是“私域” 在探讨私域流量的实际操作之前,品牌需要对“私域”有一个清晰的认识。那么,究竟什么是“私域”呢? 有观点认为,私域是品牌方掌握的用户群体,这些用户可以在品牌的运营、沉淀和变现中发挥重要…

【第二十二课】最短路:dijkstra算法 ( acwing849 / acwing850 / c++ 代码)

目录 dijkstra算法求最短距离步骤 朴素的dijkstra算法---acwing-849 代码如下 代码思路 堆优化版的dijkstra算法---acwing-850 代码如下 关于最短路问题分有好几种类型 : 单源就是指:只求从一个顶点到其他各顶点 多源是指:要求每个顶…