Android 最佳并发实践之基础篇

| PV()

引言

虽然移动开发,对并发同步要求,没有服务端那么重,但我在很多项目代码中,都看到不合理的用法,或者没有达到设想的情况。这篇文章重点分析下,移动开发开发中应该了解的并发知识。


同步的三个问题

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

为了达成这种目的,我们需要在三个方面上进行考核。

原子性

一段代码要么不执行,要么执行就不能被打断地执行到底,这样不可分割的特性,称之为原子性。这种特性非常关键,我们来举个例子。

1
int a = 100;

如果上述的赋值语句不是原子性的,而是分高低位进行赋值,那很有可能在给高位赋值后且还没来得及给低位赋值这个过程中,另一个线程进行取值,此时很可能得到一个错误的值。

当在执行一个满足原子性的操作时,我们不用担心,会有多个线程同时访问导致错误的结果。

可见性

我们不仅希望防止某个线程在使用某个对象状态时另一个线程在同时修改这种状态,而且希望确保当一个线程修改对象状态过后,其他线程能够看到发生的状态变化,这也就是可见性

可见性是一种复杂的属性,往往会和我们的直接有所违背。设想,我们往某个变量写入了值,然后在其他线程读取该值时,理论上应该总能得到最新的值,然而事实上并不是这样。

为了方便大家理解可见性这个概念,需要简要地介绍下 多处理器体系架构下的内存模型。

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性,其中一部分甚至只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及运行时,需要弥合这种在硬件能力与线程安全需求之间的差异。

java memory model

主内存:主内存被多个线程共享,对于一个共享变量而言,这里存放着其本身。
工作内存:出于性能的考虑,每个线程有自己的工作内存,是名副其实的副本。

Java 内存模型规定了 8 种支持的原子操作,可以先粗略看看。

jmm 原子操作

可以看到一个对象在主内存和工作内存中,并不能保证每个时刻值都一样,而是通过多个复合原子操作来保证一致性。

于是就有多种可能性,例如多级缓存处理器本地缓存的值对其他处理器不可见等等因素,导致不能立即看到正确的值。

例如全局变量 var 值为 0,A 线程将全局变量 var 设置为3,B 线程打印这个值。即便 A 线程先开始,也不能保证 B 线程一定能打印出 3 。

截止目前,大家还是对可见性缺乏具像的认识,我写了下面的一个例子,大家可以从 Console 的输出中看到并没有 Work is done的输出,也就说 stopFlag 即便已经修改了值,在 thread并没有看见!

visibility code

我们一定要注意可见性这个问题,否则会像陈永仁一样,陷入了三年只有又三年的无间地狱里面,等不到结束信号!

无间道

P.S. 在后续的研究中发现,上述的测试代码有考虑不周的地方。在实际执行的时候,上述的代码会被 JIT 优化成类似于 if(stopFlag){while(true){}} 这样的形式!显然这违背我们写代码进行测试的初衷。大家如果有测试可见性更好的方法,欢迎指定迷津。

有序性

代码的实际执行顺序要与申明的顺序一致。这听上去是很理所当然的,然而实际在运行时并不一定符合我们的预期。

1
2
3
4
int a = 100;
int b = 1;
③ a ++;
④ b --;

我们看看上面这段代码,理想情况下执行的顺序应该是①②③④,但实际的执行顺序可能是②①③④。那有没有可能③④在①②之前执行呢,不可能!这里需要引入一个重排序的概念,来帮助我们分析上面的顺序问题。

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

重排序并不是随便排序的,而是有一定规则的,JMM(Java Memory Model)遵循 Happens-Before 准则。我们在实际开发中,只要按照 Happens-Before 来,代码就没什么问题。

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

