1. 前言

该书由Doug Lea之外的另外一位Java并发大神Brian Goetz和Tim Peierls合著,算是Java并发领域的一本经典书籍。此书从2013年入手之后,拿起放下了三次。之前两次自己对并发的研究还不是很深,基本属于一知半解,工作当中也极少用到并发,看了就忘。最近半年在阅读JDK源代码,特别是阅读完部分java.util.concurrency包之后,对并发的感觉更深。这个时候回头来看看这本书,才真正体会到了其中的真谛,确实是字字珠玑。

本文记录下自己阅读完获得的一些感悟,具体的API就不会在这里叙述,记录更多设计和方法的部分,欢迎读者一同探讨。

2. 当我们讨论线程问题,我们在说什么

当我们讨论线程问题时,其实关注的是两个概念:可见性与原子性。

可见性

可见性就是对于AB两个线程共同操作了一个变量V,设计中A先修改了变量V;那么,A对V的修改对B是否可见。

在单线程环境中,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量的值,那么总能得到相同的值。听起来似乎是一个很简单的问题,其实不然。

在多线程和现代处理器的环境下,上述的过程就没有那么的简单。

当读线程和写线程在不同的线程中执行时,我们无法确保执行读操作的线程能适时的看到其他线程写入的值。另外在现代处理器中,一个CPU通常包含多个核。变量V的修改并非直接在内存中修改,而是现在某个核的寄存器和本地缓存中进行修改,然后再写入到内存中。这个时候,V的修改才会对其他核上的线程可见。要实现这些功能,Java通过一系列的CPU指令帮我们除了了不同CPU厂商之间的实现差异。通过内存屏障,在CPU处理到内屏屏障时,强制将本地缓存中的变量值与其他CPU同步实现可见性的语义。

原子性

原子性就是保证操作是原子的,所有中间状态都不会被其他线程访问到。

比如对一个Int的赋值x = 1;可以认为是原子的。但是自增操作x++;就不是原子的,因为需要三条jvm指令去完成它:1. 读取变量x;2. 对变量x加1;3.将结果赋值给变量x。如果在多线程环境下,因为时序的关系就可能导致x最终出现多个值。那么这个时候,自增操作就不能称为是原子的,非原子的状态操作就是线程不安全的。

2.1 安全性问题

在前面我们提到了,如果多线程环境下不满足可见性和原子性,就会发生线程不安全,那到底什么是线程安全呢?在书中这么定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协调,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

在这个定义下有这么几个描述需要进一步说明:1)正确的行为;2)主调代码中不需要任何额外的同步或协调。

2.1.1 正确的行为

什么是正确的行为?正确的行为就是符合需求规范定义的行为,这些行为的描述一般都是遵照时间顺序的,比如:

如果事件A先发生,对变量X复制1;事件B后发生,对变量X复制2;那么X的值应该是2

上面的一个描述就是一个** Requirement Specification 需求规范 ** 。这些描述通常都是遵循 ** Sequential Consistency 顺序一致性 **,满足人脑的一般思维模式(思考事情的时候不会从平行宇宙,多维时空角度来想这件事情该怎么做)。

2.1.2 额外的同步或协调

我们说一个类不是线程安全的,应该指的是这个类在没有额外的同步或协调下,会产生可见性和原子性等问题,例如我们经常说HashMap是非线程安全的,其实就是说使用HashMap如果要达到可见性和原子性的要求。

我们通过Collections.synchronizedMap(hashmap)进行包装之后,hashmap就变成了线程安全的类。如果翻看源码,其实Collections.synchronizedMap(hashmap)所做的事情,就是加了一段装饰而已:

synchronized(mutex){
 hashmap.xxx();
 }

如果将这段放在调用处,也可以让一个HashMap编程线程安全,但就加上了额外的同步和协调,就没办法说明HashMap是线程安全的。

2.2 活跃性问题

如果说线程的安全性是指“永远不发生糟糕的事情”, 那么线程的活跃性就是指“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,表现为死循环——死循环外的正确的事情永远不会发生。

在多线程环境下,通常表现为阻塞或挂起。例如线程A等待线程B持有的某个资源,而线程B一直不释放,那么A就会永久的等待下去。还有包括死锁、饥饿、活锁等,这些都属于活跃性问题。

2.3 性能问题

