Java实现Ticket, CLH, MCS自旋锁

自旋锁(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自旋锁。

主要思路为:

  1. 线程获取锁之前先获取一个自增长的号(Ticket)。
  2. 等待锁的线程都进入循环等待,检查当前叫号是否等于本线程所获取的号码。
  3. 如果叫号等于本线程获取的号码则获取到锁。
  4. 释放锁时将叫号递增,下一个获取号码等于该叫号的线程继续获取锁。

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队列来达到获取锁的公平性。

主要思路为:

  1. 线程获取锁之前新增一个节点node,连接到链表的tail指针后,这样所有等待锁的节点就形成一个队列。
  2. 各等待锁的线程都循环监听前一个节点pre的状态,如果pre的状态为释放锁的状态,则当前线程进入持有锁的状态。
  3. 根据队列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锁的基础上做出如下优化:

  1. 等待锁的线程监听本节点的锁状态,如果状态为获得锁,则进入获取锁状态。
  2. 释放锁时修改本节点的后置节点的锁状态。

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; }

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注