1.2 死锁
让我们先来看一段代码,这段代码会引起死锁,使线程t1和线程t2互相等待对方释放锁。
避免死锁的几个常见方法:
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节 码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。
2.1.volatile的定义与实现原理
volatile是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。
如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
Java编程语言允许线程访问共享变量,为了 确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
如果一个字段被声明成volatile,Java线程内存 模型确保所有线程看到这个变量的值是一致的。
2.2 synchronized的实现原理与应用
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。
Java中的每一个对象都可以作为锁。具体表现 为以下3种形式。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对 象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有 详细说明。但是,方法的同步同样可以使用这两个指令来实现。 monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
2.2.1 Java对象头
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽 等于4字节,即32bit,如表2-2所示。
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM 的Mark Word的默认存储结构如表2-3所示。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变 化为存储以下4种数据,如表2-4所示。
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如表2-5所示。
2.2.2 锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏 向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高 获得锁和释放锁的效率,下文会详细分析。
1.偏向锁
HotSpot [1]的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同 一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并 获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否 存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需 要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则 使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
(1)偏向锁的撤销 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正 在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈 会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他 线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。图2-1中的线 程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
(2)关闭偏向锁 偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如 有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:- UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
2.轻量级锁
(1)轻量级锁加锁 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并 将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失 败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成 功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是 两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级 成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮 的夺锁之争。
3.锁的优缺点对比 表2-6是锁的优缺点的对比。
2.3 原子操作的实现原理
原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”。
1.术语定义
2.处理器如何实现原子操作
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操 作。
(1)使用总线锁保证原子性
所谓总线锁就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该 处理器可以独占共享内存。
(2)使用缓存锁保证原子性
在同一时刻,我们只需保证对某个内存地址 的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下 使用缓存锁定代替总线锁定来进行优化
在Java中可以通过锁和循环CAS的方式来实现原子操作。
(1)使用循环CAS实现原子操
线程作为操作系统调度的最小单元。
4.1 线程简介
4.1.1 什么是线程
现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局 部变量等属性,并且能够访问共享的内存变量。
一个Java程序从main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程 参与,但实际上Java程序天生就是多线程程序,因为执行main()方法的是一个名称为main的线 程。
4.1.2 为什么要使用多线程
(1)更多的处理器核心
(2)更快的响应时间
(3)更好的编程模型
4.1.3 线程优先级
在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线 程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分 配时间片的数量要多于优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或者I/O操 作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较 低的优先级,确保处理器不会被独占。
注意 线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java 线程对于优先级的设定。
4.1.4 线程的状态
Java线程在运行的生命周期中可能处于表4-1所示的6种不同的状态,在给定的一个时刻, 线程只能处于其中的一个状态。
线程在自身的生命周期中, 并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态 变迁如图4-1示。
由图4-1中可以看到,线程创建之后,调用start()方法开始运行。当线程执行wait()方法之 后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状 态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将 会返回到运行状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞 状态。线程在执行Runnable的run()方法之后将会进入到终止状态。
注意 Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程 阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在 java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于 阻塞的实现均使用了LockSupport类中的相关方法。
4.1.5 Daemon线程
Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这 意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调 用Thread.setDaemon(true)将线程设置为Daemon线程。
注意 Daemon属性需要在启动线程之前设置,不能在启动线程之后设置。
注意 在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源 的逻辑。
4.2 启动和终止线程
4.2.1 构造线程
4.2.2 启动线程
线程对象在初始化完成之后,调用start()方法就可以启动这个线程。线程start()方法的含义 是:当前线程(即parent线程)同步告知Java虚拟机,只要线程规划器空闲,应立即启动调用 start()方法的线程。
注意 启动一个线程前,最好为这个线程设置线程名称,因为这样在使用jstack分析程 序或者进行问题排查时,就会给开发人员提供一些提示,自定义的线程最好能够起个名字。
4.2.3 理解中断
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行 了中断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt() 方法对其进行中断操作。
4.2.4 过期的suspend()、resume()和stop()
不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资 源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结 一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会, 因此会导致程序可能工作在不确定状态下。
注意 正因为suspend()、resume()和stop()方法带来的副作用,这些方法才被标注为不建 议使用的过期方法,而暂停和恢复操作可以用后面提到的等待/通知机制来替代。
4.2.5 安全地终止线程
在4.2.3节中提到的中断状态是线程的一个标识位,而中断操作是一种简便的线程间交互 方式,而这种交互方式最适合用来取消或停止任务。除了中断以外,还可以利用一个boolean变 量来控制是否需要停止任务并终止该线程。
4.3 线程间通信
4.3.1 volatile和synchronized关键字
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要 从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问 的可见性。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程 在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性 和排它性。
任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用 时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获 取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入BLOCKED 状态。
4.3.2 等待/通知机制
等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在所有对象的超类 java.lang.Object上,方法和描述如表4-2所示。
等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B 调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而 执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的 关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
调用wait()、notify()以 及notifyAll()时需要注意的细节,如下:
1)使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
2)调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的 等待队列。
3)notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或 notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。
4)notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll() 方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为 BLOCKED。
5)从wait()方法返回的前提是获得了调用对象的锁。
4.3.3 等待/通知的经典范式
WaitNotify示例中可以提炼出等待/通知的经典范式,该范式分为两部分,分 别针对等待方(消费者)和通知方(生产者)。
等待方遵循如下原则。
1)获取对象的锁。
2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3)条件满足则执行对应的逻辑。
通知方遵循如下原则。
1)获得对象的锁。
2)改变条件。
3)通知所有等待在对象上的线程
4.3.4 管道输入/输出流
4.3.5 Thread.join()的使用
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才 从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时 时间里没有终止,那么将会从该超时方法中返回。
4.3.6 ThreadLocal的使用
ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这 个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个 线程上的一个值。 可以通过set(T)方法来设置一个值,在当前线程下再通过get()方法获取到原先设置的值。
4.4 线程应用实例
4.4.1 等待超时模式
4.4.2 一个简单的数据库连接池示例
5.1 Lock接口
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时 访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。
使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先 获取再释放。
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
不要将获取锁的过程写在try块中,因为如果在获取锁(自定义锁的实现)时发生了异常, 异常抛出的同时,也会导致锁无故释放
5.2 队列同步器
队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组 件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获 取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状 态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3 个方法(getState()、setState(int newState)和compareAndSetState(int expect,int update))来进行操 作,因为它们能够保证状态的改变是安全的。
子类推荐被定义为自定义同步组件的静态内部 类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来 供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获 取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、 ReentrantReadWriteLock和CountDownLatch等)。
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步 器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交 互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者, 它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同 步器很好地隔离了使用者和实现者所需关注的领域。
5.2.1 队列同步器的接口与示例
5.2.2 队列同步器的实现分析
1.同步队列
//TODO
5.3 重入锁
1.实现重进入
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再 次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到 该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁 被释放时,计数自减,当计数等于0时表示锁已经成功释放。
2.公平与非公平获取锁的区别
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合 请求的绝对时间顺序,也就是FIFO
5.4 读写锁
6.1 ConcurrentHashMap的实现原理与使用
6.1.1 为什么要使用ConcurrentHashMap
在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非 常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会。
(1)线程不安全的HashMap
HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表 形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获 取Entry。
(2)效率低下的HashTable HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable 的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同 步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方 法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
(3)ConcurrentHashMap的锁分段技术可有效提升并发访问率
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的 线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么 当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并 发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存 储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数 据也能被其他线程访问。
6.1.2 ConcurrentHashMap的结构
通过ConcurrentHashMap的类图来分析ConcurrentHashMap的结构,如图6-1所示。 ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重 入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数 据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种 数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元 素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时, 必须首先获得与它对应的Segment锁,如图6-2所示。
6.1.3 ConcurrentHashMap的初始化
ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个 参数来初始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的 HashEntry数组来实现的。
1.初始化segments数组 让我们来看一下初始化segments数组的源代码。
ssize<<=1 ==> ssize=ssize*(2的1次方)
由上面的代码可知,segments数组的长度ssize是通过concurrencyLevel计算得出的。为了能 通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方 (power-of-two size),所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值 来作为segments数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里 锁的个数也是16
注意 concurrencyLevel的最大值是65535,这意味着segments数组的长度最大为65536, 对应的二进制是16位。
2.初始化segmentShift和segmentMask
这两个全局变量需要在定位segment时的散列算法里使用,sshift等于ssize从1向左移位的 次数,在默认情况下concurrencyLevel等于16,1需要向左移位移动4次,所以sshift等于4。 segmentShift用于定位参与散列运算的位数,segmentShift等于32减sshift,所以等于28,这里之所 以用32是因为ConcurrentHashMap里的hash()方法输出的最大数是32位的,后面的测试中我们 可以看到这点。segmentMask是散列运算的掩码,等于ssize减1,即15,掩码的二进制各个位的 值都是1。因为ssize的最大长度是65536,所以segmentShift最大值是16,segmentMask最大值是 65535,对应的二进制是16位,每个位都是1。
3.初始化每个segment 输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每个segment的负 载因子,在构造方法里需要通过这两个参数来初始化数组中的每个segment
上面代码中的变量cap就是segment里HashEntry数组的长度,它等于initialCapacity除以ssize 的倍数c,如果c大于1,就会取大于等于c的2的N次方值,所以cap不是1,就是2的N次方。 segment的容量threshold=(int)cap*loadFactor,默认情况下initialCapacity等于16,loadfactor等于 0.75,通过运算cap等于1,threshold等于零。
6.1.4 定位Segment 既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素 的时候,必须先通过散列算法定位到Segment。可以看到ConcurrentHashMap会首先使用 Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列。
之所以进行再散列,目的是减少散列冲突,使元素能够均匀地分布在不同的Segment上, 从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中, 不仅存取元素缓慢,分段锁也会失去意义。笔者做了一个测试,不通过再散列而直接执行散列 计算。
计算后输出的散列值全是15,通过这个例子可以发现,如果不进行再散列,散列冲突会非 常严重,因为只要低位一样,无论高位是什么数,其散列值总是一样。我们再把上面的二进制 数据进行再散列后结果如下(为了方便阅读,不足32位的高位补了0,每隔4位用竖线分割下)。
可以发现,每一位的数据都散列开了,通过这种再散列能让数字的每一位都参加到散列 运算当中,从而减少散列冲突。ConcurrentHashMap通过以下散列算法定位segment。
默认情况下segmentShift为28,segmentMask为15,再散列后的数最大是32位二进制数据, 向右无符号移动28位,意思是让高4位参与到散列运算中,(hash>>>segmentShift) &segmentMask的运算结果分别是4、15、7和8,可以看到散列值没有发生冲突。
6.1.5 ConcurrentHashMap的操作
本节介绍ConcurrentHashMap的3种操作——get操作、put操作和size操作。
1.get操作
Segment的get操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素,代码如下。
get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。我们 知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不 加锁的呢?原因是它的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前 Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线 程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写 (有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写 共享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是因为根据Java内存模 型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。
在定位元素的代码里我们可以发现,定位HashEntry和定位Segment的散列算法虽然一样, 都与数组的长度减去1再相“与”,但是相“与”的值不一样,定位Segment使用的是元素的 hashcode通过再散列后得到的值的高位,而定位HashEntry直接使用的是再散列后的值。其目的 是避免两次散列后的值一样,虽然元素在Segment里散列开了,但是却没有在HashEntry里散列 开。
2.put操作
由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个 步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位 置,然后将其放在HashEntry数组里。
(1)是否需要扩容 在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈 值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容 之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
(2)如何扩容 在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进 行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只 对某个segment进行扩容。
3.size操作
如果要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小 后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,是不是直接把 所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时 可以获取每个Segment的count的最新值,但是可能累加前使用的count发生了变化,那么统计结 果就不准了。所以,最安全的做法是在统计size的时候把所有Segment的put、remove和clean方法 全部锁住,但是这种做法显然非常低效。
因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以 ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如 果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount 变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size 前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
6.2 ConcurrentLinkedQueue
线程安全队列ConcurrentLinkedQueue。
如果要实现一个线程安全的队列有2种方式:
使用阻塞算法的队列可以用一个锁 (入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。
非阻塞的实现方式则可以使用循环CAS的方式来实现。
ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用先进先出的规 则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元 素时,它会返回队列头部的元素。它采用了“wait-free”算法(即CAS算法)来实现,该算法在 Michael&Scott算法上进行了一些修改。
6.2.1 ConcurrentLinkedQueue的结构
通过ConcurrentLinkedQueue的类图来分析一下它的结构,如图6-3所示。
ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和 指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一 张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。
private transient volatile Node tail = head;
//TODO
6.3 Java中的阻塞队列
6.3.1 什么是阻塞队列
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。
1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是 从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
在阻塞队列不可用时,这两个附加操作提供了4种处理方式,如表6-1所示。
·抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalStateException("Queue full")异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。
·返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移 除方法,则是从队列里取出一个元素,如果没有则返回null。
·一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者 线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队 列会阻塞住消费者线程,直到队列不为空。
·超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程 一段时间,如果超过了指定的时间,生产者线程就会退出。 这两个附加操作的4种处理方式不方便记忆,所以我找了一下这几个方法的规律。put和 take分别尾首含有字母t,offer和poll都含有字母o。
注意 如果是无界阻塞队列,队列不可能会出现满的情况,所以使用put或offer方法永 远不会被阻塞,而且使用offer方法时,该方法永远返回true.
6.3.2 Java里的阻塞队列
JDK 7提供了7个阻塞队列,如下。
1.ArrayBlockingQueue
ArrayBlockingQueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原 则对元素进行排序。
//TODO
6.4 Fork/Join框架
6.4.1 什么是Fork/Join框架
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干 个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork就是把一个大任务切分 为若干子任务并行的执行,Join就是合并这些子任务的执行结果,最后得到这个大任务的结果。
6.4.2 工作窃取算法
工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。那么,为什么 需要使用工作窃取算法呢?假如我们需要做一个比较大的任务,可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个 队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。比如A线程负责处理A 队列里的任务。但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有 任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列 里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。
工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列。
6.4.3 Fork/Join框架的设计
步骤1 分割任务。首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还 是很大,所以还需要不停地分割,直到分割出的子任务足够小。
步骤2 执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分 别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程 从队列里拿数据,然后合并这些数据。
Fork/Join使用两个类来完成以上两件事情。
①ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务 中执行fork()和join()操作的机制。通常情况下,我们不需要直接继承ForkJoinTask类,只需要继 承它的子类,Fork/Join框架提供了以下两个子类。
RecursiveAction:用于没有返回结果的任务。
RecursiveTask:用于有返回结果的任务。
②ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行。 任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当 一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任 务。
6.4.4 使用Fork/Join框架
使用Fork/Join框架首先要考虑到的是如何分割任务,如果希望每个子任务最多执行两个 数的相加,那么我们设置分割的阈值是2,由于是4个数字相加,所以Fork/Join框架会把这个任 务fork成两个子任务,子任务一负责计算1+2,子任务二负责计算3+4,然后再join两个子任务 的结果。因为是有结果的任务,所以必须继承RecursiveTask,实现代码如下。
通过这个例子,我们进一步了解ForkJoinTask,ForkJoinTask与一般任务的主要区别在于它 需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执 行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用fork方法时,又会进入 compute方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当 前子任务并返回结果。使用join方法会等待子任务执行完并得到其结果。
6.4.5 Fork/Join框架的异常处理
ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常, 所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被 取消了,并且可以通过ForkJoinTask的getException方法获取异常。使用如下代码。
getException方法返回Throwable对象,如果任务被取消了则返回CancellationException。如 果任务没有完成或者没有抛出异常则返回null。
6.4.6 Fork/Join框架的实现原理
ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责 将存放程序提交给ForkJoinPool的任务,而ForkJoinWorkerThread数组负责执行这些任务。 (1)ForkJoinTask的fork方法实现原理 当我们调用ForkJoinTask的fork方法时,程序会调用ForkJoinWorkerThread的pushTask方法 异步地执行这个任务,然后立即返回结果。代码如下。
pushTask方法把当前任务存放在ForkJoinTask数组队列里。然后再调用ForkJoinPool的 signalWork()方法唤醒或创建一个工作线程来执行任务。代码如下。
(2)ForkJoinTask的join方法实现原理 Join方法的主要作用是阻塞当前线程并等待获取结果。让我们一起看看ForkJoinTask的join 方法的实现,代码如下。
首先,它调用了doJoin()方法,通过doJoin()方法得到当前任务的状态来判断返回什么结 果,任务状态有4种:已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常 (EXCEPTIONAL)。 ·如果任务状态是已完成,则直接返回任务结果。 ·如果任务状态是被取消,则直接抛出CancellationException。 ·如果任务状态是抛出异常,则直接抛出对应的异常。 让我们再来分析一下doJoin()方法的实现代码。
在doJoin()方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成, 则直接返回任务状态;如果没有执行完,则从任务数组里取出任务并执行。如果任务顺利执行 完成,则设置任务状态为NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL
而Java从JDK 1.5开始提供了java.util.concurrent.atomic包(以下简称Atomic包),这个包中 的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。
因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更 新方式,分别是
Atomic包里的类基本都是使用Unsafe实现的包装类。
7.1 原子更新基本类型类
·AtomicBoolean:原子更新布尔类型。
·AtomicInteger:原子更新整型。
·AtomicLong:原子更新长整型。
以上3个类提供的方法几乎一模一样,所以本节仅以AtomicInteger为例进行讲解, AtomicInteger的常用方法如下。
·int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的 value)相加,并返回结果。
·boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方 式将该值设置为输入的值。 ·int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。
·void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他 线程在之后的一小段时间内还是可以读到旧的值。关于该方法的更多信息可以参考并发编程网翻译的一篇文章《AtomicLong.lazySet是如何工作的?》,文章地址是“http://ifeve.com/howdoes-atomiclong-lazyset-work/”。
·int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。
AtomicInteger示例代码如代码清单7-1所示。
那么getAndIncrement是如何实现原子操作的呢?让我们一起分析其实现原理, getAndIncrement的源码如代码清单7-2所示。
源码中for循环体的第一步先取得AtomicInteger里存储的数值,第二步对AtomicInteger的当前数值进行加1操作,关键的第三步调用compareAndSet方法来进行原子更新操作,该方法先检 查当前数值是否等于current,等于意味着AtomicInteger的值没有被其他线程修改过,则将 AtomicInteger的当前数值更新成next的值,如果不等compareAndSet方法会返回false,程序会进 入for循环重新进行compareAndSet操作。
Atomic包提供了3种基本类型的原子更新,但是Java的基本类型里还有char、float和double 等。那么问题来了,如何原子的更新其他的基本类型呢?Atomic包里的类基本都是使用Unsafe 实现的,让我们一起看一下Unsafe的源码,如代码清单7-3所示。
通过代码,我们发现Unsafe只提供了3种CAS方法:compareAndSwapObject、compareAndSwapInt和compareAndSwapLong,再看AtomicBoolean源码,发现它是先把Boolean转换成整 型,再使用compareAndSwapInt进行CAS,所以原子更新char、float和double变量也可以用类似 的思路来实现。
7.2 原子更新数组 通过原子的方式更新数组里的某个元素,Atomic包提供了以下4个类。
·AtomicIntegerArray:原子更新整型数组里的元素。
·AtomicLongArray:原子更新长整型数组里的元素。
·AtomicReferenceArray:原子更新引用类型数组里的元素。
·AtomicIntegerArray类主要是提供原子的方式更新数组里的整型,其常用方法如下。
·int addAndGet(int i,int delta):以原子方式将输入值与数组中索引i的元素相加。
·boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子 方式将数组位置i的元素设置成update值。
以上几个类提供的方法几乎一样,所以本节仅以AtomicIntegerArray为例进行讲解, AtomicIntegerArray的使用实例代码如代码清单7-4所示。
需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组 复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。
7.3 原子更新引用类型
原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需 要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类。
·AtomicReference:原子更新引用类型。
·AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
·AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类 型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)。
以上几个类提供的方法几乎一样,所以本节仅以AtomicReference为例进行讲解, AtomicReference的使用示例代码如代码清单7-5所示。
代码中首先构建一个user对象,然后把user对象设置进AtomicReferenc中,最后调用 compareAndSet方法进行原子更新操作,实现原理同AtomicInteger里的compareAndSet方法。代 码执行后输出结果如下。
Shinichi
17
7.4 原子更新字段类
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新。 AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
·AtomicLongFieldUpdater:原子更新长整型字段的更新器。
·AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起 来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的 ABA问题。 要想原子地更新字段类需要两步。
第一步,因为原子更新字段类都是抽象类,每次使用的 时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。 第 二步,更新类的字段(属性)必须使用public volatile修饰符。 以上3个类提供的方法几乎一样,所以本节仅以AstomicIntegerFieldUpdater为例进行讲解, AstomicIntegerFieldUpdater的示例代码如代码清单7-6所示。
CountDownLatch、CyclicBarrier和 Semaphore工具类提供了一种并发流程控制的手段,Exchanger工具类则提供了在线程间交换数 据的一种手段。
8.1 等待多线程完成的CountDownLatch
CountDownLatch允许一个或多个线程等待其他线程完成操作。
在JDK 1.5之后的并发包中提供的CountDownLatch也可以实现join的功能,并且比join的功 能更多
CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完 成,这里就传入N。
当我们调用CountDownLatch的countDown方法时,N就会减1,CountDownLatch的await方法 会阻塞当前线程,直到N变成零。由于countDown方法可以用在任何地方,所以这里说的N个 点,可以是N个线程,也可以是1个线程里的N个执行步骤。用在多个线程时,只需要把这个 CountDownLatch的引用传递到线程里即可。
如果有某个解析sheet的线程处理得比较慢,我们不可能让主线程一直等待,所以可以使 用另外一个带指定时间的await方法——await(long time,TimeUnit unit),这个方法等待特定时 间后,就会不再阻塞当前线程。join也有类似的方法。
注意 计数器必须大于等于0,只是等于0时候,计数器就是零,调用await方法时不会 阻塞当前线程。CountDownLatch不可能重新初始化或者修改CountDownLatch对象的内部计数 器的值。一个线程调用countDown方法happen-before,另外一个线程调用await方法。
8.2 同步屏障CyclicBarrier
CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一 组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会 开门,所有被屏障拦截的线程才会继续运行。
8.2.1 CyclicBarrier简介
CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数 量,每个线程调用await方法告诉CyclicBarrier我已经到达了屏障,然后当前线程被阻塞。示例 代码如代码清单8-3所示。
8.2.2 CyclicBarrier的应用场景
CyclicBarrier可以用于多线程计算数据,最后合并计算结果的场景。例如,用一个Excel保 存了用户所有银行流水,每个Sheet保存一个账户近一年的每笔银行流水,现在需要统计用户 的日均银行流水,先用多线程处理每个sheet里的银行流水,都执行完之后,得到每个sheet的日 均银行流水,最后,再用barrierAction用这些线程的计算结果,计算出整个Excel的日均银行流 水,如代码清单8-5所示。
8.2.3 CyclicBarrier和CountDownLatch的区别
CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重 置。所以CyclicBarrier能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数 器,并让线程重新执行一次。 CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得Cyclic-Barrier 阻塞的线程数量。isBroken()方法用来了解阻塞的线程是否被中断。代码清单8-5执行完之后会 返回true,其中isBroken的使用代码如代码清单8-6所示。
8.3 控制并发线程数的Semaphore
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以 保证合理的使用公共资源。
Semaphore可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。假 如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程 并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这 时我们必须控制只有10个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连 接。这个时候,就可以使用Semaphore来做流量控制
在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法 Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允 许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用 Semaphore的acquire()方法获取一个许可证,使用完之后调用release()方法归还许可证。还可以 用tryAcquire()方法尝试获取许可证。
2.其他方法 Semaphore还提供一些其他方法,具体如下。
8.4 线程间交换数据的Exchanger
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交 换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也 执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产 出来的数据传递给对方。
Exchanger可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换 两人的数据,并使用交叉规则得出2个交配结果。Exchanger也可以用于校对工作,比如我们需 要将纸制银行流水通过人工的方式录入成电子银行流水,为了避免错误,采用AB岗两人进行 录入,录入到Excel之后,系统需要加载这两个Excel,并对两个Excel数据进行校对,看看是否 录入一致,代码如代码清单8-8所示。
Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序 都可以使用线程池。在开发过程中,合理地使用线程池能够带来3个好处。
第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源, 还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用 线程池,必须对其实现原理了如指掌。
9.1 线程池的实现原理
当向线程池提交一个任务之后,线程池是如何处理这个任务的呢?本节来看一下线程池 的主要处理流程,处理流程图如图9-1所示。 从图中可以看出,当提交一个新任务到线程池时,线程池的处理流程如下。
1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作 线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这 个工作队列里。如果工作队列满了,则进入下个流程。
3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程 来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
ThreadPoolExecutor执行execute()方法的示意图,如图9-2所示。
ThreadPoolExecutor执行execute方法分下面4种情况。
1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤 需要获取全局锁)。
2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。
4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution()方法。
ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能 地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后 (当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而 步骤2不需要获取全局锁。
源码分析:上面的流程分析让我们很直观地了解了线程池的工作原理,让我们再通过源代 码来看看是如何实现的,线程池执行任务的方法如下。
工作线程:线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务 后,还会循环获取工作队列里的任务来执行。我们可以从Worker类的run()方法里看到这点。
工作线程:线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务 后,还会循环获取工作队列里的任务来执行。我们可以从Worker类的run()方法里看到这点。
线程池中的线程执行任务分两种情况,如下。
1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行。
9.2 线程池的使用
9.2.1 线程池的创建 我们可以通过ThreadPoolExecutor来创建一个线程池。
1)corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线 程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任 务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads()方法, 线程池会提前创建并启动所有基本线程。
2)runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几 个阻塞队列。
·ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原 则对元素进行排序。
·LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通 常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
·SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用 移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工 厂方法Executors.newCachedThreadPool使用了这个队列。 ·PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
3)maximumPoolSize(线程池最大数量):线程池允许创建的最大线程数。如果队列满了,并 且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是,如 果使用了无界的任务队列这个参数就没什么效果。
4)ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设 置更有意义的名字。使用开源框架guava提供的ThreadFactoryBuilder可以快速给线程池里的线 程设置有意义的名字,代码如下。
new ThreadFactoryBuilder().setNameFormat("XX-task-%d").build();
5)RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状 态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法 处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。
·AbortPolicy:直接抛出异常。
·CallerRunsPolicy:只用调用者所在线程来运行任务。
·DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
·DiscardPolicy:不处理,丢弃掉。
当然,也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录 日志或持久化存储不能处理的任务。
·keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以, 如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率。
·TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)、分钟 (MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒 (NANOSECONDS,千分之一微秒)。
9.2.2 向线程池提交任务
可以使用两个方法向线程池提交任务,分别为
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
通过以下代码可知execute()方法输入的任务是一个Runnable类的实例
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方 法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线 程一段时间后立即返回,这时候有可能任务没有执行完。
9.2.3 关闭线程池
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。
它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务 都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪 一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭 线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。
9.2.4 合理地配置线程池
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。
·任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
·任务的优先级:高、中和低。
·任务的执行时间:长、中和短。
·任务的依赖性:是否依赖其他系统资源,如数据库连接。
性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的 线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配 置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务 和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量 将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。可以通过 Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高 的任务先执行。
注意 如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能 执行。 执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让 执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越 长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点 儿,比如几千。有一次,我们系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任 务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线 程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻 塞,任务积压在线程池里。如果当时我们设置成无界队列,那么线程池的队列就会越来越多, 有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然,我们的系统所 有的任务是用单独的服务器部署的,我们使用不同规模的线程池完成不同类型的任务,但是 出现这样问题时也会影响到其他任务。
9.2.5 线程池的监控
如果在系统中大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根 据线程池的使用状况快速定位问题。可以通过线程池提供的参数进行监控,在监控线程池的 时候可以使用以下属性。
·taskCount:线程池需要执行的任务数量。
·completedTaskCount:线程池在运行过程中已完成的任务数量,小于或等于taskCount。
·largestPoolSize:线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是 否曾经满过。如该数值等于线程池的最大大小,则表示线程池曾经满过。
·getPoolSize:线程池的线程数量。如果线程池不销毁的话,线程池里的线程不会自动销 毁,所以这个大小只增不减。
·getActiveCount:获取活动的线程数。
通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的 beforeExecute、afterExecute和terminated方法,也可以在任务执行前、执行后和线程池关闭前执 行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。 这几个方法在线程池里是空方法。
protected void beforeExecute(Thread t, Runnable r) { }
在Java中,使用线程来异步执行任务。Java线程的创建与销毁需要一定的开销,如果我们 为每一个任务创建一个新线程来执行,这些线程的创建与销毁将消耗大量的计算资源。同时, 为每一个任务创建一个新线程来执行,这种策略可能会使处于高负荷状态的应用最终崩溃。
Java的线程既是工作单元,也是执行机制。从JDK 5开始,把工作单元与执行机制分离开 来。工作单元包括Runnable和Callable,而执行机制由Executor框架提供。
10.1 Executor框架简介
10.1.1 Executor框架的两级调度模型
在HotSpot VM的线程模型中,Java线程(java.lang.Thread)被一对一映射为本地操作系统线 程。Java线程启动时会创建一个本地操作系统线程;当该Java线程终止时,这个操作系统线程 也会被回收。操作系统会调度所有线程并将它们分配给可用的CPU。 在上层,Java多线程程序通常把应用分解为若干个任务,然后使用用户级的调度器 (Executor框架)将这些任务映射为固定数量的线程;在底层,操作系统内核将这些线程映射到 硬件处理器上。这种两级调度模型的示意图如图10-1所示。
从图中可以看出,应用程序通过Executor框架控制上层的调度;而下层的调度由操作系统 内核控制,下层的调度不受应用程序的控制。
10.1.2 Executor框架的结构与成员
1.Executor框架的结构
Executor框架主要由3大部分组成如下。
·任务。包括被执行任务需要实现的接口:Runnable接口或Callable接口。
·任务的执行。包括任务执行机制的核心接口Executor,以及继承自Executor的 ExecutorService接口。Executor框架有两个关键类实现了ExecutorService接口 (ThreadPoolExecutor和ScheduledThreadPoolExecutor)。
·异步计算的结果。包括接口Future和实现Future接口的FutureTask类。 Executor框架包含的主要的类与接口如图10-2所示。 下面是这些类和接口的简介。
·Executor是一个接口,它是Executor框架的基础,它将任务的提交与任务的执行分离开 来。
·ThreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。
·ScheduledThreadPoolExecutor是一个实现类,可以在给定的延迟后运行命令,或者定期执 行命令。ScheduledThreadPoolExecutor比Timer更灵活,功能更强大。
·Future接口和实现Future接口的FutureTask类,代表异步计算的结果。
·Runnable接口和Callable接口的实现类,都可以被ThreadPoolExecutor或ScheduledThreadPoolExecutor执行。 Executor框架的使用示意图如图10-3所示。
主线程首先要创建实现Runnable或者Callable接口的任务对象。工具类Executors可以把一 个Runnable对象封装为一个Callable对象(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule))。
然后可以把Runnable对象直接交给ExecutorService执行(ExecutorService.execute(Runnable command));或者也可以把Runnable对象或Callable对象提交给ExecutorService执行(ExecutorService.submit(Runnable task)或ExecutorService.submit(Callabletask))。
如果执行ExecutorService.submit(…),ExecutorService将返回一个实现Future接口的对象 (到目前为止的JDK中,返回的是FutureTask对象)。由于FutureTask实现了Runnable,程序员也可 以创建FutureTask,然后直接交给ExecutorService执行。
最后,主线程可以执行FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。
2.Executor框架的成员
本节将介绍Executor框架的主要成员:ThreadPoolExecutor、ScheduledThreadPoolExecutor、 Future接口、Runnable接口、Callable接口和Executors。
(1)ThreadPoolExecutor ThreadPoolExecutor通常使用工厂类Executors来创建。Executors可以创建3种类型的 ThreadPoolExecutor:SingleThreadExecutor、FixedThreadPool和CachedThreadPool。 下面分别介绍这3种ThreadPoolExecutor。
1)FixedThreadPool。下面是Executors提供的,创建使用固定线程数的FixedThreadPool的 API。
public static ExecutorService newFixedThreadPool(int nThreads)
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactorythreadFact
FixedThreadPool适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场 景,它适用于负载比较重的服务器。
2)SingleThreadExecutor。下面是Executors提供的,创建使用单个线程的SingleThreadExecutor的API。
public static ExecutorService newSingleThreadExecutor()
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) SingleThreadExecutor适用于需要保证顺序地执行各个任务;并且在任意时间点,不会有多 个线程是活动的应用场景。
3)CachedThreadPool。下面是Executors提供的,创建一个会根据需要创建新线程的 CachedThreadPool的API。
public static ExecutorService newCachedThreadPool()
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory)
CachedThreadPool是大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者 是负载较轻的服务器。
(2)ScheduledThreadPoolExecutor ScheduledThreadPoolExecutor通常使用工厂类Executors来创建。Executors可以创建2种类 型的ScheduledThreadPoolExecutor,如下。
·ScheduledThreadPoolExecutor。包含若干个线程的ScheduledThreadPoolExecutor。
·SingleThreadScheduledExecutor。只包含一个线程的ScheduledThreadPoolExecutor。
下面分别介绍这两种ScheduledThreadPoolExecutor。
下面是工厂类Executors提供的,创建固定个数线程的ScheduledThreadPoolExecutor的API。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize,ThreadF
ScheduledThreadPoolExecutor适用于需要多个后台线程执行周期任务,同时为了满足资源 管理的需求而需要限制后台线程的数量的应用场景。下面是Executors提供的,创建单个线程 的SingleThreadScheduledExecutor的API。
public static ScheduledExecutorService newSingleThreadScheduledExecutor()
public static ScheduledExecutorService newSingleThreadScheduledExecutor (ThreadFactory threadFactory)
SingleThreadScheduledExecutor适用于需要单个后台线程执行周期任务,同时需要保证顺 序地执行各个任务的应用场景。
(3)Future接口 Future接口和实现Future接口的FutureTask类用来表示异步计算的结果。当我们把Runnable 接口或Callable接口的实现类提交(submit)给ThreadPoolExecutor或 ScheduledThreadPoolExecutor时,ThreadPoolExecutor或ScheduledThreadPoolExecutor会向我们 返回一个FutureTask对象。下面是对应的API。
Future submit(Callable task)
Future submit(Runnable task, T result)
Future<> submit(Runnable task)
有一点需要读者注意,到目前最新的JDK 8为止,Java通过上述API返回的是一个 FutureTask对象。但从API可以看到,Java仅仅保证返回的是一个实现了Future接口的对象。在将 来的JDK实现中,返回的可能不一定是FutureTask。
4)Runnable接口和Callable接口
Runnable接口和Callable接口的实现类,都可以被ThreadPoolExecutor或ScheduledThreadPoolExecutor执行。它们之间的区别是Runnable不会返回结果,而Callable可以返回结 果。 除了可以自己创建实现Callable接口的对象外,还可以使用工厂类Executors来把一个 Runnable包装成一个Callable。
下面是Executors提供的,把一个Runnable包装成一个Callable的API。
public static Callable
下面是Executors提供的,把一个Runnable和一个待返回的结果包装成一个Callable的API。
public static Callable callable(Runnable task, T result) // 假设返回对象Call
前面讲过,当我们把一个Callable对象(比如上面的Callable1或Callable2)提交给 ThreadPoolExecutor或ScheduledThreadPoolExecutor执行时,submit(…)会向我们返回一个 FutureTask对象。我们可以执行FutureTask.get()方法来等待任务执行完成。当任务成功完成后 FutureTask.get()将返回该任务的结果。例如,如果提交的是对象Callable1,FutureTask.get()方法 将返回null;如果提交的是对象Callable2,FutureTask.get()方法将返回result对象。
10.2 ThreadPoolExecutor详解
Executor框架最核心的类是ThreadPoolExecutor,它是线程池的实现类,主要由下列4个组 件构成。
·corePool:核心线程池的大小。
·maximumPool:最大线程池的大小。
·BlockingQueue:用来暂时保存任务的工作队列。
·RejectedExecutionHandler:当ThreadPoolExecutor已经关闭或ThreadPoolExecutor已经饱和 时(达到了最大线程池大小且工作队列已满),execute()方法将要调用的Handler。
·通过Executor框架的工具类Executors,可以创建3种类型的ThreadPoolExecutor。
·FixedThreadPool。
·SingleThreadExecutor。
·CachedThreadPool。
下面将分别介绍这3种ThreadPoolExecutor。
10.2.1 FixedThreadPool详解 FixedThreadPool被称为可重用固定线程数的线程池。下面是FixedThreadPool的源代码实现。
FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指 定的参数nThreads。
当线程池中的线程数大于corePoolSize时,keepAliveTime为多余的空闲线程等待新任务的 最长时间,超过这个时间后多余的线程将被终止。这里把keepAliveTime设置为0L,意味着多余 的空闲线程会被立即终止。
FixedThreadPool的execute()方法的运行示意图如图10-4所示。
对图10-4的说明如下。
1)如果当前运行的线程数少于corePoolSize,则创建新线程来执行任务。
2)在线程池完成预热之后(当前运行的线程数等于corePoolSize),将任务加入 LinkedBlockingQueue。
3)线程执行完1中的任务后,会在循环中反复从LinkedBlockingQueue获取任务来执行。 FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为 Integer.MAX_VALUE)。使用无界队列作为工作队列会对线程池带来如下影响。
1)当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中 的线程数不会超过corePoolSize。
2)由于1,使用无界队列时maximumPoolSize将是一个无效参数。
3)由于1和2,使用无界队列时keepAliveTime将是一个无效参数。
4)由于使用无界队列,运行中的FixedThreadPool(未执行方法shutdown()或 shutdownNow())不会拒绝任务(不会调用RejectedExecutionHandler.rejectedExecution方法)。
10.2.2 SingleThreadExecutor详解
SingleThreadExecutor是使用单个worker线程的Executor。下面是SingleThreadExecutor的源 代码实现。
SingleThreadExecutor的corePoolSize和maximumPoolSize被设置为1。其他参数与 FixedThreadPool相同。SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工 作队列(队列的容量为Integer.MAX_VALUE)。SingleThreadExecutor使用无界队列作为工作队列 对线程池带来的影响与FixedThreadPool相同,这里就不赘述了。
SingleThreadExecutor的运行示意图如图10-5所示。
对图10-5的说明如下。
1)如果当前运行的线程数少于corePoolSize(即线程池中无运行的线程),则创建一个新线程来执行任务。
2)在线程池完成预热之后(当前线程池中有一个运行的线程),将任务加入LinkedBlockingQueue。
3)线程执行完1中的任务后,会在一个无限循环中反复从LinkedBlockingQueue获取任务来 执行。
10.2.3 CachedThreadPool详解
CachedThreadPool是一个会根据需要创建新线程的线程池。下面是创建CachedThreadPool的源代码。
CachedThreadPool的corePoolSize被设置为0,即corePool为空;maximumPoolSize被设置为 Integer.MAX_VALUE,即maximumPool是无界的。这里把keepAliveTime设置为60L,意味着 CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被 终止。
FixedThreadPool和SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的 工作队列。CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但 CachedThreadPool的maximumPool是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下, CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。
CachedThreadPool的execute()方法的执行示意图如图10-6所示。
对图10-6的说明如下。
1)首先执行SynchronousQueue.offer(Runnable task)。如果当前maximumPool中有空闲线程 正在执行SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer操作与空闲线程执行的poll操作配对成功,主线程把任务交给空闲线程执行,execute()方 法执行完成;否则执行下面的步骤2)。
2)当初始maximumPool为空,或者maximumPool中当前没有空闲线程时,将没有线程执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这种情况下,步骤1)将失 败。此时CachedThreadPool会创建一个新线程执行任务,execute()方法执行完成。
3)在步骤2)中新创建的线程将任务执行完后,会执行 SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)。这个poll操作会让空闲线 程最多在SynchronousQueue中等待60秒钟。如果60秒钟内主线程提交了一个新任务(主线程执 行步骤1)),那么这个空闲线程将执行主线程提交的新任务;否则,这个空闲线程将终止。由于 空闲60秒的空闲线程会被终止,因此长时间保持空闲的CachedThreadPool不会使用任何资源。
前面提到过,SynchronousQueue是一个没有容量的阻塞队列。每个插入操作必须等待另一 个线程的对应移除操作,反之亦然。CachedThreadPool使用SynchronousQueue,把主线程提交的 任务传递给空闲线程执行。CachedThreadPool中任务传递的示意图如图10-7所示
10.3 ScheduledThreadPoolExecutor详解
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运 行任务,或者定期执行任务。ScheduledThreadPoolExecutor的功能与Timer类似,但 ScheduledThreadPoolExecutor功能更强大、更灵活。Timer对应的是单个后台线程,而 ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数。
10.3.1 ScheduledThreadPoolExecutor的运行机制
ScheduledThreadPoolExecutor的执行示意图(本文基于JDK 6)如图10-8所示。 DelayQueue是一个无界队列,所以ThreadPoolExecutor的maximumPoolSize在ScheduledThreadPoolExecutor中没有什么意义(设置maximumPoolSize的大小没有什么效果)。 ScheduledThreadPoolExecutor的执行主要分为两大部分。
1)当调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()方法或者scheduleWithFixedDelay()方法时,会向ScheduledThreadPoolExecutor的DelayQueue添加一个实现了 RunnableScheduledFutur接口的ScheduledFutureTask。
2)线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务。
ScheduledThreadPoolExecutor为了实现周期性的执行任务,对ThreadPoolExecutor做了如下 的修改。
·使用DelayQueue作为任务队列。
·获取任务的方式不同(后文会说明)。
·执行周期任务后,增加了额外的处理(后文会说明)。
10.3.2 ScheduledThreadPoolExecutor的实现
前面我们提到过,ScheduledThreadPoolExecutor会把待调度的任务(ScheduledFutureTask) 放到一个DelayQueue中。 ScheduledFutureTask主要包含3个成员变量,如下。
·long型成员变量time,表示这个任务将要被执行的具体时间。
·long型成员变量sequenceNumber,表示这个任务被添加到ScheduledThreadPoolExecutor中 的序号。
·long型成员变量period,表示任务执行的间隔周期。
DelayQueue封装了一个PriorityQueue,这个PriorityQueue会对队列中的ScheduledFutureTask进行排序。排序时,time小的排在前面(时间早的任务将被先执行)。如果两个 ScheduledFutureTask的time相同,就比较sequenceNumber,sequenceNumber小的排在前面(也就 是说,如果两个任务的执行时间相同,那么先提交的任务将被先执行)。
首先,让我们看看ScheduledThreadPoolExecutor中的线程执行周期任务的过程。图10-9是 ScheduledThreadPoolExecutor中的线程1执行某个周期任务的4个步骤。
下面是对这4个步骤的说明。
1)线程1从DelayQueue中获取已到期的ScheduledFutureTask(DelayQueue.take())。到期任务 是指ScheduledFutureTask的time大于等于当前时间。
2)线程1执行这个ScheduledFutureTask。
3)线程1修改ScheduledFutureTask的time变量为下次将要被执行的时间。
4)线程1把这个修改time之后的ScheduledFutureTask放回DelayQueue中(DelayQueue.add())。
接下来,让我们看看上面的步骤1)获取任务的过程。下面是DelayQueue.take()方法的源代 码实现。
图10-10是DelayQueue.take()的执行示意图。
如图所示,获取任务分为3大步骤。
1)获取Lock。
2)获取周期任务。
·如果PriorityQueue为空,当前线程到Condition中等待;否则执行下面的2.2。
·如果PriorityQueue的头元素的time时间比当前时间大,到Condition中等待到time时间;否 则执行下面的2.3。
·获取PriorityQueue的头元素(2.3.1);如果PriorityQueue不为空,则唤醒在Condition中等待 的所有线程(2.3.2)。
3)释放Lock。 ScheduledThreadPoolExecutor在一个循环中执行步骤2,直到线程从PriorityQueue获取到一 个元素之后(执行2.3.1之后),才会退出无限循环(结束步骤2)。
最后,让我们看看ScheduledThreadPoolExecutor中的线程执行任务的步骤4,把 ScheduledFutureTask放入DelayQueue中的过程。下面是DelayQueue.add()的源代码实现。
图10-11是DelayQueue.add()的执行示意图。
如图所示,添加任务分为3大步骤。
1)获取Lock。
2)添加任务。
·向PriorityQueue添加任务。
·如果在上面2.1中添加的任务是PriorityQueue的头元素,唤醒在Condition中等待的所有线 程。
3)释放Lock。
10.4 FutureTask详解
Future接口和实现Future接口的FutureTask类,代表异步计算的结果。
10.4.1 FutureTask简介
FutureTask除了实现Future接口外,还实现了Runnable接口。因此,FutureTask可以交给 Executor执行,也可以由调用线程直接执行(FutureTask.run())。根据FutureTask.run()方法被执行 的时机,FutureTask可以处于下面3种状态。
1)未启动。FutureTask.run()方法还没有被执行之前,FutureTask处于未启动状态。当创建一 个FutureTask,且没有执行FutureTask.run()方法之前,这个FutureTask处于未启动状态。
2)已启动。FutureTask.run()方法被执行的过程中,FutureTask处于已启动状态。
3)已完成。FutureTask.run()方法执行完后正常结束,或被取消(FutureTask.cancel(…)),或 执行FutureTask.run()方法时抛出异常而异常结束,FutureTask处于已完成状态。
图10-12是FutureTask的状态迁移的示意图。
当FutureTask处于未启动或已启动状态时,执行FutureTask.get()方法将导致调用线程阻塞; 当FutureTask处于已完成状态时,执行FutureTask.get()方法将导致调用线程立即返回结果或抛 出异常。 当FutureTask处于未启动状态时,执行FutureTask.cancel()方法将导致此任务永远不会被执 行;当FutureTask处于已启动状态时,执行FutureTask.cancel(true)方法将以中断执行此任务线程 的方式来试图停止任务;
当FutureTask处于已启动状态时,执行FutureTask.cancel(false)方法将 不会对正在执行此任务的线程产生影响(让正在执行的任务运行完成);当FutureTask处于已完 成状态时,执行FutureTask.cancel(…)方法将返回false。
图10-13是get方法和cancel方法的执行示意图。
10.4.2 FutureTask的使用
可以把FutureTask交给Executor执行;也可以通过ExecutorService.submit(…)方法返回一个 FutureTask,然后执行FutureTask.get()方法或FutureTask.cancel(…)方法。除此以外,还可以单独 使用FutureTask。
当一个线程需要等待另一个线程把某个任务执行完后它才能继续执行,此时可以使用 FutureTask。假设有多个线程执行若干任务,每个任务最多只能被执行一次。当多个线程试图 同时执行同一个任务时,只允许一个线程执行任务,其他线程需要等待这个任务执行完后才 能继续执行。下面是对应的示例代码。
上述代码的执行示意图如图10-14所示。
当两个线程试图同时执行同一个任务时,如果Thread 1执行1.3后Thread 2执行2.1,那么接 下来Thread 2将在2.2等待,直到Thread 1执行完1.4后Thread 2才能从2.2(FutureTask.get())返回。
10.4.3 FutureTask的实现
FutureTask的实现基于AbstractQueuedSynchronizer(以下简称为AQS)。java.util.concurrent中 的很多可阻塞类(比如ReentrantLock)都是基于AQS来实现的。AQS是一个同步框架,它提供通 用机制来原子性管理同步状态、阻塞和唤醒线程,以及维护被阻塞线程的队列。JDK 6中AQS 被广泛使用,基于AQS实现的同步器包括:ReentrantLock、Semaphore、ReentrantReadWriteLock、 CountDownLatch和FutureTask。
每一个基于AQS实现的同步器都会包含两种类型的操作,如下。
·至少一个acquire操作。这个操作阻塞调用线程,除非/直到AQS的状态允许这个线程继续 执行。FutureTask的acquire操作为get()/get(long timeout,TimeUnit unit)方法调用。
·至少一个release操作。这个操作改变AQS的状态,改变后的状态可允许一个或多个阻塞 线程被解除阻塞。FutureTask的release操作包括run()方法和cancel(…)方法。
基于“复合优先于继承”的原则,FutureTask声明了一个内部私有的继承于AQS的子类 Sync,对FutureTask所有公有方法的调用都会委托给这个内部子类。
AQS被作为“模板方法模式”的基础类提供给FutureTask的内部子类Sync,这个内部子类只 需要实现状态检查和状态更新的方法即可,这些方法将控制FutureTask的获取和释放操作。具 体来说,Sync实现了AQS的tryAcquireShared(int)方法和tryReleaseShared(int)方法,Sync通过这 两个方法来检查和更新同步状态。
FutureTask的设计示意图如图10-15所示。
如图所示,Sync是FutureTask的内部私有类,它继承自AQS。创建FutureTask时会创建内部 私有的成员对象Sync,FutureTask所有的的公有方法都直接委托给了内部私有的Sync。 FutureTask.get()方法会调用AQS.acquireSharedInterruptibly(int arg)方法,这个方法的执行 过程如下。
1)调用AQS.acquireSharedInterruptibly(int arg)方法,这个方法首先会回调在子类Sync中实 现的tryAcquireShared()方法来判断acquire操作是否可以成功。acquire操作可以成功的条件为: state为执行完成状态RAN或已取消状态CANCELLED,且runner不为null。
2)如果成功则get()方法立即返回。如果失败则到线程等待队列中去等待其他线程执行 release操作。
3)当其他线程执行release操作(比如FutureTask.run()或FutureTask.cancel(…))唤醒当前线 程后,当前线程再次执行tryAcquireShared()将返回正值1,当前线程将离开线程等待队列并唤 醒它的后继线程(这里会产生级联唤醒的效果,后面会介绍)。
4)最后返回计算的结果或抛出异常。
FutureTask.run()的执行过程如下。
1)执行在构造函数中指定的任务(Callable.call())。
2)以原子方式来更新同步状态(调用AQS.compareAndSetState(int expect,int update),设置 state为执行完成状态RAN)。如果这个原子操作成功,就设置代表计算结果的变量result的值为 Callable.call()的返回值,然后调用AQS.releaseShared(int arg)。
3)AQS.releaseShared(int arg)首先会回调在子类Sync中实现的tryReleaseShared(arg)来执 行release操作(设置运行任务的线程runner为null,然会返回true);AQS.releaseShared(int arg), 然后唤醒线程等待队列中的第一个线程。
4)调用FutureTask.done()。
当执行FutureTask.get()方法时,如果FutureTask不是处于执行完成状态RAN或已取消状态 CANCELLED,当前执行线程将到AQS的线程等待队列中等待(见下图的线程A、B、C和D)。当 某个线程执行FutureTask.run()方法或FutureTask.cancel(...)方法时,会唤醒线程等待队列的第一 个线程(见图10-16所示的线程E唤醒线程A)。
假设开始时FutureTask处于未启动状态或已启动状态,等待队列中已经有3个线程(A、B和 C)在等待。此时,线程D执行get()方法将导致线程D也到等待队列中去等待。
当线程E执行run()方法时,会唤醒队列中的第一个线程A。线程A被唤醒后,首先把自己从 队列中删除,然后唤醒它的后继线程B,最后线程A从get()方法返回。线程B、C和D重复A线程 的处理流程。最终,在队列中等待的所有线程都被级联唤醒并从get()方法返回。
当你在进行并发编程时,看着程序的执行速度在自己的优化下运行得越来越快,你会觉得越来越有成就感,这就是并发编程的魅力。但与此同时,并发编程产生的问题和风险可能也会随之而来。本章先介绍几个并发编程的实战案例,然后再介绍如何排查并发编程造成的问题。
11.1 生产者和消费者模式
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生 产线程和消费线程的工作能力来提高程序整体处理数据的速度。
在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发 中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理 完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须 等待生产者。为了解决这种生产消费能力不均衡的问题,便有了生产者和消费者模式。
什么是生产者和消费者模式
生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消 费者彼此之间不直接通信,而是通过阻塞队列来进行通信,所以生产者生产完数据之后不用 等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取, 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。 这个阻塞队列就是用来给生产者和消费者解耦的。纵观大多数设计模式,都会找一个第 三者出来进行解耦,如工厂模式的第三者是工厂类,模板模式的第三者是模板类。在学习一些 设计模式的过程中,先找到这个模式的第三者,能帮助我们快速熟悉一个设计模式。
11.1.1 生产者消费者模式实战
我和同事一起利用业余时间开发的Yuna工具中使用了生产者和消费者模式。我先介绍下 Yuna [1]工具,在阿里巴巴很多同事都喜欢通过邮件分享技术文章,因为通过邮件分享很方便, 大家在网上看到好的技术文章,执行复制→粘贴→发送就完成了一次分享,但是我发现技术 文章不能沉淀下来,新来的同事看不到以前分享的技术文章,大家也很难找到以前分享过的 技术文章。为了解决这个问题,我们开发了一个Yuna工具。
我们申请了一个专门用来收集分享邮件的邮箱,比如[email protected],大家将分享的文 章发送到这个邮箱,让大家每次都抄送到这个邮箱肯定很麻烦,所以我们的做法是将这个邮 箱地址放在部门邮件列表里,所以分享的同事只需要和以前一样向整个部门分享文章就行。 Yuna工具通过读取邮件服务器里该邮箱的邮件,把所有分享的邮件下载下来,包括邮件的附 件、图片和邮件回复。因为我们可能会从这个邮箱里下载到一些非分享的文章,所以我们要求 分享的邮件标题必须带有一个关键字,比如“内贸技术分享”。下载完邮件之后,通过confluence 的Web Service接口,把文章插入到confluence里,这样新同事就可以在confluence里看以前分享 过的文章了,并且Yuna工具还可以自动把文章进行分类和归档。
为了快速上线该功能,当时我们花了3天业余时间快速开发了Yuna 1.0版本。在1.0版本中 并没有使用生产者消费模式,而是使用单线程来处理,因为当时只需要处理我们一个部门的 邮件,所以单线程明显够用,整个过程是串行执行的。在一个线程里,程序先抽取全部的邮 件,转化为文章对象,然后添加全部的文章,最后删除抽取过的邮件。代码如下。
Yuna工具在推广后,越来越多的部门使用这个工具,处理的时间越来越慢,Yuna是每隔5 分钟进行一次抽取的,而当邮件多的时候一次处理可能就花了几分钟,于是我在Yuna 2.0版本 里使用了生产者消费者模式来处理邮件,首先生产者线程按一定的规则去邮件系统里抽取邮 件,然后存放在阻塞队列里,消费者从阻塞队列里取出文章后插入到conflunce里。代码如下。
代码的执行逻辑是,生产者启动一个线程把所有邮件全部抽取到队列中,消费者启动 CPU*2个线程数处理邮件,从之前的单线程处理邮件变成了现在的多线程处理,并且抽取邮件 的线程不需要等处理邮件的线程处理完再抽取新邮件,所以使用了生产者和消费者模式后, 邮件的整体处理速度比以前要快了几倍。
[1] Yuna取名自我非常喜欢的一款RPG游戏《最终幻想》中女主角的名字。
11.1.2 多生产者和多消费者场景
在多核时代,多线程并发处理速度比单线程处理速度更快,所以可以使用多个线程来生 产数据,同样可以使用多个消费线程来消费数据。而更复杂的情况是,消费者消费的数据,有 可能需要继续处理,于是消费者处理完数据之后,它又要作为生产者把数据放在新的队列里, 交给其他消费者继续处理,如图11-1所示。
我们在一个长连接服务器中使用了这种模式,生产者1负责将所有客户端发送的消息存放 在阻塞队列1里,消费者1从队列里读消息,然后通过消息ID进行散列得到N个队列中的一个, 然后根据编号将消息存放在到不同的队列里,每个阻塞队列会分配一个线程来消费阻塞队列 里的数据。如果消费者2无法消费消息,就将消息再抛回到阻塞队列1中,交给其他消费者处 理。
以下是消息总队列的代码。
启动一个消息分发线程。在这个线程里子队列自动去总队列里获取消息。
使用散列(hash)算法获取一个子队列,代码如下。
使用的时候,只需要往总队列里发消息。
11.1.3 线程池与生产消费者模式
Java中的线程池类其实就是一种生产者和消费者模式的实现方式,但是我觉得其实现方 式更加高明。生产者把任务丢给线程池,线程池创建线程并处理任务,如果将要运行的任务数 大于线程池的基本线程数就把任务扔到阻塞队列里,这种做法比只使用一个阻塞队列来实现 生产者和消费者模式显然要高明很多,因为消费者能够处理直接就处理掉了,这样速度更快, 而生产者先存,消费者再取这种方式显然慢一些。
我们的系统也可以使用线程池来实现多生产者和消费者模式。例如,创建N个不同规模的 Java线程池来处理不同性质的任务,比如线程池1将数据读到内存之后,交给线程池2里的线程 继续处理压缩数据。线程池1主要处理IO密集型任务,线程池2主要处理CPU密集型任务。
本节讲解了生产者和消费者模式,并给出了实例。读者可以在平时的工作中思考一下哪 些场景可以使用生产者消费者模式,我相信这种场景应该非常多,特别是需要处理任务时间 比较长的场景,比如上传附件并处理,用户把文件上传到系统后,系统把文件丢到队列里,然 后立刻返回告诉用户上传成功,最后消费者再去队列里取出文件处理。再如,调用一个远程接 口查询数据,如果远程服务接口查询时需要几十秒的时间,那么它可以提供一个申请查询的 接口,这个接口把要申请查询任务放数据库中,然后该接口立刻返回。然后服务器端用线程轮 询并获取申请任务进行处理,处理完之后发消息给调用方,让调用方再来调用另外一个接口 取数据。
11.2 线上问题定位
有时候,有很多问题只有在线上或者预发环境才能发现,而线上又不能调试代码,所以线 上问题定位就只能看日志、系统状态和dump线程,本节只是简单地介绍一些常用的工具,以帮 助大家定位线上问题。
1)在Linux命令行下使用TOP命令查看每个进程的情况,显示如下。
我们的程序是Java应用,所以只需要关注COMMAND是Java的性能数据,COMMAND表 示启动当前进程的命令,在Java进程这一行里可以看到CPU利用率是300%,不用担心,这个是 当前机器所有核加在一起的CPU利用率。
2)再使用top的交互命令数字1查看每个CPU的性能数据。
命令行显示了CPU4,说明这是一个5核的虚拟机,平均每个CPU利用率在60%以上。如果 这里显示CPU利用率100%,则很有可能程序里写了一个死循环。这些参数的含义,可以对比表 11-1来查看。
3)使用top的交互命令H查看每个线程的性能信息。
在这里可能会出现3种情况。
·第一种情况,某个线程CPU利用率一直100%,则说明是这个线程有可能有死循环,那么 请记住这个PID。
·第二种情况,某个线程一直在TOP 10的位置,这说明这个线程可能有性能问题。
·第三种情况,CPU利用率高的几个线程在不停变化,说明并不是由某一个线程导致CPU 偏高。 如果是第一种情况,也有可能是GC造成,可以用jstat命令看一下GC情况,看看是不是因 为持久代或年老代满了,产生Full GC,导致CPU利用率持续飙高,命令和回显如下。
还可以把线程dump下来,看看究竟是哪个线程、执行什么代码造成的CPU利用率高。执行 以下命令,把线程dump到文件dump17里。执行如下命令。
sudo -u admin /opt/taobao/java/bin/jstack 31177 > /home/tengfei.fangtf/dump17
dump出来内容的类似下面内容。
dump出来的线程ID(nid)是十六进制的,而我们用TOP命令看到的线程ID是十进制的,所 以要用printf命令转换一下进制。然后用十六进制的ID去dump里找到对应的线程。
11.3 性能测试
因为要支持某个业务,有同事向我们提出需求,希望系统的某个接口能够支持2万的 QPS,因为我们的应用部署在多台机器上,要支持两万的QPS,我们必须先要知道该接口在单 机上能支持多少QPS,如果单机能支持1千QPS,我们需要20台机器才能支持2万的QPS。需要 注意的是,要支持的2万的QPS必须是峰值,而不能是平均值,比如一天当中有23个小时QPS不 足1万,只有一个小时的QPS达到了2万,我们的系统也要支持2万的QPS。
我们先进行性能测试。我们使用公司同事开发的性能测试工具进行测试,该工具的原理 是,用户写一个Java程序向服务器端发起请求,这个工具会启动一个线程池来调度这些任务, 可以配置同时启动多少个线程、发起请求次数和任务间隔时长。将这个程序部署在多台机器 上执行,统计出QPS和响应时长。我们在10台机器上部署了这个测试程序,每台机器启动了 100个线程进行测试,压测时长为半小时。注意不能压测线上机器,我们压测的是开发服务器。
测试开始后,首先登录到服务器里查看当前有多少台机器在压测服务器,因为程序的端 口是12200,所以使用netstat命令查询有多少台机器连接到这个端口上。命令如下。
$ netstat -nat | grep 12200 –c
10
通过这个命令可以知道已经有10台机器在压测服务器。QPS达到了1400,程序开始报错获 取不到数据库连接,因为我们的数据库端口是3306,用netstat命令查看已经使用了多少个数据 库连接。命令如下。
$ netstat -nat | grep 3306 –c
12
增加数据库连接到20,QPS没上去,但是响应时长从平均1000毫秒下降到700毫秒,使用 TOP命令观察CPU利用率,发现已经90%多了,于是升级CPU,将2核升级成4核,和线上的机器 保持一致。再进行压测,CPU利用率下去了达到了75%,QPS上升到了1800。执行一段时间后响 应时长稳定在200毫秒。
增加应用服务器里线程池的核心线程数和最大线程数到1024,通过ps命令查看下线程数 是否增长了,执行的命令如下。
$ ps -eLf | grep java -c
1520
再次压测,QPS并没有明显的增长,单机QPS稳定在1800左右,响应时长稳定在200毫秒。 我在性能测试之前先优化了程序的SQL语句。使用了如下命令统计执行最慢的SQL,左边 的是执行时长,单位是毫秒,右边的是执行的语句,可以看到系统执行最慢的SQL是 queryNews和queryNewIds,优化到几十毫秒。
性能测试中使用的其他命令
1)查看网络流量。
2)查看系统平均负载。
$ cat /proc/loadavg
0.00 0.04 0.85 1/1266 22459
3)查看系统内存情况。
4)查看CPU的利用率。
11.4 异步任务池
Java中的线程池设计得非常巧妙,可以高效并发执行多个任务,但是在某些场景下需要对 线程池进行扩展才能更好地服务于系统。例如,如果一个任务仍进线程池之后,运行线程池的 程序重启了,那么线程池里的任务就会丢失。另外,线程池只能处理本机的任务,在集群环境 下不能有效地调度所有机器的任务。所以,需要结合线程池开发一个异步任务处理池。图11-2 为异步任务池设计图。
任务池的主要处理流程是,每台机器会启动一个任务池,每个任务池里有多个线程池,当 某台机器将一个任务交给任务池后,任务池会先将这个任务保存到数据中,然后某台机器上 的任务池会从数据库中获取待执行的任务,再执行这个任务。 每个任务有几种状态,分别是创建(NEW)、执行中(EXECUTING)、RETRY(重试)、挂起 (SUSPEND)、中止(TEMINER)和执行完成(FINISH)。
·创建:提交给任务池之后的状态。
·执行中:任务池从数据库中拿到任务执行时的状态。
·重试:当执行任务时出现错误,程序显式地告诉任务池这个任务需要重试,并设置下一次 执行时间。
·挂起:当一个任务的执行依赖于其他任务完成时,可以将这个任务挂起,当收到消息后, 再开始执行。
·中止:任务执行失败,让任务池停止执行这个任务,并设置错误消息告诉调用端。 ·执行完成:任务执行结束。
任务池的任务隔离。异步任务有很多种类型,比如抓取网页任务、同步数据任务等,不同 类型的任务优先级不一样,但是系统资源是有限的,如果低优先级的任务非常多,高优先级的 任务就可能得不到执行,所以必须对任务进行隔离执行。使用不同的线程池处理不同的任务, 或者不同的线程池处理不同优先级的任务,如果任务类型非常少,建议用任务类型来隔离,如 果任务类型非常多,比如几十个,建议采用优先级的方式来隔离。
任务池的重试策略。根据不同的任务类型设置不同的重试策略,有的任务对实时性要求 高,那么每次的重试间隔就会非常短,如果对实时性要求不高,可以采用默认的重试策略,重 试间隔随着次数的增加,时间不断增长,比如间隔几秒、几分钟到几小时。每个任务类型可以 设置执行该任务类型线程池的最小和最大线程数、最大重试次数。
使用任务池的注意事项。任务必须无状态:任务不能在执行任务的机器中保存数据,比如 某个任务是处理上传的文件,任务的属性里有文件的上传路径,如果文件上传到机器1,机器2 获取到了任务则会处理失败,所以上传的文件必须存在其他的集群里,比如OSS或SFTP。
异步任务的属性。包括任务名称、下次执行时间、已执行次数、任务类型、任务优先级和 执行时的报错信息(用于快速定位问题)。