Loading

Java并发编程-锁

Synchronized

synchronized 是 Java 中内置的同步锁,可以修饰方法或代码块,确保同一时刻只有一个线程可以访问被修饰的代码。

synchronized 可以锁住一个对象,也可以锁住整个类。

对象锁

包括方法锁(默认锁对象为 this, 当前实例对象)和同步代码块锁(自己指定锁对象)。

方法形式

如果要用 synchronized 实现一个对象级别的锁的话,可以用 synchronized 修饰普通的 非静态方法。使用该方法后,多个线程就无法同时调用同一个对象的方法。进而实现对象级别的同步。

如下示例:

public class SynchronizedTest {
    public static final SynchronizedTest synchronizedTest = new SynchronizedTest();

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        for (int i = 0; i < 3; i++){
            new Thread(() -> {
                try {
                    countDownLatch.await();
                    synchronizedTest.test03();
                } catch (InterruptedException e) {
                    System.out.println("Thread 2 is interrupted");
                }
            }).start();
        }
        Thread.sleep(2000);
        countDownLatch.countDown();
    }

    public synchronized void test03() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "获取锁成功");
        Thread.sleep(200);
        System.out.println(Thread.currentThread().getName() + "释放锁成功");
    }
}

在该示例中,启用三个线程,但是都是调用一个对象 synchronizedTest 的 test03 方法,而该对象的 test03 方法为非静态方法,被 synchronized 修饰,所以同一时刻只会有一个线程执行该方法:

每个等待线程都要等到上一个线程释放锁之后才能继续执行。

代码块形式

当然,除了修饰方法之外,synchronized 也可以用来修饰代码块,可以指定锁定对象,锁定对象可以是 this,表示当前对象,也可以是任意其他对象。如下示例:

public class SynchronizedTest {
    public static final SynchronizedTest synchronizedTest = new SynchronizedTest();

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        for (int i = 0; i < 3; i++){
            new Thread(() -> {
                try {
                    countDownLatch.await();
                    synchronizedTest.test02();
                } catch (InterruptedException e) {
                    System.out.println("Thread 2 is interrupted");
                }
            }).start();
        }
        Thread.sleep(2000);
        countDownLatch.countDown();
    }

    public void test02() throws InterruptedException {
        // 将当前实例对象作为锁定对象
        synchronized (this){
            System.out.println(Thread.currentThread().getName() + "成功获取锁");
            Thread.sleep(200);
            System.out.println(Thread.currentThread().getName() + "成功释放锁");
        }
    }
}

自定义锁定对象

public class SynchronizedTest {
    public static final SynchronizedTest synchronizedTest = new SynchronizedTest();
    public static Integer count = 0;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        // 定义一个对象
        Object lock = new Object();
        for (int i = 0; i < 3; i++){
            new Thread(() -> {
                try {
                    countDownLatch.await();
                    synchronizedTest.test02(lock);
                } catch (InterruptedException e) {
                    System.out.println("Thread 2 is interrupted");
                }
            }).start();
        }
        Thread.sleep(2000);
        countDownLatch.countDown();
    }

    public void test02(Object lock) throws InterruptedException {
        // 自定义锁定对象
        synchronized (lock){
            System.out.println(Thread.currentThread().getName() + "成功获取锁");
            Thread.sleep(200);
            System.out.println(Thread.currentThread().getName() + "成功释放锁");
        }
    }
}

运行结果如下:

从上面的结果,我们也可以很清楚地看到,synchronized 仍然是 非公平锁,上一个线程释放锁后,所有的等待线程都会竞争锁。

类锁

当然,我们也可以使用 synchronized 来修饰静态方法,进而实现锁定整个类,当有多个线程使用该类的对象(无论多少个该类的对象)调用被 synchronized 修饰的静态方法时,只有一个线程能执行成功,其他线程都要等待。

这是因为类中的静态方法不属于任何一个对象,不管 new 了多少个对象,静态方法只有一个。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

