Android 最佳并发实践之线程篇

| PV()

引言

上一篇文章中,我们简要地了解了下 Android 开发实践中需要了解的基础知识,知道怎样的代码是并发安全的。现在我们再来看看并发中的具体执行者 – 线程又是怎样的?有什么样的知识需要了解,它们是如何合作来进行同步的?

线程作为 CPU 最小的执行单元,通常情况下的 Android 并发是指多线程环境下进行并发,那么我们首先了解下线程。CPU 负责调度线程资源,通过各种调度机制,雨露均沾,将每个线程都运转起来。但既然有调度机制,那每一个线程不可能一直在运行,这样线程就有了状态,通过对这些状态的分析,我们就能从另一个角度了解线程是如何运行的。

java thread state

上图就是 thread 可能处于的状态,看上去很复杂,但我们可以逐步突破,一个状态一个状态地分析。

再进行详细的讲解之前,先说明一下本文所讲的线程状态都是基于虚拟机的,而非操作系统的。由于 Microsoft、Linux、Unix 等等,都有自己的线程模型,具体实现的时候都各自维护着不同的状态信息,Java 是一个跨平台的语言,因而得有虚拟机这一层来屏蔽不同操作系统之间的差异。本文所研究的正是,虚拟机这一层的线程状态,而非操作系统的线程状态!

我们通过以下的代码thread.getState(),来判断当前线程所处于的状态。

1
2
3
4
5
private static void dumpThread(Thread thread) {
Log.d("thread",
String.format(
"info:%s, state:%s", thread, thread.getState()));
}

虚拟机定义了6种线程状态,咱们分别看看。

  1. NEW
  2. RUNNABLE
  3. BLOCKED
  4. WAITING
  5. TIMED_WAITING
  6. TERMINATED

New

这个状态最简单,但我们 new 出一个线程对象,并没有调用 start 方法时,它就处于 New 状态,例如下面的代码。

1
2
3
4
5
6
7
8
thread = new Thread(new Runnable() {
@Override
public void run() {
Log.d("Thread", "I'm running now.");
}
}, "ThreadStateDemo");

dumpThread(thread);

此时对应的输出是:

1
D/thread: info:Thread[ThreadStateDemo,5,main], state:NEW

Runnable

1
2
thread.start();
dumpThread(thread);

此时的日志输出是:

1
D/thread: info:Thread[ThreadStateDemo,5,main], state:RUNNABLE

在调用 start 方法过后,线程就会进入到 Runnable 状态。但此时并不会立刻执行,而是进入到 Ready To Run的子状态中,当获取到线程资源的时候,线程才会切换到 Running状态。而 CPU 在执行一段时间的线程过后,也会因为用完时间片,而再次切换回Ready To Run,周而复始地这么运转。

RUNNABLE 的注释如下

Thread state for a runnable thread. A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.

此时我们是无法通过前面的代码来区分出,线程是 Running 状态,还是 Ready To Run 状态。

因为两个状态的切换,映射到了操作系统底层的线程上,把调度委托给了操作系统,我们在虚拟机层面看到的状态实质是对底层状态的映射及包装。JVM 本身没有做什么实质的调度,把底层的 Ready To RunRunning 状态映射上来也没多大意义,因此,统一成为 Runnable 状态是不错的选择。

这里还需要特别注意的地方是 I/O 操作,通常情况下 I/O 操作的耗时程度是远大于一个时间分片的,因而正常情况下在执行 I/O 操作时,都会暂时挂起 CPU,等待 I/O 操作执行完毕过后的中断信号,而恢复运行。那么在这种情况下,对于虚拟机的线程状态而言,还是处于 Runnable 吗?答案是肯定的,从虚拟机的角度上看,该线程就是在执行 I/O 操作,才不会关心要怎样执行,要怎么处理中断,那都是操作系统的事情。

图片来自肖国栋的博客

Blocked

