(多线程)线程安全和线程不安全 产生的原因 synchronized关键字 synchronized可重入特性死锁 如何避免死锁 内存可见性 - 详解

news/2025/9/21 22:23:54/文章来源:https://www.cnblogs.com/tlnshuju/p/19104292

(多线程)线程安全和线程不安全 产生的原因 synchronized关键字 synchronized可重入特性死锁 如何避免死锁 内存可见性 - 详解

2025-09-21 22:20  tlnshuju  阅读(0)  评论(0)    收藏  举报

线程安全问题产生原因 

线程安全问题主要发生在多线程环境下,当多个线程同时访问共享资源时,
如果没有采取适当的同步措施,就可能导致数据不一致或程序行为异常

1.[根本]操作系统对于线程的调度是随机的.抢占式执行,这是线程安全问题的罪魁祸首
随机调度使一个程序在多线程环境下,执行顺序存在很多的变数.
程序猿必须保证在任意执行顺序下,代码都能正常工作.
2.多个线程同时修改同一个变量

抢占式执行策略
最初诞生多任务操作系统的时候,非常重大的发明
后世的操作系统,都是一脉相承

t1和t2线程都在修改同一个值:修改的是同一个内存空间

如果是一个线程修改一个变量--没问题
如果是多个线程,不是同时修改同一个变量--没问题
如果多个线程修改不同变量--没问题:不会出现中间结果相互覆盖的情况
如果多个线程读取同一个变量--没问题

变量进行修改.
上面的线程不安全的代码中,涉及到多个线程针对count变量进行修改,此时这个count是一个多个线程都能访问到的"共享数据"

3.修改操作,不是原子的

如果修改操作,只是对应到一个cpu指令,就可以认为是原子的
cpu不会出现"一条指令执行一半"这样的情况的
如果对应到多个cpu指令,就不是原子的

4.内存可见性问题引起的线程不安全
5.指令重排序引起的线程不安全

线程安全问题
一段代码,在多线程中,并发执行后,产生bug.
2.原因
1)操作系统对于线程的调度是随机的.抢占式执行[根本]
2)多个线程同时修改同一个变量
3)修改操作不是原子的
4)内存可见性->编译器优化
5)指令重排序
原子性介绍

什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证A进入房间之后,还没有出来;B是不是也可以进入房间,打断A在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A进去就把门锁上,其他人就进
不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条java 语句不一定是原子的,也不一定只是一条指令
是由三步操作组成的:
1.从内存把数据读到CPU
2.进行数据更新
3.把数据写回到CPU
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断,这个过程的结果很有可能是错误的。
这点也和线程的抢占式调度密切相关.如果线程不是"抢占"的,就算没有原子性,也问题不大.

如何解决线程安全问题

根据它产生的原因来解决:
1.[根本]操作系统对于线程的调度是随机的.抢占式执行
操作系统的底层设定.咱们左右不了

我能否自己写个操作系统,取缔抢占式执行,不就解决线程安全问题了嘛??
理论上当然可行,实际上难度太大了  1)技术上本身就非常难  2)推广上难上加难.

2.多个线程同时修改同一个变量,和代码的结构直接相关
调整代码结构,规避一些线程不安全的代码的,但是这样的方案,不够通用.
有些情况下,需求上就是需要多线程修改同一个变量的

超买/超卖的问题--某个商品,库存100件,能否创建出101个订单?

Java中有个东西
String就是采取了"不可变'特性,确保线程安全.
String是咋样实现的"不可变"效果??-private修饰

而是说,String没有提供public 的修改方法. 和final没有任何关系!!
String的final用来实现"不可继承'

3.修改操作,不是原子的.
Java中解决线程安全问题,最主要的方案.-----加锁
通过加锁操作,让不是原子的操作,打包成一个原子的操作.

计算机中的锁,和生活中的锁,是同样的概念.互斥/排他

其他人就得等待

把锁"锁上"称为"加锁"     把锁"解开"称为"解锁"
一旦把锁加上了,其他人要想加锁,就得阻塞等待

