深入理解Java并发编程中的ReentrantLock

207 阅读14分钟

一、ReentrantLock 是什么

在 Java 并发编程领域,ReentrantLock 可是个 “狠角色”,它来自 java.util.concurrent.locks 包,是实现了 Lock 接口的类。简单来说,它是一种可重入的互斥锁,啥意思呢?就是同一个线程能多次获取同一把锁,不会自己把自己给堵死,完美避免了死锁的尴尬。

和传统的 synchronized 关键字相比,ReentrantLock 就像是一位 “全能选手”。synchronized 固然能保证线程安全,可它在灵活性上就稍逊一筹了。ReentrantLock 不仅能手动精准控制加锁、解锁,还附带了诸如公平锁、可中断锁、超时获取锁等一系列 “超能力”,这让它在应对复杂并发场景时游刃有余,成为众多 Java 开发者手中的得力工具,接下来咱们就深入探究一下它的奥秘。

二、ReentrantLock 的实现原理

2.1 核心组件:AQS

ReentrantLock 之所以这么神通广大,AbstractQueuedSynchronizer(AQS)可是居功至伟。AQS 堪称 Java 并发包的基石,虽然它本身没有直接实现同步接口,但它运用模板方法设计模式,定义了一套精妙的资源控制逻辑框架。

它内部有个关键的 int 型变量 state,用来表征锁的持有状态,初始值为 0,代表锁未被占用;还有个双向链表结构的等待队列,用来安置那些等待获取锁的线程。当线程来争抢锁时,AQS 就像一位公正的裁判,通过 acquire 和 release 等方法,有条不紊地管理着线程对资源的获取与释放,ReentrantLock 正是巧妙地基于 AQS,实现了公平锁和非公平锁两种模式,后面咱们就深入剖析这两种模式的差异。

2.2 公平锁与非公平锁的实现机制

公平锁,听名字就知道它最讲 “先来后到”。在 lock 过程中,当一个线程来请求锁时,如果发现锁处于空闲状态(也就是 state 为 0 ),它不会贸然直接抢占,而是先通过 hasQueuedPredecessors () 方法瞅瞅等待队列里有没有前辈在排队,如果没有,才用 CAS 操作去尝试把 state 置为 1,成功了就顺理成章成为锁的主人,把 exclusiveOwnerThread 设为自己。要是 state 不为 0,那就得看看当前持有锁的是不是自己,如果是,说明是重入情况,直接把 state 加 1 就行,这也体现了 ReentrantLock 的可重入特性;若不是,那对不起,乖乖去排队吧。

非公平锁可就有点 “随性” 了。它一上来就不管不顾,先尝试用 CAS 操作把 state 从 0 改成 1,要是成功了,立马把 exclusiveOwnerThread 设为自己,占住锁就开始办事,根本不看有没有线程在排队等着,这就是所谓的 “插队” 行为。要是没抢到,才会老老实实按照 AQS 的规则,进入 acquire 流程,和公平锁一样,尝试重新获取锁或者入队等待。

咱们来看点代码示例加深理解,以非公平锁的 lock 方法为例:

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
    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)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

从这段代码能清晰看到,非公平锁先 CAS 抢锁,失败了再走常规流程。而公平锁在 tryAcquire 时多了队列判断逻辑,确保顺序性。在获取锁失败后,线程会通过 addWaiter 方法封装成 Node 节点,加入到 AQS 的等待队列,接着在 acquireQueued 方法里自旋尝试获取锁,要是一直拿不到,就乖乖阻塞等待,直到被前驱节点唤醒,再次尝试争夺锁资源,整个过程环环相扣,精妙无比。

三、ReentrantLock 的特性

3.1 可重入性

可重入性可是 ReentrantLock 的一大招牌特性。简单来讲,就是同一线程能够多次获取同一把锁,不会把自己给憋死。这就好比你进自家大门,已经拿到钥匙开门进去了,要是屋里还有个房间也上锁了,你拿着这把钥匙当然能再开这个房间的门,不用再出去重新拿钥匙,多方便呐!