如果说线程的活跃性问题是指“某件正确的事情最终会发生”,那么性能问题就是指“某件正确的事情最终会发生,应该尽快发生”。性能问题包含很多方面,如服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高或者伸缩性较低等。

3. Java为我们提供的线程安全基础设施有哪些?

Java 自1.5开始,提供了功能强大的java.util.concurrency包,其中提供了大量的并发工具供不了解并发的我们使用,帮助构建线程安全的程序。当然,里面大部分是Doug Lea大神写的。

3.1 内置锁

看新的东西之前,还是要看到Java提供的 synchronized关键字。自1.2版本开始Java就把该关键词作为了最基础的同步机制,称为内置锁。内置锁可以作用在方法、代码块中,作用在方法时表示用该类的当前实例作为锁对象加锁。

如果线程(注意,讨论的对象是线程)需要访问实例的同步方法,则需要先获取实例的内置锁,在执行完成后自动释放。如果该实例的锁已经被另外的线程获取,则当前线程会在该锁上排队等待,等待之前一个线程释放锁。锁的获取与释放都通过编译器加入monitor_entermontior_exit指令实现。

内置锁一度是java中进行同步的唯一方法,很多遗留方法还是使用了内置锁进行同步,比如著名的Vertex,Collections里面的同步包装器等。在1.6之后,内置锁的性能也得到了很大的提升,在还未具备很强的并发经验之前,还是应该优先选择内置锁。

之所以之后,会产生更多的工具来替代内置锁的部分功能,主要是因为内置锁的最大缺点——无法控制。上面我们说了,内置锁是通过编译器和JVM指令实现的,因此在程序员角度无法对加锁和解锁行为进行太多的控制;另外内置锁也是不可中断,而且错综复杂的调用关系会让内置锁的加锁组合、加锁顺序变得难以管理。因此,Java在1.5中加入了Lock接口和对应实现,称为显示锁。

3.2 显示锁(Lock, Condition, 条件谓语)

显式锁的顶层接口为Lock,提供了ReenterantLock, ReadWriteLock等实现。

使用Lock的一个模式如下:

Lock lock = new ReentrantLock();
 ...
 lock.lock();
 try {
 // 更新对象状态
 // 捕获异常,并在必要时回复不变性条件
 } finally {
 lock.unlock();
 }

3.3 信号量、栅栏、闭锁

Lock本质上开始一个开闭锁,只有开闭两个状态。在应用中,通常还会衍生出很多其他的需求。比如一个并行计算的需求,存在一个100万个数据集合,求这100万个数的和。单线程的场景就是把这些数从第一个加到最后一个,最后输出。在多核环境下,我们可以将这个问题并行化,把100万数据分成100份每份1万数据,然后由一个线程进行计算,最后将这100个线程的计算结果相加就是这100万个数据的最终结果。

由于调度机制的原因,这100个线程可能会以任意的顺序结束,但只有这100个线程全部完成的时候我们才能获得最终结果。因此需要一定的协调机制,在100个线程都结束时,调用最后的结果输出程序。这个需求都可以通过信号量、栅栏、闭锁等实现。具体实现等有时间再写一篇文章介绍(又给自己挖了坑)。

3.4 Non-blocking算法和Lock free算法

我们在2.2节中提到了活跃性和性能,通过同步处理的多线程问题,都或多或少的影响了活跃性和性能。比如线程A获得了一个锁并在执行一些耗时的计算,如果线程B同样想获得这个锁,那么程序的活跃性和性能就收到了影响。

java 1.5之后,jvm开始支持硬件的CAS(Compare and Swap)指令,CAS接受三个参数(variable, expectedValue, newValue)。CAS的语义是这样的,如果变量variable的值和expectedValue相等,那么就将variable赋值为newValue;如果和expectedValue不相等,就返回失败。

jdk1.5 使用CAS操作引入了AtomicInteger等一些列原子量,可以保证“先检查再修改”引起的多线程安全问题。听起来很高大上,其实就是一个乐观锁的思路,假设在当前线程修改期间,其他线程不会对原数据进行修改。在写入之前做一次检查,如果被修改了,则进行重试,重试到成功为止。典型代码如下:

while(true){
 int old = getState(); // 1. 读取旧制
 int new = old + 1; // 2.对旧值做出修改
 if(CAS(old, new){ //
 return true;
 }
 }