就可以使用锁,把刚才不是原子的count++包裹起来.
在count++之前,先加锁.然后进行count++.计算完毕之后,再解锁

执行3步走过程中,其他线程就没法插队了~~
加锁操作,不是把线程锁死到cpu上,禁止这个线程被调度走
但是是禁止其他线程重新加这个锁,避免其他线程的操作在当前线程执行过程中,插队

加锁/解锁本身是操作系统提供的api,很多编程语言都对于这样的api进行封装了.
大多数的封装风格,都是采取两个函数
加锁lock();//执行一些要保护起来的逻辑
解锁 unlock();

synchronized关键字

Java 中,使用synchronized这样的关键字,搭配代码块,来实现类似的效果的.
//进入代码块,就相当于加锁
synchronized{
//执行一些要保护的逻辑
}//出了代码块,就相当于解锁

()填写啥呢??    填写的是,用来加锁的对象.
要加锁,要解锁,前提是得先有一个锁  在Java中,任何一个对象,都可以用作"锁:

这个对象的类型是啥,不重要
重要的是,是否有多个线程尝试针对这同一个对象加锁(是否在竞争同一个锁)

两个线程,针对同一个对象加锁,才会产生互斥效果.
(一个线程加上锁了,另一个线程就得阻塞等待,等到第一个线程释放锁,才有机会)

下面这种不是同一个锁:

如果是不同的锁对象,此时不会有互斥效果,线程安全问题,没有得到改变的.

解决线程安全问题,不是你写了synchronized就可以.
而是要正确的使用锁~~
1)synchronized{}代码块要合适.
2)synchronized()指定的锁对象也得合适.

这俩线程并发执行过程中,相当于只有count++这个操作,会涉及到互斥
for循环里的条件判断(i<50000)和i++这俩操作不涉及到互斥

意味着整个for循环,ir<50000,i++,count++   都是"互斥"的方式执行的

如果t2是后获取锁  t1就已经lock完成了.  t2的lock就会阻塞.
等到t1执行完unlock  t2才会继续执行

保证每次循环内部的count++  在两个线程之间是串行执行的

这个写法中,只是每次count++之间是串行的for中的i<5w和i++则是并发的.执行速度更快

Java 中为啥使用synchronized+代码块做法?
而不是采用lock+unlock函数的方式来搭配呢?

就是为了防止unlock这个没有写,代码中间抛出异常  也可能使unlock执行不到

Java采取的synchronized,就能确保,只要出了}一定能释放锁.无论因为return还是因为异常
无论里面调用了哪些其他代码,都是可以确保unlock操作执行到的.

使用synchronized修饰add方法 相当于对该方法进行加锁

多个线程针对同一个对象加锁   才会产生互斥(锁冲突/锁竞争)

synchronized修饰普通方法,相当于是给this加锁
synchronized修饰静态方法,相当于给类对象加锁

synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized就会阻塞等待.

进入synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁

死锁
一旦代码触发了死锁,此时线程就卡住了.
原因
1)互斥
2)不可剥夺/不可抢占
3)请求和保持
4)循环等待
解决死锁
1)避免锁嵌套=>打破3)
2)约定加锁顺序=>打破4)
synchronized可重入特性

synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

理解"把自己锁死"----一个线程没有释放锁,然后又尝试再次加锁.
//第一次加锁,加锁成功
lock();
//第二次加锁,锁已经被占用,阻塞等待.
lock();

阻塞等待   等到前一次加锁被释放,第二次加锁的阻塞才会接触(继续执行)

看起来是两次一样的加锁,没有必要.   但是实际上开发中,很容易写出这样的代码的

一旦方法调用的层次比较深,就搞不好容易出现这样的情况

要想解除阻塞,需要往下执行才可以.   要想往下执行,就需要等到第一次的锁被释放

这样的问题,就称为"死锁"

1.第一次进行加锁操作,能够成功的(锁没有人使用)
2.第二次进行加锁,此时意味着,锁对象是已经被占用的状态.第二次加锁,就会触发阻塞等待

为了解决上述的问题,Java的synchronized就引入了可重入的概念.

