以前古老的DOS
操作系统,是单进行的零碎。零碎每次只能做一件事件,实现了一个工作能力持续下一个工作。每次只能做一件事件,比方在听歌的时候不能关上网页。所有的工作操作都依照串行的形式顺次执行。
这类服务器毛病也很显著,期待操作的过长,无奈同时操作多个工作,执行效率很差。
当初的操作系统都是多任务的操作系统,比方听歌的时候能够做关上网页,还能关上微信和敌人聊天。这几个工作能够同时进行,大大增加执行效率。
并发提高效率
一个残缺服务器,都有CPU
、内存
、IO
,三者之间的运行速度存在显著的差别:
CPU
相干的操作,执行指令以及读取CPU
缓存等操作,根本都是纳秒
级别的。CPU
读取内存,耗时是CPU
相干操作的千倍,根本都是微秒
级别。CPU
和内存之间的速度差别。IO
操作根本是毫秒的级别,是内存操作的千倍,内存
和IO
之间存在速度的差别。
CPU -> 内存 -> SSD -> 磁盘 -> 网络
纳秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒
程序中大部分的语句都要拜访内存,有些还要拜访的IO
读写。为了正当的利用CPU
的高性能,高效的均衡三者的速度差别,操作系统、编译器次要做了以下改良:
CPU
减少了CPU缓存
,用来平衡CPU
和内存
的速度差别。- 操作系统减少了多过程、多线程,用来分时复用
CPU
,从而平衡CPU
与IO
设施之间的差别。 - 编译优化程序执行程序,充分利用缓存。
做了以上操作之后,CPU
读取或者批改数据之后,将数据缓存在CPU缓存
中,CPU
不须要每次都从内存中获取数据,极大的进步了CPU
的运行速度。多线程是将时间段切成一个个小段,多个线程在上下文切换中,执行完工作,而不必等后面的线程都执行结束之后再执行。比方做一个计算,CPU
耗时1
纳秒,而从内存读取数据要1
微秒,没有多线程的话,N
个线程要耗时N微秒
,此时CPU
高效性就无奈体现进去。有了多线程之后,操作系统将CPU
时间段切成一个一个小段,多线程上下文切换,线程执行计算操作,无需期待内存读取操作
。
尽管并发能够进步程序的运行效率,然而凡事无利也有弊,并发程序也有很多诡异的bug
,本源有以下几个起因。
缓存导致可见性问题
一个线程对共享变量的批改,另外线程能立即看到,称为可见性。
在单核时代,所有的线程都是在同一个CPU
上运行,所有的线程都是操作同一个线程的CPU缓存
,一个线程批改缓存,对另外一个线程来说肯定是可见的。比方在下图中,线程A
和线程B
都是操作同一个CPU缓存
,所以线程A
更新了变量V
的值,线程B
再拜访变量V
的值,获取的肯定是V
的最新值。所以变量V
对线程都是可见的。
在多核CPU
下,每个CPU
都有本人的缓存。当多个线程执行在不同的CPU
时,这些线程的操作也是在对应的CPU缓存
上。这时候就会呈现问题了,在下图中,线程A
运行在CPU_1
上,首先从CPU_1
缓存获取变量V
,获取不到就获取内存的值,而后操作变量V
。线程B
也是同样的形式在CPU_2
缓存中获取变量V
。
线程A
操作的是CPU_1
的缓存,线程B
操作的是CPU_2
的缓存,此时线程A
对变量V
的操作对于线程B
是不可见的。多核CPU一方面进步了运行速度,然而另一方面也可能会造成线程不平安的问题。
上面应用一段代码来测试多核场景下的可见性。首先创立一个累加的办法add10k
办法,循环10000
次count+=1
的操作。而后在test
办法外面创立两个线程,每个线程都调用add10k
办法,后果是多少呢?
public class VisibilityTest { private static int count = 0; private void add10k() { int index = 0; while (index++ < 10000) { count += 1; } } @Test public void test() throws InterruptedException { VisibilityTest test = new VisibilityTest(); Thread thread1 = new Thread(() -> test.add10k()); Thread thread2 = new Thread(() -> test.add10k()); // 启动两个线程 thread1.start(); thread2.start(); // 期待两个线程执行完结 thread1.join(); thread2.join(); System.out.println(count); }}
依照直觉来说后果是20000
,因为在每个线程累加10000
,两个线程就是20000
。然而理论后果是介于10000~20000
的之间,每次执行后果都是这个范畴内的随机数。
因为线程A和线程B同时开始执行,第一次都会将count=0
缓存到本人的CPU缓存
中,执行完count += 1
之后,写入本人对应的CPU缓存
中,同时写入内存中,此时内存中的数是1
,而不是冀望的2
。之后CPU
再取到本人的CPU缓存
再进行计算,最初计算出来的count
值都是小于20000
,这就是缓存的可见性问题。
线程切换带来的原子性问题
下面提到,因为CPU
、内存
、IO
之间的速度存在很大的差别,在单过程零碎中,须要等速度最慢的IO
操作实现之后,能力接着实现下一个工作,CPU
的高性能也无奈体现进去。但操作系统有了多过程之后,操作系统将CPU
切成一个一个小片段,在不同的工夫片段内执行不同的过程的,而不须要期待速度慢的IO
操作,在单核或者多核的CPU
上能够一边的听歌,一边的聊天。
操作系统将工夫切成很小片,比例20
毫秒,开始的20
毫秒执行一个过程,下一个20
毫秒切换执行另外一个线程,20
毫秒成为工夫片
,如下图所示:
线程A
和线程B
来回的切换工作。
如果一个进行IO
操作,例如读取文件,这个时候该过程就把本人标记为休眠状态
并让出CPU
的使用权,等实现IO
操作之后,又须要应用CPU
时又会把休眠的过程唤醒,唤醒的过程就能够期待CPU
的调用了。让出CPU
的使用权之后,CPU
就能够对其余过程进行操作,这样CPU
的使用率就进步上了,零碎整体的运行速度也快了很多。
并发程序大多数都是基于多线程的,也会波及到线程上下文的切换,线程的切换都是在很短的工夫片段内实现的。比方下面代码中count += 1
尽管有一行语句,但这外面就有三条CPU
指令。
- 指令 1:把变量V从内存加载到
CPU
寄存器中。 - 指令 2:在寄存器中执行
+1
操作。 - 指令 3:将后果写入内存(也可能是写入
CPU缓存中
)。
任何一条CPU
指令都可能产生线程切换
。如果线程A在指令1执行完后做线程切换,线程A和线程B依照下图程序执行,那么咱们会发现两个线程都执行count += 1
的操作,然而最初后果却是1
,而不是2
。
编译优化带来的有序性问题
有序性是指程序依照代码的先后顺序执行,编译器为了优化性能,在不影响程序的最终后果的状况下,编译器调整了语句的先后顺序,比方程序中:
a = 2;b = 5;
编译器优化后可能变成:
b = 5;a = 2;
尽管不影响程序的最初后果,然而也会引起一些意想不到的BUG。
在Java
中一个常见的例子就是利用双重测验创立单例对象,例如上面的代码:
public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; }}
在获取实例getInstance
办法中,首先判断instance
是否为空,如果为空,则锁定Singleton.class
并再次查看instance
是否为空,如果还为空就创立一个Singleton
实例。
假如两个线程,线程A
和线程B
同时调用getInstance
办法。此时instance == null
,同时对Singleton.class
加锁,JVM
保障只有一个线程能加锁胜利,假如是线程A
加锁胜利,另一个线程就会处于期待状态,线程A
会创立一个实例,而后开释锁,线程B
被唤醒,再次尝试加锁,此时胜利加锁,而此时instance != null
,曾经创立过实例,所以线程B
就不会创立实例了。
看起来没有什么问题,但实际上也有可能问题呈现在new
操作上,原本new
操作应该是:
- 1、调配一块内存。
- 2、在内存上初始化对象。
- 3、内存的地址赋值给
instance
变量。
但理论优化后的执行程序却是如下:
- 1、调配一块内存。
- 2、将内存地址赋值给
instance
变量。 - 3、在内存上初始化对象。
优化之后会产生什么问题呢?首先假如线程A
先执行getInstance
办法,也就是先执行new
操作,当执行完指令2
时产生了线程切换,切换到线程B
上,此时线程B执行getInstance
办法,执行判断时会发现instance != null
,所以就返回instance
,而此时的instance
是没有初始化的,如果这时拜访instance
就可能会触发空指针异样。
总结
操作系统进入多核、多过程、多线程时代,这些降级会很大的进步程序的执行效率,但同时也会引发可见性
、原子性
、有序性
问题。
- 多核
CPU
,每个CPU都有各自的CPU缓存,每个线程更新变量会先同步在CPU缓存
中,而此时其余线程,无奈获取最新的CPU
缓存值,这就是不可见性。 count += 1
含有多个CPU
指令。当产生线程切换,会导致原子问题。- 编译优化器会调整程序的执行程序,导致在多线程环境,线程切换带来有序的问题。
开始学习并发,常常会看到volatile
、synchronized
等并发关键字,而理解并发编程的有序性、原子性、可见性等问题,就能更好的了解并发场景下的原理。
参考
可见性、原子性和有序性问题:并发编程Bug的源头