每个类都有一个唯一的Class对象,当类被加载时,JVM会为该类创建一个Class对象,Class对象中有唯一的类锁。无论以此类创建多少个实例对象,它们的getClass()类名.class都指向同一个Class对象。

如下示例:

public class SynchronizedTest {
    public static SynchronizedTest synchronizedTest01 = new SynchronizedTest();
    public static SynchronizedTest synchronizedTest02 = new SynchronizedTest();

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        new Thread(() -> {
            try {
                countDownLatch.await();
                // 调用被synchronized修饰的静态方法
                synchronizedTest01.test04();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                countDownLatch.await();
                // 调用被synchronized修饰的非静态方法
                synchronizedTest02.test03();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        Thread.sleep(2000);
        countDownLatch.countDown();
    }

    public synchronized void test03() throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "获取对象锁成功");
        Thread.sleep(2000);
        System.out.println(Thread.currentThread().getName() + "释放对象锁成功");
    }

    public synchronized static void test04(){
        System.out.println(Thread.currentThread().getName() + "获取类锁成功");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "释放类锁成功");
    }
}

使用两个线程,用两个对象分别调用被 synchronized 修饰的静态方法和非静态方法。

  • synchronizedTest01 调用的是类锁(唯一)。
  • synchronizedTest02 调用的是对象锁,每个对象都拥有。

结果如下,两个线程同时执行各自方法的代码,且互不干扰:

但是如果我使用两个对象都调用静态方法的话,结果是什么样的呢?可以自己下来试试。结果是两个线程先后执行,后一个等到前一个释放锁之后才会执行。

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized 给出的答案是在软件层面依赖 JVM,而 java.util.concurrent.Lock 给出的答案是在硬件层面依赖特殊的 CPU 指令。

sychronized 实现可重入锁

可重入锁:同一个线程 可以 多次 获取 同一个锁

为了体现 synchronized 支持实现可重入锁,这里尝试用递归多次调用同一个被 synchronized 锁住的方法,以达到同一个线程多次获取同一个锁。

如下示例:

public class SynchronizedTest {
    public static final SynchronizedTest synchronizedTest = new SynchronizedTest();
    public static Integer count = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(synchronizedTest::test01).start();
    }

    public synchronized void test01(){
        if (count < 1){
            count++;
            test01();
        }
        System.out.println(Thread.currentThread().getName() + "test01");
    }
}

测试结果:

JUC

JUC(Java Util Concurrency)是 Java 5.0 版本中引入的一个并发编程工具包,全称为 java.util.concurrent,位于 JDK 的 java.util.concurrent 包及其子包中。这个包提供了一系列用于处理并发编程的类和接口,大大简化了 Java 中多线程编程的复杂性,提高了并发性能和安全性。

JUC 的核心组件

JUC 包主要包含以下几个核心组件:

  1. 线程池框架Executor 框架)
    • ExecutorExecutorServiceThreadPoolExecutorScheduledExecutorService 等接口和类
    • 用于管理线程生命周期,复用线程资源,减少线程创建和销毁的开销
  2. 并发集合
    • ConcurrentHashMap:高效的线程安全哈希表
    • CopyOnWriteArrayListCopyOnWriteArraySet:写时复制的集合,适合读多写少场景
    • ConcurrentLinkedQueueConcurrentLinkedDeque:高效的线程安全队列
    • BlockingQueue 接口及其实现:阻塞队列,如 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue
  3. 同步器
    • CountDownLatch:倒计时门闩,允许一个或多个线程等待其他线程完成操作
    • CyclicBarrier:循环屏障,允许多个线程相互等待到达共同屏障点
    • Semaphore:信号量,控制同时访问特定资源的线程数量
    • Exchanger:交换器,用于线程间交换数据
    • Phaser:阶段器,可动态调整参与线程数量的同步器
  4. 锁机制
    • Lock 接口及其实现:如 ReentrantLock(可重入锁)
    • ReadWriteLock 接口及其实现:如 ReentrantReadWriteLock(读写锁)
    • StampedLock:支持乐观读的锁,性能更高
  5. 原子变量类
    • AtomicIntegerAtomicLongAtomicBoolean 等:提供原子操作的变量类
    • AtomicReferenceAtomicIntegerArray 等:引用类型和数组的原子操作类
  6. 并发工具类
    • FutureCallable:异步计算结果的接口
    • CompletableFuture:增强版的 Future,支持函数式编程和链式调用
    • Fork/Join 框架:用于并行执行任务的框架,适合递归分治算法

