Day38 | Java中更灵活的锁ReentrantLock

34 阅读8分钟

在上文Day37 | 线程安全与synchronized中,我们了解了怎么使用synchronized关键字来解决线程安全问题。

synchronized算是Java内置的一把简单好用的安全锁,学习的成本比较低,在很多场景下都可以用他。

但是简单易用,在某些特定的场景下,也会成为他的限制,复杂的并发场景里,synchronized就会显现出他的笨重和功能不足。

今天,我们一起看下java.util.concurrent包里的一个重量级成员——ReentrantLock。

他不仅能完成synchronized的所有工作,还提供了更多高级、灵活的功能。

看完本文,你应该能够清晰的理清synchronized和ReentrantLock的区别,并能在合适的场景下做出更好的选择。

一、什么是ReentrantLock

reentrant这个英文的意思就是可重入的,可再入的。所以字面翻译,ReentrantLock是一个可重入的锁。

他是java.util.concurrent.locks.Lock接口的一个强大实现。

synchronized是个Java中的关键字,而ReentrantLock是一个类。

从本质上来看,ReentrantLock作为一个普通类,可以通过实例化来灵活控制加锁与释放的时机,这样的方式对于开发者来说,主动权都掌握在自己手里。

相比之下,synchronized只能通过固定的语法形式(修饰方法或代码块)实现锁机制,他的行为完全由JVM管理,虽然使用更简洁,但在灵活性和功能扩展上存在一定限制。

下面我们通过案例直观的理解下锁的可重入性:

package com.lazy.snail.day38;

/**
 * @ClassName Day38Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/30 13:44
 * @Version 1.0
 */
public class Day38Demo {
    public synchronized void methodA() {
        System.out.println(Thread.currentThread().getName() + " 调用 methodA");
        methodB();
    }

    public synchronized void methodB() {
        System.out.println(Thread.currentThread().getName() + " 调用 methodB");
    }

    public static void main(String[] args) {
        new Day38Demo().methodA();
    }
}

示例代码中methodA和methodB都是synchronized修饰的实例方法。

main线程调用methodA的时候获取了锁,接着在methodA内部调用methodB。

因为synchronized是可重入的,所以线程可以直接调用methodB而不会造成死锁。

注意synchronized修饰实例方法,锁的是当前实例对象,类似synchronized(this)。

package com.lazy.snail.day38;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @ClassName Day38Demo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/30 13:49
 * @Version 1.0
 */
public class Day38Demo2 {
    private final Lock lock = new ReentrantLock();

    public void methodA() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 调用 methodA");
            methodB();
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 调用 methodB");
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new Day38Demo2().methodA();
    }
}

示例代码中创建了ReentrantLock的实例对象lock。其中lock()方法表示上锁,unlock()方法表示释放锁。

进入methodA方法后,在没有释放锁之前,调用了methodB方法,而methodB方法中也存在加锁的过程。

程序运行并没有导致死锁,同样说明了ReentrantLock的可重入性。

二、ReentrantLock的使用及与synchronized的差异

2.1 直观使用感受

通过上面的代码示例,其实我们可以直观的感受到二者使用的差异。

synchronized作为关键字,加锁和释放锁是隐式的,由JVM自动完成。代码块结束或方法返回时,锁会自动释放。

而ReentrantLock作为一个Java类,加锁和释放锁是显式的,需要我们手动调用lock()和unlock()方法。

ReentrantLock的unlock()操作必须放在finally代码块里。这样做不管代码有没有异常,锁都会释放,才能避免死锁。

Lock lock = new ReentrantLock();
lock.lock();
try {
    // ......
} finally {
    lock.unlock();
}

2.2 可中断

如果有一个线程长时间的等待一个synchronized锁,他是没办法被中断的,只能一直傻等。

而ReentrantLock提供了可以中断的锁获取方式。

ReentrantLock提供了lockInterruptibly()方法。

调用这个方法之后,如果线程在等待锁的过程中,其他线程对他发出了中断信号(调用了thread.interrupt()),他就不会再等待了,抛出InterruptedException异常,可以执行后面的补救或退出逻辑。

package com.lazy.snail.day38;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @ClassName Day38Demo3
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/30 14:04
 * @Version 1.0
 */
public class Day38Demo3 {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println("线程1 尝试获取锁...");
                lock.lockInterruptibly();
                try {
                    System.out.println("线程1 成功获取到锁");
                    TimeUnit.SECONDS.sleep(5);
                } finally {
                    lock.unlock();
                    System.out.println("线程1 释放了锁");
                }
            } catch (InterruptedException e) {
                System.out.println("线程1 在等待时被中断了!");
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                System.out.println("线程2 尝试获取锁...");
                lock.lockInterruptibly();
                try {
                    System.out.println("线程2 成功获取到锁");
                } finally {
                    lock.unlock();
                    System.out.println("线程2 释放了锁");
                }
            } catch (InterruptedException e) {
                System.out.println("线程2 在等待时被中断了!");
            }
        });

        thread1.start();
        TimeUnit.MILLISECONDS.sleep(100);
        thread2.start();

        TimeUnit.SECONDS.sleep(1);
        System.out.println("主线程中断线程2...");
        thread2.interrupt();
    }
}

