Java多线程_0x3_
多线程篇三
如笔者理解有误,欢迎交流指正⭐
线程安全
划重点!!!
什么是线程安全
想对线程安全做一个具体清晰的解释很困难,为什么这么说?当然是具体情况具体分析.(就像很多人都喜欢吃面条 有的人喜欢吃炸酱 有的人喜欢吃刀削【其他不具体拓展 因为饿了w】可以具体细分)
但是!我们可以这样认为 如果多线程环境下代码运行的结果是符合我们预期的 ,即使在单线程环境应得的结果,则说明这个线程是安全的.
产生线程安全的原因
抢占式执行(系统内核)
上我们熟悉的实例代码
1 | // 线程安全 |
我们的预期情况是输出100000 但是实际运行结果如下
![img](C:\Users\lenovo\Documents\Tencent Files\3023536144\nt_qq\nt_data\Pic\2024-09\Ori\6060fd35ca0792320d83c150d9658e61.png)
不是预期值且可以发现多次运行的结果并不相同
这是为什么?
1 | for (int i = 0; i < 50000; i++) { |
这部分代码就是典型的线程安全问题.
t1和t2这两个单独的线程在单独执行过程中,毫无疑问没有任何问题.
但是t1和t2两个线程并发执行上述循环,会出现逻辑上的问题.
1 | t1.start(); |
这样是t1和t2同时执行,多个线程执行上述代码时,由于线程之间的调度顺序是”随机”的,在某些调度顺序下会出现逻辑问题.
站在CPU的角度上,count++是由CPU的三个指令实现的.
1.load 把数据从内存中读取到CPU中
2.add 把寄存器中的数据进行+1
3.save 把寄存器中的数据保存到内存中
在上述3个步骤执行过程中,其实有无数种排列组合的方式.
上述代码执行完之后发现了bug
两个线程本应该是分别自增1次,但2个线程在自增过程中并没有累加而是各自独立运行,故预期得到2,实际只有1.【确实够”随机” 笑】
注意 我们得到的”随机值”一定小于100000但也存在小于50000的值(如果t1自增一次的过程中 t2自增多次【如2次】相当于自增了3次 只有一次生效了)
怎么样保持t1执行完再执行t2呢?
控制t1执行时t2未启动即可.
1 | // 线程安全 |
同一个变量被两个线程都修改
解决方法:
1.一个线程对同一个变量进行修改 ok
2.两个线程针对不同变量进行修改
3.两个线程针对一个变量读取
非原子的修改操作
什么是原子性
比如我(线程A)登录一个手游(一段代码),在我未退出游戏时,我们的游戏好友(线程B)也可以登录游戏组队打本.(打断我在游戏中的隐私)这就是不具备原子性
听起来是不是和抢占式很像?真聪明hh
如果线程不是“抢占”的就算没有原子性
一个java语句不一定是原子的,也不一定只是一条指令
上述count++先进行 了读取再进行修改操作
若一段逻辑中存在要根据一定条件决定是否修改也存在类似的问题
解决方法:想办法让count++一次性被被CPU完成上述3个步骤
内存可见性问题
可见性指的是一个线程共享变量值的修改能被其他线程看到
Java内存模型(JMM)Java虚拟机规范了Java内存模型
目的是屏蔽掉各种硬件和操作系统的内存访问差异 以实现Java程序在各种平台下都能达到一致的并发效果
线程之间共享变量存储在主内存中
每一个线程都有自己的”工作内存”
当线程要读取一个共享变量的时候 会先把变量从主内存拷贝到工作内存中 再从工作内存读取数据
当线程要修改一个共享变量的时候 会先修改工作内存中的副本 再同步回主内存
**工作内存像是一个”枢纽” 其实就是CPU的寄存器和高速缓存 **
指令重排序问题
比如我们用代码实现
1.去茶话弄取餐
2.去菜鸟拿快递
3.去买曹氏
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,不止是规规矩矩按1->2->3执行,而是可以按1->3->2执行(其他顺序也可以)这就叫指令重排序.
如何解决上述问问题呢?
简单暴力:加锁!
解决线程安全问题的方法
synchronized关键字
特性一互斥
synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象的synchronized
synchronized用的锁存在于Java对象里.
1 | synchronized () { |
()中需要表示一个用来加锁的对象.对象是什么不重要,只是通过这个对象区分两个线程是都在竞争一个同一个锁
如果两个线程是针对同一个对象加锁 就会出现锁竞争
如果不是争对同一个对象进行加锁就是正常的并发执行
1 | for (int i = 0; i < 50000; i++) { |
这样写两个线程的执行顺序就会相互影响 可以理解为**”并发执行”转变为”串行执行”**(两个线程同时尝试对一个对象加锁,出现锁竞争,一个线程能拿到线程袭击执行,另一个线程只能阻塞等待.等前一个线程释放锁之后,才机会拿到锁继续执行.)
特性二刷新内存
synchronized工作过程:
1.获得互斥锁
2.从主存拷贝变量的最新副本到工作内存中
3.执行代码
4.将更改后的共享变量的值刷新到主内存
5.释放互斥锁
synchronized也能保存内存可见性【待考证 扒源码一探究竟😀】
特性三可重入
synchronized同步块对同一条线程来说是可重入的,不会出现把自己锁死的情况.(指的就是一个线程连续对一把锁加锁两次不会出现死锁即为”可重入”)
让锁记录哪个线程让它被锁住 后续再加锁的时候 如果加锁线程就是持有锁的线程就直接加锁成功
比如说你翘课了 刚好老师点名你不在 你就被老师标记了
下一次老师点名肯定不会拒绝会再点一次你(无恶意 问就是血泪史)
如果换成一个经常坐前排的同学那就不会是这个结果了.
1 | public class Demo15 { |
tips
无论有多少层都是要在最外层才能释放锁
可以引用计数器来确定有多少层或者记录是否真的释放锁成功(计数器值为0即可)
死锁
1.一个线程针对一把锁连续加锁两次,如果不是可重入锁,就是死锁.
比如把钥匙锁在屋里了进门又需要钥匙就为死锁.
1 |
|
2.两个线程,两把锁(无论是不是可重入锁都会死锁)
1 |
|
出现死锁是因为两个线程都卡在了获取对方已经得到锁的位置.
n个线程得到m把锁
OS课上老师讲过的哲学家就餐问题
死锁的四个必要条件
1.互斥使用
一个线程拿到锁A另一个线程也想拿到锁A,就需要阻塞等待.
2.不可抢占
一个线程拿到锁之后其他线程想拿到这个锁只能等待线程A释放这个锁
3.请求保持
一个线程拿到锁A之后,在获取A的基础上还想获取锁B.(吃着碗里的看着锅里的)
4.循环等待
两个或多个线程都想互相获取对方的锁,都进入等待队列.(最容易被破坏的 但修改加锁顺序可避免 )
解决死锁的方案也就是破坏上述四个条件任何一个即可
1)引入一个额外的锁
2)去掉一个线程
3)引入计数器
4)引入加锁规则【比较推荐 普世性高->sychronized】
synchronized的使用方法
1)修饰代码块
锁任意对象
1 | public class Demo { |
锁任意对象
1 | public class Demo { |
2)修饰实例方法
注意 锁对象是什么不重要 重要的是两个线程中的对象是否是一个对象
1 | class Counter { |
3)修饰静态方法
针对类对象加锁
1 | synchronized public static void increase1() { |
注意 类对象在一个java进程中是唯一的(代码中写了一个Counter的类对象,不会有多个)
Java标准库中的新城安全机制
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
如:
1 | ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder |
有一些是线程安全的.使用了一些锁机制来控制.
如:
1 | Vector(不推荐使用),HashTable(不推荐使用),ConcurrentHashMap,StringBuffer(不推荐使用) |
还有的是没有加锁,但因为不涉及修改的特殊类,也是线程安全的.
1 | String |