划重点,这是考试必考内容。
这部分的线程状态,就与同步有着巨大的关系了,我们来具体了解下 Blocked 状态是怎样的。

Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling {@link Object#wait() Object.wait}.

从这段代码中,我们能够分析出两种情况。

  1. 需要等待监视器锁去进入一个同步块时。
  2. 在一个同步块中,执行 Object.wait 过后,再次等待进入同步块时。

Blocked - enter a synchronized block

咱们先看第一种情况,需要等待监视器锁去进入一个同步块时。根据第一篇文章中 synchronized 的原理,多个线程进入同一个代码块时,没有持有锁的线程就必须等待,这里说的就是这种情况。

我们从具体的代码入手,分析下这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void syncTest() {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (MainActivity.this) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread threadA = new Thread(runnable, "thread-A");
Thread threadB = new Thread(runnable, "thread-B");
Thread threadC = new Thread(runnable, "thread-C");
threadA.start();
threadB.start();
threadC.start();
dumpThread(threadA);
dumpThread(threadB);
dumpThread(threadC);
}

此时会输出:

1
2
3
D/thread: info:Thread[thread-A,5,main], state:TIMED_WAITING
D/thread: info:Thread[thread-B,5,main], state:BLOCKED
D/thread: info:Thread[thread-C,5,main], state:BLOCKED

B 和 C 线程都因为没有抢到锁,而不得不等待,此时的用户状态正是 Blocked。

这里引入一个概念,Entry Set,道上人也尊称为锁池。当多个线程竞争锁时,会抢占到资源的线程,会被加入到锁池中。当锁释放时,锁池中的任意一个线程会被唤醒,与其他活跃线程再次抢占锁。如果成功占用锁,就从锁池中移除,否则就老老实实呆着锁池中。

Blocked - reenter a synchronized block after object.wait

第二种情况,听上去非常拗口,一要再次进入同步块,二要调用 wait 方法。这里必须要了解两个额外的概念,wait 和 等待池。

我们设想这样一种场景,大家都在排队使用咖啡机喝咖啡。最开始的时候,大家都依次占有咖啡机(🔐),弄好咖啡过后再释放掉,队伍也严谨地往前步进;但此时,一个运气不好的小姐姐,在使用咖啡机的时候,发现咖啡机里面的牛奶🥛不够了,此时就要引申出wait这一哲学操作了。小姐姐此时只能暂时放弃咖啡机(🔐), 在一旁等待。工作人员添加牛奶🥛过后,调用 notify/notifyAll 过后,再进行排队弄咖啡。

wait 的语义是暂时放弃锁,等待其他线程调用 notify/notifyAll。要注意到 wait 方法的调用一定是在同步块中,wait 是要放弃锁的,只能在持有锁过后才会释放掉。调用 wait 操作过后,线程会进入等待池中,其他线程调用 notify/notifyAll,通知任意一个或者全部等待池中的线程,去和锁池和其他活跃线程竞争锁。

在 wait 过后,再去竞争线程资源时,此时的状态也是 Blocked。

以上就是两种 Blocked 状态的情况,大家也多注意线程在 Blocked 上如何处理的,也对我们如何写同步代码提供帮助。

Waiting

再来看看 Waiting 状态,从官方文档入手。

Thread state for a waiting thread. A thread is in the waiting state due to calling one of the following methods:

  1. {@link Object#wait() Object.wait} with no timeout
  2. {@link Thread#join() Thread.join} with no timeout
  3. {@link LockSupport#park() LockSupport.park}

no timeout wait

在上一个状态的讲解中,提到了 wait 的操作,当一个线程调用了 wait,并且在等待 notify/notifyAll 的过程中,该线程所处的状态就是 Waiting。如果读者是服务端相关的人员,可能会遇到这样一个面试题,就是用 wait/notify 来实现一个生产者-消费者模式。本文主要是将并发相关的知识,大家有兴趣具体去了解下。

no timeout join

join 就是等待另一个线程结束,如果查看源码的话,就会发现其可以当做隐式的 wait/notify。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public final void join(long millis) throws InterruptedException {
synchronized(lock) {
long base = System.currentTimeMillis();
long now = 0;

if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {
while (isAlive()) {
lock.wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
lock.wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
}

LockSupport.park

LookSupport 是 JDK 1.6 开始引入的同步工具,是线程的阻塞原语,用来阻塞线程和唤醒线程。我们可以利用 LookSupport 来实现自己的同步工具。

Timed_Waiting

A thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state.

TIMED_WAITING 与 WAITING 主要区别在于时效上面,当我们 wait,join,sleep 操作带有时效时,就会进入 TIMED_WAITING 状态。

需要重点指出的是 sleep、yield 不带有任何同步语义。什么意思呢,就是 sleep 之前持有锁,那sleep的时候还持有锁;如果不持有,sleep的时候依然不持有。与同步没有任何关系。

Waiting、Timed-Waiting 与 Blocked 状态,对于 JVM 层面,区别主要在于是否为主动的。主动地等待,就是 Waiting、Timed-Waiting,被动等待锁就是 Blocked 状态。

Terminated

这个就相对简单些了,但线程执行结束过后,就进入了 TERMINATED 状态。且无法再恢复到其他状态中去了。

Android 中查看线程状态

学以致用,接下来介绍两种常见的方法来查看线程状态。当我们遇到同步问题时,可以通过对线程状态的分析,发现症结所在。

  1. Android Debugger

在 Debugger 中点击 Get thread dump 的按钮,这样可以查看快照时的线程状态。

点击每个线程,可有详细的信息,例如:
“main@7067” prio=5 tid=0x2 nid=NA runnable

其中 prio 是指优先级、tid是线程id,nid是native线程id

android debugger thread

  1. anr dump

还有一种技巧是利用 Android 的 ANR 机制,强制应用发生 ANR,这样再 dump 出 anr 日志,即可查看各线程状态。

1
adb shell kill -3 [pid]

文档信息