likes
comments
collection
share

【锁思想】为什么synchronized的默认策略是非公平的?

作者站长头像
站长
· 阅读数 12

  大家好,我是Coder哥,在技术日新月异的今天,真正应该花费时间学习的是那些不变的编程思想,那么今天我们来聊一下公平和非公平策略的思想,前几天看到一个问题”为什么synchronized是非公平的“,我仔细的思考了一下,发现不只是synchronized, ReentrantLock 的默认策略也是非公平的,非公平是实现锁的一种策略,不只是Java,其他语言的默认锁机制也都是非公平的,那么今天我们来详细的聊一下 ”为什么各种语言中锁实现的默认策略都是非公平的“ 。这个非公平是真的完全不公平随机获取的么?

如不嫌弃可以关注一下,在此拜谢: 掘金或微信搜: todocoder 后面也会持续的分享编程思想以及Java和Go等服务端相关的内容。

公平和非公平

首先,我们来看下什么是公平锁和非公平锁,为了能让大家更清楚,我们用图例来说明一下公平和非公平的场景。

公平锁的场景

我们先看一下公平锁的场景,顾名思义,公平锁指的就是按照线程请求的顺序来分配锁; 比如,我们给临界区加了一个公平锁,此时有3个线程先后来请求锁,线程1先到,就会先获得锁,那么线程2,3会在队列中等待,等线程1释放锁后,线程2,3会依次去获得锁,如果此时有线程4来竟争锁,会排在线程2,3的后面等待。

【锁思想】为什么synchronized的默认策略是非公平的?

然后等线程 1 释放锁之后,线程 2、3、4 会依次去获取这把锁,线程 2 先获取到的原因是它等待的时间最长。

【锁思想】为什么synchronized的默认策略是非公平的?

非公平锁的场景

非公平锁指的是不按照顺序来分配,在一定的情况下,可以插队。这里需要注意的是:

这里的非公平并不是完全随机的,也不是可以任意插队,而是在合适的时机插队。

那么什么是合适的时机呢? 比如线程1执行完毕的时候,此时线程2,3,4在队列里面,这时候线程5过来请求锁,刚好线程1释放锁,那么当前的锁就会给到线程5,而不是线程2,这就是所谓的合适的时机插队,如图:

【锁思想】为什么synchronized的默认策略是非公平的?

然后等线程5执行完毕后,如果有线程6也恰巧过来,那么这个锁会给到线程6,如果没有这个恰巧的话就会给到线程2.

我知道你有疑问了,按这个逻辑如果恰巧线程7,线程8,线程9... ,那么线程2会一直等待,对,这就是非公平锁造成的线程饥饿。 这也是非公平锁的缺点,后面会再出一篇文章分析线程饥饿的解决思想,关注不迷路

看到这里,你可能更加的疑惑了,非公平锁有这样可能造成饥饿的缺点,那么为什么几乎所有语言层面的默认锁机制都是非公平策略呢?难道我们这些排队的时间都白白浪费了吗?排了半天被别人插队?这就引出了我们文章开头的问题:为什么各种语言中锁实现的默认策略都是非公平的?

为什么各种语言中锁实现的默认策略都是非公平的?

都用非公平策略是有原因的,比如线程1持有一把锁,这个时候线程2,3,4依次请求进来,那么他们依次排队到队列,陷入等待,也就是进入阻塞的状态,然后线程1执行完毕,本该轮到线程2苏醒获取到锁,但是这个时候恰巧线程5请求这把锁,那么根据非公平的原则,线程5就获取到锁了,这是因为唤醒线程2会有很大的开销,因为程序的执行大部分都很快,很可能在唤醒线程2之前,线程5就已经执行完毕了,所以按照非公平策略的逻辑,这里会让线程5先获取到锁,相比于等待线程2唤醒的漫长过程,直接执行线程5效率会更高,这是一个双赢的局面。

基于上面的场景有很多好处

对于线程5而言: 不需要任何等待直接获取到锁并执行,提高了它的效率。

对于线程2而言:它获得锁的时间并没有推迟,因为等它被唤醒的时候,线程 5 早就释放锁了,因为线程 5 的执行速度相比于线程 2 的唤醒速度,是很快的。

所以一般情况下锁的默认策略,都是非公平的策略,这是为了提高整体的运行效率。

公平和非公平的优缺点

我们接下来看一下公平和非公平的优缺点,如表格所示。

【锁思想】为什么synchronized的默认策略是非公平的?

公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点在于整体执行速度更慢,吞吐量更小,而非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。

结合源码分析

结合ReentrantLock的源码我们来分析公平和非公平锁是如何实现的,查阅源码可以看到在 ReentrantLock 类包含一个 Sync 类,这个类继承自AQS(AbstractQueuedSynchronizer),代码如下:

public class ReentrantLock implements Lock {
 
private final Sync sync;
// Sync 类的代码:
abstract static class Sync extends AbstractQueuedSynchronizer {...}
  ...
// 非公平锁
static final class NonfairSync extends Sync {...}
// 公平锁
static final class FairSync extends Sync {...}
}

根据代码可知,Sync 有公平锁 FairSync 和非公平锁 NonfairSync两个子类,下面我们来看一下公平锁与非公平锁的加锁方法的源码。

公平锁的锁获取源码如下:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
    		//这里判断了 hasQueuedPredecessors(),
      	// 判断在等待队列中是否已经有线程在排队了
        if (!hasQueuedPredecessors() && 
                compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
      	// 这里是可重入锁特性的实现,比较简单易懂,这里不做过多探讨,有兴趣可自行查阅源码。
        ...
        return true;
    }
    return false;
}

非公平锁获取锁源码如下:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
      	//这里没有判断 hasQueuedPredecessors(), 
      	// 也就是直接自旋修改状态,能改就插队了
        if (compareAndSetState(0, acquires)) { 
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        ...
        return true;
    }
    return false;
}

上面代码对比,可以明显的看出公平锁与非公平锁的唯一的区别就在于公平锁在获取锁时多了一个限制条件:就是hasQueuedPredecessors() 判断在等待队列中是否已经有线程在排队了。这也就是公平锁和非公平锁的核心区别,如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;对于非公平锁,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。

**注意:**在公平锁中,有一个特例需要我们注意一下, tryLock() 方法,它不遵守设定的公平原则。

当有线程执行 tryLock() 方法的时候,会尝试一下是否能插队,也就是类似于非公平锁的调度机制,我们可以看一下源码:

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

这里调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。

最后

综上所述,公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,这样会存在插队先获得锁的情况,但这样也提高了整体的效率,吞吐量更大,执行更快。

参考: 锁的7大分类:www.todocoder.com/bcyy/cp/18.… 《Java并发编程实战》:www.todocoder.com/pdf/java/00…