五、详解ReentrantLock

目录

死锁

ReentrantLock与Synchronized对比

源码分析

Lock接口

lock()实现

NonfairSync

tryAcquire()

addWaiter()

acquireQueued()

FairSync

tryAcquire()

NonfairSync和FairSync的本质区别

tryLock()实现

unlock()实现

Condition实现

await()

signal()和signalAll()


死锁

死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法继续执行下去。最经典的问题就是哲学家就餐问题:

假设有五位哲学家围坐在圆桌旁,进行思考和进餐。每位哲学家面前都有一碗面条,而吃面条需要用到两只筷子。每位哲学家的左右两边各有一只筷子,总共就是五只筷子。

哲学家只有在拿到左右两边的筷子后才能吃面,吃完后再把筷子放下,继续思考。  这个问题的关键在于,如果每位哲学家都先拿起自己左边的筷子,然后等待右边的筷子,那么就会出现死锁的情况,因为每位哲学家都在等待别人放下筷子,但是没有人会放下筷子。

解决死锁问题的方法有以下几种:

1、按照顺序加锁:可以针对筷子进行编号,所有哲学家必须按照编号从小到大获取筷子。

2、尝试加锁:利用trylock的思想,先拿起一根筷子,然后尝试拿下一个筷子,如果拿到了就吃饭,如果拿不到,就释放所有的筷子。

3、超时机制:让线程不要一直等待,一段时间内不能满足要求,则放弃所有的资源。

ReentrantLock与Synchronized对比

Synchronized详细看:三、详解Synchronized-CSDN博客

ReentrantLock是Java并发包java.util.concurrent.locks中的一个类,它实现了Lock接口,提供了与synchronized关键字类似的互斥锁功能。ReentrantLock的名字来源于它是一个可重入锁,也就是说,一个线程可以多次获取同一个ReentrantLock锁,每次获取锁时,计数器会递增,每次释放锁时,计数器会递减,当计数器为0时,锁被释放。

ReentrantLock相比于synchronized关键字,提供了更高的灵活性和更多的功能,例如:  

1. 可以显式地获取和释放锁,这使得锁的范围更加灵活,可以跨越多个方法或代码块。

2. 支持公平锁和非公平锁。公平锁是指等待时间最长的线程将优先获得锁,非公平锁则没有这个限制。默认情况下,ReentrantLock是非公平锁,但可以在构造函数中传入参数true来创建公平锁。

3. 提供了tryLock()方法,可以尝试获取锁,如果获取不到锁,线程可以继续执行其他任务,而不是一直等待。

4. 支持中断。当一个线程在等待锁时,可以被其他线程中断,这样可以避免死锁问题。

一个使用ReentrantLock的使用demo:

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

public class ReentrantLockExample {
    private final Lock lock = new ReentrantLock();

    public void method1() {
        lock.lock(); // 获取锁, 此时当前线程会一直等待,直到获取锁为止
        try {
            // 临界区代码
        } finally {
            lock.unlock(); // 释放锁
        }
    }

    public void method2() {
        if (lock.tryLock()) { // 尝试获取锁,可能成功,也可能失败
            try {
                // 临界区代码
            } finally {
                lock.unlock(); // 释放锁
            }
        } else {
            // 未获取到锁,执行其他任务
        }
    }
}

源码分析

Lock接口

想要学习ReentrantLock的源码,就必须先搞明白ReentrantLock类的继承关系。在idea上查看继承关系如下:

从继承关系上来看,ReentrantLock也就是简单的实现了Lock接口。那我们就先看一下这个Lock接口到底都包含啥:

public interface Lock {

    /**
     加锁
     */
    void lock();

    /**
      可打断加锁
     */
    void lockInterruptibly() throws InterruptedException;

    /**
     尝试加锁
     */
    boolean tryLock();

    /**
     在一定时间范围内尝试加锁
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    /**
     解锁
     */
    void unlock();

    /**
     返回一个锁的条件
     */
    Condition newCondition();
}

从上面的代码来看,Lock的接口提供的功能非常简单就是加锁、解锁、还有一个类似于wait方法的条件。

那么接下来看一下,在ReentrantLock中,是如何实现这个Lock接口的。

lock()实现

首先看一下核心的lock()方法是如何实现的呢?我们定位到ReentrantLock的lock()方法:

public void lock() {
        sync.acquire(1);
}

发现内部实际上是调用了一个变量sync的acquire方法。那么这个sync变量是啥呢?什么时候给赋值的呢?

