在上文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
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!
更多文章请关注我的公众号《懒惰蜗牛工坊》