【Java EE】多线程-初阶-多线程带来的的⻛险-线程安全 (重点)

多线程带来的的⻛险-线程安全 (重点)

4.1 线程安全的概念

想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
某个代码,无论是在单个线程下执行,还是多个线程下执行,都不会产生 bug.这个情况就称为"线程安全”。如果这个代码,单线程下运行正确,但是多线程下,就可能会产生 bug:这个情况就称为"线程不安全"或者"存在线程安全问题”

4.2 观察线程不安全

// 此处定义⼀个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
 Thread t1 = new Thread(() -> {
	 // 对 count 变量进⾏⾃增 5w 次
	 for (int i = 0; i < 50000; i++) {
	 	count++;
	 }
 });
 Thread t2 = new Thread(() -> {
	 // 对 count 变量进⾏⾃增 5w 次
	 for (int i = 0; i < 50000; i++) {
	 	count++;
	 }
 });
 t1.start();
 t2.start();
 // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 count 就是个 0
 t1.join();
 t2.join();
 // 预期结果应该是 10w
 System.out.println("count: " + count);
}

如果不加join,按理说,一个线程自增 5w 次,两个线程,一共自增 10w 次,最终结果应该是 10w。非但这里的结果不是 10w,并且每次运行的结果都不同!!!
实际结果不符合预期,就是 bug !!!上述这个循环自增的代码,就属于是"存在线程安全问题"的代码
count++相当于 +=1,这个 count++ 其实是三个 cpu 指令构成的
在这里插入图片描述
如果是一个线程执行上述的 三个 指令,当然没问题。如果是两个线程,并发的执行上述操作,此时就会存在变数!!!(线程之间调度的顺序是不确定的!!)在这里插入图片描述
这里一共有多少种情况??其实是无数种情况!!!
在这里插入图片描述
这里需要分析清楚,什么样的顺序下,执行结果是对的 (两次 ++,得到的结果是 2),什么顺序下结果不对(两次 ++,结果不是 2)在这里插入图片描述
在这里插入图片描述
线程的随机调度/抢占式执行~~ 在循环自增 5w 过程中,一旦运算过程中,中间的结果出现类似上面这种形态,这时候得到的最终结果就一定是 小于 10w 的.

4.3 线程不安全的原因

在这里插入图片描述

线程调度是随机的.

这是线程安全问题的罪魁祸首。随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数。
程序猿必须保证 在任意执⾏顺序下 , 代码都能正常⼯作.

修改共享数据

多个线程修改同⼀个变量

上⾯的线程不安全的代码中, 涉及到多个线程针对 count 变量进⾏修改。此时这个 count 是⼀个多个线程都能访问到的 “共享数据”
在这里插入图片描述

原子性

在这里插入图片描述
什么是原⼦性
我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。
那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。
有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。
⼀条 java 语句不⼀定是原⼦的,也不⼀定只是⼀条指令
⽐如刚才我们看到的 n++,其实是由三步操作组成的:
1.从内存把数据读到 CPU
2.进⾏数据更新
3.把数据写回到 CPU
不保证原⼦性会给多线程带来什么问题
如果⼀个线程正在对⼀个变量操作,中途其他线程插⼊进来了,如果这个操作被打断了,结果就可能是错误的。

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原⼦性, 也问题不⼤.

可见性

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

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.
在这里插入图片描述
• 线程之间的共享变量存在 主内存 (Main Memory).
• 每⼀个线程都有⾃⼰的 “⼯作内存” (Working Memory) .
• 当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷⻉到⼯作内存, 再从⼯作内存读取数据.
• 当线程要修改⼀个共享变量的时候, 也会先修改⼯作内存中的副本, 再同步回主内存.

由于每个线程有⾃⼰的⼯作内存, 这些⼯作内存中的内容相当于同⼀个共享变量的 “副本”. 此时修改线程1 的⼯作内存中的值, 线程2 的⼯作内存不⼀定会及时变化.

(1) 初始情况下, 两个线程的⼯作内存内容⼀致.
在这里插入图片描述
(2)⼀旦线程1 修改了 a 的值, 此时主内存不⼀定能及时同步. 对应的线程2 的⼯作内存的 a 的值也不⼀定能及时同步.在这里插入图片描述
这个时候代码中就容易出现问题.
此时引⼊了两个问题:
• 为啥要整这么多内存?
• 为啥要这么⿇烦的拷来拷去?
(1) 为啥整这么多内存?
实际并没有这么多 “内存”. 这只是 Java 规范中的⼀个术语, 是属于 “抽象” 的叫法。所谓的 “主内存” 才是真正硬件⻆度的 “内存”. ⽽所谓的 “⼯作内存”, 则是指 CPU 的寄存器和⾼速缓存.
(2) 为啥要这么⿇烦的拷来拷去?
因为 CPU 访问⾃⾝寄存器的速度以及⾼速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级,也就是⼏千倍, 上万倍).

⽐如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第⼀次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了.效率就⼤⼤提⾼了.

那么接下来问题⼜来了, 既然访问寄存器速度这么快, 还要内存⼲啥??
答案就是⼀个字: 贵

值的⼀提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度⼜远远快
于硬盘。 对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜。

指令重排序

什么是代码重排序
⼀段代码是这样的:
1.去前台取下 U 盘
2.去教室写 10 分钟作业
3.去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进⾏优化,⽐如,按 1->3->2的⽅式执⾏,也是没问题,可以少跑⼀次前台。这种叫做指令重排序

编译器对于指令重排序的前提是 “保持逻辑不发⽣变化”. 这⼀点在单线程环境下⽐较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执⾏复杂程度更⾼, 编译器很难在编译阶段对代码的执⾏效果进⾏预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是⼀个⽐较复杂的话题, 涉及到 CPU 以及编译器的⼀些底层⼯作原理, 此处不做过多讨论

4.4 解决之前的线程不安全问题

这⾥⽤到的机制,⻢上会给⼤家解释。

// 此处定义⼀个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
 Object locker = new Object();
 Thread t1 = new Thread(() -> {
	 // 对 count 变量进⾏⾃增 5w 次
	 for (int i = 0; i < 50000; i++) {
		 synchronized (locker) {
		 	count++;
		 }
	 }
 });
 Thread t2 = new Thread(() -> {
	 // 对 count 变量进⾏⾃增 5w 次
	 for (int i = 0; i < 50000; i++) {
		 synchronized (locker) {
		 	count++;
		 }
	 }
 });
 t1.start();
 t2.start();
 // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的
count 就是个 0
 t1.join();
 t2.join();
 // 预期结果应该是 10w
 System.out.println("count: " + count);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值