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

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

线程安全问题产生原因 

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

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.dtcms.com/a/350206.html

相关文章:

  • React Native核心技术深度解析_Trip Footprints
  • 电商商品管理效率低?MuseDAM 系统如何破解库存混乱难题
  • AR技术:航空维修工具校准的精准革命
  • 【python】if __name__ == ‘__main__‘的作用
  • 正则表达式 —— \s*
  • C语言运行时候出现栈溢出、段错误(Segmentation fault)、异常码解决?
  • 车灯最新测试标准测试设备太阳光模拟器
  • Kafka 在 6 大典型用例的落地实践架构、参数与避坑清单
  • 【Flink】运行模式
  • Rust Async 异步编程(五):async/.await
  • 怎么把iphone文件传输到windows电脑?分场景选方法
  • 【ansible】roles的介绍
  • 【完整源码+数据集+部署教程】化妆品实例分割系统源码和数据集:改进yolo11-DynamicConv
  • 【C#】.net framework 4.8非常久远的框架如何把日期格式/Date(1754548600000)/以及带T的2025-08-07T14:36:40时间格式转为统一的格式输出
  • 并发编程原理与实战(二十六)深入synchronized底层原理实现
  • 京东API分类接口实战指南:获取各类商品信息
  • Microsoft 365 中的 School Data Sync 功能深度解析:教育机构数字化管理的智能核心
  • Android音频学习(十五)——打开输出流
  • 如何用DeepSeek让Excel数据处理自动化:告别重复劳动的智能助手
  • 面试手写 Promise:链式 + 静态方法全实现
  • 扣子智能体商业化卡在哪?井云系统自动化交易+私域管理,闭环成交全流程拆解
  • 3491定期复盘代码实现设计模式的忌假应用
  • 使用Docker配置Redis Stack集群的步骤
  • React 19 与 Next.js:利用最新 React 功能
  • SQL性能调优
  • HTTP、HTTPS 与 WebSocket 详解
  • UDS诊断案例-新能源汽车电池管理系统(BMS)诊断
  • Git提交流程与最佳实践
  • debug kernel 的一些trace的方法
  • 嵌入式Linux内核编译与配置