了解这些坑,再也不会出现诡异的BUG了~
前言
在高并发的情况下,你的程序是不是经常出现一些诡异的BUG
,每次都是花费大量时间排查,但是你有没有思考过这一切罪恶的源头是什么呢?
幕后那些事
CPU
、内存
、I/O设备
的速度差异越来越大,这也是程序性能的瓶颈,根据木桶理论,最终决定程序的整体性能取决于最慢的操作-读写I/O设备
,单方面的提高CPU的性能是无用的。
为了平衡三者的差距,大牛前辈们不断努力,最终做出了卓越的贡献:
CPU
增加了缓存,平衡与内存之间的速度差异操作系统增加了进程、线程,以分时复用
CPU
,进而均衡CPU
与I/O
设备的速度差异;编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
注意:正是硬件前辈们做的这些贡献,额外的后果需要软件工程师来承担,太坑了。
坑一:CPU缓存导致的可见性问题
在单核CPU的时代,所有的线程都在单个CPU上执行,不存在CPU数据和内存的数据的一致性。
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
因为所有的线程都是在同一个CPU缓存中读写数据,一个线程对缓存的写,对于另外一个线程肯定是可见的。如下图:
从上图可以很清楚的了解,线程A对于变量的修改都是在同一个CPU缓存中,则线程B肯定是可见的。
但是多核时代的到来则意味着每个CPU上都有一个独立的缓存,信息不再互通了,此时保证内存和CPU缓存的一致性就很难了。如下图:
从上图可以很清楚的了解,线程A和线程B对变量A的改变是不可见的,因为是在两个不同的CPU缓存中。
最简单的证明方式则是在多核CPU的电脑上跑一个循环相加的方法,同时开启两个线程运行,最终得到的结果肯定不是正确的,如下:
public class TestThread {
private Long total=0L;
//循环一万次相加
private void add(){
for (int i = 0; i < 10000; i++) {
total+=1;
}
}
//开启两个线程相加
public static void calc() throws InterruptedException {
TestThread thread=new TestThread();
//创建两个线程
Thread thread1=new Thread(thread::add);
Thread thread2=new Thread(thread::add);
//启动线程
thread1.start();
thread2.start();
//阻塞主线程
thread1.join();
thread2.join();
System.out.println(thread.total);
}
上述代码在单核CPU的电脑上运行的结果肯定是20000,但是在多核CPU的电脑上运行的结果则是在10000~20000
之间,为什么呢?
原因很简单,第一次在两个线程启动后,会将total=0
读取到各自的CPU缓存中,执行total+1=0
后,各自将得到的结果total=1
写入到内存中(理想中应该是total=2
),由于各自的CPU缓存中都有了值,因此每个线程都是基于各自CPU缓存中的值来计算,因此最终导致了写入内存中的值是在10000~20000
之间。
注意:如果循环的次数很少,这种情况不是很明显,如果次数设置的越大,则结果越明显,因为两个线程不是同时启动的。
坑二:线程切换导致的原子性问题
早期的操作系统是基于进程调度CPU,不同进程间是共享内存空间的,比如你在IDEA写代码的同时,能够打开QQ音乐,这个就是多进程。
操作系统允许某个进程执行一段时间,比如40毫秒,过了这个时间则会选择另外一个进程,这个过程称之为任务切换
,这个40毫秒称之为时间片
,如下图:
在一个时间片内,如果一个进程进行IO操作,比如读文件,这个时候该进程可以把自己标记为休眠状态
并让出CPU的使用权,待文件读进内存,操作系统会将这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权。
现代的操作系统更加轻量级了,都是基于线程调度,现在提到的任务切换
大都指示线程切换
。
注意:操作系统进行任务切换是基于
CPU指令
。
基于CPU指令是什么意思呢?Java作为高级编程语言,一条简单的语句可能底层就需要多条CPU指令,例如total+=1
这条语句,至少需要三条CPU指令,如下:
指令1:将
total
从内存读到CPU寄存器中指令2:在寄存器中执行+1
指令3:将结果写入内存(缓存机制可能导致写入的是CPU缓存而不是内存)
基于CPU指令是什么意思呢?简单的说就是任务切换的时机可能是上面的任何一条指令完成之后。
我们假设在线程A执行了指令1后做了任务切换,此时线程B执行,虽然执行了total+=1
,但是最终的结果却不是2,如下图:
我们把一个或者多个操作在CPU执行过程中不被中断的特性称之为原子性。
注意:CPU仅仅能保证CPU指令执行的原子性,并不能保证高级语言的单条语句的原子性。
此处分享一道经典的面试题:
Long
类型的数据在32位操作系统中加减是否存在并发问题?答案:是,因为Long
类型是64位,在32位的操作系统中执行加减肯定是要拆分成多个CPU指令,因此无法保证加减的原子性。
坑三:编译优化带来的有序性问题
编译优化算是最诡异的一个难题了,虽然高级语言规定了代码的执行顺序,但是编译器有时为了优化性能,则会改变代码执行的顺序,比如a=4;b=3;
,在代码中可能给人直观的感受是a=4
先执行,b=3
后执行,但是编译器可能为了优化性能,先执行了b=3
,这种对于我们肉眼是不可见的,上面例子中虽然不影响结果,但是有时候编译器的优化可能导致意想不到的BUG。
双重校验锁实现单例不知大家有没有听说过,代码如下:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
这里我去掉了volatile
关键字,那么此时这个代码在并发的情况下有问题吗?
上述代码看上去很完美,但是最大的问题就在new Singleton();
这行代码上,预期中的new
操作顺序如下:
分配一块内存N
在内存N上初始化
Singleton
对象将内存N的地址赋值给
instance
变量
但是实际上编译优化后的执行顺序如下:
分配一块内存N
将内存N的地址赋值给
instance
变量在内存N上初始化
Singleton
对象
很多人问了,优化后影响了什么?
将内存N的地址提前赋值给instance
变量意味着instance!=null
是成立的,一旦是高并发的情况下,线程A
执行第二步发生了任务切换
,则线程B
执行到了if (instance == null)
这个判断,此时不成立,则直接返回了instance
,但是此时的instance
并没有初始化
过,如果此时访问其中的成员变量则会发生空指针异常
,执行流程如下图:
总结
并发编程是区分高低手的门槛,只有深刻理解三大特性:可见性
、原子性
、有序性
才能解决诡异的BUG
。
本文分析了带来这三大特性源头,如下:
CPU缓存导致的
可见性
问题线程切换带来的原子性问题
编译优化带来的
有序性
问题
有道无术,术可成;有术无道,止于术
欢迎大家关注Java之道公众号
好文章,我在看❤️