并发是提升性能的利器,同时也是最容易出bug的。编写正确的并发程序是一件比较困难的事情。并发程序的bug往往会很诡异,同时又难以重现,调试,让人抓狂。但如果我们能理解并发的本质,抽丝剥茧,并能快速,精准的找出产生问题的源头。接下来我们就来分析一下并发问题产生的源头。
大家都知道CPU,内存,IO设备这三者的速度相差悬殊。我们的程序会有访问内存,IO。根据木桶原理程序的性能取决于最慢者(IO),也就是我们单方面提高CPU性能,内存是无效的。
为了提高CPU的利用率,以及平衡三者的速度差异,操作系统和编译器做了如下措施。
1、CPU增加了缓存,以平衡CPU和内存的速度差异(将内存数据读入缓存)。
2、操作系统增加了进程,线程。以分片时间段来使用CPU。
3、编译器会优化程序的执行顺序(在不影响最终结果前提下)
以上措施为我们程序带来性能提升,同时许多的程序并发问题也是因此而产生的。
一、CPU缓存带来并发可见性问题
一个线程对共享变量的修改,另外一个线程能够看到,我们称为可见性。
在单核时代,CPU缓存与内存数据一致性是比较容易解决的。所有的线程都是在同一颗CPU运行。一个线程对CPU缓存的变更,另外一个线程是可见的。
但是在多核时代,每颗CPU都有自己的缓存,CPU缓存与内存数据一致性就不是那么容易解决了。当多个线程运行在不同CPU时。假设线程1运行在CPU1上,线程2运行在CPU2上。那么线程1操作的CPU1上的缓存,线程2操作就是CPU2上的缓存。这时候线程1对变量A的操作对于线程2来说就不具备可见性了。这就属于硬件程序给软件程序员挖的坑了。
大家可以尝试用一段代码来验证下多核CPU场景下可见性问题。
二、线程切换带来并发原子性问题
我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
由于IO太慢,一个进程在执行IO操作时候可以把自己标记为“休眠状态”并让出CPU使用权。待IO操作执行完成,操作系统会唤醒休眠状态的进程,重新获取CPU使用权。这里进程让出CPU使用权是为了让CPU在这段等待时间段里可以干其他的事情,这样一来CPU的使用率就上来了。这就是任务切换。操作系统做任务切换,可以发生在任何一条CPU指令执行完。是CPU指令,而不是我们的程序的一条语句。我们程序的一条语句可能会对应多条CPU指令。
例如我们java执行
count++;
这条语句对应CPU指令,先从内存读取count变量值,然后将count+1,再将count值刷回内存。如果CPU在执行完从内存获取count变量值为0时候。此时发生任务切换,另外一个线程将count+1并写回内存值为1。然后此线程获取到CPU执行权,将count+1得到的值为1写回内存。此时你会发现对count做了两次+1,最后值却是1。这就是由于任务切换破坏了程序的原子性而产生的问题。
CPU能保证的是CPU指令原子性操作,而无法保证高级语言操作符的原子性。这需要从高级语言层面来保证高级语言的原子性。
三、编译器优化带来并发有序性问题
有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中的语句的先后顺序。例如程序中
int a=1;
int b=2;
编译器优化后可能变成
int b=2;
int a=1;
编译器调整了语句的顺序,但是不影响最终结果。不过有时候编译器的优化可能会导致意想不到的bug。
针对以上问题java是如何解决的呢?
个人公众号
欢迎大家关注公众号,一起学习进步