【LinkedBlockingQueue源码解析】使用2把锁如何保证线程安全

74 阅读5分钟

背景

在需要保证共享变量的多线程并发读写安全时我们会使用一个synchronized块、或者一把ReentrankLock锁。但是最近在阅读LinkedBlockingQueue源码时,我发现它使用了两把锁。

这让我有点迷惑,为什么要用两把锁?两把锁如何保证并发安全?

带着这个疑问我反复阅读源码和注释,并且Google前辈们的经验,最终有了答案,写下了这篇文章。

为什么要用2把锁

如果用一把锁,不管是enqueue入队操作和dequeue出队操作,都需要获取这把唯一的排它锁。在高并发下容易引起激烈竞争导致性能下降。而我们分析下queue的2个主要操作:

  • enqueue操作:总是在队列尾部插入节点
  • dequeue操作:总是在队列头部删除节点

可以发现,enqueue和dequeue在大部分情况下都是相互独立的,并没有发生冲突,无需锁住整个队列。我们自然而然就能想到一个锁的常见优化方法:分段锁。所以队列用头尾2个锁可以大大降低冲突的概率。

但是用2把锁在临界点会有些不太好处理的逻辑:

  • 在一个空队列入队一个节点
  • 只剩最后一个节点的时候出队这个节点

这2种情况都需要同时操作修改头尾指针,即需要同时获取head lock和tail lock

危险的逻辑来了,同时获取多把锁非常容易造成死锁。即使我们小心翼翼的设计这块加锁/解锁顺序,这块代码逻辑也会出现多次加锁/解锁逻辑,性能反而可能会下降。

2把锁如何保证并发安全

先来看一下LinkedBlockingQueue源码上的注释:

“two lock queue” algorithm 的一个变种。

那什么是 "two lock queue" algorithm呢?LinkedBlockingQueue 做了哪些改动,为什么?

我们先来看一下 "two lock queue" algorithm 论文部分代码:

structure node_t {value: data type, next: pointer to node_t}
 structure queue_t {Head: pointer to node_t, Tail: pointer to node_t,
                       H_lock: lock type, T_lock: lock type}
 
 initialize(Q: pointer to queue_t)
    node = new_node()		// Allocate a free node
    node->next = NULL          // Make it the only node in the linked list
    Q->Head = Q->Tail = node	// Both Head and Tail point to it
    Q->H_lock = Q->T_lock = FREE	// Locks are initially free
 
 enqueue(Q: pointer to queue_t, value: data type)
    node = new_node()	        // Allocate a new node from the free list
    node->value = value		// Copy enqueued value into node
    node->next = NULL          // Set next pointer of node to NULL
    lock(&Q->T_lock)		// Acquire T_lock in order to access Tail
       Q->Tail->next = node	// Link node at the end of the linked list
       Q->Tail = node		// Swing Tail to node
    unlock(&Q->T_lock)		// Release T_lock
 
 dequeue(Q: pointer to queue_t, pvalue: pointer to data type): boolean
    lock(&Q->H_lock)	        // Acquire H_lock in order to access Head
       node = Q->Head		// Read Head
       new_head = node->next	// Read next pointer
       if new_head == NULL	// Is queue empty?
          unlock(&Q->H_lock)	// Release H_lock before return
          return FALSE		// Queue was empty
       endif
       *pvalue = new_head->value	// Queue not empty.  Read value before release
       Q->Head = new_head	// Swing Head to next node
    unlock(&Q->H_lock)		// Release H_lock
    free(node)			// Free node
    return TRUE		// Queue was not empty, dequeue succeeded

可以看到:队列初始化的时候new了一个空节点,这样在首次入队的时候,队列就不再是空队列,无需修改head指针。在只剩最后一个元素的时候也不是只有一个节点,无需修改tail指针, 巧妙的避开了同时修改头尾指针的情况。

但是这里还是有个并发问题,假设以下执行顺序:

  • 线程A执行enqueue到第16行
  • 线程B执行dequeue到第24行

线程A在执行完16行的时候已经将首个元素节点入队了,然而线程B在执行24行的时候很有可能没法立即读到这个最新节点。因为2个线程使用不同锁保护,且没有用类似java中的volatile的手段来保证可见性

也就是说这个算法有个可见性问题:

新入队的元素可能无法被立即读到而顺利出队

我猜也正是因为这个问题,所以才会有 "A variant of the "two lock queue" algorithm. "

我们看下LinkedBlockingQueue是怎么处理这块的吧

LinkedBlockingQueue

先贴下put()和take()的源码(jdk-1.8):

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    // Note: convention in all put/take/etc is to preset local var
    // holding count negative to indicate failure unless set.
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        /*
* Note that count is used in wait guard even though it is
* not protected by lock. This works because count can
* only decrease at this point (all other puts are shut
* out by lock), and we (or some other waiting put) are
* signalled if it ever changes from capacity. Similarly
* for all other uses of count in other wait guards.
*/
        while (count.get() == capacity) {
            notFull.await();
        }
        enqueue(node);
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
}
 public E take() throws InterruptedException {
        E x;
        int c = -1;
        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;
    }

源码看完再结合上面的注释,你的心里是不是已经有答案了?

再看一遍注释

当一个元素入队,获取了putLock并且count更新后。随后的出队线程保证能读到最新入队的节点需要用以下2种的任意一种方法:

  • 方法一:出队时获取putLock
  • 方法二:获取takeLock后,执行n = count.get()即可保证可见性

方法一:出队时获取putLock,这块大家都能理解,相当于获取2把锁,必然能保证可见性

那么方法二是如何保证的可见性呢?

这就涉及到vloatile的语义了(count是个AtomicInteger,对于AtomicInteger的读写本质上都是读写一个volatile变量):

  1. 保证被修饰的变量对所有线程的可见性
  2. 禁止指令重排序优化

总结

“two lock queue” 算法并没有保证线程可见性,而使queue的enqueue入队操作对dequeue出队立即可见,存在不可预知的延迟。然而基于此算法的LinkedBlockingQueue利用volatile的“禁止重排序”语义保证了可见性。

参考资料

  • [1] JDK 1.8源码
  • [2] 《LinkedBlockingQueue,我所忽略的并发安全细节》

作者简介

鑫茂,2022年3月参加工作,从事Java后台开发。

高度自律,中度代码洁癖,喜欢Java,看到美的东西就会拼命研究。闲暇之余,喜读思维方法、哲学心理学以及历史等方面的书,偶尔写些文字。

希望通过文章,结识更多同道中人。 开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情