我目前所在的公司是一家跨国企业,总部在瑞典。前段时间公司新开发的一个应用准备发布到应用宝平台。但是在发布之前,需要准备一系列软著相关的证明材料。而这些所有的证明材料只有总部才有资格去办理。因此上海的同事只能以邮件方式告知总部,后续所有的工作重心就全部转移到瑞典,我们只能等待总部的处理结果。
经过这么一通跨国操作之后,本来上海分部内部处理很容易很迅速的事情,也变得遥遥无期,困难重重。且不说这中间 10 多个小时的时差,还相当于无缘无故给瑞典的同事增加“额外工作"。在一个敏捷至上的团队中,这种“额外工作"很容易就被安排到后续的档期中。
每当想起这件事,我总是能联想到 Java 中的线程。Java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,就相当于工作从上海分部转换到瑞典总部的操作一样,因此状态转换需要花费很多的处理器时间。
比如如下代码:
value++ 因为被关键字 synchronized 修饰,所以会在各个线程间同步执行。但是 value++ 消耗的时间很有可能比线程状态转换消耗的时间还短,所以说 synchronized 是 Java 语言中一个重量级的操作。
要了解 synchronized 的原理需要先理清楚两件事情:对象头和 Monitor。
在“大话Java对象在虚拟机中是什么样子”这篇文章中,提到了 Java 对象在内存中的布局分为 3 部分:对象头、实例数据、对齐填充。当我们在 Java 代码中,使用 new 创建一个对象的时候,JVM 会在堆中创建一个 instanceOopDesc 对象,这个对象中包含了对象头以及实例数据。
instanceOopDesc 的基类为 oopDesc 类。它的结构如下:
其中 _mark 和 _metadata 一起组成了对象头。_metadata 主要保存了类元数据,不需要做过多介绍。这里重点看下 _mark 属性,_mark 是 markOop 类型数据,一般称它为标记字段(Mark Word),其中主要存储了对象的 hashCode、分代年龄、锁标志位,是否偏向锁等。
用一张图来表示 32 位 Java 虚拟机的 Mark Word 的默认存储结构如下:
默认情况下,没有线程进行加锁操作,所以锁对象中的 Mark Word 处于无锁状态。但是考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多的有效数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 Mark Word 默认存储结构外,还有如下可能变化的结构:
从图中可以看出,根据"锁标志位”以及"是否为偏向锁",Java 中的锁可以分为以下几种状态:
在 Java 6 之前,并没有轻量级锁和偏向锁,只有重量级锁,也就是通常所说 synchronized 的对象锁,锁标志位为 10。从图中的描述可以看出:当锁是重量级锁时,对象头中 Mark Word 会用 30 bit 来指向一个“互斥量”,而这个互斥量就是 Monitor。
Monitor 可以把它理解为一个同步工具,也可以描述为一种同步机制。实际上,它是一个保存在对象头中的一个对象。在 markOop 中有如下代码:
通过 monitor() 方法创建一个 ObjectMonitor 对象,而 ObjectMonitor 就是 Java 虚拟机中的 Monitor 的具体实现。因此 Java 中每个对象都会有一个对应的 ObjectMonitor 对象,这也是 Java 中所有的 Object 都可以作为锁对象的原因。
那 ObjectMonitor 是如何实现同步机制的呢?
首先看下 ObjectMonitor 的结构:
其中有几个比较关键的属性:
当多个线程同时访问一段同步代码时,首先会进入 _EntryList 队列中,当某个线程通过竞争获取到对象的 monitor 后,monitor 会把 _owner 变量设置为当前线程,同时 monitor 中的计数器 _count 加 1,即获得对象锁。
若持有 monitor 的线程调用 wait() 方法,将释放当前持有的 monitor,_owner 变量恢复为 null, _count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。
比如以下代码通过 3 个线程分别执行以下同步代码块:
锁对象是 lock 对象,在 JVM 中会有一个 ObjectMonitor 对象与之对应。如下图所示:
分别使用 3 个线程来执行以上同步代码块。默认情况下,3 个线程都会先进入 ObjectMonitor 中的 EntrySet 队列中,如下所示:
假设线程 2 首先通过竞争获取到了锁对象,则 ObjectMonitor 中的 Owner 指向线程 2,并将 count 加 1。结果如下:
上图中 Owner 指向线程 2 表示它已经成功获取到锁(Monitor)对象,其他线程只能处于阻塞(blocking)状态。如果线程 2 在执行过程中调用 wait() 操作,则线程 2 会释放锁(Monitor)对象,以便其他线程进入获取锁(Monitor)对象,Owner 变量恢复为 null,count 做减 1 操作,同时线程 2 会添加到 WaitSet 集合,进入等待(waiting)状态并等待被唤醒。结果如下:
然后线程 1 和线程 3 再次通过竞争获取到锁(Monitor)对象,则重新将 Owner 指向成功获取到锁的线程。假设线程 1 获取到锁,如下:
如果在线程 1 执行过程中调用 notify 操作将线程 2 唤醒,则当前处于 WaitSet 中的线程 2 会被重新添加到 EntrySet 集合中,并尝试重新获取竞争锁(Monitor)对象。但是 notify 操作并不会是使程 1 释放锁(Monitor)对象。结果如下:
当线程 1 中的代码执行完毕以后,同样会自动释放锁,以便其他线程再次获取锁对象。
实际上,ObjectMonitor 的同步机制是 JVM 对操作系统级别的 Mutex Lock(互斥锁)的管理过程,其间都会转入操作系统内核态。也就是说 synchronized 实现锁,在“重量级锁”状态下,当多个线程之间切换上下文时,还是一个比较重量级的操作。
从 Java 6 开始,虚拟机对 synchronized 关键字做了多方面的优化,主要目的就是,避免 ObjectMonitor 的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率 。其中主要做了以下几个优化: 锁自旋、轻量级锁、偏向锁。
线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力,所以 Java 引入了自旋锁的操作。实际上自旋锁在 Java 1.4 就被引入了,默认关闭,但是可以使用参数 -XX:+UseSpinning 将其开启。但是从 Java 6 之后默认开启。
所谓自旋,就是让该线程等待一段时间,不会被立即挂起,看当前持有锁的线程是否会很快释放锁。而所谓的等待就是执行一段无意义的循环即可(自旋)。
自旋锁也存在一定的缺陷:自旋锁要占用 CPU,如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的 CPU 时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
有时候 Java 虚拟机中会存在这种情形:对于一块同步代码,虽然有多个不同线程会去执行,但是这些线程是在不同的时间段交替请求这把锁对象,也就是不存在锁竞争的情况。在这种情况下,锁会保持在轻量级锁的状态,从而避免重量级锁的阻塞和唤醒操作。
要了解轻量级锁的工作流程,还是需要再次看下对象头中的 Mark Word。上文中已经提到,锁的标志位包含几种情况:00 代表轻量级锁、01 代表无锁(或者偏向锁)、10 代表重量级锁、11 则跟垃圾回收算法的标记有关。
当线程执行某同步代码时,Java 虚拟机会在当前线程的栈帧中开辟一块空间(Lock Record)作为该锁的记录,如下图所示:
然后 Java 虚拟机会尝试使用 CAS(Compare And Swap)操作,将锁对象的 Mark Word 拷贝到这块空间中,并且将锁记录中的 owner 指向 Mark Word。结果如下:
当线程再次执行此同步代码块时,判断当前对象的 Mark Word 是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁。
轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量级锁是在没有锁竞争情况下的锁状态,但是在有些时候锁不仅存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking 开启或者关闭。
偏向锁的具体实现就是在锁对象的对象头中有个 ThreadId 字段,默认情况下这个字段是空的,当第一次获取锁的时候,就将自身的 ThreadId 写入锁对象的 Mark Word 中的 ThreadId 字段内,将是否偏向锁的状态置为 01。这样下次获取锁的时候,直接检查 ThreadId 是否和自身线程 Id 一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。
其实偏向锁并不适合所有应用场景, 因为一旦出现锁竞争,偏向锁会被撤销,并膨胀成轻量级锁,而撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块时,才能体现出明显改善;因此实践中,还是需要考虑具体业务场景,并测试后,再决定是否开启/关闭偏向锁。
对于锁的几种状态转换的源码分析,可以参考:源码分析Java虚拟机中锁膨胀的过程
本课时主要介绍了 Java 中锁的几种状态,其中偏向锁和轻量级锁都是通过自旋等技术避免真正的加锁,而重量级锁才是获取锁和释放锁,重量级锁通过对象内部的监视器(ObjectMonitor)实现,其本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本非常高。实际上Java对锁的优化还有”锁消除“,但是”锁消除“是基于Java对象逃逸分析的,如果对此感兴趣可以查阅 Java 逃逸分析 这篇文章。
AQS 全称是 Abstract Queued Synchronizer,一般翻译为同步器。它是一套实现多线程同步功能的框架,由大名鼎鼎的 Doug Lea 操刀设计并开发实现的。AQS 在源码中被广泛使用,尤其是在 JUC(Java Util Concurrent)中,比如 ReentrantLock、Semaphore、CountDownLatch、ThreadPoolExecutor。理解 AQS 对我们理解 JUC 中其他组件至关重要,并且在实际开发中也可以通过自定义 AQS 来实现各种需求场景。
注意:理解 AQS 需要一定的数据结构基础,尤其是双端队列,并对 Unsafe 有一定的了解。
本课时我们主要通过 ReentrantLock 来理解 AQS 内部的工作机制。首先从 ReentrantLock 的 lock() 方法开始:
代码很简单,只是调用了一个 Sync 的 lock() 方法,这个 Sync 是什么呢?
可以看出,Sync 是 ReentrantLock 中的一个内部类。ReentrantLock 并没有直接继承 AQS,而是通过内部类 Sync 来扩展 AQS 的功能,然后 ReentrantLock 中存有 Sync 的全局变量引用。
Sync 在 ReentrantLock 有两种实现:NonfairSync 和 FairSync,分别对应非公平锁和公平锁。以非公平锁为例,实现源码如下:
可以看出在非公平锁中的 lock() 方法中,主要做了如下操作:
如果通过 CAS 设置变量 State(同步状态)成功,表示当前线程获取锁成功,则将当前线程设置为独占线程。
如果通过 CAS 设置变量 State(同步状态)失败,表示当前锁正在被其他线程持有,则进入 Acquire 方法进行后续处理。
acruire() 方法定义在 AQS 中,具体如下:
acquire() 方法是一个比较重要的方法,可以将其拆解为 3 个主要步骤:
- tryAcquire() 方法主要目的是尝试获取锁;
- addWaiter() 如果 tryAcquire() 尝试获取锁失败则调用 addWaiter 将当前线程添加到一个等待队列中;
- acquireQueued 处理加入到队列中的节点,通过自旋去尝试获取锁,根据情况将线程挂起或者取消。
以上 3 个方法都被定义在 AQS 中,但其中 tryAcquire() 有点特殊,其实现如下:
默认情况下直接抛异常,因此它需要在子类中复写,也就是说真正的获取锁的逻辑由子类同步器自己实现。
ReentrantLock 中 tryAcquire 的实现(非公平锁)如下:
解释说明:
- 获取当前线程,判断当前的锁的状态;
- 如果 state=0 表示当前是无锁状态,通过 cas 更新 state 状态的值,返回 true;
- 如果当前线程属于重入,则增加重入次数,返回 true;
- 上述情况都不满足,则获取锁失败返回 false。
最后用一张图表示 ReentrantLock.lock() 过程:
从图中我们可以看出,在 ReentrantLock 执行 lock() 的过程中,大部分同步机制的核心逻辑都已经在 AQS 中实现,ReentrantLock 自身只要实现某些特定步骤下的方法即可,这种设计模式叫作模板模式。如果你做过 Android 开发对这一模式应该非常熟悉。Activity 的生命周期的执行流程都已经在 framework 中定义好了,子类 Activity 只要在相应的 onCreate、onPause 等生命周期方法中提供相应的实现即可。
注意:不只 ReentrantLock,JUC 包中其他组件例如 CountDownLatch、Semaphor 等都是通过一个内部类 Sync 来继承 AQS,然后在内部中通过操作 Sync 来实现同步。这种做法的好处是将线程控制的逻辑控制在 Sync 内部,而对外面向用户提供的接口是自定义锁,这种聚合关系能够很好的解耦两者所关注的逻辑。
AQS 核心功能原理分析
首先看下 AQS 中几个关键的属性,如下所示:
代码中展示了 AQS 中两个比较重要的属性 Node 和 state。
state 锁状态
state 表示当前锁状态。当 state = 0 时表示无锁状态;当 state>0 时,表示已经有线程获得了锁,也就是 state=1,如果同一个线程多次获得同步锁的时候,state 会递增,比如重入 5 次,那么 state=5。 而在释放锁的时候,同样需要释放 5 次直到 state=0,其他线程才有资格获得锁。
state 还有一个功能是实现锁的独占模式或者共享模式。
- 独占模式:只有一个线程能够持有同步锁。
比如在独占模式下,我们可以把 state 的初始值设置成 0,当某个线程申请锁对象时,需要判断 state 的值是不是 0,如果不是 0 的话意味着其他线程已经持有该锁,则本线程需要阻塞等待。
- 共享模式:可以有多个线程持有同步锁。
在共享模式下的道理也差不多,比如说某项操作我们允许 10 个线程同时进行,超过这个数量的线程就需要阻塞等待。那么只需要在线程申请对象时判断 state 的值是否小于 10。如果小于 10,就将 state 加 1 后继续同步语句的执行;如果等于 10,说明已经有 10 个线程在同时执行该操作,本线程需要阻塞等待。
Node 双端队列节点
Node 是一个先进先出的双端队列,并且是等待队列,当多线程争用资源被阻塞时会进入此队列。这个队列是 AQS 实现多线程同步的核心。
从之前 ReentrantLock 图中可以看到,在 AQS 中有两个 Node 的指针,分别指向队列的 head 和 tail。
Node 的主要结构如下:
默认情况下,AQS 中的链表结构如下图所示:
获取锁失败后续流程分析
锁的意义就是使竞争到锁对象的线程执行同步代码,多个线程竞争锁时,竞争失败的线程需要被阻塞等待后续唤醒。那么 ReentrantLock 是如何实现让线程等待并唤醒的呢?
前面中我们提到在 ReentrantLock.lock() 阶段,在 acquire() 方法中会先后调用 tryAcquire、addWaiter、acquireQueued 这 3 个方法来处理。tryAcquire 在 ReentrantLock 中被复写并实现,如果返回 true 说明成功获取锁,就继续执行同步代码语句。可是如果 tryAcquire 返回 false,也就是当前锁对象被其他线程所持有,那么当前线程会被 AQS 如何处理呢?
addWaiter 首先当前获取锁失败的线程会被添加到一个等待队列的末端,具体源码如下:
有两种情况会致使插入队列失败:
- tail 为空:说明队列从未初始化,因此需要调用 enq 方法在队列中插入一个空的 Node;
- compareAndSetTail 失败:说明插入过程中有线程修改了此队列,因此需要调用 enq 将当前 node 重新插入到队列末端。
经过 addWaiter 方法之后,此时线程以 Node 的方式被加入到队列的末端,但是线程还没有被执行阻塞操作,真正的阻塞操作是在下面的 acquireQueued 方法中判断执行。
acquireQueued 在 acquireQueued 方法中并不会立即挂起该节点中的线程,因此在插入节点的过程中,之前持有锁的线程可能已经执行完毕并释放锁,所以这里使用自旋再次去尝试获取锁(不放过任何优化细节)。如果自旋操作还是没有获取到锁!那么就将该线程挂起(阻塞),该方法的源码如下:
可以看出在 shouldParkAfterFailedAcquire 方法中会判读当前线程是否应该被挂起,其代码如下:
首先获取前驱节点的 waitStatus 值,Node 中的 waitStatus 一共有 5 种取值,分别代表的意义如下:
waitStatue值 | 描述 |
CANCELLED (1) | 当前线程因为超时或者中断被取消。这是一个终结态,也就是状态到此为止 |
SIGNAL (-1) | 当前线程的后继线程被阻塞或者即将被阻塞,当前线程释放锁或者取消后需要唤醒后继线程。这个状态一般都是后继线程来设置前驱节点的 |
CONDITION (-2) | 当前线程在 condition 队列中 |
PROPAGATE (-3) | 用于将唤醒后继线程传递下去,这个状态的引入是为了完善和增强共享锁的唤醒机制。在一个节点成为头节点之前,是不会跃迁为此状态的 |
0 | 表示无锁状态 |
接下来根据 waitStatus 不同的值进行不同的操作,主要有以下几种情况:
- 如果 waitStatus 等于 SIGNAL,返回 true 将当前线程挂起,等待后续唤醒操作即可。
- 如果 waitStatus 大于 0 也就是 CANCLE 状态,会将此前驱节点从队列中删除,并在循环中逐步寻找下一个不是“CANCEL”状态的节点作为当前节点的前驱节点。
- 如果 waitStatus 既不是 SIGNAL 也不是 CANCEL,则将当前节点的前驱节点状态设置为 SIGNAL,这样做的好处是下一次执行 shouldParkAfterFailedAcquire 时可以直接返回 true,挂起线程。
代码再回到 acquireQueued 中,如果 shouldParkAfterFailedAcquire 返回 true 表示线程需要被挂起,那么会继续调用 parkAndCheckInterrupt 方法执行真正的阻塞线程代码,具体如下:
这个方法比较简单,只是调用了 LockSupport 中的 park 方法。在 LockSupport.park() 方法中调用了 Unsafe API 来执行底层 native 方法将线程挂起,代码到这已经到了操作系统的层面,没有必要再深入分析。
至此,获取锁的大体流程已经分析完毕,总结一下整个过程如下:
- AQS 的模板方法 acquire 通过调用子类自定义实现的 tryAcquire 获取锁;
- 如果获取锁失败,通过 addWaiter 方法将线程构造成 Node 节点插入到同步队列队尾;
- 在 acquirQueued 方法中以自旋的方法尝试获取锁,如果失败则判断是否需要将当前线程阻塞,如果需要阻塞则最终执行 LockSupport(Unsafe) 中的 native API 来实现线程阻塞。
释放锁流程分析
在上面加锁阶段被阻塞的线程需要被唤醒过后才可以重新执行。那具体 AQS 是何时尝试唤醒等待队列中被阻塞的线程呢?
同加锁过程一样,释放锁需要从 ReentrantLock.unlock() 方法开始:
可以看出,首先调用 tryRelease 方法尝试释放锁,如果成功最终会调用 AQS 中的 unparkSuccessor 方法来实现释放锁的操作。unparkSuccessor 的具体实现如下:
解释说明:
首先获取当前节点(实际上传入的是 head 节点)的状态,如果 head 节点的下一个节点是 null,或者下一个节点的状态为 CANCEL,则从等待队列的尾部开始遍历,直到寻找第一个 waitStatus 小于 0 的节点。
如果最终遍历到的节点不为 null,再调用 LockSupport.unpark 方法,调用底层方法唤醒线程。 至此,线程被唤醒的时机也分析完毕。
不得不说的 CAS
不管是在加锁还是释放锁阶段,多次提到了一种通用的操作:compareAndSetXXX。这种操作最终会调用 Unsafe 中的 API 进行 CAS 操作。
CAS 全称是 Compare And Swap,译为比较和替换,是一种通过硬件实现并发安全的常用技术,底层通过利用 CPU 的 CAS 指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。
它的实现过程主要有 3 个操作数:内存值 V,旧的预期值 E,要修改的新值 U,当且仅当预期值 E和内存值 V 相同时,才将内存值 V 修改为 U,否则什么都不做。
CAS 底层会根据操作系统和处理器的不同来选择对应的调用代码,以 Windows 和 X86 处理器为例,如果是多处理器,通过带 lock 前缀的 cmpxchg 指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作;如果是单处理器,通过 cmpxchg 指令完成原子操作。
自定义 AQS
理解了 AQS 的设计思路,接下来我们就可以通过自定义 AQS 来实现自己的同步实现机制。
代码中的 MyLock 就是一个最简单的独占锁,通过使用 MyLock 也能实现同 synchronized 和 ReentrantLock 相同的功能。比如如下代码:
最终打印的 count 值为 20000,说明两个线程之间是线程安全的同步操作。
总结
总体来说,AQS 是一套框架,在框架内部已经封装好了大部分同步需要的逻辑,在 AQS 内部维护了一个状态指示器 state 和一个等待队列 Node,而通过 state 的操作又分为两种:独占式和共享式,这就导致 AQS 有两种不同的实现:独占锁(ReentrantLock 等)和分享锁(CountDownLatch、读写锁等)。本课时主要从独占锁的角度深入分析了 AQS 的加锁和释放锁的流程。
理解 AQS 的原理对理解 JUC 包中其他组件实现的基础有帮助,并且理解其原理才能更好的扩展其功能。上层开发人员可以基于此框架基础上进行扩展实现适合不同场景、不同功能的锁。其中几个有可能需要子类同步器实现的方法如下。
- lock()。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。
第11讲:线程池之刨根问底
我们在课时 09 介绍 synchronized 原理时,已经了解了 Java 中线程的创建以及上下文切换是比消耗性能的,因此引入了偏向锁、轻量级锁等优化技术,目的就是减少用户态和核心态之间的切换频率。但是在这些优化基础之上,还有另外一个角度值得思考:创建和销毁线程非常损耗性能,那有没有可能复用一些已经被创建好的线程呢?答案是肯定的,那就是线程池。
另外,线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间,在线程销毁时需要回收这些系统资源,频繁地创建销毁线程会浪费大量资源,而通过复用已有线程可以更好地管理和协调线程的工作。
线程池主要解决两个问题: 一、 当执行大量异步任务时线程池能够提供很好的性能。 二、 线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。 ——《Java并发编程之美》
线程池体系
用一张图来表示线程池体系如下:
解释说明:
- Executor 是线程池最顶层的接口,在 Executor 中只有一个 execute 方法,用于执行任务。至于线程的创建、调度等细节由子类实现。
- ExecutorService 继承并拓展了 Executor,在 ExecutorService 内部提供了更全面的任务提交机制以及线程池关闭方法。
- ThreadPoolExecutor 是 ExecutorService 的默认实现,所谓的线程池机制也大多封装在此类当中,因此它是本课时分析的重点。
- ScheduledExecutorService 继承自 ExecutorService,增加了定时任务相关方法。
- ScheduledThreadPoolExecutor 继承自 ThreadPoolExecutor,并实现了 ScheduledExecutorService 接口。
- ForkJoinPool 是一种支持任务分解的线程池,一般要配合可分解任务接口 ForkJoinTask 来使用。
创建线程池
为了开发者可以更方便地使用线程池,JDK 中给我们提供了一个线程池的工厂类—Executors。在 Executors 中定义了多个静态方法,用来创建不同配置的线程池。常见有以下几种。
newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按先进先出的顺序执行。
执行上述代码结果如下,可以看出所有的 task 始终是在同一个线程中被执行的。
newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
执行效果如下:
从上面日志中可以看出,缓存线程池会创建新的线程来执行任务。但是如果将代码修改一下,在提交任务之前休眠 1 秒钟,如下:
再次执行则打印日志同 SingleThreadPool 一模一样,原因是提交的任务只需要 500 毫秒即可执行完毕,休眠 1 秒导致在新的任务提交之前,线程 “pool-1-thread-1” 已经处于空闲状态,可以被复用执行任务。
newFixedThreadPool
创建一个固定数目的、可重用的线程池。
上述代码创建了一个固定数量 3 的线程池,因此虽然向线程池提交了 10 个任务,但是这 10 个任务只会被 3 个线程分配执行,执行效果如下:
newScheduledThreadPool
创建一个定时线程池,支持定时及周期性任务执行。
上面代码创建了一个线程数量为 2 的定时任务线程池,通过 scheduleAtFixedRate 方法,指定每隔 500 毫秒执行一次任务,并且在 5 秒钟之后通过 shutdown 方法关闭定时任务。执行效果如下:
上面这几种就是常用到的线程池使用方式,但是,在 阿里Java开发手册 中已经严禁使用 Executors 来创建线程池,这是为什么呢?要回答这个问题需要先了解线程池的工作原理。
线程池工作原理分析
现实案例
先来看一个现实生活中的实际案例,某工艺品加工厂有 3 台加工机器用来生产订单所需的产品,正常情况下 3 台机器能够保证所有订单按时按需生产完毕,如下图所示:
![在这里插入图片描述](https://s2.51cto.com/images/blog/202410/12232011_670a93abb0a7871702.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_30,g_se,x_10,y_10,shadow_20,type_ZmFuZ3poZW5naGVpdGk=#pic_center)
如果订单量突然大幅增加,3 台机器已经处于满负荷状态,一时间无法完成新增的订单任务。那怎么办呢?正所谓钱是不可能不挣的,只能硬着头皮接下新来的订单,但是会将新来的订单暂存在仓库中,当有加工机器空闲下来之后,再用来生产仓库中的订单,如下图所示:
如果订单量持续快速增长,导致仓库也存储满了。那怎么办呢? 正常情况下加工厂肯定会通过购买新的加工机器来满足订单需求,如下所示:
有了仓库和新购买的加工机器加持,加工厂业务还是能够正常流转。但是当某些极端情况发生,比如双十一搞活动之后订单爆单了。这时新增的订单任务连仓库以及所有的加工机器都已经无法容纳,说明加工厂已经不能接受新的订单任务了,因此只能拒绝所有新的订单。
线程池的工作流程同上面描述的加工厂完成订单任务非常相似,并且在线程池的构造器中,通过传入的参数可以设置默认有多少台加工机器、仓库的大小、可以购买新的加工机器的最大数量等等。
线程池结构
从上图中可以大体看出,在线程池内部主要包含以下几个部分:
worker 集合:保存所有的核心线程和非核心线程(类比加工厂的加工机器),其本质是一个HashSet。
等待任务队列:当核心线程的个数达到 corePoolSize 时,新提交的任务会被先保存在等待队列中(类比加工厂中的仓库),其本质是一个阻塞队列 BlockingQueue。
ctl:是一个 AtomicInteger 类型,二进制高 3 位用来标识线程池的状态,低 29 位用来记录池中线程的数量。
获取线程池状态、工作线程数量、修改 ctl 的方法分别如下:
线程池主要有以下几种运行状态:
参数分析
线程池的构造器如下:
构造参数说明。
- corePoolSize:表示核心线程数量。
- maximumPoolSize:表示线程池最大能够容纳同时执行的线程数,必须大于或等于 1。如果和 corePoolSize 相等即是固定大小线程池。
- keepAliveTime:表示线程池中的线程空闲时间,当空闲时间达到此值时,线程会被销毁直到剩下 corePoolSize 个线程。
- unit:用来指定 keepAliveTime 的时间单位,有 MILLISECONDS、SECONDS、MINUTES、HOURS 等。
- workQueue:等待队列,BlockingQueue 类型。当请求任务数大于 corePoolSize 时,任务将被缓存在此 BlockingQueue 中。
- threadFactory:线程工厂,线程池中使用它来创建线程,如果传入的是 null,则使用默认工厂类 DefaultThreadFactory。
- handler:执行拒绝策略的对象。当 workQueue 满了之后并且活动线程数大于 maximumPoolSize 的时候,线程池通过该策略处理请求。
注意:当 ThreadPoolExecutor 的 allowCoreThreadTimeOut 设置为 true 时,核心线程超时后也会被销毁。
流程解析
当我们调用 execute 或者 submit,将一个任务提交给线程池,线程池收到这个任务请求后,有以下几种处理情况:
1.当前线程池中运行的线程数量还没有达到 corePoolSize 大小时,线程池会创建一个新线程执行提交的任务,无论之前创建的线程是否处于空闲状态。 举例:
上面代码创建了 3 个固定数量的线程池,每次提交的任务耗时 100 毫秒。每次提交任务之前都会延迟2秒,保证线程池中的工作线程都已经执行完毕,但是执行效果如下:
可以看出虽然线程 1 和线程 2 都已执行完毕并且处于空闲状态,但是线程池还是会尝试创建新的线程去执行新提交的任务,直到线程数量达到 corePoolSize。
2.当前线程池中运行的线程数量已经达到 corePoolSize 大小时,线程池会把任务加入到等待队列中,直到某一个线程空闲了,线程池会根据我们设置的等待队列规则,从队列中取出一个新的任务执行。举例:
上述代码提交的任务耗时 4 秒,因此前 2 个任务会占用线程池中的 2 个核心线程。此时有新的任务提交给线程池时,任务会被缓存到等待队列中,结果如下:
可以看到红框 1 中通过 2 个核心线程直接执行提交的任务,因此等待队列中的数量为 0;而红框 2 中表明,此时核心线程都已经被占用,新提交的任务都被放入等待队列中。
3.如果线程数大于 corePoolSize 数量但是还没有达到最大线程数 maximumPoolSize,并且等待队列已满,则线程池会创建新的线程来执行任务。
上述代码创建了一个核心线程数为 2,最大线程数为 10,等待队列长度为 2 的线程池。执行效果如下:
解释说明:
- 1 处表示线程数量已经达到 corePoolSize;
- 2 处表明等待队列已满;
- 3 处会创建新的线程执行任务。
4.最后如果提交的任务,无法被核心线程直接执行,又无法加入等待队列,又无法创建“非核心线程”直接执行,线程池将根据拒绝处理器定义的策略处理这个任务。比如在 ThreadPoolExecutor 中,如果你没有为线程池设置 RejectedExecutionHandler。这时线程池会抛出 RejectedExecutionException 异常,即线程池拒绝接受这个任务。
将上面非核心线程的代码稍微修改一下,如下:
修改最大线程数为 3,并提交 6 次任务给线程池,执行效果如下:
程序会报异常 RejectedExecutionException,拒绝策略是线程池的一种保护机制,目的就是当这种无节制的线程资源申请发生时,拒绝新的任务保护线程池。默认拒绝策略会直接报异常,但是 JDK 中一共提供了 4 种保护策略,如下:
实际上拒绝策略都是实现自接口 RejectedExecutionException,开发者也可以通过实现此接口,定制自己的拒绝策略。
整个流程的动画演示可以参考:漫画Java线程池的工作机制
为何禁止使用 Executors
现在再回头看一下为何在阿里 Java 开发手册中严禁使用 Executors 工具类来创建线程池。尤其是 newFixedThreadPool 和 newCachedThreadPool 这两个方法。
比如如下使用 newFixedThreadPool 方法创建线程的案例:
上述代码创建了一个固定数量为 2 的线程池,并通过 for 循环向线程池中提交 100 万个任务。 通过 java -Xms4m -Xmx4m FixedThreadPoolOOM 执行上述代码:
可以发现当任务添加到 7 万多个时,程序发生 OOM。这是为什么呢? 看一下newSingleThreadExecutor 和 newFixedThreadPool() 的具体实现,如下:
可以看到传入的是一个无界的阻塞队列,理论上可以无限添加任务到线程池。当核心线程执行时间很长(比如 sleep10s),则新提交的任务还在不断地插入到阻塞队列中,最终造成 OOM。
再看下 newCachedThreadPool 会有什么问题。
上述代码同样会报 OOM,只是错误的 log 信息有点区别:无法创建新的线程。
看一下 newCachedThreadPool 的实现:
可以看到,缓存线程池的最大线程数为 Integer 最大值。当核心线程耗时很久,线程池会尝试创建新的线程来执行提交的任务,当内存不足时就会报无法创建线程的错误。
以上两种情况如果发生在生产环境将会是致命的,从阿里手册中严禁使用 Executors 的态度上也能看出,阿里也是经历过血淋淋的教训。
总结
线程池是一把双刃剑,使用得当会使代码如虎添翼;但是使用不当将会造成重大性灾难。而剑柄是握在开发者手中,只有理解线程池的运行原理,熟知它的工作机制与使用场景,才会使这把双刃剑发挥更好的作用。 这节课的实例代码已经放到 github 上:拉勾教育《Android 工程师进阶34讲》
第12讲:DVM 以及 ART 是如何对 JVM 进行优化的?
什么是 Dalvik
Dalvik 是 Google 公司自己设计用于 Android 平台的 Java 虚拟机,Android 工程师编写的 Java 或者 Kotlin 代码最终都是在这台虚拟机中被执行的。在 Android 5.0 之前叫作 DVM,5.0 之后改为 ART(Android Runtime)。
在整个 Android 操作系统体系中,ART 位于以下图中红框位置:
其实称 DVM/ART 为 Android 版的 Java 虚拟机,这种说法并不是很准确。虚拟机必须符合 Java 虚拟机规范,也就是要通过 JCM(Java Compliance Kit)的测试并获得授权,但是 DVM/ART 并没有得到授权。
DVM 大多数实现与传统的 JVM 相同,但是因为 Android 最初是被设计用于手机端的,对内存空间要求较高,并且起初 Dalvik 目标是只运行在 ARM 架构的 CPU 上。针对这几种情况,Android DVM 有了自己独有的优化措施。
Dex 文件
传统 Class 文件是由一个 Java 源码文件生成的 .Class 文件,而 Android 是把所有 Class 文件进行合并优化,然后生成一个最终的 class.dex 文件。dex 文件去除了 class 文件中的冗余信息(比如重复字符常量),并且结构更加紧凑,因此在 dex 解析阶段,可以减少 I/O 操作,提高了类的查找速度。
比如在 course12 目录下,分别创建 Dex1.java 和 Dex2.java,如下所示:
分别通过 javac 命令将它们编译为 .class 文件。
javac Dex1.java -> Dex1.class
javac Dex2.java -> Dex2.class
然后通过以下命令将 Dex1.class 和 Dex2.class 打包到一个 jar 文件中。
jar cvf AllDex.jar Dex1.class Dex2.class
上述命令会在当前目录生成一个 AllDex.jar 文件。
最后使用 dx 命令将 AllDex.jar 进行优化,并生成 AllDex.dex 文件。
dx --dex --output AllDex.dex AllDex.jars
命令结束后,会在当前目录生成 AllDex.dex 文件。
正常情况下,我们无法通过反编译工具查看其源码,但是可以通过 Android SDK 中的工具 dexdump 查看其字节码:
dexdump -d -l plain AllDex.dex
上述命令会将 Dex1 和 Dex2 优化后的字节码显示到控制台,内容较多,部分结果如下:
可以看出 Dex1 和 Dex2 的信息都在此 .dex 文件中。
实际上,dex 文件在 App 安装过程中还会被进一步优化为 odex(optimized dex),此过程还会在后续介绍安装过程时再次提到。
注意:这一优化过程也会伴随着一些副作用,最经典的就是 Android 65535 问题。出现这个问题的根本原因是在 DVM 源码中的 MemberIdsSection.java 类中,有如下一段代码:
如果 items 个数超过 DexFormat.MAX_MEMBER_IDX 则会报错,DexFormat.MAX_MEMBER_IDX 的值为 65535,items 代表 dex 文件中的方法个数、属性个数、以及类的个数。也就是说理论上不止方法数,我们在 java 文件中声明的变量,或者创建的类个数如果也超过 65535 个,同样会编译失败,Android 提供了 MultiDex 来解决这个问题。很多网上的文章说 65535 问题是因为解析 dex 文件到数据结构 DexFile 时,使用了 short 来存储方法的个数,其实这种说法是错误的!
架构基于寄存器&基于栈堆结构
在之前的课时中,我已经介绍过 JVM 的指令集是基于栈结构来执行的;而 Android 却是基于寄存器的,不过这里不是直接操作硬件的寄存器,而是在内存中模拟一组寄存器。Android 字节码和 Java 字节码完全不同,Android 的字节码(smali)更多的是二地址指令和三地址指令,具体Dalvik 指令可以参考官网 Dalvik 字节码。
具体看一下 Dalvik 和 JVM 字节码的区别,在上文中提到的 Dex1.java,在 Dex1 中有 add 方法如下:
经过编译为 Dex1.class 之后,查看其字节码为下图所示:
add 方法会使用 4 行指令来完成。而通过 dx 将其优化为 .dex 文件后,再次查看它的 Dalvik 字节码为如下:
解释说明:
- add-int 指令需要 3 个寄存器参数:v0、v2、v3。这个指令会将 v2 和 v3 进行相加运算,然后将结果保存在寄存器 v0 中。
- return 指令将结果返回。
可以看出, Dalvik 字节码只需要 2 行指令。基于寄存器的指令明显会比基于栈的指令少,虽然增加了指令长度但却缩减了指令的数量,执行也更为快速。用一张表格来对比基于栈和基于寄存器的实现方式如下:
内存管理与回收
DVM 与 JVM 另一个比较显著的不同就是内存结构的区别,主要体现在对”堆”内存的的管理。 Dalvik 虚拟机中的堆被划分为了 2 部分:Active Heap 和 Zygote Heap。如下所示:
上图取自老罗的 Android 源码分析,图中的 Card Table 以及两个 Heap Bitmap 主要是用来记录垃圾收集过程中对象的引用情况,以便实现 Concurrent GC。
为什么要分 Zygote 和 Active 两部分?
Android 系统的第一个 Dalvik 虚拟机是由 Zygote 进程创建的,而应用程序进程是由 Zygote 进程 fork 出来的。
Zygote 进程是在系统启动时产生的,它会完成虚拟机的初始化,库的加载,预置类库的加载和初始化等操作,而在系统需要一个新的虚拟机实例时,Zygote 通过复制自身,最快速的提供一个进程;另外,对于一些只读的系统库,所有虚拟机实例都和 Zygote 共享一块内存区域,大大节省了内存开销。如下图所示:
解释说明: 当启动一个应用时,Android 操作系统需要为应用程序创建新的进程,而这一步操作是通过一种写时拷贝技术(COW)直接复制 Zygote 进程而来。这意味着在开始的时候,应用程序进程和 Zygote 进程共享了同一个用来分配对象的堆。然而,当 Zygote 进程或者应用程序进程对该堆进行写操作时,内核就会执行真正的拷贝操作,使得 Zygote 进程和应用程序进程分别拥有自己的一份拷贝。拷贝是一件费时费力的事情。因此,为了尽量地避免拷贝,Dalvik 虚拟机将自己的堆划分为两部分。
事实上,Dalvik 虚拟机的堆最初只有一个,也就是 Zygote 进程在启动过程中创建 Dalvik 虚拟机时,只有一个堆。但是当 Zygote 进程在 fork 第一个应用程序进程之前,会将已经使用的那部分堆内存划分为一部分,把还没有使用的堆内存划分为另外一部分。前者就称为 Zygote 堆,后者就称为 Active 堆。以后无论是 Zygote 进程,还是应用程序进程,当它们需要分配对象的时候,都在 Active 堆上进行。这样就可以使得 Zygote 堆尽可能少地被执行写操作,因而就可以减少执行写时拷贝的操作时间。
Dalvik 虚拟机堆
在 Dalvik 虚拟机中,堆实际上就是一块匿名共享内存。Dalvik 虚拟机并不是直接管理这块匿名共享内存,而是将它封装成一个 mspace,交给 C 库来管理。为什么要这样做呢?因为内存碎片问题其实是一个通用的问题,不只是 Dalvik 虚拟机在 Java 堆为对象分配内存时会遇到,C 库的 malloc 函数在分配内存时也会遇到。
Android 系统使用的 C 库 bionic 使用了 Doug Lea 写的 dlmalloc 内存分配器,也就是说,我们调用函数 malloc 的时候,使用的是 dlmalloc 内存分配器来分配内存。这是一个成熟的内存分配器,可以很好地解决内存碎片问题。
关于 dlmalloc 内存分配器的设计,可以参考Doug Lea写的这篇文章:A Memory Allocator。
总结
本课时主要从 3 个方面介绍 DVM 的一些显著特点。但是实际上 DVM 这个话题很大,有很多细节不能通过一个课时就能讲清楚。需要读者结合整套源码实现才能对 DVM 有更深的理解。
因此我整理了一些对 DVM 源码分析的资料与链接,希望对你的学习有所帮助。
- Android Dalvik官方文档
- Dalvik虚拟机字节码和指令集对照表
- Dalvik虚拟机Java堆创建过程分析