绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
Java 可重入锁的那些事(一)
2022-08-23 15:14:13

本文主要包含的内容:可重入锁(ReedtrantLock)、公平锁、非公平锁、可重入性、同步队列、CAS等概念的理解

显式锁🔒

上一篇文章提到的synchronized关键字为隐式锁,会自动获取和自动释放的锁,而相对的显式锁则需要在编程时指明何时获取锁,何时释放锁。

通常,锁提供对共享资源的独占访问:一次只能有一个线程可以获取锁,并且对共享资源的所有访问都需要先获取锁;而有一些锁可能允许并发访问共享资源。

本文主要讲解可重入锁(ReentrantLock),该锁为独占共享资源锁,即独占锁。

1.可重入锁(ReentrantLock)

可重入锁指的是同一个线程可无限次地进入同一把锁的不同代码,又因该锁通过线程独占共享资源的方式确保并发安全,又称为独占锁

举个例子:同一个类中的synchronize关键字修饰了不同的方法。synchronize是内置的隐式的可重入锁,例子中的两个方法使用的是同一把锁,只要能执行testB()也就说明线程拿到了锁,所以执行testA()方法就不用被阻塞等待获取锁了;如果不是同一把锁或非可重入锁,就会在执行testA()时被阻塞等待。

public class Demo {

    public synchronized void testA(){
        System.out.println("执行测试A");
    }

    public synchronized void testB(){
        System.out.println("执行测试B");
        testA();
    }

}

1.1.可重入锁的类图关系

ReentrantLock实现了Lock接口和Serializable接口(都没画出来),它有三个内部类(SyncNonfairSyncFairSync),Sync是一个抽象类,它继承 AbstractQueuedSynchronizer 抽象同步队列,同时有两个实现类(NonfairSyncFairSync),其中父类AQS是个模板类提供了许多以锁相关的操作,子类分别是两种不同的获取锁实现(非公平锁和公平锁)。AQS 又继承了AbstractOwnableSynchronizer类,AOS用于保存锁被独占的线程对象。


ReentrantLock 类的构造方法有如下两种,很显然,在对象实例化时将决定同步器Sync是公平还是非公平。

// ReentrantLock类

private final Sync sync;
// 默认非公平
public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

先关注ReentrantLock类的方法lock() 和 unlock()。从源码可以发现ReentrantLock类的方法是交给内部类Sync 类来实现,而lock()方法在Sync类中是个抽象方法,具体实现在子类FairSync和NonfairSync类。其实ReentrantLock类中的其他方法也是交给Sync类去处理的,所以想要理解ReentrantLock类的重点是理解Sync类。

注意一个点:Sync类中lock()抽象方法不是Lock接口的抽象方法,它们是通过调用(如下👇)代码产生关联的。

// java.util.concurrent.locks.ReentrantLock类

public void lock() {
    sync.lock();
}
public void unlock() {
    sync.release(1);
}

结论一:

  • ReentrantLock 可重入锁获取锁有两种实现:公平和非公平;注意:从类图关系我们可以知道,公平和非公平内部类只有两个方法,都是与获取锁有关,公平与否仅针对获取锁而言,也即是lock()方法。PS:tryAcquire(int)终会被lock()调用。

  • ReentrantLock的理解重点源码应该关注内部同步器Sync类和Sync的父类抽象同步队列AbstractQueuedSynchronizer。

1.2.怎么使用ReentrantLock

使用案例:并发安全访问共享资源

public class LockDemo {
    public static void main(String[] args) {
        // 简单模拟20人抢优惠
        for(int i=;i<20;i++){
            new Thread(new ThreadDemo()).start();
        }
    }

}
// 前十位可以获取优惠,凭号码兑换优惠
class ThreadDemo implements Runnable{
    private static Integer num = 10;
    private static final ReentrantLock reentrantLock = new ReentrantLock();
    @Override
    public void run() {
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 获取锁
        reentrantLock.lock();
        try {
            if(num<=){
                System.out.println("已被抢完,下次再来");
                return;
            }
            System.out.println(Thread.currentThread().getName()+"用户抢到的号码:"+num--);
        }finally {
            // 释放锁
            reentrantLock.unlock();
        }

    }
}

执行结果:

Thread-18用户抢到的号码:10

Thread-14用户抢到的号码:9

Thread-15用户抢到的号码:8

Thread-4用户抢到的号码:7

Thread-1用户抢到的号码:6

Thread-19用户抢到的号码:5

Thread-11用户抢到的号码:4

Thread-17用户抢到的号码:3

Thread-16用户抢到的号码:2

Thread-13用户抢到的号码:1

已被抢完,下次再来

已被抢完,下次再来

……

常用的一些方法