当某个线程针对一个锁,加锁成功之后   后续该线程再次针对这个锁进行加锁,
不会触发阻塞,而是直接往下走     因为当前这把锁就是被这个线程持有
但是,如果是其他线程尝试加锁,就会正常阻塞

死锁是一个非常严重的bug.使代码执行到这一块之后,就卡住.

可重入锁的实现原理,关键在于让锁对象内部保存,当前是哪个线程持有的这把锁
后续有线程针对这个锁加锁的时候,对比一下,锁持有者的线程是否和当前加锁的线程是同一个

如何自己实现一个可重入锁?
1.在锁内部记录当前是哪个线程持有的锁.后续每次加锁,都进行判定
2.通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁.

先引入一个变量,计数器(0)
每次触发{的时候把计数器 :++
每次触发}的时候,把计数器--
当计数器--为0的时候,就是真正需要解锁的时候

死锁

关于死锁
一个线程,一把锁,连续加锁两次
两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁

通俗来讲就是:两者互不相让 就会构成死锁

必须是,拿到第一把锁,再拿第二把锁.(不能释放第一把锁)

此时我们查看控制台

该线程,因为竞争锁的缘故而阻塞了.

这样就构成了死锁

如果不加sleep,很可能t1一口气就把locker1和locker2都拿到了.这个时候,t2还没开动呢~~
自然无法构成死锁.

死锁的概率 和当前电脑的运行环境有关系的
看你的当前机器上运行的任务多不多,系统调度的频次是怎样的.......

死锁的第三种情况.N个线程M把锁.
一个经典的模型,哲学家就餐问题

如何避免死锁

如何避免代码中出现死锁呢?
死锁是怎样构成的   构成死锁的四个必要条件(重要)
1.锁是互斥的.一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待.

2.锁是不可抢占的.线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待
(不可剥夺)而不是线程2直接把锁抢过来

互斥是指同一时间只能有一个线程持有锁 不可抢占是指线程获取锁之后,其它线程不能强制剥夺锁 只能等它主动释放

至少,Java 的synchronized是遵守这两点

除非是你自己实现一个锁,解决特定的问题
可以打破这两点.至少各种语言内置的锁/主流的锁实现,都是会遵守这两点

3.请求和保持.一个线程拿到锁1之后,不释放锁1的前提下,获取锁2
如果先放下左手的筷子,再拿右手的筷子,就不会构成死锁
4.循环等待.多个线程,多把锁之间的等待过程,构成了"循环"
A等待B,B也等待A或者A等待B,B等待C,C等待A

破坏掉上述的3或者4任何一个条件   都能够打破死锁

有些情况下,确实是需要拿到多个锁,再进行某个操作的.(嵌套,很难避免)

所以 第三步有时候是不能打破的

约定,每个线程加锁的时候   永远是先获取序号小的锁   后获取序号大的锁

约定好加锁的顺序,就可以破除循环等待了.

死锁的小结
1.构成死锁的场景
a)一个线程一把锁=>可重入锁
b)两个线程两把锁=>代码如何编写
c)N个线程M把锁=>哲学家就餐问题
2.死锁的四个必要条件
a)互斥b)不可剥夺c)请求和保持d)循环等待
3.如何避免死锁
打破c)和d)

也不是写了synchronized就100%线程安全.  得具体代码具体分析

这三个兄弟,虽然有synchronized.     不推荐使用.
加锁这个事情,不是没有代价的.
一旦代码中,使用了锁,意味着代码可能会因为锁的竞争,产生阻塞=>程序的执行效率大打折扣.

线程阻塞=>从cpu上调度走.    啥时候能调度回来继续执行????不好说了

内存可见性

可见性指,一个线程对共享变量值的修改,能够及时地被其他线程看到.

内存可见性是造成线程安全问题的原因之一.

虽然输入了非0的值    但是此时t1线程循环并没有结束.
t1线程持续执行

很明显,这个也是bug--------线程安全问题.
一个线程读取,一个线程修改--------修改线程修改的值,并没有被读线程读到.
"内存可见性问题"

