Java 可重入锁的那些事(一)

本文主要包含的内容:可重入锁(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用于保存锁被独占的线程对象。

image

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=0;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<=0){
                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 == 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;
}

// java.util.concurrent.locks.ReentrantLock.FairSync
// 公平:比非公平多了一步判断 !hasQueuedPredecessors()
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 主要区别:!hasQueuedPredecessors()
        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;
}

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

image

结论二:

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

2.3.实现锁的可重入特性

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

 ......
 else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    if (nextc < 0)
        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(0, 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 == 0) {
        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()<=0){
    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

image

Java中的线程安全与线程同步

Java线程状态(生命周期)--一篇入魂

自己编写平滑加权轮询算法,实现反向代理集群服务的平滑分配

Java实现平滑加权轮询算法--降权和提权

Java实现负载均衡算法--轮询和加权轮询

Java全栈学习路线、学习资源和面试题一条龙

更多优质文章,请关注WX公众号:Java全栈布道师

image

posted @ 2022-08-18 22:59  渊渟岳  阅读(679)  评论(1编辑  收藏  举报