前言
我们知道,当一个线程想要申请的锁已经被其他线程持有时,那么该线程需要等待其释放锁。等待的方式有两种:一种是将线程状态置为等待状态(非RUNNABLE),另一种就是自旋了。自旋就是不断检测该锁是否被释放,而不是将线程挂起或休眠。这两种方式适用于不同的场景。
当线程持有锁的时间较长时(一般情况下,也可以理解为,临界区代码较长),适合使用将线程挂起或休眠的方式,因为自旋太久会消耗大量CPU计算资源;当线程持有锁的时间较短时,适合自旋等待的方式,因为线程被挂起或休眠后需要被唤醒,而频繁的唤醒与挂起线程会导致大量的线程上下文切换,会带来巨大的开销。
实现
自旋锁分有多种,有排队自旋锁、MCS锁、CLH锁。一般都是使用CAS操作实现的。
最简单的自旋锁就是使用CAS操作不断将锁记录设置为当前线程,当CAS成功后,说明当前线程获得了锁,其他线程会继续CAS操作,直到当前线程释放了锁。
// 锁的持有者
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread currentThread = Thread.currentThread();
// CAS设置当前线程为锁的拥有者,成功则说明获得了锁
while (!owner.compareAndSet(null, currentThread)) {
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 只有锁的拥有者才能释放锁,释放后将锁的持有者置为null,让其他线程争夺
owner.compareAndSet(currentThread, null);
}
这种实现方式的缺点很明显,每个线程频繁使用CAS对原子变量更新,而这个操作会对各个处理器的缓存进行缓存同步,缓存同步本来就会带来很大的开销,频繁的缓存同步的消耗更是巨大。而且上述方式无法保证公平性。
排队自旋锁做了一些改进,保证了公平性,但仍有上面频繁缓存同步的问题,会大大降低系统性能。
而MCS锁和CLH锁很好地改善了上述问题。他们都是基于链表的高性能、公平的自旋锁。他们在自旋时都是在本地变量上自旋,极大的减少了不必要的缓存同步操作,降低了系统和内存总线的开销。
使用了MCS锁的线程在自旋申请锁时,是由它的前驱节点通知它结束自旋的。其实现较为复杂,这里不做过多说明。接下来说下本文的主题:CLH锁。
CLH锁的自旋也是在本地变量上自旋,它不断轮询前驱节点的状态,如果发现前驱节点释放了锁,那么自己就结束自旋。可以说CLH锁是主动去看它前面的节点有没有释放锁,而MCS锁是被动等它前面的节点通知它释放锁。Java的AQS的实现就是借鉴了CLH锁的思想。
CLS锁虽说也是基于链表,但他的链表是隐式的,也就是说,它的链表节点并没有前驱指针,即并没有实际持有前驱节点。CLH锁里,每个节点就是一个线程。他通过一个属性tail
来保存最新加入的节点(线程),每当有线程进来时,就会先把tail
保存到本地变量preNode
里,然后把tail
设置成自己。这样其实就是隐式的拿到了前驱节点preNode
。下个线程进来时也依次类推。这样也就保证了公平性,即会按照加入链表的先后依次唤醒线程。
接下来看具体代码实现:
public class CLHLock {
// 每个节点代表一个线程
static class CLHNode{
// volatile字段,默认当前线程是获得锁的
volatile boolean isLocked = true;
}
// 记录尾节点
volatile CLHNode tail;
// 原子更新器,保证只有一个线程更新成功
static final AtomicReferenceFieldUpdater UPDATER = AtomicReferenceFieldUpdater.newUpdater(
CLHLock.class,CLHNode.class,"tail"
);
public void lock(CLHNode currentThread){
// 只在本地变量上自旋,高性能缘由
CLHNode preNode = (CLHNode) UPDATER.getAndSet(this,currentThread);
if(preNode!=null){
// 方便调试,加了print语句
System.out.println(Thread.currentThread().getName() +" is waiting...");
// 等待前驱节点释放锁
while (preNode.isLocked){
}
}
System.out.println(Thread.currentThread().getName() +" get lock...");
}
public void unlock(CLHNode currentThread){
// CAS失败,说明tail != currentThread ,也就是说后面还有线程申请锁
if(!UPDATER.compareAndSet(this,currentThread,null)){
// 释放锁。此时才会冲刷写缓冲器、清空无效化队列,进行缓存同步
currentThread.isLocked = false;
}
}
}
测试
public class CLHLockDemo{
// 使用ThreadLocal为每个线程新建一个特有的节点对象
static ThreadLocal<CLHLock.CLHNode> currentThread = ThreadLocal.withInitial(() -> new CLHLock.CLHNode());
// CLH锁
CLHLock clhLock = new CLHLock();
// 申请锁
public void lock(){
clhLock.lock(currentThread.get());
}
// 释放锁
public void unlock(){
clhLock.unlock(currentThread.get());
}
public static void main(String[] args){
CLHLockDemo clhLockDemo = new CLHLockDemo();
// 新建5个线程
for(int i=0;i<5;i++) {
new Thread(()->{
clhLockDemo.lock();
try {
Thread.sleep(new Random().nextInt(5000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() +" Completed.");
clhLockDemo.unlock();
},"Thread-"+i).start();
}
}
}
打印结果如下:
Thread-1 get lock...
Thread-3 is waiting...
Thread-2 is waiting...
Thread-0 is waiting...
Thread-4 is waiting...
Thread-1 Completed.
Thread-0 get lock...
Thread-0 Completed.
Thread-2 get lock...
Thread-2 Completed.
Thread-3 get lock...
Thread-3 Completed.
Thread-4 get lock...
Thread-4 Completed.
有人可能会说,这不公平呀,线程0最先启动,应该是线程0最先获得锁吧。但不是这样的,首先要注意公平性指的是什么。比如说,链表里有5个正在申请锁的线程,那么他们获得锁的顺序必须是他们加入的顺序,也就是先来先得锁。这就是公平性。而因为我们示例里的代码比较简单,线程启动很快,所以可能线程0在start之后没被分配到时间片,而线程1在start后刚好被分配到时间片,这时它去CAS操作就拿到锁了。
我们可以在启动一个线程后休眠一下,这样就能保证是按启动顺序拿到锁了。
for(int i=0;i<5;i++) {
new Thread(()->{
clhLockDemo.lock();
try {
Thread.sleep(new Random().nextInt(2000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() +" Completed.");
clhLockDemo.unlock();
},"Thread-"+i).start();
// 休眠一小会再启动下一个线程,保证当前线程先加入链表里
Thread.sleep(100);
}
Thread-0 get lock...
Thread-1 is waiting...
Thread-2 is waiting...
Thread-3 is waiting...
Thread-4 is waiting...
Thread-0 Completed.
Thread-1 get lock...
Thread-1 Completed.
Thread-2 get lock...
Thread-2 Completed.
Thread-3 get lock...
Thread-3 Completed.
Thread-4 get lock...
Thread-4 Completed.