咱们看段代码示例就更清楚了:

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    System.out.println("第一次获取锁,执行一些操作");
    lock.lock();
    try {
        System.out.println("第二次获取锁,执行嵌套操作");
    } finally {
        lock.unlock();
    }
} finally {
    lock.unlock();
}

在这段代码里,线程先获取了锁,然后在锁内部又再次获取锁,这要是搁在不支持重入的锁那儿,早就死锁凉凉了。但 ReentrantLock 巧妙地利用了 AQS 的 state 变量来记录锁的重入次数,初始时 state 为 0,每获取一次锁,state 就加 1,释放锁时 state 减 1,当 state 变回 0 时,锁才真正被释放,如此精妙的设计,完美确保了线程嵌套获取锁的安全性,避免了死锁隐患。

3.2 可中断性

再来说说 ReentrantLock 的可中断性,这功能可太实用了。传统的锁获取方式,如果一个线程没抢到锁,就只能干巴巴地等着,要是运气不好,一直等不到,就只能耗着,资源浪费不说,还可能拖慢整个程序的节奏。

ReentrantLock 就不一样啦,它提供了 lockInterruptibly 方法,让线程在等待锁的过程中有了 “逃脱” 的机会。当多个线程竞争锁时,若某个线程调用了这个方法等待锁,而此时别的线程调用了它的 interrupt 方法,那这个等待的线程就会立马抛出 InterruptedException 异常,从阻塞中解脱出来,去干点别的事儿,避免了长时间无意义的等待,大大增强了程序的灵活性与响应性。

咱们来看个例子:

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
    try {
        lock.lockInterruptibly();
        try {
            System.out.println("线程 t1 获取到锁,执行任务");
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            System.out.println("线程 t1 被中断,停止执行");
        } finally {
            lock.unlock();
        }
    } catch (InterruptedException e) {
        System.out.println("线程 t1 获取锁时被中断");
    }
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
    t1.interrupt();
    System.out.println("线程 t2 中断线程 t1");
});
t2.start();

在这个例子里,线程 t1 尝试获取锁并执行任务,线程 t2 在 t1 等待一段时间后中断它,此时 t1 就能及时响应中断信号,终止等待,不至于一直阻塞下去,这在一些对响应及时性要求高的场景里,比如实时任务处理、用户交互响应等,简直是 “救星”。

3.3 超时获取锁

ReentrantLock 还有个超厉害的超时获取锁特性,通过 tryLock 方法来实现,而且有两种重载形式。一种是 tryLock (long time, TimeUnit unit),它允许线程在指定的时间内去尝试获取锁,如果在规定时间内成功拿到锁,那就皆大欢喜,继续往下执行;要是超时了还没抢到,也不恋战,直接返回 false,线程可以去干点别的,不至于一直傻等。另一种是不带参数的 tryLock (),它更干脆,线程调用后立即尝试获取锁,拿到就继续,拿不到立马返回 false,绝不拖沓。

这特性在实际应用里用处可大了去了。比如说在分布式系统中的资源争抢场景,多个节点同时竞争某个共享资源,要是一直死磕等锁,可能会导致整个系统卡顿。有了超时获取锁,节点等一会儿抢不到就放弃,去寻找其他替代资源,有效防止了死锁,还提升了系统整体的吞吐量与响应性能。