我们从 Double-Check 来看看上面这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {

private static Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

在这段代码中,就有可能出现由重排序导致的问题。instance = new Singleton(); 这句话,实际执行的时候,有多个步骤。

1
2
3
① // 分配空间
② // 初始化
③ // 给引用赋值

假如②与③进行了重排序,并且一个线程执行了③,还未执行②,而此时另一个线程执行到了第一个 if (instance == null) {,那么该线程就会返回一个未初始化完毕的对象,从而导致意想不到的问题。

安全性总结

光是想要在多线程环境下达到安全性的目的,就得考虑原子性、可见性和有序性。但这还没完,想要运行得好,还得等待性能、活性、死锁其他方面的问题,路漫漫其修远兮。

注:活性失败,是指某个线程始终获取不到资源,活活饿死。


volatile

在了解可见性和有序性过后,就相对好理解 Volatile 这个关键字了。Volatile 是 Java 提供的轻量级同步机制,相较于 synchronized 需要进行系统调用而言轻便了不少。

我们看看 volatile 是如何帮助我们的。

内存屏障

首先我们需要了解下内存屏障这个概念。

内存屏障是硬件层的概念,分为 readBarrier 和 writeBarrier。一般有两个作用,一是屏蔽两边的指令重排序,另一个是强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

  1. LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  2. StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  3. LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  4. StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

对于每一个被 volatile 修饰的变量,JVM 会自动地做两件事情。
1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

由此,我们可以推断出,被 volatile 关键字修饰的变量有两个特点:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。

可见性

我们来分析下第一个特点,volatile 是如何保证可见性的。回到前面可能一直会陷入死循环的代码,如果变量前加入 volatile,死循环就会结束。

1
private static volatile boolean stopFlag = false;

volatile 会使得汇编代码中,多出一道 lock 前缀指令,相当于一个内存屏障(内存栅栏),它提供了三方面的保证:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

所以,当 main 函数的执行线程,将 stopFlag 置为 true 的时候,会让另一个线程缓存中的值失效,从而执行到判断代码时,会从主内存里更新,拿到最新的值结束循环。

有序性

另一个特点就是禁止重排序,以 volatile 修饰属性的读/写操作代码行为 分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。

稍微有点拗口,我们来看看实际的代码。

1
2
3
4
5
① x = 1;
② y = 2;
③ flag = true; // flag is volatile variable
④ x = 3;
⑤ y = 4;

根据前面提到的知识,①②顺序可能颠倒,④⑤顺序可能颠倒,但它们一定在 ③ 之前和之后,从而来保证有序性。再回到 double-check 的例子,volatile 保证了给 Instance 赋值时,初始化工作已经执行完毕,因而不会有问题。

我们再来看看《Java 并发实践》中的经典例子。

1
2
3
4
5
6
context = loadContext();
contextReady = true;
while (!contextReady) {
// keep waiting.
}
doSomethingWithContext(context);

如果这里发生了指令重拍,那么有这样的一种情况,会引发异常。

1
2
3
// A 线程以这样一种顺序来执行
contextReady = true;
context = loadContext();

另一个 B 线程在执行的时候,发现 contextReady 已经为 true,但此时 context 并未构建完毕,从而在 doSomethingWithContext 发生错误。

如果 ContextReady 是 volatile 变量,由于前后的内存屏障,前面的代码不会发生重排序,因而代码是可以 work 的。

P.S. 需要特别指出的是,volatile 并不能保证原子性,类似于 i++ 这样的复合操作,使用 volatile 是不能保证线程安全的。


synchronized

我们在写同步代码的时候,最常用到的关键字就是 synchronized。我们有必要了解清楚 synchronized ,导致是怎么帮我们完成同步的。

原理说明

在学习操作系统的时候,是否还记得 Mutex(互斥锁)?

用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。

system mutex

1) 申请 mutex
2) 如果成功,则持有该 mutex
3) 如果失败,则进行spin自旋. spin的过程就是在线等待mutex, 不断发起mutex gets, 直到获得mutex或者达到spin_count限制为止
4) 依据工作模式的不同选择yiled还是sleep
5) 若达到sleep限制或者被主动唤醒或者完成yield, 则重复(1)~(4)步,直到获得为止。

Java 所有的同步,都是和对象有关系,每个对象都对应于一个可称为互斥锁的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。这也是为什么 wait 和 notify/notifyAll 是 Object 的属性方法。

JVM 本身不具有同步能力,同步操作都是依赖于操作系统,这也就意味着是 System Call,需要在用户态和核心态之间切换,是重量级操作。synchronized 本身是 Java 提供的语法糖,封装了 Mutex 的相关调用(在后面会详细说明,synchronized 自身的优化)。

synchronized block code

还是以前面那段 Double-Check 作为例子,反编译 class 文件后,得到上面的代码。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。

根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

1
2
3
4
5
6
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

我们变化下 Double-Check 的代码,直接在方法上申明 synchronized,class 文件会相应地变化。没有了 monitorenter/exit 相关的代码,而是多了一个 ACC_SYNCHRONIZED 的标记。这个标记与 monitorenter/exit 类似,当进入某个方法体时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。

synchronized method code

用法

synchronized usage

synchronized 的用法比较简单,无非是需要知道锁实例对象还是类对象的区别。

接下来,问大家几个问题,大家可以在心里面想一下输出的结果是什么,如果大家心里对这个有谱,那就是完全掌握其用法了。

Round 1

round 1

Round 2

round 2

Round 3

round 3

Round 4

round 4

Round 5

round 5


synchronized 迭代改进

我们首先需要了解 synchronized 的两个特性,::可重入::和::非公平性::。

可重入

还是从实际的代码入手,看看可重入是怎么回事。

1
2
3
4
5
6
7
8
9
public synchronized int get() {
// do fetching job...
}

public synchronized void update(int value) {
if (value != get()) {
// do updating job...
}
}

上面这段代码有两个对外提供的同步方法,其中 update 会在内部调用 get 方法。可重入是指,一个线程在已经持有锁的情况下,再次获取锁时能够自动获得,不会因为没有锁没有释放掉进入等待状态。在每次获得锁的时候,标记数+1,每次释放锁的时候,标记数-1,当标记数为0的时候,锁才会被完全释放掉。

可重入锁的好处显而易见,对开发友好,避免死锁。

非公平性

公平性是指锁严格遵循 ::FIFO::,这样可以避免线程饿死的情况,但现在大部分锁都是非公平性的,这是为什么?

这需要结合后面的知识来说明,但大家知道非公平性锁,会减少线程切换次数,且不需要维护线程顺序,小不少开销。

