博主的个人博客:文客
在优快云发布的文章都会提前在个人博客上发布,想一起交流和学习的小伙伴可以关注一下呀!感谢大家的支持!
本文摘抄了《Java并发编程之美》中的部分内容。
前言
在学校学了多年的专业课,不管学到了哪门语言,并发的知识都只会出现在书上而不是课堂上。换句话说,并发编程对于开发人员或者是学生来说,是比较高级、比较进阶的知识。我自己学习的过程中,也发现并发是比较难的,它需要你对OS、语言有一定的理解,如果你的语言基础薄弱或者不了解操作系统的话,那么学习起来是比较费力的,甚至无法学习下去。而且在学习并发编程的过程中,需要不断地去神入理解,我从去年开始读《Java并发编程的艺术》这本书,书中的很多地方我读了数十遍,每次读都会加深印象并且有新的收获。学习这类知识,大家要沉下心来,不要浮躁。
编写正确的并发程序是一件极困难的事情,并发程序的 Bug 往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪,很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些 Bug 的源头在哪里。
那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些 Bug 的源头。
并发编程模型的两个关键问题
在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。
通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
共享内存的模型中,线程之间共享程序的公共状态,通过读写公共内存来进行通信,这种通信是隐式的。
消息传递的模型中,线程之间没有公共状态,线程间必须通过显示的发送消息来进行通信。
那么Java属于哪种机制呢?
学习过操作系统的都知道,进程是系统运行程序的基本单位,我们运行一个Java程序,实际上是开启了一个进程。在OS中,还有一个比进程更小的单位:线程,一个进程中可以有多个线程,而且由于线程之间的切换负担比较小,所以线程也被成为轻量级进程。在Java程序中,多个线程共享进程的堆和方法区(JDK8的元空间),每个线程又有自己的程序计数器、虚拟机栈和本地方法栈。所以不难看出,Java采用的是共享内存模型。
在共享内存的模型中,通信是隐式进行的,同步是显示进行的,程序员必须显示地指定某个方法或代码块需要在线程之间互斥执行。如果编写多线程的Java程序员不知道隐式进行的线程之间通信的工作机制,很可能会遇到各种可见性和有序性问题。
缓存导致的可见性问题
可见性是这样定义的:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
我们先来看一下Java内存模型:
从抽象的角度来看,Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存涵盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化。其实在很多资料中,这个本地内存也被叫做缓存,它们表达的意思都是一样的,我个人比较倾向于缓存的叫法,因为从操作系统的角度来看,为了解决速度不匹配的问题大量引入了缓存机制,Java内存模型也是引用的缓存的思想。
那么可见性问题是如何造成的呢?比如内存中有一个共享变量x,线程A操作的是本地内存A中的变量x,而线程B操作的是本地内存B中的变量x,很明显,这个时候线程A对变量x的操作对于线程B而言就不具备可见性了。这个就属于Java内存模型给软件程序员挖的“坑”。
线程切换带来的原子性问题
Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如我们平时代码中的count += 1
,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量count从内存加载到缓存;
- 指令 2:之后,在寄存器中执行+1操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。如图:
我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
重排序带来的有序性问题
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令集并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
Java的源代码需要经过一次编译器的重排序和两次处理器的重排序,才能形成最终的执行代码。这些重排序也可能会导致内存可见性问题,同时也会导致一些有序性问题的bug。以著名的单例模式为例,来看下面的代码。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现instance == null
,于是同时对Singleton.class加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程 B);线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程B检查instance == null
时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。
这看上去一切都很完美,无懈可击,但实际上这个getInstance()方法并不完美。问题出在哪里呢?出在new操作上,我们以为的new操作应该是:
- 分配一块内存M;
- 在内存M上初始化Singleton对象;
- 然后M的地址赋值给instance 变量。
但是实际上优化后的执行路径却是这样的:
- 分配一块内存M;
- 将M的地址赋值给instance变量;
- 最后在内存M上初始化Singleton对象。
优化后会导致什么问题呢?我们假设线程 A 先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance != null
,所以直接返回instance,而此时的 instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
总结
要写好并发程序,首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,只要我们深刻理解Java内存模型,理解可见性、有序性、原子性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。