自旋锁
典型的自旋锁:CAS
当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
class Spinlock {
private AtomicReference<Thread> cas;
spinlock(AtomicReference<Thread> cas){
this.cas = cas;
}
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) { //为什么预期是null??
// DO nothing
System.out.println("I am spinning");
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
// 自旋锁验证
class Task implements Runnable {
private AtomicReference<Thread> cas;
private Spinlock slock ;
public Task(AtomicReference<Thread> cas) {
this.cas = cas;
this.slock = new spinlock(cas);
}
@Override
public void run() {
slock.lock(); //上锁
for (int i = 0; i < 10; i++) {
//Thread.yield();
System.out.println(i);
}
slock.unlock();
}
}
public static void main(String[] args) {
AtomicReference<Thread> cas = new AtomicReference<Thread>();
Thread thread1 = new Thread(new Task(cas));
Thread thread2 = new Thread(new Task(cas));
thread1.start();
thread2.start();
}
优点
- 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
缺点
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
使用场景
自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。
可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。
可重入的自旋锁
为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
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;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
count--;
} else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}
//可重入自旋锁验证
class Task1 implements Runnable{
private AtomicReference<Thread> cas;
private ReentrantSpinLock slock ;
public Task1(AtomicReference<Thread> cas) {
this.cas = cas;
this.slock = new ReentrantSpinLock(cas);
}
@Override
public void run() {
slock.lock(); //上锁
slock.lock(); //再次获取自己的锁!没问题!
for (int i = 0; i < 10; i++) {
//Thread.yield();
System.out.println(i);
}
slock.unlock(); //释放一层,但此时count为1,不为零,导致另一个线程依然处于忙循环状态,所以加锁和解锁一定要对应上,避免出现另一个线程永远拿不到锁的情况
slock.unlock();
}
}
自旋锁与互斥锁的异同点
- 自旋锁与互斥锁都是为了实现保护资源共享的机制。
- 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
- 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。
自旋锁开启
虽然在JDK1.4.2的时候就引入了自旋锁,但是需要使用 -XX:+UseSpinning
参数来开启。在到了JDK1.6以后,就已经是默认开启了。
在JDK中,自旋操作默认10次,我们可以通过参数 -XX:PreBlockSpin
来设置,当超过来此参数的值,则会使用传统的线程挂起方式来等待锁释放。
自适应自旋锁
自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
JUC 中的自旋锁
public class AtomicLong extends Number implements java.io.Serializable {
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, VALUE, 1L);
}
}
public final class Unsafe {
public final long getAndAddLong(Object o, long offset, long delta) {
long v;
do {
v = getLongVolatile(o, offset);
} while (!weakCompareAndSetLong(o, offset, v, v + delta));
return v;
// 这里的 do-while 循环就是一个自旋操作,
// 如果在修改过程中遇到了其他线程竞争导致没修改成功的情况
}
}
总结
- 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
- 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
- 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
- 自旋锁本身无法保证公平性,同时也无法保证可重入性。
- 基于自旋锁,可以实现具备公平性和可重入性质的锁。
网友评论