前言
随着时代的发展,软件变得越来越复杂。随之而带来的,是为了保证一定性能而不断扩大的并发需求。并发,也成为了一个程序员必须要掌握的技能。本文对并发做一个非常简单的介绍。
谈到并发,难免提到进程与线程两个概念。单看这两个名称,似乎差距不大。但我们可以继续看看它们的英文:process 和 thread。看到 process,很容易联想到 program(程序)。在操作系统中,我们恰恰可以粗略的认为,一个运行的程序是一个进程。相对于线程来说,进程是重量级的。而 thread,线,线是又轻又小的,线程也是轻量级的。
一般来说,进程与进程之间是无关的,而进程中却可以存在很多的线程。而并发,简单的来说,就是指多个线程同时运行。如果线程运行时,线程的行为不会因为其他线程的干扰而发生改变,我们认为就该线程是安全的。如果一个线程并不与其他线程进行相互,那么这个线程往往是安全的。但很多情况下,线程们往往存在于同一个进程,线程之间会互相干扰、相互协作,当这种时候,线程的行为无法保证,便不再安全了。请看这样一个例子
1 | public class Unsafe(){ |
count++虽然看似只有一个操作,但实际上,JVM 执行了多条指令。
1 | void increase(); |
当线程 A 与线程 B 同时对 count 操作,线程 A 将 count 加 1 之后就被挂起,线程 B 开始执行 increase(),在 B 执行完后 count=1,A 这之后继续执行写入,那么 count 将会=1,而不是预想中的 2。
解决这个问题有个很直观的办法,那就是将 count++转变为不会被其他线程影响的不可分割操作(原子性操作)即可。java 中便有一种内置的锁机制,可以实现这种原子性:synchronized。
导航
synchronized
1 | synchronized(JavaClass){ |
synchronized 囊括的代码块叫做临界区(同步块)。每个 java 对象都有一个内置锁,只有获得了内置锁的线程(monitor)才能进入临界区,当其离开临界区时则会释放锁。于是,不管 monitor 何时暂停(被挂起),其他的线程都由于无法进入临界区只能等待(阻塞)而无法对临界区内容造成影响。
我们可以在脑海中模拟一下这个过程,不难发现,临界区中的操作实际上只会有一个线程进行执行,也就是 synchronized 将临界区中本该并行的操作,变成了顺序执行的串行操作。这样,临界区的操作自然不可分割、受其他线程影响,于是这些操作便成为了原子操作。而,synchronized 除了确保一些操作原子之外,还保证了另一个线程间协作的关键:内存一致性。
内存一致性与 volatile
说到内存一致性,必须得了解 JVM 的内存模型。在 JVM 的内存模型中,每个线程都拥有自己的工作内存(私有),并定期与主内存进行交互(save/load)来更新工作内存的数据,线程只能从工作内存中读取数据而不能从主内存中读取。随之来的,是不同的线程不一定保证自己工作内存中的数据与其他线程工作内存一致,也不能保证与主内存中一致,这称为无法保证内存一致性。
我们来看这样一个例子:
1 | public class Unsafe(){ |
所以当 A 线程调用了 set(1),B 线程调用了 get()时,由于无法保证内存一致性,B 线程可能获取到最新的 value 值 1,也可能取到初始化的值 0。
为了确保一致性,可以将 get()也用 synchronized 修饰。
1 | public class Safe(){ |
这样,可以保证 B 线程能获取到 A 线程的更新值,换句话说,A 线程的更新对 B 线程时是可见的,也就是所说的可见性。
除了 synchronized,java 还提供了一种更为轻量级的可见性机制:volatile 关键字。
被 volatile 修饰的变量,更新操作将对所有的线程可见。在读取 volatile 变量时,总会返回最新的值。因此,上述代码也可以改为:
1 | public class Safe(){ |
悲观锁与乐观锁
在基于锁的同步机制下,锁将会被一个线程独占(独占锁),另外的线程不得进入等待(被阻塞)。线程被挂起与恢复的过程中,需要较大的开销。那么,是否存在一种方法,可以在避免线程被挂起(非阻塞)的情况下,实现同步呢?这里,我们先引入一个概念:悲观锁与乐观锁。
悲观锁假设不获得锁便无法进行正确的行为,因此总是获取锁之后才进行操作;而乐观锁假设不获得锁的情况下,也能进行正确行为,如果失败,那么就重试,直至成功。独占锁是一种悲观锁,而一个简单的乐观锁如下:
1 | public class SimpleOptimisticLock{ |
CAS(Compare and Swap)与 ABA
上述代码中,我们使用 synchronized 来保证 compareAndSet 操作的原子性。所以,尽管 increase 使用了乐观锁,依然无法避免阻塞。幸运的是,现代处理器中,我们可以使用单一指令进行比较与交换(CAS)。在 java 中,更是提供了不少的原子操作,因此,我们可以将上面的代码改写成
1 | public class SimpleOptimisticLock{ |
运用 CAS 实现的乐观锁,避免了线程的阻塞,通常情况下,效率高于如 synchronized 的独占锁。但在一些时候,却会出现 ABA 问题:线程 t1 执行 V.compareAndSwap(A,C)时,线程 t2 执行了 V.compareAndSwap(A,B)与 V.compareAndSwap(B,A)。A 看到的 V 值仍然是 A,但事实上,V 值已经发生了变化。解决这个问题有一个相对简单的方案:每次更新除了更新值之外,还更新一个版本号(如 AtomicStampedReference 类),借此判断是否是原值。
False Sharing 与@Contended
JVM 中,每个线程都有私有内存(ThreadLocalAllocBuffer,缓存)来减少与主内存的交互,提升效率。每个线程从各自的缓存中读取数据,而非直接从主内存中读取数据。运行线程也并非每次从缓存中读取一个字节,而是一次性从连续的内存地址中读取一定数量的字节(目前多数为 64byte)。这些被一次读取的连续内存块被称为缓存行。
所以当线程 t1 中的缓存行 L 中的一个数据值发生了改变的时候,将会导致整个缓存行 L 进行更新。与此同时,为了确保数据正确的共享,主内存与其他线程中的对应缓存行也会随之更新(内存一致性)。显而易见,那些缓存行中并不需要共享值的数据,额外承担了共享的代价,这就是 False Sharing(False Sharing 的解释来自 wiki,私以为与通常的翻译“伪共享”相比,可能英文更能表达原意)。
为了减少 False Sharing,我们可以用一些额外字节来进行占位填充(pad)。在 java 中,.class 文件里每个对象都有固定的表示规则,我们可以通过在字段之间填充占位字段,来将不同的字段存放至不同的缓存行。不过随着 jdk 的不断发展,无用的填充可能因为优化而被去掉。好在,java 提供了@Contended 来更方便的进行缓存行的处理:拥有该注解的字段,将会和其他字段放置在不同的缓存行。