这里主要介绍 JUC 下的锁机制,即 java.util.concurrent.locks 包下的类即接口是如何实现锁机制的。

AbstractQueuedSynchronizer

首先来介绍一下 AbstractQueuedSynchronizer,也就是常说的 AQS

AQS的定位是为构建锁及其他同步组件(如Semaphore、ReentrantLock等)提供通用、可扩展的底层实现模板。通过封装线程排队、状态管理和阻塞唤醒等复杂逻辑,AQS让同步工具开发者只需关注资源控制的核心逻辑。

AQS定义了两种资源共享模式:

  • 独占模式:同一时刻只有一个线程能够获取资源
    • 重写tryAcquire/tryRelease
    • 示例:ReentrantLockReentrantReadWriteLock.WriteLock
  • 共享模式:同一时刻可以有多个线程获取共享资源
    • 重写tryAcquireShared/tryReleaseShared
    • 示例:CountDownLatchCyclicBarrier

一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryReleasetryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

同步状态管理

AQS 通过 volatile int state 变量统一管理资源状态,并提供原子操作方法:

在AQS中,用一个int型变量 state 来表示同步状态。同时使用volatile修饰保证state对所有线程均可见。

在子类中可以自由定义state的含义:

  • RenntrantLock:state=0表示未锁定,state>0表示锁插入次数。
  • Semaphore:state表示剩余许可证数量。
  • CountDownLatch:state表示未触发的倒计数次数(state减到0,等待线程会一起执行)。

state 的状态信息通过AQS中的 procted 类型的 getState,setState,compareAndSetState 进行操作。这几个方法都是Final修饰的,说明子类中无法重写它们。我们可以通过修改state字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。

08

线程排队与调度

那AQS又是如何管理等待队列的呢?

AQS内置 CLH变体的FIFO双向队列,自动处理线程排队与唤醒。队列中的每个节点的定义如下:

static final class Node {
    // Marker to indicate a node is waiting in shared mode
    static final Node SHARED = new Node();
    // Marker to indicate a node is waiting in exclusive mode
    static final Node EXCLUSIVE = null;

    // waitStatus value to indicate thread has cancelled
    static final int CANCELLED =  1;
    // waitStatus value to indicate successor's thread needs unparking
    static final int SIGNAL    = -1;
    // waitStatus value to indicate thread is waiting on condition
    static final int CONDITION = -2;
    // waitStatus value to indicate the next acquireShared should unconditionally propagate
    static final int PROPAGATE = -3;

    volatile int waitStatus;

    volatile Node prev;

    volatile Node next;

    volatile Thread thread;

    Node nextWaiter;

    ......
}

Node 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。

  • 节点封装:竞争失败的线程被包装为Node节点(含线程引用、等待状态waitStatus
  • 队列管理:通过headtail指针维护队列,确保线程安全入队(CAS操作)
  • 唤醒策略
    • 独占模式下唤醒头节点的有效后继节点(跳过已取消节点)
    • 共享模式下唤醒连续多个节点(如Semaphore释放时)

阻塞与唤醒机制

基于LockSupport实现线程精准控制:

  • 阻塞:竞争失败的线程调用LockSupport.park()挂起
  • 唤醒:释放资源时,通过unparkSuccessor()唤醒队列中的后继线程
  • 避免惊群效应:仅当节点是头节点的后继时才尝试获取资源

ReentrantLock

独占锁

同一时间仅允许一个线程持有锁。

ReentrantLock 可以实现独占锁,但不能实现共享锁。因为 ReentrantLock 只重写了 AQS 中独占模式的抽象方法:tryAcquire/tryRelease。

其状态变量 state 的语义为锁的重入次数,而非共享资源的数量(如 Semaphore 的许可数)。

abstract static class Sync extends AbstractQueuedSynchronizer {

    ...

    abstract void lock();

    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;
    }

    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;
    }

    protected final boolean isHeldExclusively() {
        return getExclusiveOwnerThread() == Thread.currentThread();
    }

    ...
}