跟踪代码,看到sync是ReentrantLock类中的一个属性变量,而属性变量一般都会在构造方法中赋值,因此,查看ReentrantLock的构造方法:

    /**
     *返回一个非公平锁
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     *返回公平锁或者非公平锁
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

这里可以看出来,当我们执行构造方法时,默认创建了一个NonfairSync对象。也就是非公平锁,

那么具体什么是公平锁,什么是非公平锁,这里简单描述就是:公平指的是先来的线程先获取到锁,后来的线程后获取到锁。而非公平指的是不管先来后来,后来的锁也可以先获取到锁。后面我们详细解释是如何做到的。

好了,从上面的构造函数来看,有多了 FairSync和NonfairSync这个两个类。我们还是先看一下这个两个类的继承结构。

FairSync:

NonfairSync:

从图上看,这个两个类的继承关系一样,那么为什么需要设置两个类呢?这个两个类到底哪些地方不同呢?

NonfairSync

我们先来分析一下非公平锁,这个也是ReentrantLock默认的实现。当调用lock()方法的时候,实际上是调用了NonfairSync.acquire()

具体看NonfairSync.acquire() 是什么逻辑,这个acquire在NonfairSync类中本身是没有提供的,但是由于他继承了父类,那么acquire肯定在其父类AbstractQueueSynChronizer。

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}

在这个方法中,有三个重要的方法: 

1、tryAcquire :尝试去获取, 如果为true,则直接返回,那就意味着lock方法返回,继续执行,

如果为false,那么执行addWaiter方法

2、addWaiter:将线程添加到同步

3、acquireQueued:从同步队列中获取一个线程来执行

tryAcquire()

那么我们先看tryAcquire是干啥的,继续跟踪,发现这个方法居然是protected的,这就意味着这个方法应该交由子类实现

 protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
}

而当前AQS的子类就是我们的NonfairSync。那么就看一下NonfairSync中的tryAcquire到底是干啥呢?

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) // overflow
                throw new Error("Maximum lock count exceeded");
           setState(nextc);
           return true;
       }
       return false;
}

我们尝试理解一下这段逻辑:

1、获取当前执行的线程,这个线程就是一开始调用lock.lock()那个线程。

2、获取状态,

3、如果状态为0, 那么就尝试利用cas的方式将0设置为acquire,这里acquire就是1(画外音:利用state为0表示无锁,<0表示有锁。)

4、如果说cas成功,那么就设置ExclusiveOwnerThread为自己当前线程,然后返回true。(画外音:如果cas成功,那么意味加锁成功,并且将【持有锁线程】设置为当前线程,然后返回true)

5、如果说状态不是0,那么判断ExclusiveOwnerThread是否为自己,如果是自己那么就将状态值加1 (画外音:如果现在已经有别的线程加了锁,那么先判断一下,【持有锁线程】是否为自己,如果为自己,那么就将state累计加1,表示当前线程重入的次数

6、如果上述都不满足则返回false。(画外音:如果有线程加锁,而且加锁的线程不是当前线程,那么就返回false)

addWaiter()

接下来看一下addWaiter方法。

private Node addWaiter(Node mode) {
        Node node = new Node(mode);

        for (;;) {
            Node oldTail = tail;
            if (oldTail != null) {
                node.setPrevRelaxed(oldTail);
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return node;
                }
            } else {
                initializeSyncQueue();
            }
        }
    }

尝试分析:

1、先创建一个node

2、不断死循环,判断同步队列是否有一个队尾,如果有,则利用cas将当前的node插入到同步队列,之后break。

3、如果原始同步队列没有队尾,那么就初始化一个队列,然后再次进入循环。

好,到此为止,我们整理一下,lock方法到底干了啥:

1、先尝试加锁,也就是cas去设置state,如果加锁成功,那么就执行lock之后的代码,如果加锁失败,则创建一个node,并加入到同步队列。

acquireQueued()

最后一步,执行acquireQueued(), 继续跟踪这个方法的逻辑:

final boolean acquireQueued(final Node node, int arg) {
        boolean interrupted = false;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node))
                    interrupted |= parkAndCheckInterrupt();
            }
        } catch (Throwable t) {
            cancelAcquire(node);
            if (interrupted)
                selfInterrupt();
            throw t;
        }
    }

继续分析:

1、进入一个死循环

2、获取到刚刚新创建的node的前驱节点。如果说前驱节点为head,那么就去再次执行tryAcquire()。(画外音:如果前驱节点为head,那就意味着当前新创建的node前面没有任何被阻塞的节点了,那么当前线程就应该去继续尝试加锁)

3、如果加锁成功了,则把当前node设置为head,然后返回。注意这里的返回,意味着lock方法已经结束了,可以执行lock里面的临界区代码了

4、如果说当前node的前驱节点不是head,那么当前线程就会被park住,等待。这就意味着lock方法被阻塞,线程等待锁。

到此,应该彻底明白了lock方法到底都干啥了:

1、尝试加锁(tryAcquire),如果成功,则直接返回,执行lock后面的代码。

2、如果失败,将node添加到同步队列(addWaiter)

3、之后当前线程一直死循环去判断当前node是否为head,如果为head,继续尝试加锁(tryAcquire),如果成功则直接返回,执行lock后面代码。如果失败,那么就被park,等待其他线程唤醒。

FairSync

上面分析了非公平锁的加锁逻辑,现在看看公平锁的加锁逻辑,到底是如何做到公平的。

tryAcquire()

跟踪一下这个公平的锁tryAcquire()

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

继续分析:

1、获取当前线程

2、是否有锁,如果没有锁,先判断当前等待队里中是否已经有了node。如果说有了node,那么就直接返回加锁失败,如果之前没有node,则尝试加锁,并设置【锁持有线程】

3、如果有锁,判断【持有锁的线程】是否为当前线程,如果是,则state加1,返回加锁成功

4、如果有锁且不是当前线程持有锁,那么返回加锁失败。

NonfairSync和FairSync的本质区别

对比两个类的traAcquire方法可以看出来,当发现state=0(没有锁的时候),两个类的加锁方式不同:
1、非公平:当前线程直接尝试加锁

2、公平:先看一下,同步队列中是否已经有其他线程在等待锁,如果有,那么当前线程放弃加锁,直接去排队。如果没有,才开始加锁。

tryLock()实现

tryLock和lock本质的区别就是:trylock一次尝试加锁,不论成功与否就直接返回了,而lock多次加锁,成功就返回,失败了就死循环一个尝试。

public boolean tryLock() {
        return sync.nonfairTryAcquire(1);
}

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

可以看到tryLock的方法中直接就调用了tryAcquire,而不需要调用addWaiter和accquireQueued。因此改方法成功与否都会直接返回。

unlock()实现

unlock方法就是解锁,解锁的话就不需要区分公平不公平了,因为执行解锁的线程就是当前持有锁的线程。

public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
}

尝试分析一下:

1、调用tryRelease方法。这个方法也是一个需要被子类实现的方法。这个道理很简单,因为tryAcquire方法是子类实现的,那么解锁的tryRelease的方法也应该是子类实现。因为不通子类加锁和解锁的逻辑是不一样的。

2、获取到head节点,unpark等待的其他线程。

看一下是如何进行tryRelease的:

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

分析:

1、将锁的重入次数减去release,当前release=1。意味着当前持有线程的重入减1

2、判断当前【锁持有线程】是否和当前线程一样,如果不一样那就报错,这里一般来说肯定是一样的,因为只有lock加锁成功的线程才会向下执行,才会执行到unlock方法。

3、如果state=0,将【锁持有线程】设置为null。意味着没有锁了

4、之后直接setState,这里没有用cas,因为解锁的逻辑只有一个线程执行,不会冲突。

当前线程解锁之后,如何通知其他等待的线程继续来抢锁呢?答案就是在unparkSuccessor方法中。

private void unparkSuccessor(Node node) {
    
    int ws = node.waitStatus;
    if (ws < 0)
        node.compareAndSetWaitStatus(ws, 0);
    //知道这里为什么唤醒的node.next吗,因此node是当前线程,next才是要被唤醒的线程。
    //具体可以看一下,addWaiter()方法,初始的时候head是一个dummyNode, 
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node p = tail; p != node && p != null; p = p.prev)
            if (p.waitStatus <= 0)
                s = p;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

核心的逻辑就是LockSupport.unpark()。 当此行代码执行,那么在同步队列中等待的第一个node中的线程将被唤醒,然后执行accquireQueued中的那个死循环。

到此为止,ReentrantLock的加锁和解锁就讲完了。总结:

1、ReentrantLock中有两种锁,一种公平,一种非公平,默认是非公平。两者的区别就是在加锁的时候判断是否需要排队。

2、加锁的时候,利用cas将一个state变量设置为1,然后将【锁持有线程】设置为自己。重入的时候,将state累计加1.

3、加锁失败了之后,会封装成一个node,然后将node插入到同步队列的尾部。之后执行for循环,一直不断地尝试加锁,如果加锁失败,则park住,让出cpu。

4、解锁流程就是将state不断地减1,一旦state减为0,那就意味着线程所有重入逻辑结束,直接释放【锁持有线程】,并唤醒同步队列中的其他线程。

Condition实现

Condition是一个接口主要包含了一下几个方法:
 

public interface Condition {

    /**
     等待
     */
    void await() throws InterruptedException;
    /**
     打断等待
     */
    void awaitUninterruptibly();

    /**
     超时等待
     */
    long awaitNanos(long nanosTimeout) throws InterruptedException;

    /**
     超时等待
     */
    boolean await(long time, TimeUnit unit) throws InterruptedException;

    /**
     唤醒
     */
    void signal();

    /**
     全部唤醒
     */
    void signalAll();
}

