为什么要使用ReentrantLock,如果可以使用synchronized(this)呢?

384

我正在尝试理解在并发中锁定的重要性,如果可以使用synchronized (this),那么是什么让锁如此重要。在下面的示例代码中,我可以执行以下任一操作:

  1. 将整个方法同步或将可脆弱区域同步(synchronized(this){...}
  2. 或者使用ReentrantLock锁定易受攻击的代码区域。

代码:

    private final ReentrantLock lock = new ReentrantLock(); 
    private static List<Integer> ints;

    public Integer getResult(String name) { 
        .
        .
        .
        lock.lock();
        try {
            if (ints.size()==3) {
                ints=null;
                return -9;
            }   

            for (int x=0; x<ints.size(); x++) {
                System.out.println("["+name+"] "+x+"/"+ints.size()+". values >>>>"+ints.get(x));
            }

        } finally {
            lock.unlock();
        } 
        return random;
}

4
顺便提一下,所有Java内置锁本质上都是可重入的。 - Aniket Thakur
@pongapundit 所以 synchronized(this){synchronized(this){//some code}} 不会导致死锁。对于内在锁,如果它们获得了资源的监视器,并且想再次获取它,它们可以在不发生死锁的情况下获取它。 - Aniket Thakur
object.lock;......;object.unlock 等同于 synchronized(this.class),它是类级别的锁,而不是对象级别的。 - kungho
8个回答

557

一个ReentrantLock非结构化的,不像synchronized构造--也就是说,您不需要使用块结构进行锁定,甚至可以跨方法持有锁。例如:

private ReentrantLock lock;

public void foo() {
  ...
  lock.lock();
  ...
}

public void bar() {
  ...
  lock.unlock();
  ...
}

这样的流程无法通过单个监视器在一个synchronized结构中表示。


除此之外,ReentrantLock 还支持 锁轮询可中断的带有超时的锁等待ReentrantLock 还支持 可配置的公平性策略, 允许更灵活的线程调度。
该类的构造函数接受一个可选的公平性参数。当设置为true时,在争用情况下,锁会优先授予等待时间最长的线程访问。否则,此锁不保证任何特定的访问顺序。使用被多个线程访问的公平锁的程序可能显示出较低的总吞吐量(即速度较慢;通常慢得多),但具有更小的获得锁的时间方差并保证没有饥饿。但请注意,锁的公平性不能保证线程调度的公平性。因此,使用公平锁的众多线程中的一个线程可能连续多次获得它,而其他活动线程则无法进展且当前未持有锁。还要注意,未计时的tryLock方法不遵守公平设置。如果锁可用,则它将成功,即使其他线程正在等待。

ReentrantLock 可能 更加可扩展,在高争用率下表现更好。您可以here了解更多信息。

然而,这一说法受到了质疑,请参见以下评论:

在可重入锁测试中,每次都会创建一个新的锁,因此不存在独占锁定,结果数据无效。此外,IBM链接没有提供基准测试的源代码,因此无法确定测试是否正确进行。


什么时候应该使用ReentrantLock?根据那篇developerWorks文章...

答案很简单——当您实际需要它提供的某些东西,而synchronized没有提供,例如定时锁等待、可中断锁等待、非块结构锁、多个条件变量或锁轮询。ReentrantLock还具有可扩展性优势,如果您实际上具有高争用情况,则应使用它,但请记住,绝大多数synchronized块几乎从不表现出任何争用,更不用说高争用了。我建议在同步方面开发,直到同步被证明是不足的,而不是简单地假设“如果您使用ReentrantLock,性能将更好”。请记住,这些是高级用户的高级工具。(真正的高级用户倾向于使用他们可以找到的最简单的工具,直到他们确信简单的工具是不足的。)与往常一样,先把它做对,然后再担心是否必须使其更快。


最后一个即将在不久的将来变得更加相关的方面与Java 15和Project Loom有关。在虚拟线程的(新)世界中,底层调度程序将能够比使用synchronized更好地使用ReentrantLock,至少在最初的Java 15版本中是如此,但以后可能会进行优化。

在当前的Loom实现中,当堆栈上存在本机帧并且在synchronized块或方法内部时,虚拟线程可以固定。在这些情况下,阻塞虚拟线程将阻塞承载它的物理线程。一旦本机调用完成或监视器被释放(退出synchronized块/方法),线程将取消固定。

如果您有一个由synchronized保护的常见I/O操作,请使用ReentrantLock替换监视器,以便在我们修复监视器固定之前让您的应用程序充分受益于Loom的可扩展性提升(或者,最好使用更高性能的StampedLock,如果可以的话)。

28
应删除“已知更具可扩展性”的链接lycog.com。在可重入锁测试中,每次都创建一个新锁,因此不存在排他锁定,导致结果数据无效。另外,IBM链接未提供基准测试的源代码,因此无法确定测试是否正确执行。个人建议删除关于可扩展性的整行内容,因为这个声明本质上是没有支持的。 - Dev
3
根据您的回复,我修改了这篇文章。 - obataku
8
如果性能对你很重要,不要忘记寻找一种无需任何同步的方法。 - mcoolive
4
性能这件事对我来说一点也不合理。如果可重入锁表现更好,那为什么不直接像可重入锁一样在内部实现 synchronized 呢? - tObi
2
@user2761895,Lycog链接中的ReentrantLockPseudoRandom代码在每次调用setSeednext时都使用全新的、无争用锁。 - obataku
显示剩余6条评论

16

ReentrantReadWriteLock 是一种专门的锁,而 synchronized(this) 是一种通用的锁。它们类似但并不完全相同。

你说得对,你可以使用 synchronized(this) 替代 ReentrantReadWriteLock,但反过来则并非总是如此。

如果您想更好地了解什么使得 ReentrantReadWriteLock 特殊,请查找一些有关生产者-消费者线程同步的信息。

通常情况下,您可以记住整个方法同步和通用目的同步(使用 synchronized 关键字)可用于大多数应用程序,无需过多考虑同步的语义,但如果您需要从代码中挤出性能,您可能需要探索其他更精细的或特殊目的的同步机制。

顺便提一下,使用 synchronized(this) - 以及使用公共类实例进行锁定 - 可能会存在问题,因为它会打开您的代码,由于某人在程序中的其他位置不知情而尝试针对您的对象进行锁定,从而导致潜在死锁。


3
为防止由于其他地方的锁定而导致潜在的死锁,可以使用一个私有的Object实例作为同步监视器。像这样: private final Object protectedLongLockingMonitor = new Object(); private long protectedLong = 0L; public void incrementProtectedLong() { synchronized(protectedLongLockingMonitor) { protectedLong++; } } }``` - sushicutta

13
一个使用ReentrantLock的简单示例。
import java.util.concurrent.locks.ReentrantLock;

class ReentrantLockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void safeMethod1() {
        try {
            lock.lock();
            /* your business logic goes here */

        } finally {
            lock.unlock();
        }
     }
}

来自Oracle关于ReentrantLock的文档页面:

可重入互斥锁,具有与使用同步方法和语句访问的隐式监视器锁相同的基本行为和语义,但具有扩展功能。

此类的构造函数接受一个可选的公平性参数。当设置为true时,在争用情况下,锁倾向于授予最长等待的线程访问权。否则,此锁不保证任何特定的访问顺序。

ReentrantLock关键API比synchronized更好

  1. lockInterruptibly():除非当前线程被中断,否则获取锁。
  2. tryLock():仅在调用时锁未被其他线程持有时才获取锁。
  3. tryLock(long timeout, TimeUnit unit):如果在给定的等待时间内锁没有被其他线程持有并且当前线程没有被中断,则获取锁。此 API 避免了使用 synchronized 时出现的线程饥饿问题。
  4. public ReentrantLock(boolean fair):公平策略的可重入锁。

6

同步锁并不提供等待队列机制,因此在一个线程执行后,任何正在并行运行的线程都无法获取锁。这导致系统中长时间运行的线程永远无法访问共享资源,从而导致饥饿现象。

可重入锁非常灵活,并且具有公平策略,如果一个线程等待的时间较长,那么在当前执行的线程完成后,我们可以确保更长时间等待的线程有机会访问共享资源,从而降低了系统的吞吐量,增加了时间消耗。


4

您可以使用具有公平策略或超时的可重入锁来避免线程饥饿。您可以应用线程公平策略,这将有助于避免线程无限期地等待获取您的资源。

private final ReentrantLock lock = new ReentrantLock(true);
//the param true turns on the fairness policy. 

“公平策略”选择下一个可运行的线程来执行。它基于优先级、上次运行时间等因素。

另外,如果同步块无法退出,则Synchronize可能会无限期地阻塞。Reentrantlock可以设置超时。


3

需要记住的一件事是:

名称“ReentrantLock”传达了一个错误的信息,即其他锁定机制不是可重入的。这是不正确的。在Java中,通过“synchronized”获取的锁也是可重入的。

关键区别在于,“synchronized”使用内部锁(每个对象都有一个)而Lock API则不使用。


0

我认为wait/notify/notifyAll方法不应该放在Object类中,因为这会使得所有对象都带有很少使用的方法。它们更适合放在专门的Lock类中。从这个角度来看,也许最好使用一个明确设计用于当前工作的工具 - 即ReentrantLock。


-1
假设这段代码正在一个线程中运行:
private static ReentrantLock lock = new ReentrantLock();

void accessResource() {
    lock.lock();
    if( checkSomeCondition() ) {
        accessResource();
    }
    lock.unlock();
}

因为线程拥有锁,它将允许多次调用lock(),从而重新进入锁定状态。这可以通过引用计数来实现,因此它不必再次获取锁。


2
一个synchronized块具有完全相同的可重入行为(引用计数),这不是ReentrantLock的优势/特性之一。 - AndrewF

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接