来看个代码示例:

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
    try {
        if (lock.tryLock(3, TimeUnit.SECONDS)) {
            try {
                System.out.println("线程 t1 在 3 秒内获取到锁,执行任务");
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("线程 t1 超时未获取到锁,放弃尝试");
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});
t1.start();

在这个例子里,线程 t1 尝试在 3 秒内获取锁,要是能抢到就顺利执行任务,要是超时还没抢到,也能优雅地退出,不影响程序整体运行,让资源分配更加合理高效。

四、ReentrantLock 的使用场景

4.1 多线程共享资源访问控制

在多线程编程里,常常会碰到多个线程同时访问共享资源的情况,这时候要是不加以管控,数据就很容易 “乱套”。比如说,多个线程同时对一个数据库进行写操作,如果没有合适的锁机制,数据一致性就没法保证,可能会出现重复写入、数据丢失等乱七八糟的问题。

ReentrantLock 在这种场景下就能大显身手。就像下面这段代码:

import java.util.concurrent.locks.ReentrantLock;
public class DatabaseWrite {
    private static final ReentrantLock lock = new ReentrantLock();
    private static int data;
    public static void writeData(int newData) {
        lock.lock();
        try {
            // 模拟数据库写操作耗时
            Thread.sleep(100); 
            data = newData;
            System.out.println("写入数据: " + data);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> writeData(1));
        Thread t2 = new Thread(() -> writeData(2));
        t1.start();
        t2.start();
    }
}

在这个例子里,多个线程想要对共享的数据库数据进行写操作时,必须先获取 ReentrantLock 锁。这样一来,同一时间就只有一个线程能拿到锁,进入写操作代码块,其他线程只能乖乖等着,等当前线程写完释放锁了,它们才有机会去争抢锁资源,进而执行写操作,如此便确保了数据的一致性,有效防止了因并发写入导致的数据混乱与错误。

4.2 生产者 - 消费者模式

生产者 - 消费者模式可是多线程编程里的经典场景,ReentrantLock 搭配 Condition 能把这模式实现得稳稳当当。

假设咱们有个有界缓冲区,生产者负责往里面生产数据,消费者负责从里面取数据消费,缓冲区满了生产者就得等着,缓冲区空了消费者就得等着,这就需要精准的线程间协作与同步。

看下面这段代码示例:

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumer {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private final Queue<Integer> buffer = new LinkedList<>();
    private static final int BUFFER_SIZE = 5;
    public void produce(int value) throws InterruptedException {
        lock.lock();
        try {
            while (buffer.size() == BUFFER_SIZE) {
                notFull.await();
            }
            buffer.add(value);
            System.out.println("生产者生产: " + value);
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    public int consume() throws InterruptedException {
        lock.lock();
        try {
            while (buffer.isEmpty()) {
                notEmpty.await();
            }
            int value = buffer.remove();
            System.out.println("消费者消费: " + value);
            notFull.signal();
            return value;
        } finally {
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();
        Thread producerThread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    pc.produce(i);
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread consumerThread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    pc.consume();
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        producerThread.start();
        consumerThread.start();
    }
}

在这段代码里,ReentrantLock 保证了同一时刻只有一个生产者或消费者能进入缓冲区操作。生产者生产数据时,先获取锁,如果缓冲区满了,就通过 notFull.await () 进入等待状态,释放锁资源,等消费者消费了数据,缓冲区有空位了,消费者会通过 notEmpty.signal () 唤醒生产者;消费者消费数据同理,缓冲区空了就等待,生产者生产了新数据就唤醒消费者,如此这般,实现了线程间高效、有序的协作,让生产者和消费者能和谐共处,稳定运行。

五、ReentrantLock 与 synchronized 的对比

5.1 功能特性对比

首先说说可中断性,这可是 ReentrantLock 的一大亮点。前面咱们提到过,它的 lockInterruptibly 方法能让线程在等锁时响应中断,及时 “止损”,避免长时间干耗。反观 synchronized,就比较 “轴” 了,一旦线程陷入等待锁的状态,那真是雷打不动,非得等到锁到手或者抛出异常才会罢休,完全没有中途 “反悔” 的机会,这在一些对响应及时性要求高的场景里,局限性就凸显出来了。

超时获取锁方面,ReentrantLock 的 tryLock 方法家族优势明显。咱可以根据实际业务需求,灵活设置超时时间,让线程等个几秒就撤,别一棵树上吊死,去寻找其他资源,大大提高了资源利用率与程序灵活性。synchronized 呢,就没这 “心眼儿”,只能傻乎乎地一直等,要是运气不好,前面的线程一直占着锁,就只能干着急,容易造成死锁隐患。

公平性设置上,ReentrantLock 能屈能伸,既可以设置成公平锁,让线程们规规矩矩按先来后到顺序排队拿锁,避免 “插队” 乱象,适合像排队买票、资源分配顺序敏感的场景;也可以选择非公平锁,追求极致性能。synchronized 就简单粗暴多了,一直奉行 “丛林法则”,默认是非公平锁,谁手快谁抢到算谁的,不管先来后到,在高并发场景下,可能会让一些线程长时间 “挨饿”,眼巴巴等不到锁资源。

还有条件变量这一块,ReentrantLock 搭配 Condition 接口,那玩法可太多了。咱们能创建多个条件变量,相当于给线程们建了不同的 “休息室”,满足不同条件时精准唤醒特定线程,实现复杂的线程协作逻辑,就像生产者 - 消费者模式里,生产者和消费者能各就各位,有序等待与唤醒。synchronized 虽说也有类似的 wait、notify 等方法实现等待 - 唤醒机制,但在灵活性上差了一大截,要么随机叫醒一个线程,要么一股脑全叫醒,容易造成资源浪费与混乱,很难满足精细化的线程管控需求。

5.2 性能对比

在低竞争环境下,synchronized 凭借 JVM 底层的优化加持,表现相当亮眼。它简单直接的加锁、解锁流程,没有过多额外开销,像一些简单的单例模式、小规模共享资源访问场景,能快速高效地保证线程安全,代码简洁又高效,让人省心省力。

可一旦进入高竞争的 “战场”,局势就发生变化了。ReentrantLock 的非公平锁开始展现实力,它允许线程插队竞争,减少线程上下文切换与等待时间,让 CPU 资源得到更充分利用,吞吐量显著提升。要是再合理搭配上自旋锁等优化策略,性能更是如虎添翼,能轻松应对高并发挑战。虽说公平锁模式下,由于要维护严格的等待队列顺序,会有些性能损耗,但在对公平性有刚需的场景里,这点代价换来的有序性也是非常值得的。

总的来说,ReentrantLock 胜在灵活多变,功能丰富,能应对各种复杂刁钻的并发场景;synchronized 则以简洁易用、低开销在简单场景站稳脚跟。在实际开发中,咱们得权衡利弊,依据业务需求、并发程度、资源特性等多方面因素,做出明智抉择,让程序在性能与功能之间找到完美平衡。

六、总结

在本篇文章中,咱们全方位探秘了 ReentrantLock 这个 Java 并发编程的得力工具。从原理上看,它依托 AQS 框架,巧妙运用 CAS 操作,实现了公平锁与非公平锁两种精妙模式,为线程资源的争夺制定了清晰规则。

特性方面,可重入性让线程在复杂的嵌套调用场景中游刃有余,避免自我阻塞;可中断性赋予线程在漫长等待中 “及时止损” 的能力,增强程序响应性;超时获取锁特性更是合理分配资源,防止死锁,提升系统吞吐量。

使用场景涵盖多线程共享资源访问控制,为数据一致性保驾护航;在生产者 - 消费者模式里,它配合 Condition 精准协调线程间的生产与消费节奏。与传统的 synchronized 关键字相比,ReentrantLock 功能更为强大、灵活,在公平性、条件变量等功能特性上优势明显,性能表现也能依据场景切换,适应不同并发强度。

总而言之,ReentrantLock 为 Java 开发者处理复杂并发任务提供了强大助力。在实际项目开发中,大家需依据具体业务场景、性能诉求,明智抉择合适的同步机制,巧用 ReentrantLock 的优势,编写出高效、稳定、健壮的多线程代码,让程序在并发的浪潮中稳健前行。希望这篇文章能成为各位在并发编程路上的实用指南,助力大家攻克多线程难题,不断提升代码质量。