背景
在需要保证共享变量的多线程并发读写安全时我们会使用一个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变量):
- 保证被修饰的变量对所有线程的可见性
- 禁止指令重排序优化
总结
“two lock queue” 算法并没有保证线程可见性,而使queue的enqueue入队操作对dequeue出队立即可见,存在不可预知的延迟。然而基于此算法的LinkedBlockingQueue利用volatile的“禁止重排序”语义保证了可见性。
参考资料
- [1] JDK 1.8源码
- [2] 《LinkedBlockingQueue,我所忽略的并发安全细节》
作者简介
鑫茂,2022年3月参加工作,从事Java后台开发。
高度自律,中度代码洁癖,喜欢Java,看到美的东西就会拼命研究。闲暇之余,喜读思维方法、哲学心理学以及历史等方面的书,偶尔写些文字。
希望通过文章,结识更多同道中人。 开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情