static final class NonfairSync extends Sync {

    ...

    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

// 公平锁 FairSync 的逻辑类似 NonfairSync,也是自定义了 lock 方法和 tryAcquire 方法

公平锁/非公平锁

ReentrantLock 既可以实现公平锁,也可以实现非公平锁。

在解释公平锁和非公平锁之前首先要明白,AQS的唤醒机制。当前一个线程调用 unlock() 释放资源时,AQS的唤醒机制会唤醒等待队列中头节点之后的第一个可以唤醒的线程(为什么唤醒前面的线程?因为等待队列是尾插法,前面的线程先进来的,等了那么久,不得让人家先走啊)。

有的小伙伴这个时候就要疑惑了:那等待线程都是放在队列中,每次都按FIFO顺序唤醒一个线程,这不是非常公平吗?

唉!首先你要明白,当一个线程被唤醒去抢占资源的时候,也可能会存在其他新的线程(刚进来还没放到队列中的线程)一起和这个唤醒的线程竞争资源。那这个时候不就体现出公平的重要性了嘛。

对于那个被唤醒的线程来说,好不容易轮到我了,你们这些刚来的线程,凭什么插队啊?新来的就该放到队列里面去等着。

对于非公平锁来说,所谓的非公平是指被唤醒的线程和新的线程竞争锁,可能会存在一直竞争不过,导致线程饥饿

因此,对于ReentrantLock的公平锁与非公平锁而言,区别就在于:对于新的线程是直接让其竞争资源,还是优先将其放到等待队列中。

非公平锁的关键源码:

static final class NonfairSync extends Sync {
    ...
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
	...
}

看源码就可以明白非公平锁的原理了。就是有线程调用lock()竞争资源的时候,不先放队列里面,而是直接去竞争资源,如果新线程没有拿到资源,才会放到等待队列里面。

下面给出实现非公平锁的测试代码示例:

// 参数false或不传参数时,为非公平锁;参数true时为公平锁
private static final ReentrantLock lock = new ReentrantLock(false);

public static void main(String[] args) throws InterruptedException {
    test2();
}
public static void test2() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);
    for (int i = 0; i < 3; i++){
        new Thread(() -> {
            try {
                countDownLatch.await();
                for (int j = 0; j < 3; j++){
                    lock.lock();
                    System.out.println(Thread.currentThread().getName() + "进入");
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("InterruptedException");
            }
        }).start();
    }

    Thread.sleep(1000);
    countDownLatch.countDown();
}

测试结果如下图:

为什么Thread-0、Thread-2、Thread-1分别连续执行了三次呢?

这是因为刚开始的时候三个线程一起竞争资源,0竞争成功后执行第一次,1和2就被放到等待队列中等待了。

当0执行完第一次并释放资源时,2会被唤醒,此时0也会进入第二轮循环,尝试去竞争资源。

但是2刚被唤醒,刚被唤醒的线程需要一些时间来恢复到之前的状态,这涉及到操作系统的线程调度和上下文切换。虽然时间很短,但是也足够让2在与0竞争资源时处于劣势。但是上面的运行结果也不是绝对的,也有可能一个线程不连续获取锁。

那下面再来了解一下公平锁。

公平锁的关键源码:

static final class FairSync extends Sync {
    ...
    final void lock() {
        acquire(1);
    }
    
	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;
    }
}

