本文旨在 总结线程安全问题的原因和解决方案
多线程带来的风险——线程安全
线程是并发执行 调度是随机的
1序言——实例
1.1.start 和join
我们可以通过start()方法启动线程 再使用 join() 方法使得对应线程 先执行完毕

T1 T2线程谁先join 谁后join无所谓
(1) t1先结束 t2后结束:
main在t1.join阻塞等待 t1结束 main 在t2.join阻塞等待 t2结束
main继续执行后续打印——最终结果,打印的值,是t1 t2都执行完的值

(2) t2先结束 t1后结束:
main在t1.join阻塞 等 t2结束 t1.join继续阻塞 t1结束 t1.join继续执行
main执行到 t2.join (由于t2 已经结束 此处是t2.join是不会阻塞的)
main继续执行后续打印——最终结果,打印的值,还是t1 t2都执行完的值
以上两者 总的阻塞时间都一样 区别在于是 两个join各自阻塞一会 还是在一个join全都阻塞完
1.2.原子操作

以 count++ 为例
看起来是一行代码 但是站在CPU的视角 是三条指令
1.load 把内存中的值 (count变量)读取到cpu寄存器
2.add 把指定寄存器的值,进行+1操作(结果还是在这个寄存器里)
3.save 把寄存器中的值 写回到内存中
CPU执行这三条指令的过程中,随时可能触发线程的调度切换
2线程安全问题 产生的根本原因
2.1五个常见原因
1.【根本】操作系统对线程的调度 是随机的——抢先式执行
2.多个线程同时修改同一个变量
3.修改操作,不是原子的
4.内存可见性问题,引起的线程不安全
5.指令重排序 引起的线程不安全
2.2如何解决线程安全问题
1.【根本】操作系统对线程的调度 是随机的——抢先式执行
——操作系统的底层设定,普通程序员无法左右
2.多个线程同时修改同一个变量
——调整代码结构,规避一些线程不安全的代码(此方案不够通用)
3.修改操作,不是原子的
——Java中解决线程安全问题 最主要的方案—— 加锁
通过加锁操作,把非原子操作 打包成原子操作
把锁 “锁上” 称为“加锁”
把锁 “解开”称为“解锁”
一旦把锁加上,其他人想要加锁,只能进行阻塞等待
针对于上文 实例2 就可以使用锁 把非原子操作 count++ 包裹起来
在count++之前 加锁 计算结束后,再解锁
3.加锁操作 Synchronized
3.1常规写法
加锁前 需要定义锁对象 一般常用Object 类型

基本用法如下

为了解决线程安全问题——两个线程,针对同一个对象加锁,才会产生互斥效果
(一个线程上锁,另一个就要阻塞等待,等到第一个i线程释放,才有机会)
如果是不同的锁对象,此时不会有互斥效果,线程安全问题,没有得到改变
synchronized(locker){ XXXX }
其中 { 表示加锁 } 表示释放锁
3.2变种写法
可以使用synchronized 修饰方法
用synchronized修饰代码块

可变形为:

使用synchronized修饰普通方法 相当于 给this加锁
此外如果
使用synchronized修饰静态方法,相当于给 类对象 加锁

4.可重入
4.1由来

实际开发中很容易 写出两次一样的加锁
内层产生阻塞等待,等到前一次加锁被释放,第二次加锁的阻塞才会接触(继续执行)
1.第一次加锁可以成功(锁没有人用)
2.第二次进行加锁 会触发阻塞等待 因为锁对象已经被占用
为了解决上述问题 Java中synchronized就引入可重入
4.2具体实现
当某个线程针对一个锁,加锁成功后
后续该线程再次针对这个锁进行加锁——不会触发阻塞,而是继续往下执行
因为当前这把锁被这个线程持有——但如果是其他线程尝试加锁,就会正常阻塞
可重入锁的实现原理——关键在于让锁对象,内部保存,当前是哪个线程持有的这把锁
后续有线程针对 这个锁加锁的时候,对比一下,锁持有者的线程和请求者的线程是否相同
同理 可以引入一个变量 计数器(0)
每次触发 { 计数器++ 每次触发 } 计数器--
当计数器--为0时,就是真正需要解锁的时候

5.死锁
一个线程 一把锁 连续加锁两次
两个线程 两把锁 每个线程获得一把锁之后,尝试获取对方的锁
N个线程 M把锁——哲学家就餐问题
5.1死锁的构成
1.锁是互斥的,一个线程拿到锁之后,另一个线程再尝试获得锁,必须要阻塞等待
2.锁是不可抢占的(不可剥夺) t1拿到锁后,t2想获得此锁,只能阻塞等待,不可抢占
3.请求和保持 一个线程拿到锁1之后,不释放锁1的前提下,获取锁2
——把嵌套的锁改成并列的锁
4.循环等待 多把锁互相等待形成循环(A等B B等C C等A)
——约定好加锁顺序就可以破除等待循坏
5.2小结

6.内存可见性
6.1具体表现
——构成线程安全问题的原因之一

T1线程负责读取 T2线程负责修改

虽然输入非0值 但此时t1线程循环没有结束
故这是一个BUG——修改线程修改的值,没有被读取线程成功读到——”内存可见性问题“
原因:在多线程程序中,编译器的判断会出现失误,导致逻辑出错
6.2 Volatile——解决内存可见性问题
在语法中 引入volatile关键字 修饰某个变量
此时编译器对这个变量的读取操作,不会被优化成寄存器


t2修改了 t1能及时看见
7.Wait / Notify
7.1 基本含义
等待/通知 ——用于协调线程之间的执行逻辑的顺序
可以让后执行的逻辑,等待先执行的逻辑 先跑
虽然无法直接干预 调度器的调度顺序
但是可以让后执行的逻辑(线程)等待,等待到 先执行的逻辑跑完了
通知当前的线程,让他继续执行
7.1 join和wait的区别
join 是等另一个线程彻底执行完,才继续走
wait 等待另一个线程执行 notify 唤醒 ,才继续走(并不需要另一个线程执行完)
锁的等待是不受控制的
当多个线程竞争一把锁的时候
获取到锁的线程如果释放了,接下来是哪个线程获得锁? ——不确定(随机调度)
操作系统的调度是随机的
其他线程都属于在锁上阻塞等待,是阻塞状态
当前这个释放锁的线程,是就绪状态——很有可能再次拿到这个锁
例子: 把CPU视为鸟妈妈 每个线程视为鸟宝宝
资源分配不合理 容易造成线程饥饿 甚至线程饿死
当拿到锁的线程,发现要执行的任务,时机还不成熟,就用wait阻塞等待
wait 和notify 是Object的方法——Java中的任意对象都提供了 wait 和notify方法
7.3锁的释放时机
wait 关键在于——要先释放锁,给其他线程获得锁的机会
要先上锁,才谈释放

同理 要先拿到锁 再唤醒线程

务必确保 先wait 后notify 才有作用
如果先notify 后wait ,此时wait无法被唤醒
notify这个线程没有副作用 (不报错)
8.wait 和 sleep区别
wait 和join类型 ——同样提供“死等” “限时等待”版本

wait有等待时间 sleep也有等待时间
wait可以使用notify提前唤醒 sleep也可以用Interrupt提前唤醒
最主要的区别在于——对锁的操作
- wait 必须要搭配锁 ,先加锁 再使用 wait sleep不需要
- 如果都是在 synchronized 内部使用,wait会释放锁 sleep不会释放锁

抱着锁 睡 其他线程无法获取这个锁
2405

被折叠的 条评论
为什么被折叠?



