自旋锁
在多线程竞争下,执行同步代码的时候,通常会有两种方式解决同步问题:
- 通过锁的方式将没有获得锁的线程阻塞
- 没有获得锁的线程不进入阻塞,而是一直循环,看是否能够获得到锁
所以为了解决并发下,线程进入阻塞,需要不断地从内核态和用户态进行转换,如果频繁的操作就会对系统的并发性有一定的影响,所以但是如果对共享资源的占用时间极短的话,比如只是增加或者减少,要不断的挂起、恢复线程的话,转换的时间可能比同步时间还要长,就得不偿失了。所以为了解决这种同步代码较短的情况下,不阻塞线程,而是执行一个忙等待(自旋操作),不断的判断持有锁的线程是否已经释放锁,这就是自旋锁。
自旋锁可能存在的问题
- 过多的占用CPU资源,如果线程A一直长时间执行自己的逻辑,那么其他线程一直循环等待,过度占用CPU资源。所以在使用自旋锁的时候需要注意同步代码的执行长短,如果太长将不适合使用自旋锁。
- 自旋锁不是公平的,任何一个线程都可能获得锁,不公平就存在线程饥饿问题(有可能有的线程永远得不到锁,永远没有执行权限)
简单自旋锁
先上代码,后结合图片分析:
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
初始化
初始化的自旋锁有一个原子引用的Thread类型的成员变量,原子引用保证线程一定能够修改成功
lock()
先获取thread1的当前thread对象,并赋给局部变量current
while (!cas.compareAndSet(null, current))就是不停的在判断,null是否和cas相同,因为初始化的时候就为null,所以返回true,跳出while循环,执行同步代码块,并将thread1的current设置给cas
这时如果thread2进入lock()方法的话
thread2会获取当前线程的current和cas的引用对比,发现此时cas引用为thread1的current,所以返回false,就会在while中循环,此时thread2进入循环等待状态
unlock()
解锁就比较简单了,先获取到当前线程,赋给局部变量current,比较cas的引用和当前线程的current是否相同,并将cas设置为null,此时thread2的while中判断返回true,跳出while循环执行下面的同步代码
至于为什么这里要比较一下,我认为可能是防止其他线程误用unlock()方法,只能是thread1加的锁,thread1进行解锁,不能thread1加锁,thread2进行解锁这种情况的发生,最终导致代码逻辑混乱。
同时可以写成如下这样:
if(cas.get() != current){
//exception ...
}
cas.set(null);
可重入自旋锁
可重入自旋锁是对简单自旋锁的优化,因为简单自旋锁是不支持重入的,重入就是说thread1拿到锁后,thread1在同步代码块中还能继续拿到这个锁,也就是锁上加锁,而在简单自旋的实现上必须是一次加锁,一次解锁,一一对应的。所以引入了可重入自旋解决这个问题
可重入自旋锁实现
先上代码,后结合图片分析:
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) {
count++;
return;
}
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {
count--;
} else {
cas.compareAndSet(cur, null);
}
}
}
}
在简单自旋的基础上加入了一个count变量
初始化
lock()
thread1在第一次进入的时候,判断if (current == cas.get()),current和cas的引用是否相同,因为初始化的时候cas为null,所以current和cas不相同,不进入if判断,执行while中的判断cas.compareAndSet(null, current),因为是第一次进入,所以直接进入同步代码块。
当thread1再次执行lock()方法的时候
thread1再次进入的时候,发现cas的引用正好和current相同,就会将count加一后返回,继续执行同步代码块,此时实现了重入。
unlock()
此时thread1在第一次解锁的时候判断current线程是否等于cas引用的线程,如果相等,发现count的值大于0,说明之前重入过,需要将之前重入减一,后返回,继续执行后面的解锁
thread1再次解锁的时候,发现count已经减为0了,说明thread1之前的重入解锁都已经完成,此时是最后一次thread1的解锁了,只需要将cas的引用设置为null就可以完成thread1的全部解锁了
TicketLock锁
TicketLock锁主要解决的就是简单自旋中的公平性问题
TicketLock实现
先上代码后结合图片分析:
public class TicketLock {
private AtomicInteger serviceNum = new AtomicInteger(0);
private AtomicInteger ticketNum = new AtomicInteger(0);
private final ThreadLocal<Integer> myNum = new ThreadLocal<>();
public void lock() {
myNum.set(ticketNum.getAndIncrement());
while (serviceNum.get() != myNum.get()) {
}
}
public void unlock() {
serviceNum.compareAndSet(myNum.get(), myNum.get() + 1);
myNum.remove();
}
}
初始化
lock()
thread1设置myNum为tickNum的值,并将tickNum的值加一
while中判断serviceNum和thread1的myNum比较是否相等,此时thread1第一次进入,结果是想等,所以跳出while循环,进入同步代码块此时thread2进入同步代码,调用lock()方法
此时thread2获取ticketNum的值为1,设置thread2的myNum为1,并将ticketNum的值设置加一,设置为2
在while循环判断中,thread2比较自己的myNum和serviceNum的值,判断不相等,为true,所以一直在while循环判断,等待进入同步代码块
unlock()
thread1在进行解锁的时候,会先判断thread1的myNum的值和serviceNum的值是否相等,如果相等,说明从0(也就是thread1)开始解锁,然后将serviceNum的值加一
再将thread1的myNum移除掉
此时thread2发现thread2的myNum的值和serviceNum的值相等,就进入到同步代码块中执行同步代码了,在解锁的时候同样,将serviceNum的值加一,后将thread2的myNum移除。
最后保持serviceNum的值和ticketNum的值一致,后续可以继续使用。
所以TicketLock锁的思路就是每个进入锁的线程先有个编号,再有个临时的变量,每次加锁的时候都将自己的编号加一,线程的编号和临时的变量对上了就进入同步代码,对不上就自旋,解锁的时候就将临时变量加一,后续编号的线程发现对上了就进入同步代码块了。所以通过这个编号,也就是ticketNum,保证了线程的公平性,先进先出。
但是由于这种方式都是在一个变量上自旋的,也就是ticketNum,所以在多线程的情况下,会产生缓存一致性流量风暴问题:为了多个核之间缓存的同步,占用大量的系统总线和内存流量。处理器中的cache line失效,导致其他处理器的数据也被失效,从而影响其他原本与锁不存在竞争的处理器的执行速度。所以产生了CLH锁和MCS锁,不在一个变量上自旋。
CLH锁
CLH锁即Craig, Landin, and Hagersten (CLH) locks,CLH锁是一个自旋锁,能确保无饥饿性,提供先来先服务的公平性。
对上面一句话解释一下:
- CLH锁由这Craig, Landin, and Hagersten三个人发明,所以就取了三个人名字的开头
- CLH是自旋锁,就是不停的忙等待
- 确保无饥饿性的意思就是多线程情况下可以保证所有线程都有执行的机会,不会出现某个线程一直处于阻塞的状态
- 提供先来先服务的公平性的意思是,说多线程下可以保证先进入的线程先执行
CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
- 基于链表本身就是可以扩展的,所以具有可扩展性
- 高性能的意思是说之前的自旋锁操作都是限制在一个本地变量上,所以会有频繁的多CPU缓存同步,但是这种算法就没有这种问题
CLH锁实现
先上代码,后结合图片分析:
public class CLHLock {
private final AtomicReference<QNode> tail;
private final ThreadLocal<QNode> myPred;
private final ThreadLocal<QNode> myNode;
private static class QNode {
volatile boolean locked = false;
}
public CLHLock() {
tail = new AtomicReference<QNode>(new QNode());
myNode = new ThreadLocal<QNode>() {
@Override
protected QNode initialValue() {
return new QNode();
}
};
myPred = new ThreadLocal<QNode>() {
@Override
protected QNode initialValue() {
return null;
}
};
}
public void lock() {
QNode node = myNode.get();
node.locked = true;
QNode pred = tail.getAndSet(node);
myPred.set(pred);
while (pred.locked) {}
}
public void unlock() {
QNode qnode = myNode.get();
qnode.locked = false;
myNode.set(myPred.get());
// myNode.set(new QNode());
}
}
CLH锁内部有一个QNode类,QNode类内部维护了一个volatile类型的Boolean变量(多线程下保证变量的可见性)
CLH锁内部有一个AtomicReference类型的QNode代表这个引用是原子性的,多线程下会通过CAS修改成功
CLH锁内部有两个ThreadLocal类型的QNode,代表这个线程私有的
初始化
CLH锁在创建的时候会进行初始化
lock()
初始化完成后,第一个线程进入同步代码,调用lock()方法后
获取自己的本地变量QNode,并将QNode的值设置为true
tail.getAndSet(node)方法的意思是返回tail原来的值,设置新的值为node引用,返回的原来引用赋给了临时变量pred
将自己线程本地变量的myPred设置为pred
循环判断pred里的变量,也就是在这里自旋的,因为thread1第一次获得lock,所以直接进入到同步代码块中
可能单线程下看起来有点蒙,但是如果是多线程的话可能就会比较明显了,现在又有一个线程thread2执行同步代码,调用了lock()方法
同样的thread2获取自己的本地变量QNode,并将QNode的值设置为true
thread2在调用tail.getAndSet(node)方法的时候,将自己的node设置为tail,并获取到了thread1中的myNode,并将thread1的myNode引用赋给了临时变量pred
将自己线程本地变量的myPred设置为pred
thread2不停的去判断thread1的myNode,在这里自旋,一直等待thread1的myNode改变
unlock()
看完了加锁过程,可以看到多线程下,后一个线程一直在等待前一个线程的状态改变,那么什么时候会改变呢。下面看一下解锁的过程
可以看到线程先获取到自己的本地变量myNode,并将QNode的值改为false,让thread2的循环判断跳出,执行thread2的同步代码块
最后将myNode的内容指向myPred的引用
至于为什么需要将myNode的内容指向myPred的引用这个多此一举的事情呢?
因为可能会发生一种情况就是,thread1执行任务速度非常快,在thread2的while的一次判断之后和下一次判断之前,thread1执行完同步代码块,调用unlock()方法解锁后,又马上申请了锁调用了lock()方法,又一次将thread1的myNode改为了true,同时将tail的值设置为thread1的myPred,thread1一直循环thread2的myNode,而thread2一直循环thread1的myNode。
其实产生的主要原因就是thread1复用了原来的QNode,导致再次使其值变为true,而thread2还在一直判断这个复用的QNode,导致thread1和thread2相互等待对方,而解决的方法也很简单,只要thread1不复用原来的QNode就行,所以可以将thread1的myNode设置为thread1的myPred就可以了,也可以替换成为myNode.set(new QNode()),但是还需要再创建一个QNode,直接用原来的就可以。
CLH优缺点
优点:
- 空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail)(对于这句话我是一脸懵逼,等以后有时间学习算法的时候在对这句话解释吧)
- 是公平锁,满足先进先出队列(FIFO)
- 申请加锁的线程只需要在其前驱节点的本地变量上自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销
缺点:
- 在NUMA系统结构下性能很差,在这种系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的locked域,性能将大打折扣,但是在SMP系统结构下该法还是非常有效的
在说这个缺点的时候可能会有点蒙,那么什么是NUMA系统,什么是SMP系统
SMP和NUMA简介
我们知道CPU为了提高运算效率,会有L1、L2、L3级的缓存,每个核都有自己私有的存储空间,如果不同的核要同时访问或者修改同一个内存变量的时候,不同的核会有多个拷贝,那么如何同步这些拷贝呢?
对于不同的硬件结构,处理的方式会有不同,对于处理器与处理器之间,处理器与主存之间存在两种基本的通讯方式:SMP(symmetric multiprocessing 对称多处理)和NUMA(nonuniform memory access 非一致内存访问)
SMP:
NUMA:
SMP系统结构非常普通,很多小型服务器采用这种结构,处理器和内存之间通过总线互联,处理器和内存都有负责发送和监听总线广播的信息和总线控制单元。但是同一时刻只能有一个处理器在总线上广播,所有的处理器都可以监听到,总线的使用是SMP结构的瓶颈,如果多个共享对象或者共享对象频繁的改变会占用大量的总线,导致处理性能下降。
NUMP系统结构中,都是通过点对点进行互联的,像一个小网络,每个节点包含一个或多个处理器和一个高速缓存,一个节点的缓存对于其他节点是可见的,所有的节点组成了所有处理器可以共享的内存,NUMP的高速缓存是共享的,和SMP有所不同。NUMP的问题是高速缓存之间的复制和多个节点之间的通讯问题(处理器比较多的时候,访问非本地内存开销很大),需要更复杂的协议,访问自己的缓存快于访问其他节点的缓存,NUMP的扩展性比较好,所以多数应用在中大型的服务器中。
CLH对于NUMA系统结构
对于CLH锁来说需要不停的去判断前置QNode的状态,在NUMA系统结构下,如果前置QNode的内存为止离得比较远,性能会大打折扣,因为时间都花在了通讯上面,但是在SMP系统结构下,通过总线控制,不会有因为距离原因而产生的性能问题。
那么如何解决CLH锁在NUMA系统结构的问题呢?所以引入了MCS锁
MCS锁
MCS 的名字也是由发明人的名字组合来的(John Mellor-Crummey and Michael Scott),特点和CLH锁一样,只不过实现方式不同
MCS锁实现
直接上代码:
public class MCSLock {
public class QNode {
volatile boolean locked = false;
QNode next = null;
}
private AtomicReference<QNode> tail;
private ThreadLocal<QNode> myNode;
public MCSLock() {
tail = new AtomicReference<QNode>(null);
myNode = new ThreadLocal<QNode>() {
protected QNode initialValue() {
return new QNode();
}
};
}
public void lock() {
QNode qnode = myNode.get();
QNode pred = tail.getAndSet(qnode);
if (null != pred) {
qnode.locked = true;
pred.next = qnode;
}
while (qnode.locked) {
}
}
public void unlock() {
QNode qnode = myNode.get();
if (null == qnode.next) {
if (tail.compareAndSet(qnode, null)) {
return;
}
while (null == qnode.next) {
}
}
qnode.next.locked = false;
qnode.next = null;
}
}
MCS锁内部有一个QNode类,QNode类内部维护了一个volatile类型的Boolean变量(多线程下保证变量的可见性),同时还有一个指向next的指针
MCS锁内部有一个AtomicReference类型的QNode代表这个引用是原子性的,多线程下会通过CAS修改成功
MCS锁内部有一个ThreadLocal类型的QNode的myNode,代表这个线程私有的
初始化
MCS锁在创建的时候会进行初始化
lock()
thread1获取myNode的引用赋给本地变量qnode
设置tail的引用为thread1的qnode,并将初始化tail的null赋给局部变量pred
判断pred是否为null,因为是第一次进入所以为null,直接进行下一步
此时thread1判断qnode的locked,直接进入同步代码块此时如果有第二个线程进来
thread2进入lock()方法,获取并设置tail的引用为thread2的qnode,同时获取到之前thread1的qnode,判断不为null,进入if判断
将thread2的QNode的locked设置为true,并将thread1的QNode的next的引用设置为thread2的qnode引用
thead2在自己的QNode中进行循环
unlock()
情况一:
在有thread2竞争的情况下,thread1的next不为null,所以thread1的next指向的是thread2的qnode,所以qnode不为空。实际上是通过next引用告诉thread2的循环退出,进入同步代码。情况二:
这种情况比较简单,只有一个线程的情况下,thread1的next为null,进入if (null == qnode.next)判断,然后比较tail的引用,因为此时就只有一个线程,所以tail指向的就是thread1,然后设置tail为null,后直接返回
情况三(有点复杂):
在thread1在执行解锁的时候,这个时候恰好thread2要进行加锁,进入同步代码块,但是还没来得及将thread1的next引用指向thread2的qnode
这时首先会进入if (null == qnode.next)的判断,然后判断tail的qnode和thread1的qnode是否一致,此时不一致,因为现在thread2已经将tail设置为thread2的qnode了,所以比较和设置失败,继续进行while循环判断,判断thread1的qnode不为空为止
thread1继续退出,将自己的qnode的next设置为null,同时将thread2的循环设置为false,让thread2进入同步代码块
MCS优缺点
优点
- 使用于NUMA系统架构,因为每个线程都是在自己的ThreadLocal里面进行自旋,符合NUMA的系统架构,都是访问自己的本地变量,所以会比较快
缺点
- 释放锁也需要自旋等待
- 比CLH锁的读写CAS操作多
CLH锁/MCS锁比较
不同:
- CLH锁比MCS锁代码简单
- CLH锁是在前驱节点上自旋的,而MCS锁是在本地变量上自旋的
- CLH锁是隐式的队列,MCS锁是显示的队列
- CLH锁释放需要改变自己的属性,MCS锁释放需要改变后继节点的属性
相同:
- 都是独占的(只能有一个线程进入同步代码)
- 不能重入(没有提供重入机制)
- 解决了自旋锁的公平性问题,可以保证先入先出
- 解决了自旋锁一致性流量问题(自旋锁需要在同一变量上面进行自旋,导致多线程下会有大量的缓存失效和有效,导致占用大量的内存总线流量,影响其他正常的读写,引起缓存一致性流量风暴),所以通过CLH和MCS对不同的系统架构进行了优化