这个方法用于使当前线程等待,直到它被其他线程唤醒,或者被中断。

常见的方法为 await()和signal()。在调用await()方法之前,线程必须持有与Condition相关联的Lock。调用await()方法后,线程会释放这个锁,并进入等待状态。当其他线程调用Condition的signal()或signalAll()方法时,等待的线程之一会被唤醒并重新获取锁。

下面给一个简单demo:
 

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

lock.lock();
try {
    while (!conditionMet()) {
        condition.await(); //等待
    }
    // 执行一些操作
} catch (InterruptedException e) {
    // 处理中断
} finally {
    lock.unlock();
}

await()

我们首先分析一下await方法输入

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //将当前节点加入到等待队里
    Node node = addConditionWaiter();
    //释放当前线程的所有锁资源
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
        //挂起当前线程
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }

    //当线程被唤醒之后,开始尝试获取锁,进行重新加锁
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

分析这段逻辑:

1、先将自己的node加入到等待队列中。这里流程和链表添加node一样,不需要加锁,因为此时只有持有锁的线程才会执行到这一步

2、释放当前线程的所有锁资源。在这里,释放资源之后,会唤醒其他同步队里的线程。

3、将当前线程park住

4、如果当前线程被其他线程unpark,则开始重新尝试加锁(acquireQueued方法)