对于公平锁而言,它就不会去直接竞争资源。但也不是直接放到队列中的。

在调用lock()方法后,会有一个调用tryAcquire()方法的过程。

可以看到在tryAcquire方法中,即使资源空闲,也会去调用 hasQueuedPredecessors 方法判断等待队列,只有队列为空队列未初始化当前线程就是头节点之后的第一个线程时,才会去获取资源。否则就会把当前线程封装成 Node,然后放到等待队列尾部。

下面给出公平锁的测试代码示例(其实和非公平锁一样,只是使用的是ReentrantLock的公平锁):

// 参数false或不传参数时,为非公平锁;参数true时为公平锁
private static final ReentrantLock lock = new ReentrantLock(true);

public static void main(String[] args) throws InterruptedException {
    test2();
}
public static void test2() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);
    for (int i = 0; i < 3; i++){
        new Thread(() -> {
            try {
                countDownLatch.await();
                for (int j = 0; j < 3; j++){
                    lock.lock();
                    System.out.println(Thread.currentThread().getName() + "进入");
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                System.out.println("InterruptedException");
            }
        }).start();
    }

    Thread.sleep(1000);
    countDownLatch.countDown();
}

测试结果如下:

为什么Thread-0、Thread-2、Thread-1分别间断执行了三次呢?

这是因为刚开始的时候三个线程一起竞争资源,0竞争成功后执行第一次,1和2就被放到等待队列中等待了。

当0执行完第一次并释放资源时,2会被唤醒,而0会被放到等待队列末尾。

此时只有2在竞争资源,毫无疑问,2会拿到锁并执行。同样,2执行完后,1被唤醒,2被放到队列末尾。

可重入锁

什么是可重入锁?同一个线程可以多次获取同一个锁。

ReentrantLock 本身的主要功能就是实现可重入锁。

ReentrantLock 实现可重入锁的原理就是当同一个线程多次获取同一个锁的时候,对 AQS 中的 state 进行累加,每获取一次就加1,每释放一次就减1,直到state减为0,表示该线程彻底释放该锁。其他线程此时可以竞争该锁。

看源码(这里以公平锁为例,非公平锁的逻辑是一样的):

static final class FairSync extends Sync {
	...
    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;
    }
}

在源码的中,首先会通过 AQS 的 getState 方法判断是否有线程持有锁;如果有线程持有锁,会判断正在持有锁的线程和当前尝试获取锁的线程是否是同一个线程;如果是同一个线程,就会调用 AQS 的 setState 方法给 state 加 1。

从上面的逻辑也可以看出来,state 的值就是同一个线程获取该锁的次数。

再来看释放锁的过程:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    // 如果正在持有锁的线程和当前线程不是同一个线程,就会报错
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 如果state为0,表示锁释放完了,将占用该锁的线程设置为null
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

释放锁的过程也很清晰易懂,就是每次释放时,将 state 减 1,当减到 0 时,锁就完全释放了。

下面给出可重入锁的测试实例:

public class ReentrantLockTest {
    private static final ReentrantLock lock = new ReentrantLock(true);

    public static void main(String[] args) throws InterruptedException {
        test();
    }

    public static void test(){
        lock.lock();
        int count = lock.getHoldCount();
        System.out.println(count);
        if (count < 3){
            test();
        }
        lock.unlock();
        System.out.println(lock.getHoldCount());
    }
}

代码是递归调用获取锁后,打印获取锁后的 state 值,释放锁后,也会打印释放后的 state 值。

测试结果如下:

测试结果不出所料,果然在最后释放锁后,state 值减为 0,表示当前线程已经完全释放锁。

ReentrantReadWriteLock

想象这样一种场景:在一个“读多写少”的场景中,在没有读写锁的情况下,如果使用传统的互斥锁(如synchronized或ReentrantLock),在多个线程进行读取操作时,也只能排队串行读取,这会大大影响并发性能。

在实际业务中,仅仅只是读取数据的话,并不会影响数据的准确性,此时如果还用独占锁的话,会极大影响的性能。

