待解决的问题
在理解自旋锁之前,必须要先知道自旋锁要解决的难题是什么:阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
自旋锁的思路:
如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需要等一等(也叫做自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在用户态和内核态之间的频繁切换而导致的时间消耗。
这时可能有读者要问了,线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会导致CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用。那该如何解决这个问题呢?常见的做法是设定一个自旋等待的最大时间(即时间阈值,如何设置将在下段中说明),在线程执行的时间超过自旋等待的最大时间后,线程会立即释放锁。
自旋锁的时间阈值
自旋锁用于使当前线程占着CPU资源不释放,等到下次自旋获取锁资源后立即执行相关操作,但如何选择自旋锁的执行时间呢?如果自旋的执行时间太长,则会有大量的线程处于自旋状态且占用CPU资源,造成系统资源的浪费。因此,对自旋的周期选择将直接影响系统的性能。
Java中JDK的不同版本所采用的自选周期不同,JDK1.5为固定的时间,JDK1.6引入了适应性自旋锁,适应性自旋锁的自旋时间不再是固定值,而是由上一次在同一锁上的自旋时间及锁的拥有者的状态来决定的,可基本认为一个线程上下文切换的时间就是一个锁自旋的最佳时间。
自旋锁的优缺点
优点
自旋锁可以减少CPU上下文的切换,对于占用锁时间非常短或锁竞争不激烈的代码块来说性能大幅提升,因为自旋的CPU耗时明显少于线程阻塞、挂起、再次唤醒时两次CPU上下文切换的耗时。
缺点
在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU资源的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。
Java实现
非公平自旋锁
import java.util.concurrent.atomic.AtomicReference;
/**
* @author CaveWang
* @date 2022/10/5 11:10
*/
public class SpinLockDemo {
/**
* 锁的持有者
*/
private AtomicReference<Thread> owner = new AtomicReference<>();
/**
* 记录锁重入次数
*/
private volatile int count = 0;
public void lock() {
Thread current = Thread.currentThread();
// 当前线程已经持有锁, 则记录重入次数即可
if( current == owner.get() ) {
count++;
return;
}
while ( !owner.compareAndSet(null, current) );
}
public void unlock() {
Thread current = Thread.currentThread();
if( current == owner.get() ) {
if( count>0 ) {
// 锁重入, 直接自减即可
count--;
} else {
owner.set(null);
}
}
}
public static void main(String[] args) {
SpinLockDemo spinLock = new SpinLockDemo();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
}
运行结果如下所示:
Thread-1开始尝试获取自旋锁
Thread-0开始尝试获取自旋锁
Thread-1获取到了自旋锁
Thread-1释放了了自旋锁
Thread-0获取到了自旋锁
Thread-0释放了了自旋锁
缺点:
1.加锁线程需要不停自旋来检测锁的状态,明显浪费CPU,这也是自旋锁的通用弊端
2.当多个线程想要获取锁时,谁最先将设置owner谁便能取到锁,这可能会造成某些线程一直都未获取到锁造成线程饥饿,因此说该实现方式是一个非公平的自旋锁。
公平自旋锁
如何实现公平的自旋锁呢?举个例子,假设银行只有一个窗口,一群人都想去去抢占这个窗口,谁先抢到窗口谁就可以办理业务,如何保证公平性呢?那肯定是挂号叫号咯,所以我们可以给当前线程分配一个排队号码,然后该线程开始自旋。直到被它叫到号才退出自旋,即它的排队号码等于当前服务号码。
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author CaveWang
* @date 2022/10/5 11:50
*/
public class SpinLock_Fair {
/**
* 当前持有锁的号码
*/
private AtomicInteger serviceNum = new AtomicInteger(0);
/**
* 记录锁重入次数
*/
private volatile int count = 0;
/**
* 排队号码
*/
private AtomicInteger ticketNum = new AtomicInteger(0);
/**
* 各线程存放自己所申请的排队号码
*/
private static ThreadLocal<Integer> threadLocalNum = new ThreadLocal<>();
public void lock() {
Integer num = threadLocalNum.get();
if( num!=null && num==serviceNum.get() ) {
// 当前线程已经持有锁, 则记录重入次数即可
count++;
return;
}
// 申请一个排队号码
num = requestTicketNum();
threadLocalNum.set( num );
// 自旋等待, 直到该排队号码与serviceNum相等
while ( num != this.serviceNum.get() );
}
public void unlock() {
Integer num = threadLocalNum.get();
if( num!=null && num==serviceNum.get() ) {
if( count>0 ) {
// 锁重入, 直接自减即可
count--;
} else {
threadLocalNum.remove();
// 自增serviceNum, 以便下一个排队号码的线程能够退出自旋
serviceNum.set( num+1 );
}
}
}
/**
* 申请一个排队号码
*/
private int requestTicketNum() {
return ticketNum.getAndIncrement();
}
public static void main(String[] args) {
SpinLock_Fair spinLock = new SpinLock_Fair();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
Thread t3 = new Thread(runnable);
Thread t4 = new Thread(runnable);
t1.start();
t2.start();
t3.start();
t4.start();
}
}
执行结果如下:
Thread-0开始尝试获取自旋锁
Thread-1开始尝试获取自旋锁
Thread-2开始尝试获取自旋锁
Thread-3开始尝试获取自旋锁
Thread-0获取到了自旋锁
Thread-0释放了了自旋锁
Thread-1获取到了自旋锁
Thread-1释放了了自旋锁
Thread-2获取到了自旋锁
Thread-2释放了了自旋锁
Thread-3获取到了自旋锁
Thread-3释放了了自旋锁