这里有几个细节需要注意:

1、当线程被唤醒之后,为什么没有看到 addWaiter()这一步呢。acquireQueued方法只是循环去判断当前node是否为head,但是什么时候node被迁移到同步队列中去的呢?答案在signal中。

2、当fullyRelease(node)返回了savedState表示当前线程重入了几次。当释放完所有资源之后,state=0,唤醒其他线程去抢锁。当当前线程被唤醒之后,去重新加锁的时候将savedState传递给了acquireQueued方法,使得state又变成了原来的数值

signal()和signalAll()

这两个方法的本质区别在于是唤醒一个线程,还是唤醒所有线程。

        private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&
                     (first = firstWaiter) != null);
        }


        private void doSignalAll(Node first) {
            lastWaiter = firstWaiter = null;
            do {
                Node next = first.nextWaiter;
                first.nextWaiter = null;
                transferForSignal(first);
                first = next;
            } while (first != null);
        }

对比两者之间的逻辑:

singalAll:先唤醒当前node,然后while里面在唤醒next,一直循环,直到队尾

signal:先将当前节点和队列断开,然后去唤醒,如果唤醒成功,则直接break这个while。如果唤醒失败,那么继续唤醒等待队列中的下一个节点,直到成功唤醒一个节点或等待队列为空。

重点看一下这个transferForSignal方法:

final boolean transferForSignal(Node node) {
        
        if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
            return false;

        //这一步解决之前的疑问,signal的时候不单单去执行唤醒,还执行插入同步队列
        Node p = enq(node); 
        int ws = p.waitStatus;
        if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
            LockSupport.unpark(node.thread); //唤醒当前线程
        return true; 
    }

这个方法先利用循环cas的方式将node迁移到同步队列中,然后再唤醒当前线程。之后线程的执行逻辑就是从await方法的park函数返回,执行加锁逻辑。

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
重入锁(ReentrantLock)是一种独占锁,也就是说同一时间只能有一个线程持有该锁。与 synchronized 关键字不同的是,重入锁可以支持公平锁和非公平锁两种模式,而 synchronized 关键字只支持非公平锁。 重入锁的实现原理是基于 AQS(AbstractQueuedSynchronizer)框架,利用了 CAS(Compare And Swap)操作和 volatile 关键字。 重入锁的核心思想是“可重入性”,也就是说如果当前线程已经持有了该锁,那么它可以重复地获取该锁而不会被阻塞。在重入锁内部,使用了一个计数器来记录当前线程持有该锁的次数。每当该线程获取一次锁时,计数器就加 1,释放一次锁时,计数器就减 1,只有当计数器为 0 时,其他线程才有机会获取该锁。 重入锁的基本使用方法如下: ```java import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockTest { private static final ReentrantLock lock = new ReentrantLock(); public static void main(String[] args) { new Thread(() -> { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " get lock"); Thread.sleep(1000L); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); System.out.println(Thread.currentThread().getName() + " release lock"); } }, "Thread-1").start(); new Thread(() -> { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " get lock"); } finally { lock.unlock(); System.out.println(Thread.currentThread().getName() + " release lock"); } }, "Thread-2").start(); } } ``` 在上面的示例代码中,我们创建了两个线程,分别尝试获取重入锁。由于重入锁支持可重入性,因此第二个线程可以成功地获取到该锁,而不会被阻塞。当第一个线程释放锁后,第二个线程才会获取到锁并执行相应的操作。 需要注意的是,使用重入锁时一定要记得在 finally 块中释放锁,否则可能会导致死锁的问题。同时,在获取锁时也可以设置超时时间,避免由于获取锁失败而导致的线程阻塞问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值