自旋锁(Spinlock)是计算机科学多线程同步的一种锁。线程反复检查锁变量是否可用,使用忙等待的方式保持线程执行。由于自旋锁保持线程执行,避免了进程上下文的调度(该过程操作系统会发生用户态和内核态的切换,开销较大),所以在锁等待时间较短的场景下,性能上更有优势。
1.简单自旋锁
简单的自旋锁实现一般使用CAS(Compare And Swap),Java代码如下: public class SimpleLock {
private AtomicBoolean flag = new AtomicBoolean(); //锁变量
public void lock() {
while (!flag.compareAndSet(false, true)) {
//自旋
}
}
public void unlock() {
flag.set(false);
}
}
这种方式实现的自旋锁的问题是不公平自旋锁,可能会出现线程饥饿问题。
2.Ticket自旋锁
为了实现公平自旋锁,可以模拟银行柜台办理业务的排队叫号机制,就是Ticket自旋锁。
主要思路为:
- 线程获取锁之前先获取一个自增长的号(Ticket)。
- 等待锁的线程都进入循环等待,检查当前叫号是否等于本线程所获取的号码。
- 如果叫号等于本线程获取的号码则获取到锁。
- 释放锁时将叫号递增,下一个获取号码等于该叫号的线程继续获取锁。
Java实现Ticket锁代码如下: public class TicketLock {
private AtomicInteger ticket = new AtomicInteger();
private AtomicInteger call = new AtomicInteger();
public void lock() { int tick = ticket.getAndIncrement(); //取号
while (tick != call.get()) { //检查叫号和取的号是否相等
//自旋
}
}
public void unlock() {
call.getAndIncrement();
}
}
Ticket自旋锁解决了锁公平的问题,先取得号码的线程可以先获取锁(FIFO)。在多CPU计算机中,由于所有竞争锁的线程循环监听一个共享变量call的内存值,为了保证该变量对所有线程都保持可见性,各CPU的缓存会失效,每次循环读取该变量都必须从主存去读取。由于主存的带宽有限,这无疑会产生昂贵的负担。
3.CLH自旋锁
该锁的名称取自其发明人Craig,Landin和Hagersten名字的首字母。该锁采用隐式的链表实现FIFO队列来达到获取锁的公平性。
主要思路为:
- 线程获取锁之前新增一个节点node,连接到链表的tail指针后,这样所有等待锁的节点就形成一个队列。
- 各等待锁的线程都循环监听前一个节点pre的状态,如果pre的状态为释放锁的状态,则当前线程进入持有锁的状态。
- 根据队列FIFO的特性,各线程即可依次获取锁。
CLH自旋锁Java实现如下:
public class ClhLock {
private AtomicReference<Node> tail;
private ThreadLocal<Node> node;
private ThreadLocal<Node> pre;
public ClhLock() {
tail = new AtomicReference<>(new Node());
node = ThreadLocal.withInitial(Node::new);
pre = new ThreadLocal<>();
}
public void lock() {
Node curr = new Node(); //新增node节点
curr.flag = true; //节点设置为待获取锁状态
node.set(curr);
Node pr = tail.getAndSet(curr); //读取当前节点的前置节点并将当前节点链接到tail后
pre.set(pr);
while (pr.flag){ //检查前置节点的锁状态
//自旋
}
}
public void unlock() {
Node curr = node.get();
curr.flag = false; //当前节点置为未持有锁状态
pre.set(null); //解除前置节点与当前节点的链接
}
private class Node {
private volatile boolean flag = false; //true=待获取锁或持有锁状态,false=未持有锁状态
}
}
CLH锁实现了公平锁,每个线程只循环监听前置节点的锁状态,所以大大降低了内存同步的压力。Java并发包java.util.concurrent中众多锁实现的基础类AbstractQueuedSynchronizer就采用了CLH锁的变体实现,请看JDK1.8源码AbstractQueuedSynchronizer类304行注释:
/**
* Wait queue node class.
*
* <p> The wait queue is a variant of a "CLH" (Craig, Landin, and
* Hagersten) lock queue. CLH locks are normally used for
* spinlocks. We instead use them for blocking synchronizers, but
* use the same basic tactic of holding some of the control
* information about a thread in the predecessor of its node.
**/
CLH锁在对称多处理器(英语:Symmetric Multi-Processing,简称SMP)架构的CPU中运行正常。但是在非统一内存访问架构(英语:Non-uniform Memory Access,简称NUMA)的CPU中会存在频繁访问远端内存的问题,造成性能较差。因为NUMA架构,每个CPU的内存单元都是独立的,本CPU的内存为本地内存,其他CPU的内存为远程内存。而访问远程内存的性能要远远低于访问本地内存。而各等待锁的线程可能同时在不同的CPU上执行,当当前线程监听的前置节点在另一个CPU时,就会频繁的去读取远程内存,这样性能会显著降低。为了解决这个问题,就需要使用MCS自旋锁。
4.MCS自旋锁
该锁的名称依然取自其发明人John Mellor-Crummey和Michael Scott名字的首字母。该锁在CLH锁的基础上做出如下优化:
- 等待锁的线程监听本节点的锁状态,如果状态为获得锁,则进入获取锁状态。
- 释放锁时修改本节点的后置节点的锁状态。
MCS锁通过自旋监听本节点状态解决了CLH锁在NUMA架构下频繁读取远程内存的性能降低问题,在SMP和NUMA两种架构下都可以获得理想的性能。
MCS自旋锁Java实现如下:
public class McsLock {
private AtomicReference<Node> tail;
private ThreadLocal<Node> node;
public McsLock() {
tail = new AtomicReference<>();
node = ThreadLocal.withInitial(Node::new);
}
public void lock() {
Node curr = node.get();
Node pre = tail.getAndSet(curr);
if (pre != null) { //没有前置节点则当前
pre.next = curr;
while (!curr.flag) { //检查当前节点的锁状态
//自旋
}
}
}
public void unlock() {
Node curr = node.get();
if (curr.next == null) { //如果当前节点是尾结点,则将尾结点置为null
if (tail.compareAndSet(curr, null)) { //由于多线程执行,所以再次检查当前节点是否是尾节点
return;
}
while (curr.next == null){ //说明当前节点后又新增了节点
}
}
curr.next.flag = true; //将当前节点的后置节点锁状态置为true
curr.next = null; //断开当前节点和后置节点的连接
}
private class Node {
private volatile boolean flag = false;
private volatile Node next;
}