编译器,虽然声称优化操作,是能够保证逻辑不变.尤其是在多线程的程序中,编译器的
可能导致编译器的优化,使优化后的逻辑,和优化前的逻辑出现细节上的偏差:
研究JDK的程序员,就希望通过让编译器&JVM对程序员写的代码,自动的进行优化
本来写的代码是进行xxxxx,编译器/VM会在你原有逻辑不变的前提下,对你的代码进行调整.
使程序效率更高

编译器,虽然声称优化操作,是能够保证逻辑不变.尤其是在多线程的程序中,编译器的判断可
可能导致编译器的优化,使优化后的逻辑,和优化前的逻辑出现细节上的偏差

上面的这个循环操作:

短时间之内,这个循环,就会循环很多次

load是读内存操作  cmp是纯cpu 寄存器操作
load的时间开销可能是cmp的几千倍

jvm:执行这么多次读flag的操作   发现值始终都是0.
既然都是一样的结果既然还要反复执行这么多次
于是就把读取内存的操作,优化成读取寄存器这样的操作
(把内存的值读到寄存器了.后续再load不再重新读内存,直接从寄存器里来取)

于是,等到很多秒之后,用户真正输入新的值,真正修改flag,
此时t1线程,就感知不到了.(编译器优化,使得t1线程的读操作,不是真正读内存

修改一下上面的代码:

本来这个循环,转的飞起   1s钟几千万次,上亿次.....
但是加了sleep(1)之后    循环次数大幅度降低了.
当引入 sleep 之后,sleep消耗的时间相比于上面load flag的操作,就高了不知道多少了.
假设本身读取flag的时间是1ns的话,如果把读内存操作优化成读寄存器,1ns=>0.xxns,优化50%以上
如果引入sleep,sleep直接占用1ms.此时又不优化flag无足轻重.

所以就不会进行优化操作了

编译器的优化,本身是一个比较复杂的工程
具体怎么优化,咱们作为普通程序员很难感知到

针对内存可见性问题,也不能指望通过sleep来解决
使用sleep大大影响到程序的效率.
希望,不使用sleep也能解决上述的内存可见性问题呢?

在语法中,引入volatile关键字:通过这个关键字来修饰某个变量,此时编译器这对这个变量的读取操作,就不会被优化成都寄存器.

这样的变量的读取操作,就不会被编译器进行优化了.

t2修改了,t1就能及时看到了

volatile解决内存可见性问题   不是解决原子性问题

2)volatile
编译器优化,出bug.
使用这个关键字修饰的变量,就属于"易失""易变"
必须每次重新读取内存中的数据了.

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

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

相关文章

Linux 笔记本充电限制【转发】

Linux 笔记本充电限制【转发】https://www.bilibili.com/opus/970651970527297540 最近主要把笔记本放家里用。在学校用的时候搬来搬去,当然充满电最好。然而如果是固定地用,只要插电就行了,电量无所谓。 对于大部分…

别样的CSP-S初赛大战(又名:我和油一的那些年)

