并发包中的ConcurrentLinkedQueue
和LinkedBlockingQueue
有什么区别?
典型回答
有时候我们把并发包下面的所有容器都习惯叫作并发容器,但是严格来讲,类似 ConcurrentLinkedQueue
这种“Concurrent
”容器,才是真正代表并发。
关于问题中它们的区别:
Concurrent
类型基于 lock-free,在常见的多线程访问场景,一般可以提供较高吞吐量。- 而
LinkedBlockingQueue
内部则是基于锁,并提供了BlockingQueue
的等待性方法。
java.util.concurrent
包提供的容器(Queue
、List
、Set
)、Map
,从命名上可以大概区分为 Concurrent
、CopyOnWrite
和 Blocking
等三类,同样是线程安全容器,可以简单认为:
Concurrent
类型没有类似CopyOnWrite
之类容器相对较重的修改开销。- 但是,凡事都是有代价的,
Concurrent
往往提供了较低的遍历一致性。你可以这样理解所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。 - 与弱一致性对应的,就是同步容器常见的行为“fail-fast”,也就是检测到容器在遍历过程中发生了修改,则抛出
ConcurrentModificationException
,不再继续遍历。 - 弱一致性的另外一个体现是,
size
等操作准确性是有限的,未必是 100% 准确。 - 与此同时,读取的性能具有一定的不确定性。
考点分析
这个问题是又是一个引子,考察你是否了解并发包内部不同容器实现的设计目的和实现区别。
队列是非常重要的数据结构,我们日常开发中很多线程间数据传递都要依赖于它,Executor
框架提供的各种线程池,同样无法离开队列。面试官可以从不同角度考察,比如:
- 哪些队列是有界的,哪些是无界的?
- 针对特定场景需求,如何选择合适的队列实现?
- 从源码的角度,常见的线程安全队列是如何实现的,并进行了哪些改进以提高性能表现?
知识扩展
线程安全队列一览
常见的集合中如 LinkedList
是个 Deque
,只不过不是线程安全的。下面这张图是 Java 并发类库提供的各种各样的线程安全队列实现,注意,图中并未将非线程安全部分包含进来。
我们可以从不同的角度进行分类,从基本的数据结构的角度分析,有两个特别的Deque实现,ConcurrentLinkedDeque
和 LinkedBlockingDeque
。Deque
的侧重点是支持对队列头尾都进行插入和删除,所以提供了特定的方法,如:
- 尾部插入时需要的addLast(e)、offerLast(e)。
- 尾部删除所需要的removeLast()、pollLast()。
从上面这些角度,能够理解 ConcurrentLinkedDeque
和 LinkedBlockingQueue
的主要功能区别,也就足够日常开发的需要了。但是如果我们深入一些,通常会更加关注下面这些方面。
从行为特征来看,绝大部分 Queue
都是实现了 BlockingQueue
接口。在常规队列操作基础上,Blocking
意味着其提供了特定的等待性操作,获取时(take)等待元素进队,或者插入时(put)等待队列出现空位。
/**
* 获取并移除队列头结点,如果必要,其会等待直到队列出现元素
*/
E take() throws InterruptedException;
/**
* 插入元素,如果队列已满,则等待直到队列出现空闲空间
*/
void put(E e) throws InterruptedException;
另一个 BlockingQueue
经常被考察的点,就是是否有界(Bounded、Unbounded),这一点也往往会影响我们在应用开发中的选择,简单总结一下。
ArrayBlockingQueue
是最典型的的有界队列,其内部以final
的数组保存数据,数组的大小就决定了队列的边界,所以我们在创建ArrayBlockingQueue
时,都要指定容量,如
public ArrayBlockingQueue(int capacity, boolean fair)
LinkedBlockingQueue
,容易被误解为无边界,但其实其行为和内部代码都是基于有界的逻辑实现的,只不过如果我们没有在创建队列时就指定容量,那么其容量限制就自动被设置为Integer.MAX_VALUE
,成为了无界队列。SynchronousQueue
,这是一个非常奇葩的队列实现,每个删除操作都要等待插入操作,反之每个插入操作也都要等待删除动作。那么这个队列的容量是多少呢?是 1 吗?其实不是的,其内部容量是 0。PriorityBlockingQueue
是无边界的优先队列,虽然严格意义上来讲,其大小总归是要受系统资源影响。DelayedQueue
和LinkedTransferQueue
同样是无边界的队列。对于无边界的队列,有一个自然的结果,就是put
操作永远也不会发生其他BlockingQueue
的那种等待情况。
如果我们分析不同队列的底层实现,BlockingQueue
基本都是基于锁实现,一起来看看典型的 LinkedBlockingQueue
。
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
ArrayBlockingQueue
,其条件变量与 LinkedBlockingQueue
版本的实现是有区别的。notEmpty
、notFull
都是同一个再入锁的条件变量,而 LinkedBlockingQueue
则改进了锁操作的粒度,头、尾操作使用不同的锁,所以在通用场景下,它的吞吐量相对要更好一些。
下面的 take
方法与 ArrayBlockingQueue
中的实现,也是有不同的,由于其内部结构是链表,需要自己维护元素数量值,请参考下面的代码。
public E take() throws InterruptedException {
final E x;
final int c;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
类似 ConcurrentLinkedQueue
等,则是基于 CAS 的无锁技术,不需要在每个操作时使用锁,所以扩展性表现要更加优异。
相对比较另类的 SynchronousQueue
,在 Java 6 中,其实现发生了非常大的变化,利用 CAS 替换掉了原本基于锁的逻辑,同步开销比较小。它是 Executors.newCachedThreadPool()
的默认队列。
队列使用场景与典型用例
在实际开发中,Queue 被广泛使用在生产者 - 消费者场景,比如利用 BlockingQueue
来实现,由于其提供的等待机制,我们可以少操心很多协调工作,你可以参考下面样例代码:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ConsumerProducer {
public static final String EXIT_MSG = "Good bye!";
public static void main(String[] args) {
// 使用较小的队列,以更好地在输出中展示其影响
BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);
new Thread(producer).start();
new Thread(consumer).start();
}
static class Producer implements Runnable {
private BlockingQueue<String> queue;
public Producer(BlockingQueue<String> q) {
this.queue = q;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
try{
Thread.sleep(5L);
String msg = "Message" + i;
System.out.println("Produced new item: " + msg);
queue.put(msg);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
System.out.println("Time to say good bye!");
queue.put(EXIT_MSG);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
static class Consumer implements Runnable{
private BlockingQueue<String> queue;
public Consumer(BlockingQueue<String> q){
this.queue=q;
}
@Override
public void run() {
try{
String msg;
while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
System.out.println("Consumed item: " + msg);
Thread.sleep(10L);
}
System.out.println("Got exit message, bye!");
}catch(InterruptedException e) {
e.printStackTrace();
}
}
}
}
上面是一个典型的生产者 - 消费者样例,如果使用非 Blocking
的队列,那么我们就要自己去实现轮询、条件判断(如检查 poll 返回值是否 null)等逻辑,如果没有特别的场景要求,Blocking
实现起来代码更加简单、直观。
前面介绍了各种队列实现,在日常的应用开发中,如何进行选择呢?
以 LinkedBlockingQueue
、ArrayBlockingQueue
和 SynchronousQueue
为例,我们一起来分析一下,根据需求可以从很多方面考量:
- 考虑应用场景中对队列边界的要求。
ArrayBlockingQueue
是有明确的容量限制的,而LinkedBlockingQueue
则取决于我们是否在创建时指定,SynchronousQueue
则干脆不能缓存任何元素。 - 从空间利用角度,数组结构的
ArrayBlockingQueue
要比LinkedBlockingQueue
紧凑,因为其不需要创建所谓节点,但是其初始分配阶段就需要一段连续的空间,所以初始内存需求更大。 - 通用场景中,
LinkedBlockingQueue
的吞吐量一般优于ArrayBlockingQueue
,因为它实现了更加细粒度的锁操作。 ArrayBlockingQueue
实现比较简单,性能更好预测,属于表现稳定的“选手”。- 如果我们需要实现的是两个线程之间接力性(handoff)的场景,可能会选择
CountDownLatch
,但是SynchronousQueue也是完美符合这种场景的,而且线程间协调和数据传输统一起来,代码更加规范。 - 可能令人意外的是,很多时候
SynchronousQueue
的性能表现,往往大大超过其他实现,尤其是在队列元素较小的场景。
Java并发类库提供的线程池有哪几种? 分别有什么特点?
典型回答
通常开发者都是利用 Executors
提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的 ExecutorService
类型或者不同的初始参数。
Executors 目前提供了 5 种不同的线程池创建配置:
newCachedThreadPool()
,它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:- 它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;
- 如果线程闲置的时间超过 60 秒,则被终止并移出缓存;
- 长时间闲置时,这种线程池,不会消耗什么资源。其内部使用
SynchronousQueue
作为工作队列。
newFixedThreadPool(int nThreads)
,重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有nThreads
个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目nThreads
。newSingleThreadExecutor()
,它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。newSingleThreadScheduledExecutor()
和newScheduledThreadPool(int corePoolSize)
,创建的是corePoolSize
个ScheduledExecutorService
,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。newWorkStealingPool(int parallelism)
,这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。
考点分析
Java 并发包中的 Executor 框架无疑是并发编程中的重点,题目考察的是对几种标准线程池的了解,提供的是一个针对最常见的应用方式的回答。
在大多数应用场景下,使用 Executors 提供的 5 个静态工厂方法就足够了,但是仍然可能需要直接利用 ThreadPoolExecutor
等构造函数创建,这就要求你对线程构造方式有进一步的了解,你需要明白线程池的设计和结构。
另外,线程池这个定义就是个容易让人误解的术语,因为 ExecutorService
除了通常意义上“池”的功能,还提供了更全面的线程管理、任务提交等方法。
Executor
框架可不仅仅是线程池,至少下面几点值得深入学习:
- 掌握
Executor
框架的主要内容,至少要了解组成与职责,掌握基本开发用例中的使用。 - 对线程池和相关并发工具类型的理解,甚至是源码层面的掌握。
- 实践中有哪些常见问题,基本的诊断思路是怎样的。
- 如何根据自身应用特点合理使用线程池。
知识扩展
首先,我们来看看 Executor
框架的基本组成,请参考下面的类图。
我们从整体上把握一下各个类型的主要设计目的:
Executor
是一个基础的接口,其初衷是将任务提交和任务执行细节解耦,这一点可以体会其定义的唯一方法。
void execute(Runnable command);
Executor
的设计是源于 Java 早期线程 API 使用的教训,开发者在实现应用逻辑时,被太多线程创建、调度等不相关细节所打扰。就像我们进行 HTTP 通信,如果还需要自己操作 TCP 握手,开发效率低下,质量也难以保证。
ExecutorService
则更加完善,不仅提供service
的管理功能,比如shutdown
等方法,也提供了更加全面的提交任务机制,如返回Future而不是void
的submit
方法。
<T> Future<T> submit(Callable<T> task);
注意,这个例子输入的可是Callable,它解决了 Runnable
无法返回结果的困扰。
- Java 标准类库提供了几种基础实现,比如ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。这些线程池的设计特点在于其高度的可调节性和灵活性,以尽量满足复杂多变的实际应用场景,会进一步分析其构建部分的源码,剖析这种灵活性的源头。
Executors
则从简化使用的角度,为我们提供了各种方便的静态工厂方法。
下面从源码角度,分析线程池的设计与实现,将主要围绕最基础的 ThreadPoolExecutor
源码。ScheduledThreadPoolExecutor
是 ThreadPoolExecutor
的扩展,主要是增加了调度逻辑,如想深入了解,你可以参考相关教程。而 ForkJoinPool
则是为 ForkJoinTask
定制的线程池,与通常意义的线程池有所不同。
这部分内容比较晦涩,罗列概念也不利于你去理解,所以会配合一些示意图来说明。在现实应用中,理解应用与线程池的交互和线程池的内部工作过程,你可以参考下图。
简单理解一下:
- 工作队列负责存储用户提交的各个任务,这个工作队列,可以是容量为 0 的
SynchronousQueue
(使用newCachedThreadPool
),也可以是像固定大小线程池(newFixedThreadPool
)那样使用LinkedBlockingQueue
。
private final BlockingQueue<Runnable> workQueue;
- 内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认 60 秒)后结束线程。
private final HashSet<Worker> workers = new HashSet<>();
线程池的工作线程被抽象为静态内部类 Worker
,基于AQS实现。
ThreadFactory
提供上面所需要的创建线程逻辑。- 如果任务提交时被拒绝,比如线程池已经处于
SHUTDOWN
状态,需要为其提供处理逻辑,Java 标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需求自定义。
从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从字面我们就可以大概猜测到其用意:
corePoolSize
,所谓的核心线程数,可以大致理解为长期驻留的线程数目(除非设置了allowCoreThreadTimeOut
)。对于不同的线程池,这个值可能会有很大区别,比如newFixedThreadPool
会将其设置为nThreads
,而对于newCachedThreadPool
则是为 0。maximumPoolSize
,顾名思义,就是线程不够时能够创建的最大线程数。同样进行对比,对于newFixedThreadPool
,当然就是nThreads
,因为其要求是固定大小,而newCachedThreadPool
则是Integer.MAX_VALUE
。keepAliveTime
和TimeUnit
,这两个参数指定了额外的线程能够闲置多久,显然有些线程池不需要它。workQueue
,工作队列,必须是BlockingQueue
。
通过配置不同的参数,我们就可以创建出行为大相径庭的线程池,这就是线程池高度灵活性的基础。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
进一步分析,线程池既然有生命周期,它的状态是如何表征的呢?
这里有一个非常有意思的设计,ctl
变量被赋予了双重角色,通过高低位的不同,既表示线程池状态,又表示工作线程数目,这是一个典型的高效优化。试想,实际系统中,虽然我们可以指定线程极限为 Integer.MAX_VALUE
,但是因为资源限制,这只是个理论值,所以完全可以将空闲位赋予其他意义。
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 真正决定了工作线程数的理论上限
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
// 线程池状态,存储在数字的高位
private static final int RUNNING = -1 << COUNT_BITS;
…
// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
为了让你能对线程生命周期有个更加清晰的印象,这里画了一个简单的状态流转图,对线程池的可能状态和其内部方法之间进行了对应,如果有不理解的方法,请参考 Javadoc。注意,实际 Java 代码中并不存在所谓 Idle 状态,我添加它仅仅是便于理解。
前面都是对线程池属性和构建等方面的分析,下面选择典型的 execute
方法,来看看其是如何工作的,具体逻辑请参考添加的注释,配合代码更加容易理解。
public void execute(Runnable command) {
…
int c = ctl.get();
// 检查工作线程数目,低于 corePoolSize 则添加 Worker
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// isRunning 就是检查线程池是否被 shutdown
// 工作队列可能是有界的,offer 是比较友好的入队方式
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次进行防御性检查
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 尝试添加一个 worker,如果失败意味着已经饱和或者被 shutdown 了
else if (!addWorker(command, false))
reject(command);
}
线程池实践
线程池虽然为提供了非常强大、方便的功能,但是也不是银弹,使用不当同样会导致问题。这里介绍些典型情况,经过前面的分析,很多方面可以自然的推导出来。
- 避免任务堆积。前面说过
newFixedThreadPool
是创建指定数目的线程,但是其工作队列是无界的,如果工作线程数目太少,导致处理跟不上入队的速度,这就很有可能占用大量系统内存,甚至是出现 OOM。诊断时,你可以使用 jmap 之类的工具,查看是否有大量的任务对象入队。 - 避免过度扩展线程。我们通常在处理大量短时任务时,使用缓存的线程池,比如在最新的 HTTP/2 client API 中,目前的默认实现就是如此。我们在创建线程池的时候,并不能准确预计任务压力有多大、数据特征是什么样子(大部分请求是 1K 、100K 还是 1M 以上?),所以很难明确设定一个线程数目。
- 另外,如果线程数目不断增长(可以使用 jstack 等工具检查),也需要警惕另外一种可能性,就是线程泄漏,这种情况往往是因为任务逻辑有问题,导致工作线程迟迟不能被释放。建议你排查下线程栈,很有可能多个线程都是卡在近似的代码处。
- 避免死锁等同步问题。
- 尽量避免在使用线程池时操作
ThreadLocal
,。
线程池大小的选择策略
线程池大小不合适,太多或太少,都会导致麻烦,所以我们需要去考虑一个合适的线程池大小。虽然不能完全确定,但是有一些相对普适的规则和思路。
- 如果我们的任务主要是进行计算,那么就意味着 CPU 的处理能力是稀缺的资源,我们能够通过大量增加线程数提高计算能力吗?往往是不能的,如果线程太多,反倒可能导致大量的上下文切换开销。所以,这种情况下,通常建议按照 CPU 核的数目 N 或者 N+1。
- 如果是需要较多等待的任务,例如 I/O 操作比较多,可以参考 Brain Goetz 推荐的计算方法:
这些时间并不能精准预计,需要根据采样或者概要分析等方式进行计算,然后在实际中验证和调整。
- 上面是仅仅考虑了 CPU 等限制,实际还可能受各种系统资源限制影响。
另外,在实际工作中,不要把解决问题的思路全部指望到调整线程池上,很多时候架构上的改变更能解决问题,比如利用背压机制的Reactive Stream、合理的拆分等。
AtomicInteger
底层实现原理是什么?如何在自己的产品代码中应用CAS操作?
典型回答
AtomicIntger
是对 int
类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAS(compare-and-swap)技术。
所谓 CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用 CAS 指令试图进行更新。
- 如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。
- 否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。
从 AtomicInteger
的内部属性可以看出,它依赖于 Unsafe
提供的一些底层能力,进行底层操作;以 volatile
的 value
字段,记录数值,以保证可见性。
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;
具体的原子操作细节,可以参考任意一个原子更新方法,比如下面的 getAndIncrement
。
Unsafe
会利用 value
字段的内存地址偏移,直接完成操作。
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
因为 getAndIncrement
需要返归数值,所以需要添加失败重试逻辑。
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;a
}
而类似 compareAndSet
这种返回 boolean
类型的函数,因为其返回值表现的就是成功与否,所以不需要重试。
public final boolean compareAndSet(int expectedValue, int newValue)
CAS 是 Java 并发中所谓 lock-free 机制的基础。
考点分析
这个问题有点偏向于 Java 并发机制的底层了,虽然我们在开发中未必会涉及 CAS 的实现层面,但是理解其机制,掌握如何在 Java 中运用该技术,还是十分有必要的,尤其是这也是个并发编程的面试热点。
CAS 更加底层是如何实现的,这依赖于 CPU 提供的特定指令,具体根据体系结构的不同还存在着明显区别。比如,x86 CPU 提供 cmpxchg
指令;而在精简指令集的体系架构中,则通常是靠一对儿指令(如“load and reserve”和“store conditional”)实现的,在大多数处理器上 CAS 都是个非常轻量级的操作,这也是其优势所在。
大部分情况下,掌握到这个程度也就够用了,认为没有必要让每个 Java 工程师都去了解到指令级别,我们进行抽象、分工就是为了让不同层面的开发者在开发中,可以尽量屏蔽不相关的细节。
如果作为面试官,很有可能深入考察这些方向:
- 在什么场景下,可以采用 CAS 技术,调用 Unsafe 毕竟不是大多数场景的最好选择,有没有更加推荐的方式呢?毕竟我们掌握一个技术,cool 不是目的,更不是为了应付面试,我们还是希望能在实际产品中有价值。
- 对
ReentrantLock
、CyclicBarrier
等并发结构底层的实现技术的理解。
知识扩展
关于 CAS 的使用,你可以设想这样一个场景:在数据库产品中,为保证索引的一致性,一个常见的选择是,保证只有一个线程能够排他性地修改一个索引分区,如何在数据库抽象层面实现呢?
可以考虑为索引分区对象添加一个逻辑上的锁,例如,以当前独占的线程 ID 作为锁的数值,然后通过原子操作设置 lock 数值,来实现加锁和释放锁,伪代码如下:
public class AtomicBTreePartition {
private volatile long lock;
public void acquireLock(){}
public void releaseeLock(){}
}
那么在 Java 代码中,我们怎么实现锁操作呢?Unsafe 似乎不是个好的选择,例如,类似 Cassandra 等产品,因为 Java 9 中移除了 Unsafe.moniterEnter()
/moniterExit()
,导致无法平滑升级到新的 JDK 版本。目前 Java 提供了两种公共 API,可以实现这种 CAS 操作,比如使用 java.util.concurrent.atomic.AtomicLongFieldUpdater
,它是基于反射机制创建,我们需要保证类型和字段名称正确。
private static final AtomicLongFieldUpdater<AtomicBTreePartition> lockFieldUpdater =
AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!lockFieldUpdater.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
…
}
}
Atomic 包提供了最常用的原子性数据类型,甚至是引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选。
使用原子数据类型和 AtomicFieldUpdater
,创建更加紧凑的计数器实现,以替代 AtomicLong
。优化永远是针对特定需求、特定目的,这里的侧重点是介绍可能的思路,具体还是要看需求。如果仅仅创建一两个对象,其实完全没有必要进行前面的优化,但是如果对象成千上万或者更多,就要考虑紧凑性的影响了。而 atomic
包提供的LongAdder,在高度竞争环境下,可能就是比 AtomicLong
更佳的选择,尽管它的本质是空间换时间。
回归正题,如果是 Java 9 以后,我们完全可以采用另外一种方式实现,也就是 Variable Handle API,这是源自于JEP 193,提供了各种粒度的原子或者有序性的操作等。将前面的代码修改为如下实现:
private static final VarHandle HANDLE = MethodHandles.lookup().findStaticVarHandle
(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!HANDLE.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
…
}
}
过程非常直观,首先,获取相应的变量句柄,然后直接调用其提供的 CAS 方法。
一般来说,我们进行的类似 CAS 操作,可以并且推荐使用 Variable Handle API 去实现,其提供了精细粒度的公共底层 API。这里强调公共,是因为其 API 不会像内部 API 那样,发生不可预测的修改,这一点提供了对于未来产品维护和升级的基础保障,坦白说,很多额外工作量,都是源于我们使用了 Hack 而非 Solution 的方式解决问题。
CAS 也并不是没有副作用,试想,其常用的失败重试机制,隐含着一个假设,即竞争情况是短暂的。大多数应用场景中,确实大部分重试只会发生一次就获得了成功,但是总是有意外情况,所以在有需要的时候,还是要考虑限制自旋的次数,以免过度消耗 CPU。
另外一个就是著名的ABA问题,这是通常只在 lock-free 算法下暴露的问题。CAS 是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A 的更新,仅仅判断数值是 A,可能导致不合理的修改操作。针对这种情况,Java 提供了 AtomicStampedReference
工具类,通过为引用建立类似版本号(stamp)的方式,来保证 CAS 的正确性,具体用法请参考这里的介绍。
前面介绍了 CAS 的场景与实现,幸运的是,大多数情况下,Java 开发者并不需要直接利用 CAS 代码去实现线程安全容器等,更多是通过并发包等间接享受到 lock-free 机制在扩展性上的好处。
下面来介绍一下 AbstractQueuedSynchronizer
(AQS),其是 Java 并发包中,实现各种同步结构和部分其他组成单元(如线程池中的 Worker)的基础。
学习 AQS,如果上来就去看它的一系列方法(下图所示),很有可能把自己看晕,这种似懂非懂的状态也没有太大的实践意义。
建议的思路是,尽量简化一下,理解为什么需要 AQS,如何使用 AQS,至少要做什么,再进一步结合 JDK 源代码中的实践,理解 AQS 的原理与应用。
Doug Lea曾经介绍过 AQS 的设计初衷。从原理上,一种同步结构往往是可以利用其他的结构实现的。但是,对某种同步结构的倾向,会导致复杂、晦涩的实现逻辑,所以,他选择了将基础的同步相关操作抽象在 AbstractQueuedSynchronizer
中,利用 AQS 为我们构建同步结构提供了范本。
AQS 内部数据和方法,可以简单拆分为:
- 一个
volatile
的整数成员表征状态,同时提供了setState
和getState
方法
private volatile int state;
- 一个先入先出(FIFO)的等待线程队列,以实现多线程间竞争和等待,这是 AQS 机制的核心之一。
- 各种基于 CAS 的基础操作方法,以及各种期望具体同步结构去实现的
acquire
/release
方法。
利用 AQS 实现一个同步结构,至少要实现两个基本类型的方法,分别是 acquire
操作,获取资源的独占权;还有就是 release
操作,释放对某个资源的独占。
以 ReentrantLock
为例,它内部通过扩展 AQS 实现了 Sync 类型,以 AQS 的 state 来反映锁的持有情况。
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer { …}
下面是 ReentrantLock
对应 acquire
和 release
操作,如果是 CountDownLatch
则可以看作是 await()
/countDown()
,具体实现也有区别。
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
排除掉一些细节,整体地分析 acquire
方法逻辑,其直接实现是在 AQS 内部,调用了 tryAcquire
和 acquireQueued
,这是两个需要搞清楚的基本部分。
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先,我们来看看 tryAcquire
。在 ReentrantLock
中,tryAcquire
逻辑实现在 NonfairSync
和 FairSync
中,分别提供了进一步的非公平或公平性方法,而 AQS 内部 tryAcquire
仅仅是个接近未实现的方法(直接抛异常),这是留个实现者自己定义的操作。
我们可以看到公平性在 ReentrantLock
构建时如何指定的,具体如下:
public ReentrantLock() {
sync = new NonfairSync(); // 默认是非公平的
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
以非公平的 tryAcquire
为例,其内部实现了如何配合状态与 CAS 获取锁,注意,对比公平版本的 tryAcquire
,它在锁无人占有时,并不检查是否有其他等待者,这里体现了非公平的语义。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();// 获取当前 AQS 内部状态量
if (c == 0) { // 0 表示无人占有,则直接用 CAS 修改状态位,
if (compareAndSetState(0, acquires)) {// 不检查排队情况,直接争抢
setExclusiveOwnerThread(current); // 并设置当前线程独占锁
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 即使状态不是 0,也可能当前线程是锁持有者,因为这是再入锁
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
接下来再来分析 acquireQueued
,如果前面的 tryAcquire
失败,代表着锁争抢失败,进入排队竞争阶段。这里就是我们所说的,利用 FIFO 队列,实现线程间对锁的竞争的部分,算是是 AQS 的核心逻辑。
当前线程会被包装成为一个排他模式的节点(EXCLUSIVE),通过 addWaiter
方法添加到队列中。acquireQueued
的逻辑,简要来说,就是如果当前节点的前面是头节点,则试图获取锁,一切顺利则成为新的头节点;否则,有必要则等待,具体处理逻辑请参考添加的注释。
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {// 循环
final Node p = node.predecessor();// 获取前一个节点
if (p == head && tryAcquire(arg)) { // 如果前一个节点是头结点,表示当前节点合适去 tryAcquire
setHead(node); // acquire 成功,则设置新的头节点
p.next = null; // 将前面节点对当前节点的引用清空
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node)) // 检查是否失败后需要 park
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);// 出现异常,取消
if (interrupted)
selfInterrupt();
throw t;
}
}
到这里线程试图获取锁的过程基本展现出来了,tryAcquire
是按照特定场景需要开发者去实现的部分,而线程间竞争则是 AQS 通过 Waiter
队列与 acquireQueued
提供的,在 release
方法中,同样会对队列进行对应操作。