而 ReentrantReadWriteLock 就针对这种读多写少的情况,兼顾了性能和并发的问题。

ReentrantReadWriteLock 是Java并发包(JUC)中的一个锁实现,它允许多个线程同时读取共享资源,但在写入时只能有一个线程进行。这种锁由两部分组成:读锁(共享锁)和写锁(独占锁)。

下面是读写锁的设计思想:

  • 读读共享:允许多个线程同时获取读锁,并发读取共享资源。只要没有线程持有写锁,读锁就可以被多个线程同时获取。
  • 读写互斥:当读锁被持有时,其他线程不能获取写锁。
  • 写写互斥:当写锁被持有时,只有持有该写锁的线程可以继续获取读锁(锁降级)或重入写锁,而其他任何线程都不能获取读锁或写锁。

state同时表示读锁和写锁

在介绍读写锁的获取逻辑之前,我们先来介绍一下ReentrantReadWriteLock是如何表示读锁和写锁的获取次数的。

ReentrantReadWriteLock 对 AQS 中的 state 的设计非常巧妙,用一个变量,就能同时表示读锁的获取次数和写锁的获取次数。

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

// 返回读锁被获取的次数
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
// 返回写锁被获取的次数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
  • SHARED_UNIT:等于65536(即2的16次方),该常量用于表示读锁的计数单位,每新加一个读锁,都会给state加上65536,以达到用高16位计数的目的。
  • MAX_COUNT:等于65535,该掩码用于表示读锁或写锁的最大数量。
  • EXCLUSIVE_MASK:等于65535,该掩码用于提取低16位的值,表示写锁的计数。
  • sharedCount(int c)
    • 参数c是当前的state值。
    • 无符号右移16位(>>> SHARED_SHIFT)将高16位移到低16位,高16位补0。这样得到的就是高16位表示的读锁数量。
    • 例如:如果state为0x00010000(二进制为0000000000000001 0000000000000000),那么无符号右移16位得到0x00000001,即读锁数量为1。
  • exclusiveCount(int c)
    • 参数c是当前的state值。
    • 使用按位与操作,将stateEXCLUSIVE_MASK(0xFFFF)做操作,即保留低16位,高16位置0。
    • 例如:state为0x00010001,那么与0xFFFF进行按位与操作,低16位得到0x0001,即写锁的重入次数为1。

这样解释不够明显,下面来举个例子形象解释一下在 ReentrantReadWriteLock 中对 state 的使用原理。

假设当前有3个读锁(由3个不同线程持有,或者一个线程重入了3次),2次写锁重入(由持有写锁的线程重入两次)。

那么 state 的值应该为65536+65536+65536(三个读锁)+1+1(两次写锁重入) = 196610。

用二进制表示为:0000 0000 0000 0011 0000 0000 0000 0010

那我们分别用上面两个方法去得到读锁和写锁的数量:

sharedCount(int c) -> 0000 0000 0000 0000 0000 0000 0000 0011 -> 3

exclusiveCount(int c)

0000 0000 0000 0011 0000 0000 0000 0010
0000 0000 0000 0000 1111 1111 1111 1111
                   |
                   | 按位与
0000 0000 0000 0000 0000 0000 0000 0010  ->  2

获取读锁/写锁的逻辑

基于上面的设计思想,我们结合源码可以得出获取读锁和获取写锁的逻辑如下:

获取读锁(共享锁)的逻辑

源码:

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
    // 如果没有写锁被持有,或者当前线程已经持有写锁(锁降级),则可以获取读锁。
    // 如果有写锁被其他线程持有,则不能获取读锁,当前线程会被阻塞。
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    int r = sharedCount(c);
    // readerShouldBlock在公平模式和非公平模式下有不同的逻辑,作用都是判断当前获取读锁的线程是否应该被阻塞
    // 在非公平模式下,如果等待队列的头结点是等待写锁的线程,当前线程将被阻塞(等待该写锁获取执行);否则读锁可以获取。
    // 在公平模式下,读锁需要检查等待队列中是否有前驱节点(任何等待的线程),如果有,则当前线程需要排队等待。
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}
// 非公平模式下,判断等待队列第一个等待线程是否为获取写锁的线程
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}
// 公平模式下,判断等待队列是否有等待线程(无论是读线程还是写线程)
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