一次平凡的出行勾起的胡思乱想。事实上,这篇文章和标题有关的内容并不是很多,所以我补了个又名。但它的全部确实是由此引起的。(引用可以这样使用么?><今年的气温转变得很突然。前两天我还在嫌弃机房的空调太…

第01周 预习、实验与作业:绪论与Java基本语法

1.【1】企业级服务器后端开发 【2】Web应用后端/API构建 【3】Android移动应用等邻域 2.【1】Java程序编译一次就会生成.class文件,在任何有安装Java虚拟机(JVM)的操作系统上都能运行,不用重新编译。 【2】要安装J…

刷新记录:TapData Oracle 日志同步性能达 80K TPS,重塑实时同步新标准 - 指南

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

范德蒙德卷积入门

范德蒙德卷积入门范德蒙德卷积 范德蒙德卷积(Vandermonde Convolution)是组合数学中的一个重要公式,常用来计算两个组合数的卷积。 定义 给出公式: \[\sum_{i=0}^{k}\binom{n}{i}\binom{m}{k-i} = \binom{n+m}{k} \]证…

详细介绍:算法题(203):矩阵最小路径和

详细介绍:算法题(203):矩阵最小路径和pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "…

JAVA中ArrayList主要语法(小白)

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

使用jdbcTemplate查询数据库

springboot2版本项目中已经整合了mybatis框架,yml文件中配置好了数据源, 现在想再使用jdbcTemplate查询另外一个数据库,需要怎么配置 # 这是你现有的MyBatis数据源配置(假设使用默认前缀) spring:datasource:url:…

STM32 单片机创建 - I2C 总线

STM32 单片机创建 - I2C 总线2025-09-21 21:55 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important…

线性结构之链表预备知识typedef[基于郝斌课程]

typedef:是c语言的一个关键字,用于给已知数据类型取一个别名 例如typedef unsigned char uint8;此时 uint8 也就是 unsigned char 与define的区别:typedef只可用于对数据类型进行取新名字,define无此限制 typedef由…

Excel滚动表格表头不见了,来回翻动很麻烦,Excel如何固定显示表头?

Windows 系统 冻结首行表头:选中第二行,按下 Ctrl + Shift + F,然后松开按键,再按 F 键,就能冻结首行,让表头始终显示。 冻结首列表头:选中第二列,按下 Ctrl + Shift + F,松开后按 C 键 ,即可冻结首列。 冻结…

asfp导入framework搭建环境

修改大内存,必须要32GB及以上 先关闭正在使用的 swapfile # 查看当前挂载的交换分区/文件 sudo swapon --show# 若显示有 /swapfile,执行以下命令关闭 sudo swapoff /swapfile# 创建新的 swapfile(以 16GB 为例,根…

赛前训练2 连通性问题

以下,斜体表示注意点,粗体表示技巧点。 A spfa 最长路、环具有特殊性质考虑缩点。 容易发现环上的点可以通过跑很多次直到点权全部为 \(0\),于是缩点跑 spfa 最长路即可。实现B 必经边考虑割边,割边考虑边双。 我们…

用 【C# + WinUI3 + 图像动画】 来理解:高数 - 函数 - 初等函数 - 行人-

我记性差,记不住数学公式,希望通过图像化来加深视觉记忆,转化为数学“感觉” “直觉”,在需要的时候,能够 “想到” 类似的场景。通过 C# + WinUI3,对 函数的分类、基本初等函数 进行了学习,图像中眼花缭乱的感…

ansible语句

when判断就是符合条件的话,就可以执行这个模块的内容了默认的when判断,识别变量名,不需要{{}}来引用变量,但是如果是值的话,就需要双引号或者单引号才行vars:name: rhel9when: name # 不需要引号when: "test…

Window 连接 Ubuntu远程桌面

打开Ubuntu远程桌面配置自动登录 编辑 GDM 配置文件(适用于 GNOME 桌面) sudo nano /etc/gdm3/custom.conf [daemon] AutomaticLoginEnable = true AutomaticLogin = 你的用户名 # 替换为实际用户名固定远程密码 创…

代码随想录算法训练营第四天 |24. 两两交换链表中的节点、19.删除链表的倒数第N个节点、面试题 02.07. 链表相交、142.环形链表II

24. 两两交换链表中的节点 思路:一次移动两个单位,然后交换两个节点,感觉不难。 注意的点:注意交换节点时候的顺序。/*** Definition for singly-linked list.* type ListNode struct {* Val int* Next *L…

提高杂题

初赛结束了,开始加训复赛。 来自 misaka16172 大手子的推荐。%%% 这里是题单链接:link CF1153E Serval and Snake *2200 交互,思维,二分 首先非常困难的一点就是要注意到当回答为奇数时,说明有恰好一个端点(头尾…

【比赛记录】2025CSP-S模拟赛51

A B C D Sum Rank100 9 54 - 163 11/24A. 算术 列个表格:\(a_i\to\)\(a_j\downarrow\) \(\le0\) \(1\) \(>1\)\(\le0\) ❎ ✅ ✅\(1\) ✅ ✅ ✅\(>1\) ✅ ✅ ❎记录当前 \(=1\)、\(>1\)、\(\ge1\) 的数量即可…