Java并发编程-锁(五)

文章目录

  • AQS
    • 示例:ReentrantLock 实现
      • 公平与非公平对比
        • 1. 调度机制差异
        • 2. 性能差距的核心原因
        • 3. 典型案例分析
        • 4. 取舍与适用场景
        • 总结
      • 可重入
      • 公平性

AQS

示例:ReentrantLock 实现

  • 可重入 :synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

  • 公平性:按照时间顺序, 先获取锁的请求先被满足,那么这个锁是公平的,反过来,就是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁 是否是公平的。

事实上,公平的锁机制往往没有非公平的效率高, 为啥? 留给大家自己思考。

公平与非公平对比

公平锁与非公平锁的核心区别在于**获取锁的线程调度策略**,两者在效率上的差异主要源于**线程上下文切换频率**和**竞争优化机制**的不同。

1. 调度机制差异
  1. 公平锁(FIFO 严格顺序)

    • 策略:仅允许同步队列的头部节点(最早等待的线程)尝试获取锁。

    • 行为:新请求线程必须直接加入队列尾部,即使此时锁已释放

  2. 非公平锁(允许“插队”)

    • 策略:线程在请求锁时直接尝试抢占,无需判断队列状态。

    • 行为:新请求线程可能与队列中的等待线程竞争成功,减少队列操作

2. 性能差距的核心原因

以下因素导致**非公平锁吞吐量更高**:

  1. 上下文切换次数更少

    • 公平锁需频繁唤醒队列中阻塞的线程(每次释放锁后必须通知队列头部节点),导致高频率的线程切换

    • 示例:同一基准测试中,公平锁的线程切换次数是非公平锁的 **133 倍**,耗时为其 **94.3 倍**

  2. CPU 资源利用率优化

    • 释放锁的线程优先重入

    刚释放锁的线程很可能继续持有 CPU 缓存和资源,直接重入锁时无需重新加载状态(非公平锁允许此行为)

    • 避免队列排队延迟

    非公平锁通过抢占机制减少线程阻塞时间,降低锁空置概率

3. 典型案例分析

场景:线程 T1 释放锁后,立即有新线程 T2 请求锁。

  • 公平锁处理

T2 必须加入队列尾部,等待唤醒队列头部线程(即使此时锁可用)→ 触发两次切换(T1 退出、队列头部线程唤醒)

  • 非公平锁处理

T2 直接抢占锁成功 → **零切换**(T1 可能直接重入锁,无需唤醒操作)

4. 取舍与适用场景

选择建议

  • 默认使用非公平锁:适用于大多数高并发场景(如 Web 服务、消息队列),优先提升吞吐量

  • 谨慎使用公平锁:仅在线程等待时间差异显著或需严格避免饥饿时使用(如优先级调度)

总结

公平锁通过 FIFO 规则保障公平性,但牺牲了性能;非公平锁通过竞争式抢占减少线程切换和系统调用,实现更高吞吐量。这种效率差异是设计上对**公平性与性能的权衡**结果

下面我们来简单分析下ReentrantLock是如何实现可重入和公平性获取锁的特性的。

可重入

可重入指的是任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,这个特性的实现需要解决两个问题:

  • 第一个就是线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再 次成功获取。

  • 第二个是 锁的最终释放。 线程重复n次获取了锁,随后在第n次释放锁之后,其他线程能够获取到锁。这就要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁 被释放时,计数自减,当计数等于0时 表示锁已经成功释放。

我们还是来简单看下下代码,看右边栏,我们可以看到,ReentrantLock也是通过组合自定义同步器来实现锁的获取与释放:

Java并发编程-锁(五)_第1张图片

现在我们就先以非公平性的(默认的)实现为例子,看下获取同步状态的代码,也就是内部类 Sync 的 nonfairTryAcquire方法。

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        // 当前线程就是获取锁的线程
        int nextc = c + acquires; // 增加同步状态值
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

这个方法增加了同一线程再次获取同步状态的处理逻辑: 通过判断当前线程是否为获取锁的线程来 决定获取操作是否成功,如果是获取了锁的线程再次请求,则增加同步状态值并返回 true,表示获取同步状态成功。
这也就要求ReentrantLock在释放同步状态时减少同步状态值,我们看下 tryRelease 方法。

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) { // 同步状态完全释放
        free = true;
        setExclusiveOwnerThread(null); //没有线程占有锁了
    }
    setState(c);
    return free;
}

代码也很简单,如果这个锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了,才能返回true。可以看到,这个方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,把占有线程设置为null,并返回true,表示释放成功。

公平性

看完重入的特性,我们再来看下公平性。

我们刚刚的nonfairTryAcquire(int acquires)方法里面,也就是非公平锁,只要CAS设置同步状态成功,就表示当前线程获取了锁,而公平锁却不同,看下FairSync的tryAcquire方法。

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() && //当前节点是否有前驱节点
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

这个方法和nonfairTryAcquire(int acquires)比较,唯一不同的位置就是判断条件多了 hasQueuedPredecessors()方法,也就是加入了同步队列中当前节点是否有前驱节点的判断,如果这个方法返回true,就表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释 放锁之后才能继续获取锁。

你可能感兴趣的:(Java基础系列,java,开发语言)