锁的优化

从前面的章节里面,能够了解锁的实现,需要调用操作系统的互斥量,那这就必须涉及到系统调用,内核切换,开销很大。这种方式也被称之为重量级锁。

那有没有可能,减少这种开销了?Java 进行了一系列优化,咱们一步一步地看看。

先放出一张图,大家感受一下(坏笑
sync optimize

我们首先来想这样一个问题,线程的竞争是否真的激烈?在绝大多数时间内,是否只有一个线程在实际运行?就算是两个线程,它们是不是交替进行的了?如果真的是同时运行,有竞争关系,那才有必要使用重量级锁🔒。

于是乎,我们就有了一下几种锁
偏向锁:只有一个线程进入临界区;
轻量级锁:多个线程交替进入临界区;
重量级锁:多个线程同时进入临界区。

前面提及到锁,都是基于对象的,那么我将对应的状态记录到对象里面去,后续根据对象的状态,使用不同的锁,那就能达到优化的目的了。

事实上,java 就是这么优化锁的,object 里面的线程状态字段叫做::Mark Word::。

1
2
3
synchronized (lockObject) {
// do something
}

上述同步代码块中存在一个临界区,假设当前存在 Thread1 和 Thread2 这两个用户线程,分三种情况来讨论。

Case 1:只有 Thread1 会进入临界区;
Case 2: Thread1 和 Thread2 交替进入临界区;
Case 3: Thread1 和 Thread2 同时进入临界区。

上述的情况一是偏向锁的适用场景,此时当 Thread1 进入临界区时,JVM会将lockObject的对象头Mark Word的锁标志位设为“01”,同时会用CAS操作把 Thread1 的线程ID记录到 MarkWord 中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于 Thread1 ,若接下来没有其他线程进入临界区,则 Thread1 再出入临界区无需再执行任何同步操作。也就是说,若只有 Thread1 会进入临界区,实际上只有 Thread1 初次进入临界区时需要执行CAS操作,以后再出入临界区都不会有同步操作带来的开销。

然而情况一是一个比较理想的情况,更多时候 Thread2 也会尝试进入临界区。若 Thread2 尝试进入时 Thread1 已退出临界区,即此时lockObject处于未锁定状态,这时说明偏向锁上发生了竞争(对应情况二),此时会撤销偏向,Mark Word中不再存放偏向线程ID,而是存放hashCode和GC分代年龄,同时锁标识位变为“01”(表示未锁定),这时 Thread2 会获取lockObject的轻量级锁。因为此时 Thread1 和 Thread2 交替进入临界区,所以偏向锁无法满足需求,需要膨胀到轻量级锁。

轻量级锁在实际执行过程中,是针对两个线程交替进行的,实则没有竞争。再说轻量级锁什么时候会膨胀到重量级锁。若一直是 Thread1 和 Thread2 交替进入临界区,那么没有问题,轻量锁hold住。一旦在轻量级锁上发生竞争,即出现“ Thread1 和 Thread2 同时进入临界区”的情况,轻量级锁就hold不住了。

就算轻量级锁失败,也不一定会真正在操作系统层面上进行挂起,还会有一项叫做自旋锁的优化方案。

自旋锁基于这样一种假设,需要持有锁的时间可能非常的短,那为这点时间,进行系统调用,状态切换很不知道。那么可以让线程进入轮询状态,等待锁的释放,不让出 CPU 资源。如果得到锁,就顺利进入竞争区,否则在一定次数过后,还是不能获得锁,就在操作系统上进行操作。后续,Java 继续优化,自旋的策略,自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

除了前面的锁优化策略,Java 还做了::锁消除::和::锁粗化::来优化。

锁消除是指,万恶的JIT会在运行时,扫描上下文,去消除一些不可能存在竞争的锁。例如以下的代码,在实际运行时,并不存在 object 的锁。

1
2
3
4
5
6
public void justDemo() {
Object object = new Object();
synchronized(object) {
// just a block
}
}

锁粗化是指扩大锁的范围,避免反复加锁和释放锁,例如下面的代码,在循环中进行加锁和释放锁,代价有点大。

1
2
3
4
5
6
7
public void thinLock() {
for (int i = 0; i < 1000; i++) {
synchronized(this) {
// do something
}
}
}

在实际运行时,JIT 会对次进行优化,等价于下面的代码。

1
2
3
4
5
6
7
public void roughLock() {
synchronized(this) {
for (int i = 0; i < 1000; i++) {
// do something
}
}
}

参考文献

  1. The Java® Language Specification
  2. JavaTM Memory Model and Thread Specification
  3. GitHub - CL0610/Java-concurrency: Java并发知识点总结
  4. https://mp.weixin.qq.com/s/3HCWuE4EQNOKbX6G6oOYpQ
  5. 再有人问你Java内存模型是什么,就把这篇文章发给他。-HollisChuang’s Blog
  6. 漫画:什么是volatile关键字? - 知乎
  7. volatile与内存屏障总结 - 知乎
  8. 内存屏障 - 简书
  9. https://zhuanlan.zhihu.com/p/75880892

文档信息