多线程带来的的⻛险-线程安全
摘要:本文分析了多线程环境下的线程安全问题。通过一个count自增的示例,展示了线程不安全的原因:1)线程调度随机性;2)共享数据修改;3)操作缺乏原子性。解释了Java内存模型(JMM)中的主内存和工作内存概念,以及可见性问题。同时介绍了指令重排序对多线程的影响。最后给出了使用synchronized同步锁解决线程安全问题的方案,确保count变量在多线程环境下能正确累加到预期值10万。文章涵盖
观察线程不安全
// 此处定义⼀个 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);
}
线程安全的概念
如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是 线程安全的。
线程不安全的原因
线程调度是随机的
这是线程安全问题的罪魁祸⾸
随机调度使⼀个程序在多线程环境下,执⾏顺序存在很多的变数.
程序猿必须保证在任意执⾏顺序下,代码都能正常⼯作.
修改共享数据
多个线程修改同⼀个变量
上⾯的线程不安全的代码中,涉及到多个线程针对count 变量进⾏修改. 此时这个count 是⼀个多个线程都能访问到的"共享数据"

原⼦性

什么是原⼦性
我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊ 房间之后,还没有出来;B是不是也可以进⼊房间,打断A在房间⾥的隐私。这个就是不具备原⼦性 的。
那我们应该如何解决这个问题呢?是不是只要给房间加⼀把锁,A进去就把⻔锁上,其他⼈是不是就进 不来了。这样就保证了这段代码的原⼦性了。 有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。
⼀条java语句不⼀定是原⼦的,也不⼀定只是⼀条指令
⽐如刚才我们看到的n++,其实是由三步操作组成的:
1. 从内存把数据读到CPU
2. 进⾏数据更新
3. 把数据写回到CPU
不保证原⼦性会给多线程带来什么问题
如果⼀个线程正在对⼀个变量操作,中途其他线程插⼊进来了,如果这个操作被打断了,结果就可能 是错误的。
这点也和线程的抢占式调度密切相关.如果线程不是"抢占"的,就算没有原⼦性,也问题不⼤.
可⻅性
可⻅性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.
Java内存模型(JMM):Java虚拟机规范中定义了Java内存模型.
⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的 并发效果.

• 线程之间的共享变量存在主内存(MainMemory).
• 每⼀个线程都有⾃⼰的"⼯作内存"(WorkingMemory).
• 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷⻉到⼯作内存,再从⼯作内存读取数据.
• 当线程要修改⼀个共享变量的时候,也会先修改⼯作内存中的副本,再同步回主内存.
由于每个线程有⾃⼰的⼯作内存,这些⼯作内存中的内容相当于同⼀个共享变量的"副本".此时修改线 程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以及编译器的⼀些底层⼯作原理,此处不做过多讨论
解决之前的线程不安全问题
// 此处定义⼀个 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);
}
更多推荐



所有评论(0)