线程安全问题
文章目录
- 一、线程安全问题
- 二、线程安全问题原因
- 修改共享数据
- 原子性
- 内存可见性
- 指令重排序
- 三、解决线程安全问题
一、线程安全问题
多线程带给我们效率提升的同时,也为我们带来了风险-线程安全,因为多线程的抢占式执行,带来的随机性。
我们想使用两个线程将一个变量同时增加5000次
class Num{
public int num;
public void add() {
this.num++;
}
}
public class ThreadDemo {
public static void main(String[] args) {
Num num = new Num();
//创建两个线程,分别对调用5000次add()
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num.add();
}
});
//启动线程
t1.start();
t2.start();
try {
//等待线程结束
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("num = "+num.num);
}
}
为什么程序会出来这个情况?
++操作本质上要分为三步
1.先把内存中的值读取到CPU的寄存器中 load指令
2.把CPU寄存器里的数值+1操作 add指令
3.把结果值保存到内存中 save指令
此时两个线程的并发执行,就相当于两组load add save指令并发执行。所以这两组指令的执行顺序存在了许多可能性。
这里我举一个线程安全的例子和线程不安全的例子。
自增两次,结果为2,是线程安全的。
因为t1已经load但是还没有add,save,t2也load了,所以t1,t2执行完之后是1(这里和事务的脏读问题是一样的,read uncommitted,t2读到的是t1还没来得及提交的脏数据)
1. 那基于这两个线程有这么多不安全的情况,是否可能结果正好是1w呢?
也是存在可能性的。
虽然存在这样执行5000次的可能,但概率十分地小。
2.当前这个结果一点大于5000吗?
当两个线程调度出现上述情况,t2自增两次或多次,t1自增一次,最后还是加1.
二、线程安全问题原因
最根本的原因: 抢占式执行,随机调度
修改共享数据
我们上述线程代码之所以不安全,因为涉及到我们两个线程同时去修改一个相同的变量。
一个线程修改一个变量,安全
多个线程读取同一个变量,安全
多个线程修改多个不同的变量,安全
原子性
什么是原子性?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
我们刚才所进行的++操作就不是原子的,由三步组成,也就造成了线程问题,t1结果还没提交,t2就读了。
不保证原子性会给多线程带来什么问题?
一个线程正在对一个变量操作,中途其他线程插进来了,对这个操作造成了打断,可能会造成结果的错误。这和线程的抢占式调度有关,如果不是抢占式,就算不是原子性,也问题不大。
内存可见性
java内存模型(JMM): java虚拟机规定了java内存模型。
目的: 屏蔽各种硬件和操作系统的内存访问差异,实现java程序在各平台下都达成一致的并发效果。
1.线程之间的共享变量存在 主内存 (Main Memory).
2.每一个线程都有自己的 “工作内存” (Working Memory) .
3.当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
4.当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
一旦线程t1修改了变量,但未来得及同步,对应t2读取工作内部的值就可能出现错误。
这个时候代码就会出现问题。
为什么要整这么多内存?
其实没有这么多内存,只能java中进行了”抽象”的叫法。
主内存: 硬件角度的内存
工作内存: CPU寄存器 和 高度缓冲器Cache
为什么要复制来复制去?
因为在内存读取速度相比于工作内存的读取速度慢了好几个数量级,但是工作内存有十分小(而且还贵),所以就得不断地复制,将急需的一些东西放在里面。
指令重排序
指令重排序实际上也是编译器优化,简单的来说,就是我们把一个东西写的太烂了,JVM.CPU指令集会对其进行优化。
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
三、解决线程安全问题
我们采取的策略是针对原子性入手,来解决上述线程安全问题。
通过加锁,把不是原子的转成”原子的”.
class Num{
public int num;
public synchronized void add() {
this.num++;
}
}
public class ThreadDemo {
public static void main(String[] args) {
Num num = new Num();
//创建两个线程,分别对调用5000次add()
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
num.add();
}
});
//启动线程
t1.start();
t2.start();
try {
//等待线程结束
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("num = "+num.num);
}
}
我们可以发现,程序达到了我们预期的效果。
我们代码的唯一不同,就是此方法加了个synchronized关键字,表示加锁。
加入了synchronized后,进入add()方法就会加锁,出方法后就会解锁。当一个线程获取到锁之后,另一个线程想要获取锁,只能阻塞等待,知道另一个线程释放锁,当前线程才能成功加锁
lock的阻塞,把t2 的load 推迟到t1的save之后,避免了脏读操作。
此处的原子性,并不是说,让load add save这三个指令操作一步完成,而是在进行这三步操作时,不再进行调度,让其他线程进行阻塞等待。
同时我们进行了加速后,数据的准确性提高了,同时执行效率也降低了,但仍然比单线程快很多。
还没有评论,来说两句吧...