示例代码中,我们想模拟thread1先拿到了锁lock,thread2启动起来也想去拿锁lock,但是发现锁被thread1拿着(sleep(5):thread1要拿着锁5秒),thread2就一直等着。

等待期间主线程给thread2发了个中断的信号,thread2收到信号之后就直接抛出了InterruptedException,直接结束了。

thread1没受到任何影响,拿着锁sleep5秒后,正常结束退出。

代码示例中其实有两个小细节,也是我们在调试或者模拟并发问题的常用技巧。

TimeUnit.MILLISECONDS.sleep(100);这行代码主要是给thread1一点提前量,让thread1能有更高的概率先执行。如果没有这个提前量,两个线程几乎同时启动,谁先拿到锁就不一定了。

TimeUnit.SECONDS.sleep(1);这行代码主要是不想让主线程太早的调用thread2.interrupt(),如果thread2还没通过lockInterruptibly()进入等待状态,主线程就发了中断信号,那可能根本就没法响应这个中断信号了。

输出结果:

你可以尝试将lockInterruptibly方法换成lock方法,看看会发生什么。

2.3可限时的锁获取

synchronized采取的是不撞南墙不回头的机制,他会一直等锁。

ReentrantLock提供了tryLock(long timeout, TimeUnit unit)方法。

他会尝试在给定的时间内获取锁,如果成功,返回true;

如果超时还没获取到,返回false,线程可以继续去做别的事情,而不是无限期的等待。

package com.lazy.snail.day38;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @ClassName Day38Demo4
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/30 14:32
 * @Version 1.0
 */
public class Day38Demo4 {
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("线程1 获取了锁,并持有10秒");
                try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) {}
            } finally {
                lock.unlock();
                System.out.println("线程1 释放了锁");
            }
        }).start();

        try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) {}

        new Thread(() -> {
            System.out.println("线程2 尝试获取锁...");
            try {
                if (lock.tryLock(2, TimeUnit.SECONDS)) {
                    try {
                        System.out.println("线程2 成功获取了锁!");
                    } finally {
                        lock.unlock();
                    }
                } else {
                    System.out.println("线程2 等待了2秒后,获取锁失败,不等了。");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

示例代码中,第一个线程先启动,调用lock.lock(),成功获得锁,并持有10 秒(sleep(10))。

第二个线程100ms后启动,调用带参tryLock方法,这个方法只会等2秒钟。

由于第一个线程要持有锁10秒钟,第二个线程在2秒内肯定是拿不到锁的,自然会走到else分支。

在多线程竞争激烈、不能无限等待锁资源的场景里,tryLock(timeout) 是非常实用的限时抢锁机制。

可以很好的防止因线程长时间等待锁而导致的系统卡顿或死锁。

2.4公平性选择

synchronized是非公平锁。当锁被释放的时候,JVM可以从等待队列里随机挑选一个线程(或者新来的线程直接插队)来获取锁,这种方式吞吐量高,但可能导致某些线程长时间获取不到锁,这就是我们常说的线程饥饿。

ReentrantLock给我们提供了两种选择,一种非公平锁,一种公平锁。

无参构造默认创建非公平锁,有参构造可以通过参数选择是否创建公平锁。

公平锁会按照线程请求的先后顺序来分配锁,就像排队买票,先到先得,不会产生饥饿现象。

但既然要公平肯定是要付出一定代价的,公平性需要额外的机制来维护顺序,所以通常性能会低于非公平锁。

三、选择

前文我们也提到了,自Java1.6之后,JVM对synchronized进行了大量优化(如偏向锁、轻量级锁、自适应自旋等)。

基于这些对synchronized的优化,不管是在无竞争或低竞争下场景下,synchronized与ReentrantLock的性能差距已经微乎其微。

当前的开发场景中,对于synchronized和ReentrantLock的选择,主要依据是功能需求,而不是性能。

如果锁的竞争不激烈,或者业务逻辑非常简单,不需要任何高级功能,那么synchronized是首选。

他的语法更简洁,不容易出错,而且JVM的持续优化也让他的性能非常可靠。

如果你需要能够响应中断、能够设置获取锁的超时时间、能实现公平锁或者需要在一个锁上创建多个等待/通知的条件队列,那就选择ReentrantLock。

结语

今天,我们通过synchronized和ReentrantLock对比学习,又了解了一个新的锁机制的实现ReentrantLock。

其实通过上面的对比,我们可以简单的把synchronized看成是自动挡汽车,好开容易上手,很多东西都交给了JVM。

而ReentrantLock有点像手动挡的汽车,操作起来稍微复杂了点,但是操作体验感和自由度肯定也要好一些。

下一篇预告

Day39 | Java线程通信的两种方式wait/notify和Condition

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》