方法名称描述
void lock()获取锁
boolean tryLock()尝试获取锁,调用该方法不会阻塞,会立即返回获取结果,获取到则返回true,获取不到则返回false
boolean tryLock(long timeout, TimeUnit unit)尝试在阻塞的指定时间内获取锁
void lockInterruptibly()获取锁,除非当前线程是interrupted,即发生中断时,结束锁的获取
void unlock()释放锁
boolean isHeldByCurrentThread()查询此锁是否由当前线程持有
boolean isLocked()查询此锁是否由任何线程持有

2.一些概念的理解

2.1.锁和同步队列的关系

前面讲述过:ReentrantLock类的方法都是交给内部类Sync类来实现的。

Sync和它的子类都实现了,为什么还要ReentrantLock类来套这么一层呢?这关系到锁的使用和实现的问题。

  • 锁是面向开发者,隐藏细节让锁的开发变得更简洁;

  • 抽象同步队列是面向锁的实现,屏蔽了同步状态的管理、线程的排队、等待与唤醒等底层操作,简化了自定义同步器和锁的实现。

说白了,ReentrantLock(锁)类为了简化开发者的使用,具体实现交由其内部类自定义的同步器Sync去处理,而AQS则以模板的方式提供一系列有关锁的操作及部分可被子类Sync重写的模板方法。

2.2.公平锁与非公平锁概述

公平与非公平指的是获取锁的机制不同。

公平锁强调先来后到,表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定,即同步队列记录线程先后顺序,队列的特性FIFO(先进先出);

非公平锁只要CAS设置同步状态成功,当前线程就会获取到锁,没获取成功的依然放在同步队列中按FIFO原则等待,等待下一次的CAS操作。

从源码上可以知道它们的主要区别是多一个判断:!hasQueuedPredecessors()

该判断表示:加入了同步队列中当前节点是否有前驱节点,即在同步队列中有没有比当前线程更早的线程在队列中等待了,而非公平锁是没有这个判断的

// java.util.concurrent.locks.ReentrantLock.NonfairSync
// 非公平
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);

}
// java.util.concurrent.locks.ReentrantLock.Sync
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == ) {
        if (compareAndSetState(, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < ) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

// java.util.concurrent.locks.ReentrantLock.FairSync
// 公平:比非公平多了一步判断 !hasQueuedPredecessors()
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == ) {
        // 主要区别:!hasQueuedPredecessors()
        if (!hasQueuedPredecessors() &&
            compareAndSetState(, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < )
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

附上获取锁时公平锁和非公平锁的源码区别图

image

结论二:

公平锁和非公平锁的主要区别是:!hasQueuedPredecessors(),表示同步队列中当前节点是否有前驱节点,即在同步队列中有没有比当前线程更早的线程在队列中等待了,而非公平锁没有这个判断

2.3.实现锁的可重入特性

前面在公平锁与非公平锁概述这点中,附上了对比两者的关键源码,其中可重入的源码是一样的👇

 ......
 else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    if (nextc < )
        throw new Error("Maximum lock count exceeded");
    setState(nextc);
    return true;
}

判断当前线程和当前拥有独占访问权限的线程对比,是同一个线程则可以重新进入同一把锁。处理逻辑是:对同步状态state加上acquires=1,然后返回true,返回true即获取锁成功。

AbstractOwnableSynchronizer类用于保存锁被独占的线程对象,AOS类只有以下两个方法:

  • Thread getExclusiveOwnerThread()为获取当前拥有独占访问权限的线程,

  • void setExclusiveOwnerThread(Thread)为设置当前拥有独占访问权限的线程。

所以每次在获取锁成功后会做这么一步:setExclusiveOwnerThread(current)👇

if (compareAndSetState(, acquires)) {
    setExclusiveOwnerThread(current);
    return true;
}

ReentrantLock的内部类Sync继承AQS实现模板方法tryRelease(int) 实现锁的释放规则,源码如下👇方法参数releases=1。

先判断该线程是否为当前拥有独占访问权限的线程,再判断同步状态,如果状态不为0,则锁还没释放完,不执行 setExclusiveOwnerThread(null) 即不释放独占访问权限的线程。因为发生锁的重入时,同步状态state>1,所以锁释放时同步状态需要一层层出来,直到同步状态为0时,才会置空拥有独占访问权的线程。因此AQS的state状态表示锁的持有次数。

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == ) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

结论三:公平和非公平的可重入性都一样,并且同步状态state的作用如下

  • 同步状态state<0 表示throw new Error("Maximum lock count exceeded");

  • 同步状态state=0 表示锁没有被占用

  • 同步状态state=1 表示锁被占用了

  • 同步状态state>1 表示锁发生了重新进入

即同步状态state等于锁持有的次数。

2.4.CAS概述

CAS的全称是Compare And Swap,意思是比较并交换,是一种特殊的处理器指令。

以方法compareAndSetState(int expect,int update)为例:

处理逻辑是:期望参数expect值跟内存中当前状态值比较,等于则原子性的修改state值为update参数值。

获取锁操作:compareAndSetState(0, 1),当同步状态state=0时,则修改同步状态state=1

compareAndSetState() 方法调用了Unsafe 类下的本地方法compareAndSwapInt(),该方法由JVM实现CAS一组汇编指令,指令的执行必须是连续的不可被中断的,不会造成所谓的数据不一致问题,但只能保证一个共享变量的原子性操作

同步队列中还有很多CAS相关方法,比如:

compareAndSetWaitStatus(Node,int,int):等待状态的原子性修改

compareAndSetHead(Node):设置头节点的原子性操作

compareAndSetTail(Node, Node):从尾部插入新节点的原子性操作

compareAndSetNext(Node,Node,Node):设置下一个节点的原子性操作

除了同步队列中提供的CAS方法,在Java并发开发包中,还提供了一系列的CAS操作,我们可以使用其中的功能让并发编程变得更高效和更简洁。

java.util.concurrent.atomic一个小型工具包,支持单个变量上的无锁线程安全编程。

比如:num++ 或num--,自增和自减这些操作是非原子性操作的,无法确保线程安全,为了提高性能不考虑使用锁(synchronized、Lock),可以使用AtomicInteger类的方法来完成自增、自减,其本质是CAS原子性操作。

AtomicInteger num = new AtomicInteger(10);
// 自增
System.out.println(num.getAndIncrement());
// 自减
System.out.println(num.getAndDecrement());

注意:只是在自增和自减的过程是原子性操作。

如下代码👇下面整块代码是非线程安全的,只是num.getAndDecrement()自减时是原子性操作,也即是并发场景下num.get()无法确保获取到新值。

private static AtomicInteger num = new AtomicInteger(10);
......
if(num.get()<=){
    System.out.println("已被抢完,下次再来");
    return;
}
System.out.println("号码:"+num.getAndDecrement());

支持哪些数据类型呢?

    基本数据类型

  • AtomicBoolean:原子更新布尔值类型

  • AtomicInteger:原子更新整数类型

  • AtomicLong:原子更新长整型

  • 数组类型

  • AtomicIntegerArray:原子更新整型数组里的元素

  • AtomicLongArray:原子更新长整型数组里的元素

  • AtomicReferenceArray:原子更新引用类型数组里的元素

  • 引用类型

  • AtomicReference:原子更新引用类型

  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)

  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。

  • 更新类型中的字段

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器

  • AtomicLongFieldUpdater:原子更新长整型字段的更新器

  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段