总结下来就是:

  1. 如果没有写锁被持有,或者当前线程已经持有写锁(锁降级),则可以获取读锁。
  2. 如果有写锁被其他线程持有,则不能获取读锁,当前线程会被阻塞。
  3. 在非公平模式下,为了避免写锁饥饿,规定:如果等待队列的头结点是等待写锁的线程,则新的读锁获取将被阻塞(等待该写锁获取执行);否则(等待队列为空或者头结点是读锁请求),读锁可以获取。
  4. 在公平模式下,读锁需要检查是否有前驱节点(任何等待的线程),如果有,则当前线程需要排队等待。

如果上面的获取读锁失败,则执行fullTryAcquireShared,会处理重试逻辑,直到成功或失败。这保证了在竞争激烈的情况下,获取读锁的请求不会被轻易放弃,同时避免了立即进入排队状态(如果可能,仍然会尝试非阻塞获取)。

获取写锁(独占锁)的逻辑

源码:

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    
    if (c != 0) {
        // 如果有读锁被其他线程持有,或者当前线程不是持有写锁的线程,则当前线程无法获取写锁。
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // 如果写锁被持有,且持有写锁的线程就是当前线程,则执行重入逻辑
        setState(c + acquires);
        return true;
    }
    // 如果没有任何锁(读锁和写锁)被持有,则可以立即尝试获取写锁。
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}
// 非公平模式下,永远返回false,不管等待队列如何,当前线程都可以尝试获取锁(能不能获取到另说)
final boolean writerShouldBlock() {
    return false;
}
// 公平模式下,判断等待队列是否有等待线程(无论是读线程还是写线程)
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}

总结下来就是:

  1. 如果没有任何锁(读锁和写锁)被持有,则可以立即尝试获取写锁。
  2. 如果有读锁被其他线程持有,或者写锁被其他线程持有,则当前线程无法获取写锁,会被放入等待队列。
  3. 如果当前线程已经持有写锁,则可以重入(增加写锁的计数)。
  4. 在非公平模式下,写锁可以尝试插队(barging),即不管等待队列中是否有其他等待线程,只要当前没有线程持有锁(包括读锁和写锁),就可以尝试获取。在公平模式下,写锁需要等待队列前面的线程都执行完毕(或等待)才能获取。

锁降级

ReentrantReadWriteLock 支持锁降级,按照获取写锁 -> 获取读锁 -> 释放写锁的顺序,就能够将写锁降级为读锁。

下面给出实例代码:

public class ReentrantReadWriteLockTest {
    private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public static void main(String[] args) throws InterruptedException {
        lock.writeLock().lock();
        System.out.println("获取写锁" + lock.getWriteHoldCount());
        try {
            Thread.sleep(2000);
            lock.readLock().lock();
            System.out.println("获取读锁" + lock.getReadHoldCount());
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            System.out.println("InterruptedException");
        }finally {
            lock.writeLock().unlock();
            System.out.println("释放写锁" + lock.getWriteHoldCount());
        }
        lock.readLock().unlock();
        System.out.println("释放读锁" + lock.getReadLockCount());
    }
}

示例运行结果如下:

根据结果可以看到,在写锁还未释放时,就可以成功获取到读锁。

欲知后续,静候更新

参考博客:

Java全栈知识体系——JUC锁

JavaGuide——AQS详解

二哥的Java进阶之路——重入锁ReentrantLock

当然还少不了万能的:豆包、ChatGPT、腾讯元宝

posted @ 2025-06-13 16:38  maoxianjia  阅读(104)  评论(2)    收藏  举报