Java 并发问题、产生的原因及解决方法
在java
编程中,当多个线程同时读写一个变量时,会造成并发问题。那并发问题的是什么,又是什么原因造成的呢?
什么是线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
为什么不把所有操作都做成线程安全的? 实现线程安全是有成本的,比如线程安全的程序运行速度会相对较慢、开发的复杂度也提高了,提高了人力成本。
并发问题是什么
并发问题就是线程不安全。
当多线程同时读写一个变量时,因为原子性、缓存可见性、指令重排序等原因,导致变量的实际执行结果和预期不一致。
并发问题出现场景
当多线程同时读写一个变量时,会出现并发问题。根据变量类型和所处位置不同,具体有如下三种场景:
- 静态变量,多线程访问类的同一实例
- 静态变量,多线程访问类的不同实例
- 实例成员变量,多线程访问同一实例
并发问题表现是什么
public class Test {
static int m = 0;
int n = 0;
public void inc() {
m++;
n++;
}
public static void inc2() {
m++;
}
}
如上代码,inc()
和inc2()
都不是线程安全的。
当两个线程持有Test
类的同一个实例对象,同时执行inc()
方法时,且各自执行1万次时,得到的结果并不是m
和n
都等于2万,而是都是1万多。
当两个线程同时执行Test.inc2()
1万次时,得到的结果也不是m
等于2万,而是1万多。
并发问题产生的原因
计算机上CPU和外设IO、主存之间存在巨大的速度差巨,为了提高CPU计算能力的利用率,提升整体系统性能,做了诸多改进。 为了解决外设IO速度慢的问题,引入了进程和线程等任务的切换功能,由此引发了操作的原子性问题。 为了解决CPU和主存速度差异问题,并提升CPU计算能力,引用了多内核CPU,并且每个内核都有各自的独立缓存,由此引发了缓存可见性问题。 为了提升CPU单个内核流水线的利用效率,编译器和内核都对指令的执行顺序进行了重排序。 这些系统的优化机制,导致了多线程同时读写一个变量时,会引发并发问题。
1. 线程切换导致的原子性问题
CPU执行的速度远远快于外设IO的响应速度,为了提升CPU执行效率,避免CPU大部分时间都处于等待IO返回数据的闲置状态,操作系统引入了进程进制,因等待IO而阻塞、或者时间片用完的进程会被暂停执行,切换到其他进程继续执行。 为进一步提升进程执行效率,进一步提升CPU利用率,系统又引入了线程的概念。线程同样可以被系统调度,而且线程切换占用系统资源更少,所以现代系统调度切换的任务主要是线程。 进程和线程本质上是增加并行的任务数量来提升CPU的利用率。 原子性是指一个操作要么全部执行完毕,要么全部不执行。 并发读写同一变量的线程之间任务切换时,如果对变量的读写操作不是原子性的,就会导致并发问题。 比如以下代码,当多线程并发执行时,就会有并发问题:
int num = 0;
num = num + 1;
因为第二行的代码不是原子操作,它实际上是3条指令:
- 从缓存或内存读取num值存储到寄存器
- CPU对num值做+1操作
- CPU把结果写回缓存或内存 由于系统可能在任何时刻切换线程执行,如果当线程1执行完第1步时,系统切换到线程2执行,那么线程2读到num数据和线程1读到的数据相同,但预期是线程2应该读到线程1执行完成后结果,这样就导致了并发问题,实际执行结果和预期不一致。
2. 多核CPU缓存导致的不一致问题
由于CPU执行速度远远快于内存存取速度,所以为了提高CPU利用率,系统在CPU和内存之间添加了高速缓存cache
,cache通常减少IO速度来提高CPU利用率。CPU读取数据时,先访问cache,如果cache命中,就不再访问主存,直接返回cache中的数据。当CPU写数据到内存时,会先写到cache,通常不会马上写回主存,只有cache要被替换或者cache无效时,才会写回主存。
单核CPU不会有不一致问题,这个问题只会出现在多核CPU上,现代处理器大部分都是多核心CPU。
在多核CPU上,每个内核都有自己的独立缓存。当多线程读写同一变量时,如果线程运行在不同内核上,那么它们对同一变量的读写操作就分别在不同的cache中执行。每个cache都是独立的,互相不可见,单个cache中对变量缓存的操作不会影响别的cache,也不知道别的cache中的数据,这样就会导致最终结果的不可控。
3. 指令重排序问题
为提升内核自身执行速度,内核内部使用流水线并行执行多条指令。 指令之间通常有因数据相关、名称相关、控制相关造成的依赖关系,这导致流水线中后面的指令需要等待前面的指令完成后才能执行,大大降低了流水线的并行度。 为提高流水线并行性能,编译器和内核通常会对指令进行静态和动态调度,在不影响单线程执行结果的前提下,把无关的指令插入到指令空闲的位置提前执行,以提升流水线性能。
public class Test {
private static int value;
private static boolean flag;
public static void init() {
value = 8; // 语句1
flag = true; // 语句2
}
public static void getValue() {
if (flag) {
System.out.println(value);
}
}
}
上面代码,如果单线程按顺序执行,打印的值必定是8。不过init()
经过重排序后,因为语句1和语句2没有相关性,可能重排序后的顺序为语句2在前,语句1在后。如果是这样的话,当多线程同时访问时,线程1执行完语句2后,线程2执行getValue()
方法,那么此时线程2打印的结果就可能是错误的0。
解决方法
Java
提供了以下方法来保证线程安全,根据是否需要同步,分为两种类型。
同步方案: 如果线程间需要协作,即一个线程的执行需要依赖其他线程执行的结果,那么就说线程之间是需要同步的。这种情况下一般用添加互斥锁的方式解决,主要有以下两种方法:
无同步方案: 但是互斥锁会阻塞其他线程的执行,效率较低。所以如果线程间不需要协作,即一个线程的执行不依赖于其他线程的执行结果,这种情况下就不需要使用互斥锁的方案,Java提供了以下无同步方案来处理这种场景下的问题:
参考
转载自:https://juejin.cn/post/7052609223523794981