文章目录
- [*1. 前言*](#1. 前言)
- [*2. 正文*](#2. 正文)
-
- [1. Thread类及常见方法(续)](#1. Thread类及常见方法(续))
-
- [1.1 中断线程(续)sleep 淘气😜](#1.1 中断线程(续)sleep 淘气😜)
- [1.2 join() 线程等待](#1.2 join() 线程等待)
- [1.3 currentThread() 获取当前线程](#1.3 currentThread() 获取当前线程)
- [1.4 sleep() 休眠](#1.4 sleep() 休眠)
- [2. 线程状态](#2. 线程状态)
-
- [2.1 与进程状态的区别](#2.1 与进程状态的区别)
- [2.2 NEW 状态](#2.2 NEW 状态)
- [2.3 TERMINATED 状态](#2.3 TERMINATED 状态)
- [2.4 RUNNABLE 状态](#2.4 RUNNABLE 状态)
- [2.5 TIMED_WAITING 状态](#2.5 TIMED_WAITING 状态)
- [2.6 WAITING 状态](#2.6 WAITING 状态)
- [2.7 BLOCKED 状态](#2.7 BLOCKED 状态)
- [3. 线程安全问题 (重点)](#3. 线程安全问题 (重点))
-
- [3.1 线程不安全示例](#3.1 线程不安全示例)
- [3.2 出现 bug (76250)的 "罪魁祸首"](#3.2 出现 bug (76250)的 “罪魁祸首”)
- [3.3 两个线程的抢占执行🔍(出现 bug 的底层原因)](#3.3 两个线程的抢占执行🔍(出现 bug 的底层原因))
- [3.4 线程安全问题的产生原因🔎(5点)](#3.4 线程安全问题的产生原因🔎(5点))
- [3.5 如何解决线程不安全(5点中的3点)](#3.5 如何解决线程不安全(5点中的3点))
- [4. 锁 synchronized🔒](#4. 锁 synchronized🔒)
-
- [4.1 什么是加锁](#4.1 什么是加锁)
- [4.2 Java 如何加锁🔍(使用synchronized)](#4.2 Java 如何加锁🔍(使用synchronized))
- [4.3 加锁的注意事项](#4.3 加锁的注意事项)
- [4.4 线程不安全示例的最优解(加锁🔒)](#4.4 线程不安全示例的最优解(加锁🔒))
- [4.5 lock 与 unlock](#4.5 lock 与 unlock)
- [4.5 synchronized 的变种写法(看懂)](#4.5 synchronized 的变种写法(看懂))
- [*3. 结语*](#3. 结语)
A bold attempt is half success!
1. 前言
本文我们将继续深入学习JavaEE中多线程的知识,包含线程不安全问题,锁,本篇博客也正式开展我们JavaEE阶段的关键内容,内含丰富代码,请小伙伴们''细嚼慢咽''
2. 正文
1. Thread类及常见方法(续)
1.1 中断线程(续)sleep 淘气😜
书接上回,我们使用Thread.Interrupted方法来终止线程时,程序却发生报错


这是上篇博客中,小编为大家留下的悬念,"小编别卖关子了,你快说吧!",别急,不知道有没有小伙伴猜到这其实是sleep在搞鬼,"小编,这怎么能扯上sleep呢?" 正常来说,调用Interrupt方法就会修改while循环中isInterrupted的标志位为true,由于上述代码极大概率正在执行Thread.sleep(1000),调用Interrupted方法就把sleep唤醒了!
"可能有小伙伴会疑惑:sleep 被唤醒后,为什么会直接抛出异常呢?"大家不妨回忆一下,Java 中调用 Thread.sleep () 方法时,必须显式处理异常,而这里抛出的正是 InterruptedException。简单来说:一旦 sleep 过程中被 interrupt () 方法强行唤醒,sleep 就会立即触发 InterruptedException 异常。而我们上面的代码,在捕获到这个异常后,直接向上抛出了 RuntimeException,这也正是程序最终报错的原因!

如果把向上抛异常的操作,改为 break,也就是 sleep 被唤醒时,捕获到 Interrupted 异常,并执行 break,则会直接跳出while 循环

这样就可以使程序优雅的结束了🕶
那么问题又来了,如果这里捕获异常不处理的话,会发生什么呢??

"小编,当然是直接打印日志了,这还用问?"

这是上述代码执行的结果,会不会使你大吃一惊😮,线程竟然没有被终止,而是继续打印了,这是为什么???
原因还是由于 sleep 淘气鬼造成的!下面举一个🌰

派大星正在 sleep,与此同时,闹钟⏰响了,要叫派大星起床,但是今天是周六,派大星不需要上班工作

派大星被吵醒了很生气,用锤子把闹钟砸了,又继续 sleep 了
这里的闹钟相当于我们调用的 Interrupt 方法,去唤醒派大星,派大星醒了会看今天是周几,是否需要去工作,如果没有任何工作,则会砸掉闹钟,继续 sleep!
结合到我们的代码中去,当程序员调用 Interrupt 方法唤醒 sleep,并修改while循环中isInterrupted的标志位为true;与此同时 sleep 被唤醒首先会把 while 循环中 isInterrupted 标志位重新置为 false,再去捕获异常!也就是说派大星起床之后,不管要不要上班,它都会把闹钟⏰砸掉~~

同理如果我们将 catch 捕获中,填入 continue 会发生什么呢?


continue 会结束当前循环进入下一次循环,进入下一次前 sleep 已经将 isInterrupted 设为 false,那么就会一直循环下去。。。
另外从以上 Java sleep 的做法我们可以总结出:Java 中的线程终止不是一个强制性措施,不是main 线程让 t 线程终止(调用 Interrupted 方法等)t 就终止,选择权在 t 自己手上!
举一个🌰
小编的女友👩让小编下楼买袋盐,可小编正在打瓦,此时对于小编来说就有三个choose:
1)放下游戏,立马下楼买盐(不符合小编的作风)--> 线程立即终止
2)告诉女友,打完这把就下楼买 --> 线程稍后终止
3)装作没听见 --> 线程不终止
可能会有小伙伴认为,让线程终止就终止这不才是最好的方案吗?小编想说:强扭的瓜🍉不甜,强制的爱就会不爱!假设线程正在给长度 1w 的数组进行初始化操作,线程刚初始化了 5000 个元素,收到 main 线程的强制终止,就会导致剩下 5000 还是未初始化状态,使程序得到一个不上不下的结果,所以把决定权交给线程自己才是最好的选择🕶
1.2 join() 线程等待
(1) 不带参数 join()
多个线程之间是随机调度的,并发执行的,站在程序员的角度,我们不喜欢随机的东西,前两篇播客就强调程序员无法干预线程的随机调度,指定线程的执行顺序;但是join方法能够规定多个线程结束的先后顺序
join单词翻译过来是 加入 的意思,如果主线程中调用t.join(),那么就是让主线程等待t线程结束,大家这里一定不要搞反!

在main中调用t.join()也就是t加入main中,main必须要等t结束才结束
"小编我们可以通过sleep休眠时间来控制线程的结束顺序,为什么还要用join()啊?🤔" 虽然可以通过sleep休眠控制线程结束的顺序,但是有的情况下,sleep并不科学,例如有的时候希望t先结束,main马上紧跟着结束,此时再通过sleep设置时间的方式就会很繁琐,所以我们引入join方法可以完美解决这个问题!

当我们创建一个线程,并在主线程调用 join 方法时,发现竟然报错了??"小编,你怎么又瞎说??" 别急!我们看一下报错信息,与 sleep 一样,join 在使用时也需要处理一下异常

在 main 方法 声明一下 Interrupted 异常即可 !

小编把代码完善了一下,在 main 线程中调用 t.join 方法就会让 main 线程等待 t 先结束!

当执行到 t.join ,此时 main 线程就会进入''阻塞等待''状态,一直等到 t 线程执行完毕,join 才能继续执行;我们这里也可以使用 Jconsole 查看一下 main 线程的状态 ,只不过要把循环次数增加一些,小编就直接把 3次改为 5w 次并打开 Jconsole 了~


我们可以看到 main 线程的总等待数为 1;且代码追踪到 17 行,也就是 t.join 的位置
以上就是 join() 方法的具体使用方式。看到这里,是不是感觉 join() 给我们的编程开发带来了极大的便利?不过老话常说,任何事物都有两面性,join() 方法也不例外,既有实用的优点,也存在一定的局限性和缺点! So,小编下面开始指出 join 的不足的地方!
(2) 带参数join()
我们上面所说的 join 的等待具体的意思是:只要 t 线程不结束,主线程的 join 就会一直一直等待下去~~
举个🌰:

不知道大家是否约过自己的女神👩去看电影,假设小编今天 19:00 约女神去看《海绵宝宝》大电影,女神答应了小编的邀请;小编18:40 到达电影院门口,迟迟不见女神的身影,于是小编等了又等等了又等,一看表20:00 了,于是小编给女神打电话☎️,却迟迟得不到响应,于是小编只能一直等着女神等到第二天天亮~~
通过这个悲伤😭的🌰,相信大家能对 join 的缺点有所了解,小编这种死等的行为就是 join 方法,(等到天昏地暗~)相比于小编这种恋爱脑,更加理性的做法应该是:等到一定时间,咱就不等了,这位女神不让咱做沸羊羊,咱就找下一位女神👩~
为了解决''死等''这一现象 ,join 提供了带参数的版本,即指定"超时时间"也就是等待的最大时间,超出这个时间线程就不等了~

注意,这里传入的参数也是 ms(毫秒)级别的,使用带参数的 join 意味着 main 线程只会等待 t 线程 3s,3s 后就不等待了

换言之:如果 3s 之内,比如刚过 1s,t 就结束了,此时 jion 立即继续执行(不会等满 3s);如果 t 执行时间超过 3s,此时 join 也会继续执行,就不等了~
此外,join()也可以传入两个参数

第二个参数是纳秒;1s=1000ms;1ms=1000us;1us=1000ns;这是对线程等待时间的进一步精确,在日常开发中一般不会使用~
1.3 currentThread() 获取当前线程
在线程中断章节中,我们已使用过 currentThread() 方法,该方法会返回调用它的当前线程对象引用。

以main线程为例:#1代表线程Id;main代表线程名称;5代表线程优先级(默认值 0-10);第二个main代表线程所属的线程组(不过多解释)
该方法实现逻辑简单,此处不作过多展开说明。
1.4 sleep() 休眠

小编,我们前面都使用了这么久sleep方法,你怎么还要讲sleep呢?? 为了博客的完整性小编不得不在这再次强调一下sleep方法!
sleep()大家都知道是休眠的方法,程序写了sleep(1000),那么就真的休眠1秒吗??
答案是否定的 !实际上会比1秒略多亿点点🤏~ 这是为什么??当代码调用sleep,该线程不仅要进入休眠状态,还要让出cpu资源,后续时间到了,操作系统内核再把这个线程重新调度到CPU才能执行,也就是说这亿点点🤏是多在:线程需要让出CPU资源
其次小编还要补充一点,关于 sleep(0) 这一写法 :
"小编,休眠0秒有什么意义,这不就是没有休眠吗?" 大错特错!❌ 刚刚小编说遇到sleep方法线程就要让出CPU资源,sleep(0)是sleep的特殊写法,写了sleep(0)意味着让当前线程立即放弃CPU资源,给别的线程一点机会,并等待操作系统的重新调用
还记得小编是女神那个🌰吗?小编同时谈了🐱A;🐱B;🐱C 三位男友,有一天🐱A 去出差了就不能参与调度了,小编就无法约🐱A 了,一个月之后,🐱A 回来了,意味着小编可以随时约🐱A 出去玩耍,但是小编很有可能把🐱A 晾两三天惩罚他出差不陪小编,晾完之后再约🐱A~~
2. 线程状态
2.1 与进程状态的区别
首先回忆一下进程的状态,进程分为 就绪状态 ;阻塞状态 ,这是站在操作系统的视角下来看待的 !操作系统也为线程分配了五种状态,分别为新建态;就绪态;运行态;阻塞态;终止态,我们了解即可;重要的是站在Java视角下看待线程 ,Java 中的线程也是对操作系统的线程的封装 ,针对线程状态这里,Java也进行了重新封装并表示!
| 线程状态 | 说明 |
|---|---|
NEW |
安排了工作 ,但还没有开始行动 |
RUNNABLE |
可工作的,(就绪状态)可以分成正在工作和即将开始工作 |
BLOCKED |
表示排队等着其他事情 |
WAITING |
表示排队等着其他事情 |
TIMED_WAITING |
表示排队等着其他事情 |
TERMINATED |
表示工作完成了 |
Java规范中定义的线程状态只有这六种状态,我们一一分析
2.2 NEW 状态
NEW 状态的本质,是线程对象已通过 new Thread() 实例化,但尚未调用 start() 方法的初始状态。 接下来我们通过具体代码实例来深入理解它。

不知道大家是否还记得小编在上篇播客介绍线程方法✈️时,提过一嘴getState(),这个方法就是用来获取当前线程状态的!我们可以看到,创建线程后没有调用start(),线程状态即为NEW状态~
2.3 TERMINATED 状态
TERMINATED状态是指内核中的线程已经结束了,但 Thread 对象还在,此时线程状态就为TERMINATED状态 (终止状态)

线程 t 调用 start() 后会执行 run() 方法里的逻辑,打印出 hello thread。执行完成后,t 线程就会正常结束。而主线程 main 此时还在执行 Thread.sleep(1000),会暂停 1 秒。
等主线程休眠结束,再调用 t.getState() 时,t 线程早已执行完毕,所以打印出的状态就是 TERMINATED(终止状态)。
2.4 RUNNABLE 状态
RUNNABLE状态类似于进程的就绪状态,同样也分为:线程正在CPU上执行,和线程随时可以去CPU上执行

如图代码就是,t线程正在while死循环里工作(派大星正在吃🐔),此时查看t线程状态就会为RUNNABLE状态
2.5 TIMED_WAITING 状态
TIMED_WAITING 状态指 线程正在指定时间的阻塞,注意:是指定时间,不是死等,不是一直干等女神当沸羊羊,这个女神不给我们甜头,我们就换个女神🐑~

当我们在while死循环中,加入指定时间的休眠操作,并获取当前线程状态,我们就会发现此时 t 线程状态为 TIMED_WAITING 状态,也就是说,当我们调用 t.getState()方法,与此同时,t线程正在执行 sleep(3000) ~~
当然,有参数版本的join()方法,也可以触发线程的 TIMED_WAITING 状态

我们打开Jconsole小工具📦,查看当前main线程的状态

此时 main 线程被 Jconsole 捕获时,正在执行指定时间的等待操作,main 线程的状态也为 TIMED_WAITING 状态~
2.6 WAITING 状态
WAITING 状态就是我们所说的 死等,没有超时时间的阻塞等待 。。。 等到天荒地老~

我们再次使用Jconsole 查看当前 main 线程的状态

当前main线程就是WAITING 状态,t线程就是main线程的女神,main线程就是沸羊羊,一直等着 t 线程单身,等到天荒地老~~
2.7 BLOCKED 状态
BLOCKED状态也是一种阻塞等待,比较特殊,是由于 锁🔒 阻塞,我们后面介绍锁时会详细讲解~
小编,你突如其来抛出这么多知识给我,我哪能记得住?? 考虑到线程状态有点杂,小编利用一张简易图总结一下~

通过这张图可以帮助大家更好的记忆,我们也会在后面的学习中慢慢完善这张图~~
回过头来,介绍完这部分知识,不知道小伙伴们是否有一个疑问: 我了解线程的状态有什么用? 用处可大着呢~ 想想一个场景:我们在编写多线程代码中,出现了bug,小伙伴们会怎么做??
1)利用 Jconsole / 其他工具查看当前进程中所有线程,找到对应逻辑的线程是谁
2)看看当前线程的状态:如果看到的是阻塞状态,考虑是否没有及时唤醒,或出现死锁;如果看到的是RUNNABLE状态,则线程本身没有问题,考虑逻辑上某些条件没有预期触发~
3)再看线程具体的调用栈,也就是当前线程执行到哪一行
也就是说Jconsole 和 线程状态,对于我们校验代码帮助非常之大,所以小伙伴一定要好好消化这部分知识哦~
3. 线程安全问题 (重点)
3.1 线程不安全示例
线程安全问题是整个多线程的关键要点,重中之重!!如果不理解线程安全问题,则很难保证写出正确的多线程代码!!大家可能之前多少接触过这部分的知识,但是小编在这里要进行系统的全方位讲解,让大家充分理解这部分内容~~
"小编,线程不安全到底是啥样的,什么代码会出现线程安全问题?" 别急,🌰来咯~

"小编,这有什么好说的,两个线程分别对count 进行 50000 次++,再加起来不就是 100000?" 太天真了~ 多线程世界里,这种操作大概率会翻车!

结果输出:0 ,这是一个出乎意料的结果,为什么会输出0???
原因: 看到0,说明main线程先执行打印操作了,也就是 t1 t2 线程还没来得及对count进行++,main就直接打印了,可我们希望的是,先把 t1 t2 执行完再执行 main,结果与预期不符,这就是线程不安全!
那么问题又来了,如何解决这个问题? "小编,用 join() 等待一下不就好了~",想到这里的小伙伴都完全掌握了上面所学的知识 ,可 关键问题是 (1)怎么用 join()?(2)谁在前谁在后对代码是否有影响?(3)只用一个 join 是否可以?

首先回答第二个问题 ,t1 和 t2 俩线程,谁先 join 谁后 join都无所谓 ,因为 main 线程最后打印的是 count 的值,等待俩线程都结束再打印即可,与 join 先后顺序无关;而重心点需要放在 t1 和 t2 谁先结束谁后结束~
- t1 先结束, t2 后结束
main 先在 t1 阻塞等待;t1 结束后,main 再到 t2 阻塞等待;t2 结束后,main 继续执行后续打印 - t2 先结束, t1 后结束
main 先在 t1 阻塞等待,t2 结束,t1 未结束;t1 结束了, main 继续执行到 t2.join(),此时由于 t2 已经结束了,此处的 join 并不会发生阻塞等待!! main 继续执行后续打印
以上两种情况总的阻塞时间是一样长的,区别在于 是分两个 join 各自阻塞一会,还是一个 join 全部阻塞完毕~
话题再放到第三个问题上,只用一个 join 是否可以呢? 答案肯定是不可以的,比如 只写 t1 . join() ,假设 t1 先结束,t2 后结束(第一种情况),意味着 t1 join 返回时,t2 还有一部分工作正在进行,这样肯定不行!
"小编,你讲的这两块我都听明白了,代码我也按照上面的图敲了,肯定没问题了" 还是太年轻了~~ 当我们真正运行上述代码时,我们发现结果还是差很多~

"小编,你就骗我们吧😡,一直吊着胃口~" 嘿嘿,很明显,出现 76250 这种数就是出 bug 了,这种由于多线程并发执行代码所引起的 bug 就称之为"线程安全问题",或者 叫做 "线程不安全",那么如何解决这个问题呢??把两个线程,变为 串行执行(一个执行完了,再执行另一个),就可以完美的解决了~

所以怎么用 join的问题,我们也成功解决了~
3.2 出现 bug (76250)的 "罪魁祸首"
话题再回到出现 bug 的那一串代码中,为什么会出现 bug ??因为什么出现的bug??

如果我们多试几次就会发现,每次呈现的结果还不一样~
这一系列问题需要站在 CPU 执行指令的角度去看待!! 我们之前提及过CPU执行指令的顺序,点击即可跳转✈️;那么,CPU 在执行指令时,究竟是谁在 "搞破坏",让这段看似简单的代码产生线程安全问题的呢?

我们知道操作系统对于线程的调度是随机的,执行123三个指令时,不一定是"一口气执行完的"而很有可能是执行到其中一部分,该线程就被调度走了
这就好比你正在和女神约会,约会过程中,你的 boss 打电话说公司有紧急状况,你必须马上回去,这时你就被 boss 调度走了,而不是继续和女神约会了👸
线程也是一个牛马,可能执行指令 1,2,3 后才被调度走;也可能执行指令 1,调度走,调度回来,执行指令 2,3;也有可能执行指令 1,2 ,调度走,调度回来,执行指令 3...
这些情况均有可能发生,这也侧面说明了线程的调度是随机的!并且是抢占式执行,这也是导致线程安全问题以及上述代码 bug 的罪魁祸首~ 我们就不要误会 CPU 了,责任全在线程~
3.3 两个线程的抢占执行🔍(出现 bug 的底层原因)
接下来小编就用两个线程争抢执行 count++ 的🌰,给大家一步步拆解分析;但实际上,参与抢占竞争的线程并不只有两个。

不知道大家看到这张图,是否联想到了奥特曼~~

回归正题,前文我们提到,两个线程在 CPU 中运行时,既可能是并发执行(运行在同一个 CPU 核心),也可能是并行执行(运行在不同 CPU 核心)。对于开发者而言,无法自主判定线程究竟以并发还是并行的方式调度执行,无论是并发还是并行,对于线程安全问题本质上是雷同的,因此小编以双线程并行执行作为演示场景,假设两个线程分别运行在两个独立的 CPU 核心上~

图中展示了线程 t1 和 t2 执行 count++ 的指令序列:load(读取)、add(自增)、save(写回)。时间轴代表指令执行顺序,同一时刻只能执行一条指令。接下来,我们将结合这一指令执行模型,在上方的硬件结构图(奥特曼图)中演示整个过程。






t1,t2 分别对 count++ 如果按上方的指令模型图操作,那么得出的结果是正确的, "小编,除了这一 种指令模型,还有别的指令模型吗?" 当然! 线程是随时有可能被调度走的,而当前的指令模型中的 t1,t2 线程并没有被调度走,因此结果是线程安全的~

如果指令调度顺序变为上图所示,此时的count++操作还能保证线程安全吗?





通过结果我们也不难发现,按照上方的指令调度顺序,就会导致线程安全问题,明明++ 了两次,最后的结果还是 1~
"那么小编,什么样的调度顺序是线程安全的,什么样的又是线程不安全的呢?" 别急着下结论,我们再多举几种不同的指令执行顺序,一起找找规律。

以上只是列举了五种典型的指令交错场景,而实际并发调度中,线程指令的执行顺序远不止这几种可能。大家可以先思考一下:这五种调度顺序里,哪一种才能得到正确的结果,保证线程安全?
在这五种调度顺序中,只有左下角第四种的执行结果符合预期。由此我们可以得出一条关键规律:一个线程的load指令,必须发生在另一个线程的save指令完成之后。也就是一个线程的 load 得在另一个线程的 save 之后~
这里还有一个至关重要的点需要强调:操作系统的线程调度是完全随机的,这意味着上面所有的指令执行顺序都有可能被触发。这就是为什么我们最终得到的count值永远不会稳定在预期的 100000,而是会出现类似 76250 这样的随机结果。(重点)
目光再回到我们的代码中~

根据上面指令调度顺序的分析,我们可以肯定的是:代码的结果 count <= 100000,那么小编再抛出一个问题 :count 是否会出现小于 50000的情况?
" 小编,当然不会出现啦~ 上面的指令调度顺序我都学懂了,两个线程抢占式执行 count++ 最坏导致的结果也才是结果的一半,怎么会出现小于 50000 的情况呢? "
如果有这种想法的小伙伴,小编相信你前面肯定是学明白了,但是小编"留了一手后手没有传授~" 咳咳🥸,其实指令调度顺序的真实情况是不止我们想的这么简单的~

指令调度顺序是完全有可能出现上述情况的,还是刚才的🌰,你在和女神约会逛街,老板打电话让你过来维护一下数据库,当你去当牛马的同时,女神给她好闺蜜叫来了,陪她继续逛街,期间还和闺蜜一起吃了个大餐并且不停诉苦,当你回来再去找女神时,机会已经错失了~~ 这里你就是 t1 线程,女神闺蜜就是 t2 线程,在你被调度走的同时,闺蜜来重新进行这个任务,并且吃了个饭~~
我们简单分析一下这个指令调度顺序得到的结果会是多少

一共++ 3 次,最后得出的结果 count 为 1,好比你今天白和女神约会了,还被发张好人卡~~,所以,上方代码的结果是会出现小于 5w 次的情况,但是概率更小,这是因为如果count 小于 5w 则会出现大量上述图中的指令调度顺序,而线程t1刚执行完load,就被 CPU 切走,本身就是个小概率事件。更难的是,切走的时间得足够长,让t2连着跑好几轮count++,CPU 大部分时候不会这么极端地调度~

小编也试了很多次,出现 5w 以下的概率真的很低,不知道小伙伴们有没有成功的运行出来
接下来,小编还有一个问题,如果把这里的 5w 换成 50,那么程序的结果是否是线程安全的呢? "小编,这还用问,肯定是线程不安全啊~"

结果有没令屏幕前的你们大吃一惊👀 "小编,这是什么情况,这打破了我们之前所说的所有知识啊! "
嘿嘿🙈,其实线程不安全的问题仍然存在,只是概率变低了,循环 50 次很有可能在 t . start() 开始前,t1 线程就已经算完了,等后续 t2 线程再执行,那么就变成纯串行了~~ 多试几次,仍然会出现 count <100 的情况~
3.4 线程安全问题的产生原因🔎(5点)
(1)操作系统对于线程的调度是随机的,线程是抢占式执行的 (根本原因)
(2)代码中多个线程同时修改同一个变量
我们的示例代码也是如此,t1,t2线程同时修改count变量,也就是同时修改同一个内存空间,所以导致了线程安全问题
换句话说,如果是以下几种情况,则不会出现线程安全问题
- 一个线程修改一个变量 -> 没问题;
- 多个线程修改不同变量 -> 没问题;
- 多个线程不是同时修改同一个变量 -> 没问题;
- 多个线程读取同一个变量 -> 没问题;
- ... ...
这里的读操作不会涉及到对变量进行修改,不会出现中间结果的覆盖情况,所以不涉及线程安全问题~
(3) 修改操作,不是原子的
"小编,什么是原子的呀?" 其实在数据库里我们就学过事务的原子性,原子性的本质就是一句话:不可再分。简单来说就是:如果一个操作只对应一条 CPU 指令,那它就是原子的,中间不会被别的线程打断。 但我们刚才讲的 count++,就不是原子操作!它底层要拆成 load → add → save 三条指令,是可以被线程中途打断的,所以才会出现线程不安全的问题。
"小编,那在 Java 中有原子的修改操作吗?" 当然有了,例如"="赋值操作,在 Java 中就是原子的,它只对应 save 这一个指令(load 是读,save 是写)
(4)内存可见性问题,引起的线程不安全
(5)指令重排序问题引起的线程不安全
后面这两点原因,我们后续再讨论,也是非常重要的两点,大家一定要先熟知引起线程安全问题的这五大原因~
3.5 如何解决线程不安全(5点中的3点)
解决线程安全问题,也就是解决上述产生线程安全问题的5点原因~
1) 操作系统对于线程调度是随机的,线程是抢占式执行的~
关于这点导致的线程安全问题是我们解决不了的~难不成我们重新写一个操作系统? 取缔抢占式执行?? 理论上可以,但是实际上有亿点点难~ 所以在 Java 中解决线程安全问题的重心肯定不是放在创造新的操作系统身上~
这就好比你的大脑🧠随时随地都在想任何事情,可能正在想关于线程安全问题的知识点,这时突然冒出想和girl👸约会的想法,难不成还能换个脑子🧠?🙈
2)多个线程同时修改同一个变量
这点是与我们的代码结构直接相关的,例如 3.1 的例子中,只需要将 join 方法放到合适的位置,即可规避线程安全问题~
但是这样的方法并不够通用,有些情况下,就是需要多个线程同时修改同一个变量的;最典型的🌰就是超买 / 超卖问题,某个商品卡点发售,仅有 100 件,大家一窝蜂都在抢,能否创建出 101 个订单? 肯定不行🙅♀️ ;这就是多个线程同时修改同一个共享变量(库存)引发的线程安全问题。
面对这种必须多线程同时修改共享变量的场景,不同语言走了完全不同的路线。像Erlang 这类语言,更偏向采用不可变思想做顶层设计:共享变量一旦创建只能读、不能写,从根源尽量避免并发修改竞争。 但要注意:单纯不可变解决不了秒杀、库存扣减这类必须改值的业务,Erlang 是配合独立进程 + 消息串行整套机制才规避超卖,不是只靠不可变。
而 Java 不走这条路;Java 允许多线程共享变量、也允许修改变量,所以没有采取 "不可变" ; Java 专门采用加锁🔒这一方法,解决多个线程同时修改同一变量这一问题~
但是在Java 中,还是采用了"不可变" 思想,只是处理多线程没有采用~;大家应该都记得 String 的不可变特性,其实就是 "不可变" 的设计思想,那么小编的问题来了,String 是如何实现"不可变"的效果?咳咳🕶,是不是有小伙伴忘记啦? String 的不可变性是由于根本没有提供 public 修饰的方法,与 final 可没有任何关系,final 只是用作"不可被继承"
3)修改操作,不是原子的
Java 对于非原子的修改操作,也通过加锁🔒的方式,让不是原子的操作,打包成一个原子的操作~
对于 4) 5)两点我们在之后的学习中再进一步讨论解决方式
4. 锁 synchronized🔒
4.1 什么是加锁
什么是锁? 举个🌰: 操作系统中的多线程就像好多人同时抢一个公共物品,比如一起改同一个数字、抢同一个资源。如果没人管,大家同时改、同时读,数据就会乱掉、结果出错。Java 里的锁,就是一个「排队规则 + 门禁」:谁先拿到锁,谁就能进去操作共享数据;其他人没拿到锁,就只能在外面排队等着,不能插队乱抢。换句话说计算机的中的锁🔒,就是和生活中的锁,是同样的概念,就是互斥/排他~
"那小编,你上面还提到了加锁,到底什么是加锁🔒?
小编再举一个小🌰 --> 什么是 "加锁" : 假设派大星在上厕所。。。

上厕所的过程中,派大星肯定要给厕所上锁~

当派大星上完厕所,锁🔒就被解开了🔓
我们把上锁这个动作称为 "加锁" ,把解开这个动作称为" 解锁 "
"那小编, 第一个派大星上厕所的时间太久了,后面的派大星等着急了,直接暴力拆锁了咋整?" 注意! 在计算机中,是不允许暴力拆锁的,只能阻塞等待~,也就是说后面的派大星只能憋着,等第一个派大星上完🚽
目光回到出现线程安全的上方代码(3.1)中,我们就可以通过加锁的方式,把不是原子的修改操作(count++)给包裹成原子的操作~也就是说,在 count++ 前进行加锁,count++结束后进行解锁,count++ 就是派大星上厕所,上厕所前上锁,完事后把锁解开,让下一个派大星来上🚽~
这里注意:加锁操作可不是把当前线程锁死到 cpu 上,禁止加锁过程中该线程被调度走,而是禁止其他线程重新加锁,避免其他线程的操作,也就是避免其他线程去插队! "小编,你这里说的线程加锁都被锁死了,不就是禁止被调度走吗?" 这可不是一回事!! 举个🌰:代码中有三个任务 1 2 3,有三个线程 a b c,其中的线程 a拿到了任务 1 的锁,并且上锁,此时 "钥匙" 就在 a 手上,a 可以选择先不解开任务1 的锁,去任务 2,任务 3 转一圈~与此同时,如果线程 b,线程 c 被 CPU 调度去执行任务 1,b,c 是打不开任务 1 的锁的,只能阻塞等待,此时 CPU 不会在 b,c 阻塞等待过程中将它们调度走的! 可能线程 a 玩了一圈回来,累了于是又休眠了一阵子 sleep(1000),才打开锁~
再结合到小编是个女神的🌰,小编今天约了🐱A 出去约会,约会的时间从 8:00-20:00,这就相当于🐱A 对小编加锁🔒了,此时,在整个加锁后的过程中,小编都全心全意地和🐱A 玩耍,如果🐱B 来找小编,小编只能让🐱B 等一等了,小编可不敢一边和🐱A 玩耍,一边和🐱B 网聊,这不坏了~
4.2 Java 如何加锁🔍(使用synchronized)
相信通过以上的🌰,大家都被喂饱了🥸;其实加锁和解锁本身是操作系统提供的 api 很多编程语言都对其进行了封装,Java 也不例外,在 Java 中使用 synchronized 关键字并搭配代码块实现加锁解锁类似的效果 ;"小编这个单词好长,翻译成中文是什么意思?"

小编相信大家都过了四六级的,读肯定没问题,但是为什么要翻译成 "同步的"呢? 在计算机中,同步有很多含义,这里指的就是 "互斥",锁不就是为了互斥,后续在学习 IO 中还会提及到~
"小编,我会了,直接 synchronized 加代码块即可使用这个神器了~"

嘿嘿报错了,小编还是藏了一手,synchronized 后面是要跟小括号的()~

怎么还是报错呢,看来是想让我们在小括号中填点东西,那填啥呢? " 小编,你就别卖关子了!" 大家不妨思考一下,派大星上厕所要上锁,这个锁,锁在哪里了?是不是在门上啊,所以你光有锁🔒够吗? 一定要有你想要锁的东西对吧!所以小括号中要加一个锁对象~
"那小编,Java 的锁对象是啥,长啥样" Java 中的锁对象是你自己定义的,你想锁哪就锁哪! 换句话说,加锁的锁对象的类型是任意的!

我们甚至可以使用 Object 类(万物之父)定义出一个锁对象来当"门",对这个门使用 synchronized 上锁即可~

Java 里的锁对象,没有任何类型限制,只要是引用类型的对象,都能拿来当锁(注意内置类型可不能用作锁对象);不管是 Integer、String、自定义类,还是 new Object(),只要它是一个对象,就能当 synchronized 的锁; 但是实际开发中,我们不建议如图这种方式选取锁对象,因为用作锁对象的对象,我们只希望它当锁对象就好,不希望它还进行别的操作,这就好比在一部电影中,每个演员只扮演好自己的角色就好,而不是又当正派又当反派~
"好了,小编,我已彻底掌握这个武林秘籍了!"🤫我们敬请期待~
4.3 加锁的注意事项
通过以上内容,我们基本了解了在 Java 中如何加锁,接下来我们谈谈,加锁需要注意什么:
"加锁还能注意啥啊小编,难道注意别锁错门吗?" 对!我们换种说法,别锁成别人的门~
加锁的锁对象的类型是任意的,重要的是,是否有多个线程尝试针对同一个锁对象进行加锁(是否在竞争同一个锁);如果两个线程,没有竞争同一把锁,没有对同一个锁对象进行加锁,那么加锁的意义又何在呢?
举个🌰,把锁对象想象成女神👸,小编和一个小哥🥸同时追求女神,如果小编先追到女神,小哥就没机会了,相当于小编对女神加锁了!小哥只能阻塞等待咯,只有当小编和女神分手了,小哥才有机会~; 但是如果说小编和小哥追求的是不同的女神👸,也就是不同的锁对象,此时小编和小哥的进度就互不影响,加不加锁对双方都无作用~
4.4 线程不安全示例的最优解(加锁🔒)
学习完上述如何加锁以及注意事项,我们再次把目光投向出现 bug 的代码上~

"小编,我悟了,只需要使用 synchronized 给count++包裹一下即可"

没错,利用 synchronized 加锁,完美的解决了线程安全问题,这也是 Java 解决线程安全问题的关键宝典,将 count++ 打包成原子的操作,就不会出现 bug 结果~
"小编,我的加锁方式怎么和你的不一样?" 小编预判了你们可能会使用到另一种加锁的方式

"小编,执行的结果都一样,那么这两种加锁方式是不是同等地位了" 这两种加锁方式还是有本质上的区别的!如图这样加锁意味着将整个 for 循环,条件判断,i++,count++ 都以互斥的方式去执行,但是for 循环,条件判断,i++ 这些操作本身就是线程安全的,那么就会影响到效率!
"小编,这怎么就能影响到效率了,第一种方式加锁不也是加锁,第二种加锁也是加锁只不过包的多了,效率就低了?" 理论上是这样的!


相比于第一种加锁方式,第二种加锁方式相当于完全的串行,而第一种加锁方式只是每次 count++ 串行,for 循环的 i<5w 和 i++ 都是并发的,执行速度更快! 这就好比你让同级别的运动员参加 4000m 比赛,第一支队伍只有一人;第二支队伍有四人,每人跑 1000m;结果肯定第二支队伍获胜~ 一个人 vs 四个人 <==> 串行 vs 并发
但是小编这里要另外提一嘴,理论上说确实是第一种加锁方式效率更高,但实际上,我们还要算上锁冲突的开销,加锁解锁的开销,如果以 5w 次来算的话,加锁解锁以及锁冲突的开销就会把并发效率高的优势给吃掉了🍽️~
4.5 lock 与 unlock
学过操作系统这门课的铁铁肯定对这个再熟悉不过了,lock()代表加锁,unlock()代表解锁,那为啥 Java 不延续这样的风格去加锁解锁,而是使用 synchronized 关键字呢?
其实在Java中是有 lock() unlock()方法的,但是很少使用,因为 synchronized 太好用😀~ 使用lock() 就得随时注意搭配unlock() ,一旦未解锁,代码直接死掉了~咱可不敢保证咱很心细,"小编,我可是心很细的人~" 那么好,我们看看这段伪代码是否成功加锁和解锁
java
public static void main(String[] args) throws InterruptedException {
lock();
...;
return;
unlock();
}
这种代码就会出现一个非常严重的问题,锁还没解呢,就 return 了,unlock()根本执行不到,那么我们该怎么办?可以搭配 try finally 去使用,等遇到这类问题我们再进一步讲解~
换句话说,Java 采用的 synchronized 就能确保,只要出了 } 就一定能释放所,无论因为 return,还是异常,都可以确保 unlock() 一定能执行到~
4.5 synchronized 的变种写法(看懂)
synchronized 的使用可不止一种方法
1)synchronized (this)

我们将对 count++ 放到 Counter 类中进行操作,这也实现了解耦合的效果~,我们可以看到这里使用了 synchronized(this),"小编,我知道,谁调用add方法,谁就是this",没错,谁调用了add 谁就是 this!! 这里都是 count 对象调用 add 方法,那么 count 对象就是锁对象,t1 t2线程也实现了对同一个锁对象进行加锁,所以代码没有任何问题~
2)synchronized 方法
我们可以令 add 方法里的 synchronized(this) 直接修饰方法!

效果与 1)相同,也是一种更加简化的版本
3) 静态方法加 synchronized
我们前面的方法都是非静态方法,非静态方法可以直接在方法返回值前加 synchronized 实现上锁的效果,那么由 static 所修饰的静态方法,该如何使用 synchronized 呢?
static 修饰的静态方法不存在 this,所以我们只能采用类对象的知识,去使用 synchronized,"小编,啥是类对象?" 这是 JavaSE 语法中反射相关的内容,忘记的小伙伴不要紧,这里了解即可~

大家能看懂即可~注意,static 修饰的方法里若想直接使用成员变量,成员变量必须也是静态的哦~
3. 结语
以上就是本文主要的内容,相信各位读者都吃饱了~,本文内容特别丰富,需要大家费点功夫好好消化,下面我们还是继续多线程的学习,量大管饱哦!有不明白的地方可以留言小编会回复,希望读者们多提建议,小编会改正,共同进步!谢谢大家。 🌹🌹🌹