ConcurrentLinkedDeque是JDK中提供的线程安全双向并发队列,它采用非阻塞算法实现,允许从队列头部和尾部同时进行插入、删除与遍历操作。与同步容器不同,它不依赖锁,而是借助CAS(Compare And Swap)和细粒度的内存可见性控制来保证线程安全。在处理首尾变量存取时,其内部逻辑尤其精巧,既避免了伪共享带来的性能损耗,也降低了线程争用概率。

一、核心数据结构与头尾指针设计
ConcurrentLinkedDeque内部维护一个双向链表,每个节点为Node对象,包含item(数据)、prev(前驱)和next(后继)指针。与单向队列不同,它显式持有head和tail两个引用,但在大部分时刻这两个引用是“松弛”的,即它们不一定指向真正的首个或末个存活节点,而只保证通过它们能遍历到有效端点。
这种设计的核心原因在于:如果每次存取都强制将head或tail更新到准确的边界节点,就会在多线程场景下引发频繁的CAS竞争。通过允许指针滞后,线程在本地缓存中操作节点,仅在必要时才执行跨线程的指针修正,从而显著提升吞吐量。下面的代码展示了Node的基础定义:
static final class Node<E> {
volatile E item;
volatile Node<E> prev;
volatile Node<E> next;
Node(E item) {
// 使用UNSAFE.putObject保证可见性
this.item = item;
}
}
从内存布局看,head和tail被声明为volatile,确保多线程下读取到的引用是最新提交的值。但正如前文所说,它们只是“逻辑锚点”。例如,当从头部出队时,若head指向的节点已被移除,算法会从head出发沿next向后寻找第一个item非空的节点,并顺便将head松弛地推进到该位置。
这种松弛策略的代价是遍历可能多走几步,但收益是减少了写冲突。在尾部入队时同理:tail可能停在倒数第二个节点,新节点插入后,只有当下次插入发现tail.next不为空时才更新tail。该权衡在高并发首尾混合访问时极为关键。
二、头部存取的底层逻辑
头部操作主要包括addFirst(头插)和pollFirst(头取)。头插时,新节点被链接到当前head之前,并通过CAS将head指向新节点。若CAS失败,说明其他线程已修改head,此时线程会重新读取head并重试,这种自旋不放弃CPU但也不会阻塞。
具体来看,头插逻辑会先定位“真实头”:从head开始向后跳过已被逻辑删除(item为null)的节点。找到后,将新节点的next指向该节点,该节点的prev指向新节点,最后CAS更新head。下面简化代码说明了这一流程:
private void linkFirst(E e) {
Node<E> newNode = new Node<E>(e);
restart:
for (;;) {
Node<E> h = head;
Node<E> first = h.next; // 可能需跳过期节点
// 寻找第一个有效节点
while (first != null && first.item == null) {
first = first.next;
}
newNode.next = first;
if (first != null) first.prev = newNode;
if (casHead(h, newNode)) {
break;
}
}
}
在pollFirst中,线程不会立即将节点从链表物理摘除,而是先将item通过CAS置为null(逻辑删除),再在后续遍历中跳过它,并由其他操作顺势调整链接。这避免了在读取线程正遍历该节点时修改指针造成混乱。
这种“先逻辑删、后物理松绑”的方式,使头部读取(peekFirst)即使遇到并发删除也能返回一致性视图:要么看到旧值,要么看到新头,不会出现中间态。对于需要严格顺序的消费场景,该特性非常重要。
三、尾部存取的底层逻辑
尾部操作addLast和pollLast与头部对称,但方向朝前。尾插时,新节点链接到tail之后,并视情况更新tail。由于tail的松弛性,插入线程可能连续多次不更新tail,直到某次发现tail.next非空才顺带推进。
以下代码展示了尾插的核心循环:
private void linkLast(E e) {
Node<E> newNode = new Node<E>(e);
for (;;) {
Node<E> t = tail;
Node<E> last = t.prev; // 类似头部,向前找真实尾
while (last != null && last.item == null) {
last = last.prev;
}
newNode.prev = last;
if (last != null) last.next = newNode;
if (casTail(t, newNode)) {
break;
}
}
}
尾取时,算法从tail向前寻找最后一个item非空的节点,逻辑删除其item,并可能将tail回退到更靠近有效节点的位置。注意,由于双向结构,尾取和头插可能在同一时刻操作相邻节点,但因为各自只修改自己一侧的链接且依赖CAS,不会相互覆盖。
在极高并发下,首尾同时存取会形成一种“对流”效应:头部线程向后清理,尾部线程向前清理,中间节点逐渐被逻辑删除。这种结构虽然暂时冗余,但保证了无锁环境下的安全,也解释了为何size()方法需要遍历全表且结果不精确。
四、首尾并发存取的一致性保障
ConcurrentLinkedDeque遵循弱一致性迭代器规范,且不抛出ConcurrentModificationException。当多线程分别从头和尾存取时, Happens-Before关系由volatile读写与CAS的硬件屏障共同建立。一次成功的CAS同时具备原子性与可见性,使得头尾变量的修改对其它线程及时可读。
我们可通过一个简单测试观察首尾并发行为:
ConcurrentLinkedDeque<Integer> deque = new ConcurrentLinkedDeque<>();
// 线程A持续头插
new Thread(() -> {
for (int i = 0; i < 1000; i++) deque.addFirst(i);
}).start();
// 线程B持续尾插
new Thread(() -> {
for (int i = 0; i < 1000; i++) deque.addLast(i);
}).start();
// 主线程混合取
while (!deque.isEmpty()) {
deque.pollFirst();
deque.pollLast();
}
上述代码在运行期不会出现数据丢失,因为头尾操作各自维护边界指针,插入和删除均基于CAS重试。即使某线程在更新head瞬间被挂起,其他线程也能通过遍历找到正确边界,不会误判队列为空。
不过需要注意,该队列不支持阻塞等待,若业务要求“队列空时等待”,需配合外部同步或使用LinkedBlockingDeque。此外,由于松弛指针的存在,监控时不能依赖head或tail的直接位置来判断元素数量,而应接受近似性。
五、实践中的注意事项
在使用ConcurrentLinkedDeque处理首尾变量存取时,首先应明确场景是否真正需要双端并发。若只有单端生产单端消费,其性能可能不如ArrayDeque加外部锁直观,但在多生产者多消费者双端模式(如工作窃取池的任务队列)中优势明显。
其次,避免在迭代中假设快照一致。例如以下代码可能遗漏或重复元素:
for (Integer x : deque) {
// 迭代期间其他线程头尾修改
System.out.println(x);
}
最后,由于节点在逻辑删除后不会立即回收,长时间极高吞吐且元素体积大的情况下,应注意堆内存压力。可通过定期调用一次poll类操作触发链接整理,或评估是否改用有界结构。理解其头尾存取逻辑,才能在高并发系统中扬长避短。
ConcurrentLinkedDeque非阻塞算法双向队列修改时间:2026-08-02 13:33:16