3.抽象同步队列AQS

AbstractQueuedSynchronizer 抽象同步队列,它是个模板类提供了许多以锁相关的操作,常说的AQS指的就是它。AQS继承了AbstractOwnableSynchronizer类,AOS用于保存线程对象,保存什么线程对象呢?保存锁被独占的线程对象

抽象同步队列AQS除了实现序列化标记接口,并没有实现任何的同步接口,该类提供了许多同步状态获取和释放的方法给自定义同步器使用,如ReentrantLock的内部类Sync。抽象同步队列支持独占式或共享式的的获取同步状态,方便实现不同类型的自定义同步器。一般方法名带有Shared的为共享式,比如,尝试以共享式的获取锁的方法int tryAcquireShared(int),而独占式获取锁方法为boolean tryAcquire(int)

AQS是抽象同步队列,其重点就是同步队列如何操作同步队列

3.1同步队列

双向同步队列,采用尾插法新增节点,从头部的下一个节点获取操作节点,节点自旋获取同步锁,实现FIFO(先进先出)原则。

image

理解节点中的属性值作用

  • prev:前驱节点;即当前节点的前一个节点,之所以叫前驱节点,是因为前一个节点在使用完锁之后会解除后一个节点的阻塞状态;

  • next:后继节点;即当前节点的后一个节点,之所以叫后继节点,是因为“后继有人”了,表示有“下一代”节点承接这个独有的锁🔒;

  • nextWaiter:表示指向下一个Node.CONDITION状态的节点(本文不讲述Condition队列,在此可以忽略它);

  • thread:节点对象中保存的线程对象,节点都是配角,线程才是主角;

  • waitStatus:当前节点在队列中的等待状态

因篇幅原因,关于抽象同步队列AQS、锁的获取过程、锁的释放过程、自旋锁、线程阻塞与释放、线程中断与阻塞关系等内容将在下一篇文章展开讲解。

👇图是新增节点的过程

image

分享好友

分享这个小栈给你的朋友们,一起进步吧。

Java 学习之路
创建时间:2022-01-21 10:41:14
Java 从入门到实战的分享
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

栈主、嘉宾

查看更多
  • 程序员小跃
    栈主

小栈成员

查看更多
  • miemieMIA
  • LCR_
  • gaokeke123
戳我,来吐槽~