重入锁ReentrantLock与AQS

几个基本概念

rt.jar包功能

rt.jar(Runtime JAR)是Java Runtime Environment(JRE)中的一个关键JAR文件,它包含了Java标准库的核心类和运行时环境所需的类。该JAR文件包含了Java平台的核心类库,使得Java应用程序能够在JRE上运行,提供了Java语言的基本功能和标准API支持。
主要作用包括:

  1. 提供Java的基本类和数据结构:rt.jar中包含了java.lang、java.util、java.io等包的类,提供了Java语言的基本数据类型和数据结构,如字符串、集合、文件操作等。
  2. 实现Java平台的标准API:rt.jar包含了Java标准库的实现,包括集合框架、I/O操作、网络通信、多线程、反射等功能的类和方法。
  3. 提供运行时环境支持:rt.jar中包含了Java运行时环境所需的类,例如类加载器、安全管理器、系统属性管理等,它们在JRE的运行时环境中起着重要的作用。
  4. 支持Java虚拟机的核心功能:rt.jar中包含了支持Java虚拟机的核心类,如java.lang.Class、java.lang.Thread、java.lang.Object等,这些类是Java虚拟机的基础。

总之,在运行Java程序时,JVM会使用rt.jar中的类来执行Java程序,因此它是Java运行时环境的重要组成部分。

sun.misc包功能

在Java中,sum.misc是rt.jar包下的一个包,是sum公司实现操作系统相关资源申请和释放的包。其很多方法都是native方法,功能涉及操作内存、调用GC、注册信号等。

  1. Unsafe: sun.misc.Unsafe类提供了直接操作内存和执行低级别操作的功能。它可以绕过Java的安全机制和访问限制,因此被认为是非常危险的类。该类通常用于JDK内部和一些高级别库的实现,如JUC中就用到了Unsafe的park方法,用于阻塞当前线程,使其进入等待状态。
  2. Cache: sun.misc.Cache是一个缓存实现类,用于提供高效的缓存功能。但由于是非官方的类,不推荐在生产代码中使用。应该使用Java标准库中的缓存实现,如java.util.HashMap或java.util.LinkedHashMap。
  3. Signal: sun.misc.Signal类用于注册信号处理器,并用于处理Unix系统级别的信号。但由于非官方,不推荐使用。在Java中,通常不需要直接处理信号,可以使用更高级的并发机制来实现类似的功能。
  4. SoftCache: sun.misc.SoftCache是一个软引用缓存实现类,用于实现类似java.util.WeakHashMap的功能。但同样,由于不是官方的API,不推荐在正式代码中使用。

park与unpark

阻塞与唤醒。这是Unsafe类的方法,java.util.concurrent包下的LockSupport类封装了Unsafe类的该方法,用于实现各种线程间同步的场景,它与其他同步工具(如锁、信号量、条件变量等)相比,更加轻量级,并且不会产生死锁。但需要注意的是,LockSupport类需要程序员手动管理线程的挂起和恢复,因此需要特别谨慎使用。推荐使用java.util.concurrent包中提供的锁和同步器。

  1. park(Object blocker): 这个方法用于阻塞当前线程,使其进入等待状态。当一个线程调用park方法时,当前线程会被挂起,直到某些其他线程调用了相应的unpark方法,或者当前线程被中断(interrupt)或虚假唤醒(spurious wake-up)。park方法可以传递一个blocker对象,用于在线程堆栈跟踪和调试中识别阻塞的原因,但这是可选的。
  2. unpark(Thread thread): 这个方法用于解除对应线程的阻塞状态,使其可以继续执行。unpark方法传递一个目标线程作为参数,然后该线程就不再阻塞,可以继续执行。

怎么让当前线程阻塞

在Java中,线程是由操作系统中的原生线程(Native Threads)支持的。Java虚拟机(JVM)负责管理Java线程与原生线程之间的映射关系,确保Java线程能够挂载到操作系统线程上。

当JVM启动时,它会向操作系统请求创建一个或多个原生线程,并将这些原生线程与Java线程关联起来。在Java程序中,我们通过Thread类或者Runnable接口创建Java线程,然后通过start()方法启动线程。

在start()方法被调用时,JVM会将Java线程和一个原生线程进行绑定。这意味着每个Java线程都对应一个原生线程,它们之间有一一对应的关系(这种对应关系主要看操作系统支持,也有多对多的关系)。当Java线程运行时,对应的原生线程也会运行。当Java线程被阻塞、挂起或者执行完毕时,对应的原生线程也会相应地被操作系统进行调度。

JVM负责处理Java线程与原生线程之间的状态同步和通信,以及线程的生命周期管理。例如,当Java线程调用sleep()方法时,JVM会将对应的原生线程暂时挂起(即将当前线程的执行信息从CPU中移出到内存),然后在指定的时间后再重新启动它。类似地,当Java线程调用wait()方法时,JVM会将对应的原生线程放入等待队列,直到其他线程调用相同对象的notify()或notifyAll()方法来唤醒它。

而线程阻塞状态表示线程由于某种原因暂时无法继续执行,并且需要等待特定条件的发生或其他线程的操作来解除阻塞。在阻塞状态下,线程会暂停执行,不会占用CPU资源,直到条件满足或者等待时间超时。一旦条件满足,线程将从阻塞状态解除,重新进入可运行状态,等待CPU分配执行时间。

在Java中,线程的阻塞状态可以由多种原因引起,例如:
等待获取锁:当线程尝试获取一个锁,但该锁已经被其他线程持有时,该线程会进入阻塞状态,被放置在锁的等待队列中。
调用Object.wait()方法:线程调用一个对象的wait()方法时,它会进入该对象的等待队列,直到其他线程调用了相同对象的notify()或notifyAll()方法来唤醒该线程。
调用Thread.sleep()方法:线程调用Thread.sleep()方法会使线程进入计时等待状态,暂时停止执行指定的时间。
调用LockSupport.park()方法:线程调用LockSupport.park()方法时会阻塞当前线程,直到其他线程调用了相应线程的unpark()方法。

公平锁与非公平锁

在执行lock操作时,公平锁会先去判断AQS阻塞队列当中有没有线程,如果有,将该线程放入到队尾,所有等待线程先后执行。而非公平锁的操作是,某个线程尝试去获取锁的时候,先执行CAS看是否可以将当前线程同步状态(也可以理解为重入次数)从0改为1。

公平锁的优点是等待锁的线程按顺序执行,缺点是吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

羊群效应

羊群效应就是多个线程同时竞争同一个锁时,假设锁被某个线程占用,如果多个线程是成千上万,当锁被释放时,同时唤醒这么多线程去竞争刚释放的锁,这时就会发生羊群效应。海量的竞争必然造成CPU、内存资源的剧增与浪费。最终却只有一个线程能获取成功,其他线程还是得老老实实回到等待状态。

AQS的FIFO等待队列就是用来解决羊群效应问题的。AQS中维持一个等待队列,队列每个节点只关心其前面节点的状态 ,线程唤醒也只能唤醒队头的等待线程。这个思路已经Zookeeper的分布式锁的改进方法中应用。

ReentrantLock例子

因为ReentrantLock相比于synchronized具有对共享资源竞争时,有可中断、可限时、公平锁的特点。所以,下面例子特意考虑可中断、可限时的使用。假设有5只猴子抢吃一袋香蕉,该袋中装有10根香蕉。规定如下,每抢到袋子,从中取出一根香蕉。然后让出袋子给其他猴子抢,包括自己。代码如下。

public class ReentrantLockTest {
    volatile static int bananas = 10;//一个包含10根香蕉的袋子
    static Lock lock = new ReentrantLock(true);//设置公平锁
    static Condition grabCondition = lock.newCondition();
    static ExecutorService executorService = Executors.newCachedThreadPool();
    static boolean putBack = true;

    private static void grabAndPutBack() throws InterruptedException {
        lock.lock();
        if (!putBack) {
            try {
                grabCondition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        if (bananas <= 0) {
            System.exit(1);//当香蕉数<0时,强制进程退出。
            return;
        }
        System.out.println(Thread.currentThread().getName() + " get the bananas " + bananas);
        bananas--;
        Thread.sleep(new Random().nextInt(500));//模拟随机执行时长
        putBack = true;
        System.out.println(Thread.currentThread().getName() + " put back the left bananas " + bananas);
        grabCondition.signalAll();
        lock.unlock();
    }

    public static void main(String[] args) {
        IntStream.range(0, 5).forEach(i -> executorService.submit(() -> { //用5个线程表示5只猴子
            try {
                System.out.println(Thread.currentThread().getName() + " begin to grab banana");
                do {
                    grabAndPutBack();
                } while (bananas > 0);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }));
        executorService.shutdown();
    }
}

这个案例很有趣,当设置new ReentrantLock(false)非公平锁时,发现了全部香蕉都被第一个占用香蕉袋的猴子抢去了。这个就是可重入锁的缺点,就是让其他的4只猴子一直在等待,第一个拿到香蕉的猴子因为它的可重入性(可重入性,就是当获得锁的线程解锁后,重新来获取锁的时候会判断自己以前是否获取过锁,如果获取过就无需竞争,直接获取),不需要获取锁,直接拔掉所有香蕉。这样显然惹毛了其他猴子,占用CPU资源。

但当设置为公平锁时,会发现猴子们很有礼貌,抢到香蕉后就转给排在后面的猴子。这个就是等待队列节点。每个节点其实维护了一个线程及获取共享资源的状态信息。

这里还有个问题,就是为啥系统要强制退出?因为系统的主线程检测到banana数量为0时,退出当前的while循环。

下面以追根溯源的方式分析ReentrantLock的内部结构。因为重入锁ReentrantLock内部使用了AQS对共性资源进行控制访问。将ReentrantLock与AQS放在一起进行探讨比较合适。以下是ReentrantLock的类图。

在这里插入图片描述

从上图我们可以看出,一共包含以下几个核心类。

  1. 可重入锁类ReentrantLock:实现了Lock接口,内部类有Sync、NonfairSync、FairSync,构造ReentrantLock时可以指定是非公平锁(NonfairSync)还是公平锁(FairSync)。

  2. 同步锁Sync:抽象类,也是ReentrantLock内部类,因为无论是非公平锁还是公平锁,释放锁的逻辑都一样,所以,Sytnc自己实现了tryRelease方法,但获取锁逻辑不一样,所以tryAccquire方法由它的子类NonfairSync、FairSync自己实现。

  3. 抽象队列同步器AbstractQueuedSynchronizer(AQS):抽象类,代码中却没有一个抽象方法,其中获取锁(tryAcquire方法)和释放锁(tryRelease方法)并没有提供默认实现,需要子类重写这两个方法实现具体逻辑。

  4. 节点对象Node:AQS的内部类,本质上是一个双向链表,每个节点内部维持了一个获取锁的线程。

  5. ConditionObject: 提供了条件锁的同步实现,实现了Condition接口。

下面详细讲解每个类的内部逻辑。

Lock接口

Lock接口是在JDK1.5中引入的,提供了比synchronized方法更易于扩展的锁操作。Lock允许更多的结构,可以有不同的属性,也可以支持多个相关的Condition对象。所以,Lock实现了比synchronized方法和语句块更加广泛的操作。比如,synchronized只能是非公平锁。
Lock是多线程控制访问共享资源的工具,Lock接口提供了独占访问共享资源的方式:在某个时刻仅仅一个线程能获取锁,对共享资源的所有访问都必须获取锁。但是有些Lock接口的实现能并发地访问共享资源,比如可重入读写锁ReentrantReadWriteLock,内部维持了两个实现了Lock接口的锁,一个是读锁 ReadLock,一个是写锁 WriteLock。通过两个锁实现了读读不互斥,读写、写写互斥。所以,很适合读多写少的情况,但是如果读太多,将导致写线程"饥饿",长时间修改不了共享数据。所以JDK1.8中提供了 StampedLock类,它是读写锁的一个改进版本。采用一种乐观方式的读策略,读时完全不会阻塞写操作。

Lock虽然灵活,但也带来了额外的责任。需要自己在代码中释放锁。在大多数情况下,应使用以下代码块。一定将释放锁放入finally中,以确保在必要时释放锁。

 Lock l = ...;
 l.lock();
 try {
   // access the resource protected by this lock
 } finally {
   l.unlock();
 }

Lock接口主要包括获取锁、释放锁和获取锁条件对象。

接口方法作用
void lock()获取锁,如果获取不到锁,则当前线程挂起,处于休眠状态,直到获取锁为止。在Lock的实现类中,使用锁时能检测到错误倾向,如果调用导致死锁,可能会抛出一个未检查异常,建议在Lock锁的实现类中对异常处理写上注释。
void lockInterruptibly() throws InterruptedException可被中断获取锁,如果锁当前不可用,当前线程进行等待,直到(1)当前线程获取锁,(2)其他线程中断了当前线程,并且能支持锁的中断捕获。
boolean tryLock()非阻塞获取锁,没有获取到锁不会一直等待,会立即返回。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException获取锁,在给定的等待时间内获取锁就返回true,否则给定时间内拿不到锁,则返回false。
void unlock()释放锁。释放锁的实现通常会对线程释放加以限制:只有锁的持有者才能释放锁。如果违反了这条限制,则抛出检查时异常。
Condition newCondition()返回绑定到此锁实例的Condition实例。在等待条件之前,锁必须由当前线程持有,Condition.await()方法将原子性释放锁,并在等待返回前重新获取锁。

通过上面的方法了解到获取锁共有三种方式,分别是可中断、不可中断和定时。下面举个例子对可中断获取锁进行分析。有A、B两个线程同时通过Lock#lockInterruptibly阻塞获取某个锁时,如果此时A线程获取到了锁,则线程B只有继续等待;此时,A线程对B线程调用threadB.interrupt()方法,能够中断线程B的等待,让线程B可以先做其他事情。但是如果用内置锁synchronized,当一个线程处于等待某个锁的状态时,是无法被中断的,只有一直等待。这就是中断锁的灵活之处。注意,一个线程获取了锁之后,相当于已经切到CPU中执行了,是不会被Thread#interrupt()方法中断的。

Condition接口

Condition接口提供了一组与Object的本地监视器方法(wait,notify和notifyAll)功能相似的方法,Object的native监测方法是配合对象监测器在JAVA底层完成线程间的等待/通知机制。而Condition与Lock配合,是基于Java语言层面完成的等待/通知机制。Condition监测方法可以分多个对象,与Lock配合可以完成一个对象具有多个等待集的等待通知模式。Condition只能通过Lock#newCondition()方法获取,所以Condition是依赖于Lock的,而在调用这个方法之前,线程需要先获得锁。同时,在一个Lock中,可以获取多个Condition对象。

以下是一个简单的“ConditionObject”使用案例,用于实现一个生产者-消费者模型。当缓冲区已满时,生产者线程会调用notFull.await()进入等待状态,直到缓冲区有空闲位置时被唤醒。而当缓冲区为空时,消费者线程会调用notEmpty.await()进入等待状态,直到缓冲区有数据时被唤醒。通过使用ConditionObject类,我们实现了线程之间的等待和唤醒,确保生产者和消费者线程之间的正确协作,避免了忙等待,提高了系统的效率。

import java.util.concurrent.locks.*;

public class ProducerConsumerExample {
    private static final int BUFFER_SIZE = 5;
    private static final Lock lock = new ReentrantLock();
    private static final Condition notFull = lock.newCondition();
    private static final Condition notEmpty = lock.newCondition();
    private static final int[] buffer = new int[BUFFER_SIZE];
    private static int count = 0;

    public static void main(String[] args) {
        Thread producerThread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                produce(i);
            }
        });

        Thread consumerThread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                consume();
            }
        });

        producerThread.start();
        consumerThread.start();
    }

    private static void produce(int value) {
        lock.lock();
        try {
            // 如果缓冲区已满,则生产者线程等待
            while (count == BUFFER_SIZE) {
                notFull.await();
            }
            buffer[count] = value;
            count++;
            System.out.println("Producing: " + value);
            // 唤醒消费者线程
            notEmpty.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    private static void consume() {
        lock.lock();
        try {
            // 如果缓冲区为空,则消费者线程等待
            while (count == 0) {
                notEmpty.await();
            }
            int value = buffer[count - 1];
            count--;
            System.out.println("Consuming: " + value);
            // 唤醒生产者线程
            notFull.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

通过对比Object的监视器方法和Condition接口,可以更详细地了解Condition的特性,对比如下:

对比项Object Monitor MethodsCondition
前置条件获取对象的锁1.调用Lock.lock()获取 2.调用Lock.newCondition()获取Condition对象
调用方式直接调用,如:object.wait()直接调用,如:condition.await()
等待队列个数一个多个
当前线程释放锁并进入等待状态支持支持
当前线程释放锁并进入等待状态,在等待状态中不响应终端不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

Condition也叫条件队列或者条件变量。为一个线程提供了暂停执行(等待)直到另一个线程通知某些条件状态现在为true时的方法。因为访问这种共享状态信息发生在多个不同的线程,必须为protected类型。所以某种形式的锁与条件相关。等待为一个条件提供的关键属性自动释放相关的锁并悬挂当前线程,像Object.wait()方法一样。

一个锁可以有多个条件,每个条件上可以有多个线程等待,通过调用await()方法,可以让线程在该条件下等待。当调用signalAll()方法,又可以唤醒该条件下的等待的线程。

一个Condition实例本质上绑定到一个锁,要为一个特定的锁获取一个Condition实例可以使用newCOndition()方法。

在这里插入图片描述

如图所示,Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。

condition可以通俗的理解为条件队列。当一个线程在调用了await方法以后,直到线程等待的某个条件为真的时候才会被唤醒。这种方式为线程提供了更加简单的等待/通知模式。Condition必须要配合锁一起使用,因为对共享状态变量的访问发生在多线程环境下。一个Condition的实例必须与一个Lock绑定,因此Condition一般都是作为Lock的内部实现。

AbstractQueuedSynchronizer

AQS是JDK1.5提供的一个基于FIFO等待队列实现的同步器基础框架。JUC包里几乎所有有关的锁、多线程并发及线程同步都是基于AQS这个框架。AQS基于模板设计模式实现。整个类没有任何一个abstract的抽象方法,而是父类写有一个模板,需要子类去实现那些方法。否则直接调用父类的方法会抛出UnsupportedOperationException异常来提醒子类去修改。AQS本身的核心思想是基于volatile int state属性配合Unsafe类的CAS原子性的操作来实现,保证了线程的可见性

AQS是采用模板方法的设计模式构建的,它作为基础组件,封装的是核心并发操作,但是实现上分为两种模式,即共享模式(如Semaphore)与独占模式(如Reentrantlock,这两个模式的本质区别在于多个线程能不能共享一把锁),而这两种模式的加锁与解锁实现方式是不一样的,但AQS只关注内部公共方法实现并不关心外部不同模式的实现,所以提供了模板方法给子类使用:也就是说实现独占锁,如ReentrantLock需要自己实现tryAcquire()方法和tryRelease()方法,而实现共享模式的Semaphore,则需要实现tryAcquireShared()方法和tryReleaseShared()方法,这样做的好处是显而易见的,无论是共享模式还是独占模式,其基础的实现都是同一套组件(AQS),只不过是加锁解锁的逻辑不同罢了,更重要的是如果我们需要自定义锁的话,也变得非常简单,只需要选择不同的模式实现不同的加锁和解锁的模板方法即可。

AQS定义两种资源共享方式:Exclusive(独占、只有一个线程执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

  1. 独占式资源共享

在谈synchronized的资源共享实现方式的时候,当线程A访问共享资源的时候,其它的线程全部被堵塞,直到线程A读写完毕,其它线程才能申请同步互斥锁从而访问共享资源。

  1. 共享式资源共享

以CountDownLatch为例,共享资源可以被N个线程访问,也就是初始化的时候,state就被指定为N(N与线程个数相等),线程countDown()一次,state会CAS减1,直到所有线程执行完(state=0),那些await()的线程将被唤醒去执行执行剩余动作。

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功就返回。
  2. 没成功,则addWaiter()将线程加入等待队列的尾部,并标记为独享模式。
  3. acquireQueued()使线程在等待队列中休息,有机会时会去尝试获得资源。获得资源后返回。如果整个过程有中断过返回true,否则返回false。
  4. 如果线程在等待过程中中断过,它是不响应的。只是获得资源后才再进行自我中断selfInterrupt(),将中断补上。

而 ReentrantReadWriteLock 实现了共享锁功能。这篇文章主要是从ReentrantLock的使用入手去分析AQS独占式锁的实现。

同步器AQS内部结构

static final class Node; // 维护由线程构成的节点
private transient volatile Node head; //线程节点的头结点
private transient volatile Node tail; //线程节点的尾节点
private volatile int state; //状态位 0 - 未被任何线程持有  等于1 已经有线程持有,大于1 重入锁次数
//父类AbstractOwnableSynchronizer
private transient Thread exclusiveOwnerThread;//父类AOS保持独占线程

AQS的同步队列基于FIFO的双向链表实现,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中去。AQS中内部维护了一个头节点,尾节点,当前线程执行的state状态,当前持有锁的线程等内容。

AQS内部通过state来控制同步状态,当执行lock时,如果state=0时,说明当前没有任何线程占有共享资源,此时线程会获取到锁并把state设置为1;当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列(FIFO的双向队列)进行等待,并挂起当前线程。当获得锁的线程释放后,就会调用unlock方法,会从队列中唤醒下一个被挂起的节点(线程)。

Node内部类

AQS内部同步队列中的每个节点对象为Node对象。每个Node维护了线程、前后Node的指针和当前节点的等待状态等参数。


 static final class Node {
 static final Node SHARED = new Node(); // 当前节点是否是共享模式
 static final Node EXCLUSIVE = null;  // 排他模式
 static final int CANCELLED =  1; // 取消状态
 static final int SIGNAL    = -1;  // 等待触发状态
 static final int CONDITION = -2; // 等待唤醒条件
 static final int PROPAGATE = -3; // 节点状态需要向后传播
 volatile int waitStatus; // 等待状态有4种取值(除掉初始化状态)
   //双向链表  这两个指针用于同步队列构建链表使用的   下面还有一个 nextWaiter 是用来构建等待单链表队列
 volatile Node prev; //当前节点的前驱节点
 volatile Node next;//当前节点的后继节点
 volatile Thread thread; // 当前节点持有的线程,Node是对线程的封装,AQS实质上用Node构建了双向队列的线程,维护了线程的同步队列
 Node nextWaiter;//存储在条件等待队列中的后继节点

关于waitStatus的状态说明:
0状态 为初始化状态。
CANCELLED = 1 , 当前节点为取消状态。在同步队列中等待的线程超时或者被中断,会将该节点的waitStatus的状态置为1。大于0表示异常线程。
SIGNAL = -1, 后继节点处于等待状态。即等待触发转到同步状态。如果当前节点的线程释放了同步状态或者是被取消,会通知后继状态为SIGNAL的节点。 当前节点的后继节点被PARK,当前节点释放时,必须调用UNPARK通知后面节点,当后面节点竞争时,会将前面节点更新为SIGNAL。
CONDITION = -2, 该Node的线程处于等待队列中(注意不是同步队列),当其他线程调用了Condition的signal() 方法后,该线程从等待队列当中移动到同步队列中,等待获取同步锁。
PROPAGATE = -3, 在共享模式中,该状态的Node的线程处于可运行状态。 共享模式下释放节点时设置的状态,被标记为当前状态是表示无限传播下去 。
nextWaiter: 等待队列中的后继节点,如果当前节点是共享的,nextWaiter是 SHARED常量,也就是说节点类型和等待队列中的后继节点共用同一个字段 。

这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的后将加入同步队列等待,而另一种则是等待队列(可有多个),通过Condition调用await()方法释放锁后,将加入等待队列。注意两种队列的作用和转换。

AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中去。

AQS内部分为共享模式(如Semaphore)和独占模式(如Reentrantlock),无论是共享模式还是独占模式的实现类,都维持着一个虚拟的同步队列,当请求锁的线程超过现有模式的限制时,会将线程包装成Node结点并将线程当前必要的信息存储到node结点中,然后加入同步队列等会获取锁,而这系列操作都有AQS协助我们完成,这也是作为基础组件的原因,无论是Semaphore还是Reentrantlock,其内部绝大多数方法都是间接调用AQS完成的。
接下来我们看详细实现。

这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的后将加入同步队列等待,而另一种则是等待队列(可有多个),通过Condition调用await()方法释放锁后,将加入等待队列。

ConditionObject内部类

ConditionObject是AbstractQueuedSynchronizer内部的一个内部类,用于支持条件变量的实现。

条件变量是多线程编程中一种重要的同步工具,用于线程之间的等待和通知。在Java中,条件变量通常与锁一起使用,用于实现线程间的等待和唤醒机制,以便在特定条件下线程可以正确地等待或被唤醒。在ConditionObject内部维护了一个单链的等待队列。在这个类中使用了两个指针firstWaiter和lastWaiter。这里主要看下await()、signal()和signalAll()方法。

从上面的实例中得知,我们可以通过Condition.await()将线程加入到等待队列。

关键成员变量
/** 等待队列的第一个节点. */
private transient Node firstWaiter;
/** 等待队列的最后一个节点. */
private transient Node lastWaiter;
/** 等待到退出后状态的是重新中断模式 */
private static final int REINTERRUPT =  1;
/** 等到到退出后状态是抛出InterruptedException异常模式 */
private static final int THROW_IE    = -1;
等待队列

每个Condition对象都包含一个队列(等待队列)。等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。AQS有一个同步队列和多个等待队列,节点都是Node。等待队列的基本结构如下所示。
在这里插入图片描述

当一个线程调用Condition.await()方法,将会以当前线程构造节点,并将节点加入到等待队列尾部。将Condition的lastWaiter修改指向为最新的队尾节点。 因为调用await()方法必须在lock.lock之后操作(参考示例代码),所以前面的操作不需要CAS保证。是线程安全的操作。

当线程调用了Condition的await()方法以后。线程就作为队列中的一个节点被加入到等待队列中去了。同时会释放锁的拥有。当从await方法返回的时候。当前线程一定会获取condition相关联的锁。

如果从队列(同步队列和等待队列)的角度去看await()方法,当调用await()方法时,相当于同步队列的首节点(获取锁的节点)移动到Condition的等待队列中。

调用该方法的线程成功的获取锁的线程,也就是同步队列的首节点,该方法会将当前线程构造成节点并加入到等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,然后当前线程会进入等待状态。

当等待队列中的节点被唤醒的时候,则唤醒节点的线程开始尝试获取同步状态。如果不是通过 其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException异常信息。因为在lock.lock中,已经将所有线程加入到了同步队列中。

在这里插入图片描述

await方法

关于await, 当线程调用了await方法以后。线程就作为队列中的一个节点被加入到等待队列中去了。同时会释放锁的拥有。当从await方法返回的时候。一定会获取condition相关联的锁。当等待队列中的节点被唤醒的时候,则唤醒节点的线程开始尝试获取同步状态。如果不是通过 其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException异常信息。

public final void await() throws InterruptedException {
            //等待可中断
            if (Thread.interrupted())
                throw new InterruptedException();
            Node node = addConditionWaiter();//加入等待队列队尾
            //释放锁将lock等待队列的下一个节点进行unpark通知
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            //循环判断是否在AQS的等待队列中,不在就进行park动作。之后不论是被unpark唤醒,还是中断,均会跳出次循环。
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            //运行到此处说明已经被唤醒了,因为结束了循环。
            //唤醒后,首先自旋获取锁,同时判断是否当前线程被中断了
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            //清理队列中的状态不是Condition的任务,包括被唤醒的SIGNAL和被取消的CANCELLED任务
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            //被中断,抛出异常
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
}

加入到等待队列。

       /**
         * 添加到等待队列
         * @return 返回新节点
         */
        private Node addConditionWaiter() {
            Node t = lastWaiter;//获取条件队列中的最后节点
            // 如果lastWaiter被取消,则把它清理干净.
            if (t != null && t.waitStatus != Node.CONDITION) {//条件队列尾部节点状态不为等待,则从队列中删除
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
            Node node = new Node(Thread.currentThread(), Node.CONDITION);//用当前线程新建节点
            if (t == null)//最后节点为空,表示等待队列中还没有节点
                firstWaiter = node;//则当前节点为第一个节点
            else
                t.nextWaiter = node;//否则等待队列中有节点,增加该节点到队尾
            lastWaiter = node;//等待队列中的lastWaiter指向新的队尾节点
            return node;
}
signal/doSignal/signalAll方法

这几个方法都是唤醒在Condition上等待的线程。如上面示例,signalAll()就是唤醒等待队列中的所有线程。 Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。

signal方法

调用signal()方法首先判断是否获取到了独占锁,如果没有获取到就抛出异常。这说明只有获取了独占锁的线程才能执行signal操作。然后获取等待队列中的第一个节点,也是等待最长时间的节点执行doSignal。

public final void signal() {
            //获取独占锁
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            //获取等待队列中的第一个等待节点
            Node first = firstWaiter;
            if (first != null)
                doSignal(first);
}
doSignal方法

doSignal主要操作有:将其移动到同步队列并且利用LockSupport唤醒节点中的线程。节点从等待队列移动到同步队列如下图所示:

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

   /**
     * 将头节点从等待队列移动到同步队列,移动成功返回true,否则表示节点在通知前已经被取消了
     */
    final boolean transferForSignal(Node node) {
        /*
         * 在等待队列中的节点只有condition和cancelled两种状态,如果waitStatus状态不为CONDITION,说明任务已被取消。则更新失败。
         */
        if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
            return false;
      /*
       *将当前节点加入到同步队列上,并尝试设置前置任务的waitStatus,以指示线程(可能)正在等待。如果取   
       *消或尝试设置waitStatus失败,则唤醒以重新同步(在这种情况下,waitStatus可能会暂时错误但无害)。
       */
        Node p = enq(node);//此方法在同步期中已经讲解,将node节点加入到同步队列
        int ws = p.waitStatus;//获取新节点的状态
        if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
            LockSupport.unpark(node.thread);
        return true;
}

在这里插入图片描述

ReentrantLock

分析完以上基础内容,我们最后分析ReentrantLock的内部实现。ReentrantLock相比synchronized关键字更加灵活,在CopyOnWriteArrayList、CyclicBarrier和LinkedBlockingDeque等类中都有用到,是整个JUC的核心,在面试中必考。

那么Sync为什么要被设计成内部类呢?我们可以看看AQS主要提供了哪些protect的方法用于修改state的状态,我们发现Sync被设计成为安全的外部不可访问的内部类。ReentrantLock中所有涉及对AQS的访问都要经过Sync,Sync被设计成为内部类主要是为了安全性考虑。

唯一sync属性

ReentrantLock内部只有一个同步器属性。所有逻辑都由该同步器实现。

/** Synchronizer providing all implementation mechanics */
private final Sync sync;

Sync类

非公平锁NonfairSync

ReentrantLock的获取锁和释放锁到这里就讲完了,总的来说还是比较清晰的一个流程,通过AQS的state状态来控制锁获取和释放状态,AQS内部用一个双向链表来维护挂起的线程。在AQS和ReentrantLock之间通过状态和行为来分离,AQS用管理各种状态,并内部通过链表管理线程队列,ReentrantLock则对外提供锁获取和释放的功能,具体实现则在AQS中。下面我通过两张流程图总结了公平锁和非公平锁的流程。

代码分析

lock加锁过程

在示例代码中调用lock.lock();就可以进入加锁过程。ReetrantLock实现了Lock接口,重写了lock方法。但Sync类的lock()方法是一个抽象方法,NonfairSyncFairSync分别对lock()方法进行了实现。

//非公平锁重写lock方法,先以CAS的方式,尝试将AQS中的state从0改为1
static final class NonfairSync extends Sync {
    ......
    final void lock() {
        if (compareAndSetState(0, 1)) //插队操作,// 以CAS的方式,尝试将state从0改为1,0为锁空闲,空闲时则设置为1
            setExclusiveOwnerThread(Thread.currentThread()); //获取锁成功后设置当前线程为占有锁线程
        else
            acquire(1);//再次尝试获取,获取失败,则加入到等待队列中或者重入直接执行
    }
}

//公平锁的lock实现
static final class FairSync extends Sync {
    final void lock() {
        acquire(1);
    }
    ......
}
lock加锁过程中的acquire方法尝试执行

接下来了解下AbstractQueuedSynchronizeracquire(1)方法。而这个方法中的tryAcquire(int acquires)在AQS子类SyncNonfairSync实现。

//AQS
/**
     * 无论是公平锁还是非公平锁,都会调用acquire方法
     * @param arg,默认为1
     */
    public final void acquire(int arg) {
        /**
         * tryAcquire方法,分为两种实现。第一种是公平锁,第二种是非公平锁
         * 公平锁操作:如果有人排队,我就排队;如果是重入锁,直接获取锁
         * 非公平锁操作:如果state为0;如果为0,直接尝试CAS修改。如果是锁重入的操作,直接获取锁
         */
        if (!tryAcquire(arg) &&
                // addWaiter方法,在线程没有通过tryAcquire拿到锁资源时,需要将当前线程封装为Node,排到AQS末尾
                // acquireQueued方法,查看当前线程是否排在队列前面,如果是就尝试获取锁资源;
                //                   如果长时间没拿到锁,也需要将当前线程挂起,让出cpu
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    
//ReentrantLock.NonfairSync
protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
}
//ReentrantLock.Sync--非公平获取锁
final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            // 拿到state值
            int c = getState();
            // 如果state=0,代表当前没有线程占用锁资源
            if (c == 0) {
                // 直接基于CAS的方式,尝试修改state,从0->1,如果成功代表拿到了锁资源
                if (compareAndSetState(0, acquires)) {
                    //将ExclusiveOwnerThread属性设置为当前线程,即独占模式
                    setExclusiveOwnerThread(current);
                    //返回true,表示当前线程拿到了锁,非公平锁这里已经是第二次尝试获取锁,且这次获取成功了
                    return true;
                }
            }
            // 说明state肯定不为0,不为0代表当前lock被线程占用
            // 判断占用锁资源的线程是不是当前线程
            else if (current == getExclusiveOwnerThread()) {
                // 锁重入操作,对state + 1 ,表示重入加1次
                int nextc = c + acquires;
                if (nextc < 0) // overflow,超过了锁重入的最大限制
                    throw new Error("Maximum lock count exceeded");
                // 将AQS的state设置好
                setState(nextc);
                return true;
            }
            return false;//表示获取锁失败,只有加入到等待队列
        }

lock加锁过程中的addWaiter方法添加节点
//AQS-addWaiter将当前线程封装成Node,排到AQS队列中
private Node addWaiter(Node mode) {
        // 将当前线程封装为Node对象
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;//获取AQS同步器队尾节点赋值给pred,即当做当前节点的前一个节点
        if (pred != null) {//AQS的同步队列的尾节点不为空,表示已经有节点在等待队列中
            node.prev = pred;//设置当前线程新建的节点的前一个节点为队列中的AQS尾部节点,即新建节点链接到AQS同步器尾部
            if (compareAndSetTail(pred, node)) {// 为了避免并发问题,通过CAS增加新建节点到队列当做新的尾部节点
                pred.next = node;//设置等待队列中的原队尾节点为新节点,即原节点的next链接到新节点,与前面的node.prev = pred构成双向队列
                return node;//返回新节点
            }
        }
        enq(node);// 如果在队列为空,或者CAS操作失败后,会执行enq方法,将当前node排队队列末尾
        return node;//返回新节点
}
//AQS-通过自旋将当前节点加入到队列
private Node enq(final Node node) {
        for (;;) {//死循环,自旋,这里会阻塞当前节点,耗CPU,但保证一定排到队列中
            Node t = tail;//获取AQS等待队列队尾节点
            if (t == null) { // 如果等待队列的尾节点为空,则必须初始化head节点,作为头
              //如果尾部节点为null,表示节点队列还没有节点,初始化一个无参构造器节点
                if (compareAndSetHead(new Node()))//新建空的头节点作为等待队列的头节点
                    tail = head;//因为只有一个节点,所以队尾和队头全部指向该新节点
            } else {
               // 队列不为空,将当前节点插入到队列的末尾作为tail,循环到插入成功为止
                node.prev = t;// 将当前节点的头节点指向队列中的尾节点,这里不需要要考虑并发,这里一共有两个节点,后一个节点中含有当前线程
              // 竞争到的节点将会变成节点队列的尾部节点
                if (compareAndSetTail(t, node)) {//CAS设置尾部节点
                    t.next = node;//设置成功后,将尾节点的下一个节点设置为当前节点
                    return t;//返回尾部节点的前一个节点,刚新建的节点
                }
            }
    }
}

以上就是ReentrantLock的非公平锁调用lock()过程,首先去尝试改变AQS设为state的状态,改变成功就获取了锁,失败后再次通过判断当前的state是否为0,即未锁定状态,再次尝试改变state状态获取锁,如果state不为0,即锁已经被其他线程持有,则判断当前线程是不是已经持有该锁,如果是,则获取锁成功,且锁的次数增加。否则加入到Node队列,加入队列后在在for循环中通过判断其前节点的状态来决定是否需要阻塞,可以看出在加入队列前及阻塞前多次尝试去获取锁,而避免进入线程阻塞,这是因为阻塞、唤醒都需要cpu的调度,以及上下文切换,这是个重量级的操作,应尽量避免

lock加锁过程中的acquireQueued方法添加到同步链表

此时则进行acquireQueued这个方法,这个方法是不间断的去获取已经入队队列中的前节点的状态,如果前节点的状态为大于0,则代表当前节点被取消了,会一直往前面的节点进行查找,如果节点状态小于0并且不等于SIGNAL则将其设置为SIGNAL状态,设置成功后将当前线程挂起,挂起线程后也有可能会反复唤醒挂起操作,原因后面会讲到。

//AQS-获取队列(node为刚添加到队列的尾部节点),如果在AQS同步队列前,竞争锁资源;不在AQS同步队列,线程挂起
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {//无限循环
            final Node p = node.predecessor(); //获取当前节点的前置节点
            if (p == head && tryAcquire(arg)) { //如果前置节点是头节点,说明当前节点是第一个挂起的线程节点,只要等待前一个正在运行的线程执行完,就是它要执行了。所以再次cas尝试获取锁,这里有点自旋的意思。可以看出一个节点是否能获取锁只和他前面的节点有关
                setHead(node); //之前节点执行完了,当前节点也获取锁了,设置当前节点为头节点,并且将Node中的prev和thread置为null
                p.next = null; //将之前的头节点的next置为null,没有当前节点指向到前一个节点,让GC将之前的head回收掉
                // 将获取锁失败的标识置为false
                failed = false;
                 // 返回线程中断标识,默认情况为false
                return interrupted;
            }
           // 如果当前Node不为head节点的下一个,尝试挂起
            if (shouldParkAfterFailedAcquire(p, node) && //非头节点或者获取锁失败,检查节点状态,查看是否需要挂起线程
                parkAndCheckInterrupt())  //挂起线程,当前线程阻塞在这里!让出CPU
                interrupted = true;
        }
    } finally {
        if (failed)//最终没有获取锁,则结束线程请求
            cancelAcquire(node);
    }
}

此时会进入到shouldParkAfterFailedAcquire方法,这个方法是获取不到锁时需要停止继续无限期等待锁,其实就是内部的操作逻辑也很简单,就是如果前节点状态为0时,需要将前节点修改为SIGNAL,如果前节点大于0则代表前节点已经被取消了,应该移除队列,并将前前节点作为当前节点的前节点,一直循环直到前节点状态修改为SIGNAL或者前节点被释放锁,当前节点获取到锁停止循环。
可以看到这个方法是一个自旋的过程,首先获取当前节点的前置节点,如果前置节点为头结点则再次尝试获取锁,失败则挂起阻塞,阻塞被取消后自旋这一过程。是否可以阻塞通过shouldParkAfterFailedAcquire方法来判断,阻塞通过parkAndCheckInterrupt方法来执行。

private void setHead(Node node) {
        head = node;
        node.thread = null;//它已经不再需要在等待队列中保持节点信息了,因为它已经获得了锁并进入了临界区(正在执行)
        node.prev = null;//当前节点的前一个节点也断开引用,释放GC
    }

setHead方法里面的前驱Node是Null,也没有线程,那么为什么不用一个在等待的线程作为Head Node呢?
这么做的目的是为了避免内存泄漏。一旦一个线程成功获取到锁,并成为头结点后,它已经不再需要在等待队列中保持节点信息了,因为它已经获得了锁并进入了临界区。由于节点信息还包含着对前一个节点的引用,如果不将node.thread设置为null,可能会导致前一个节点的线程引用仍然保持对当前线程的引用,从而无法被垃圾回收器正确回收,造成内存泄漏。

所以,将node.thread设置为null是为了释放对当前节点的引用,帮助垃圾回收器回收不再需要的节点,从而防止内存泄漏问题。这种设计保证了等待队列中只有当前正在等待获取锁的节点以及前一个节点的引用,从而节省了内存空间,使得等待队列能够高效地进行节点的出队操作,而不会因为无法回收节点导致内存持续增长。

线程入队后能够挂起的前提是,它的前驱节点的状态为SIGNAL。所以shouldParkAfterFailedAcquire会先判断当前节点的前驱是否状态符合要求,若符合则返回true,然后调用parkAndCheckInterrupt,将自己挂起。如果不符合,再看前驱节点是否>0(CANCELLED),若是那么向前遍历直到找到第一个符合要求的前驱,若不是则将前驱节点的状态设置为SIGNAL。

//AQS.class 判断当前线程是否可以挂起
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;//获取前置节点的等待状态
        if (ws == Node.SIGNAL)//前置节点的状态为-1(等待被唤醒的状态)
            //返回true表示可以将当前节点挂起
            return true;
        if (ws > 0) {//如果waitStatus>0,表示前置节点被取消。则继续向前查找状态不为CANCELLED的节点作为新的前置节点。
            do {
               // 循环,直到找到上一个节点为小于等于0的节点
                node.prev = pred = pred.prev;//前置节点的前置节点指针指向前置节点自己,并将当前节点的前置节点设置为前置节点
            } while (pred.waitStatus > 0);
            pred.next = node;//前置节点的下个节点设置为自己
        } else {
            /* 可能为0,-2,-3,直接以CAS的方式将节点状态改为-1
             * waitStatus 一定是0或PROPAGATE.表示当前节点还没有被挂起park,我们需要个信号。调用者需要重试确保在park之前不能获取      */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);//CAS设置前置节点的状态为SIGNAL
        }
        return false;
    }

//AQS 取消获取锁
private void cancelAcquire(Node node) {
        // 尾部结点不能为空
        if (node == null)
            return;
        // 清空尾部结点线程对象
        node.thread = null;

        // 获取尾部节点的前继节点
        Node pred = node.prev;
        while (pred.waitStatus > 0)
          // 如果该节点状态为取消(waitStatus大于0),将尾部结点的前置节点前移
            node.prev = pred = pred.prev;

        // 获取前移后的前置节点的下一个节点
        Node predNext = pred.next;

        // 更新尾部节点的waitStatus状态为取消
        node.waitStatus = Node.CANCELLED;

        // 如果自己是尾部节点,则移除自己。通过无锁竞争,将尾部节点设为之前尾部节点的前置节点,即移除现有的尾部节点
        if (node == tail && compareAndSetTail(node, pred)) {
          通过无锁竞争,将更新后的尾部节点的下一个节点设为null
            compareAndSetNext(pred, predNext, null);
        } else {
             //如果node不是尾部节点了,即node在节点列表中被移除了
        int ws;
        //更新后的尾部节点不为头节点且该尾部节点的等待状态为待唤醒状态(不为待唤醒状态也会被无锁竞争更新为待唤醒状态)
        if (pred != head &&
            ((ws = pred.waitStatus) == Node.SIGNAL ||
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            pred.thread != null) {
            //获取node的下一个节点
            Node next = node.next;
            //如果该节点不为null且该节点等待状态不为取消状态
            if (next != null && next.waitStatus <= 0)
                //通过无锁竞争,将该节点设为现在尾部节点的下一个节点
                compareAndSetNext(pred, predNext, next);
        } else {
            //如果更新后的尾部节点的等待状态为取消状态,唤醒前置节点中等待状态不为被取消状态的节点
            unparkSuccessor(node);
        }

            node.next = node; // help GC
        }
}

上面的方法其实很容易理解就是等待挂起信号,如果前节点的状态为0或PROPAGATE则将前节点修改为SIGNAL,则代表后面前节点释放锁后会通知下一个节点,也就是说唤醒下一个可以唤醒的节点继续争抢所资源,如果前节点被取消了那就继续往前寻找不是被取消的节点,这里不会找到前节点为null的情况,因为它默认会有一个空的头结点,也就是上图内容,此时的队列状态是如何的我们看一下,这里它会进来两次,以为我们上图可以看到当前节点前节点是Ref-724此时waitStatus=0,他需要先将状态更改为SIGNAL也就是运行最有一个else语句,此时又会回到外面的for循环中,由于方法返回的是false则不会运行parkAndCheckInterrupt方法,而是又循环了一次,此时发现当前节点争抢锁又失败了,然后此时队列的状态如下图所示:

再次进入到方法之后发现前驱节点的waitStatus=-1,表示当前节点需要进行挂起等到,此时返回的结果是true,则会运行parkAndCheckInterrupt方法,这个方法很简单就是将当前线程进行挂起操作,如下所示:

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);                        //挂起线程
    return Thread.interrupted();                //判断是否被中断,获取中断标识
}

The thread fails to acquire the lock fails as the lock is already acquired by another thread. The thread is added to a double linked list queue where the header is a dummy node with a mode indicating that it is waiting for a signal and the current thread which failed to acquire the lock becomes the tail node and is the next node to header. Since the current thread fails to get the lock, it is parked as it remains blocked till some other thread unblocks it.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ww1MzCqB-1607175685236)(https://ask.qcloudimg.com/http-save/4069756/8qobm1l04l.png?imageView2/2/w/1620)]

Second thread tries to acquire the lock, it too fails so gets queued. It becomes the new tail and the thread is parked.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-va2tkEm7-1607175685237)(https://ask.qcloudimg.com/http-save/4069756/jejtbb4v09.png?imageView2/2/w/1620)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OVAQxPj1-1607175685237)(C:\Users\12430\AppData\Roaming\Typora\typora-user-images\image-20191126002844150.png)]

尝试为等待队列获取锁
不中断模式获取锁

当线程T2添加自己到等待节点链表后,它仍将尝试以独占不中断的方式获取锁。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XOtTvVJs-1607175685237)(E:\muke\images\acquireQueued1-2.png)]

中断模式获取锁

可中断锁。可中断锁是指线程尝试获取锁的过程是否可以响应终端。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。

这里体现了经典的自旋+CAS组合来实现非阻塞的原子操作。由于compareAndSetHead的实现使用了unsafe类提供的CAS操作,所以只有一个线程会创建head节点成功。假设线程B成功,之后B、C开始第二轮循环,此时tail已经不为空,两个线程都走到else里面。假设B线程compareAndSetTail成功,那么B就可以返回了,C由于入队失败还需要第三轮循环。最终所有线程都可以成功入队。

我们可以在某个线程在等待时, 被其他线程给中断了。该线程会抛出 InterruptedException 异常,并不需要再次尝试了。我们在这种情况下可以使用 ReentrantLock.lockInterruptibly() 来尝试获取锁。如果线程由于释放锁而断开连接,那么它将重试获取锁。

在acquireQueued中,

第一次执行:发现自己前序节点是head节点,它肯定获取不了锁,但是还不会park,会再次尝试获取锁,获取失败后会检查前置节点的状态是否是正确的状态,再shouldParkAfterFailedAcquire方法中把前序节点设置为Singal状态。

第二次执行:再次尝试获取锁,但因为前序节点是Signal状态了,所以执行parkAndCheckInterrupt把自己休眠起来进行自旋。

在这里插入图片描述

如果还没有获取到锁,PARK

在上一步中,线程会检查当前节点是否是头节点的后继节点。如果是,则会再尝试去获取锁。如果节点不在头节点后,则需要重新检查并移除掉取消状态的节点。

为了避免多次检查。需要维持一个状态域。任何节点将要parked必须只有前一个节点的waitStatus状态为-1,意味着当前节点想要park,只需要它的前置节点给个将要释放锁的"Signed"信号。调用线程将在park之前会重试确保它不能获取锁并且它的前一个节点的状态设置为"Signal"。

在这里插入图片描述

非公平锁:

在这里插入图片描述

在这里插入图片描述

非公平锁释放锁

调用lock.unlock释放锁。从代码上看,释放锁比较简单,就是拥有锁的线程将volatile类型的state变量减去1。如果state为0,表示已经释放锁。如果当前线程没有拥有锁,则会抛出IllegalMonitorStateException异常。假设当前线程是锁的拥有者,下面是锁的释放的过程。

//ReentrantLock类的unlock
public void unlock() {
        sync.release(1);//调用AQS的release
}
//AQS的release
public final boolean release(int arg) {
        if (tryRelease(arg)) {//调用Sync类的tryRelease,释放成功
            Node h = head;//等待队列的头结点,这也是需要释放锁的节点
            if (h != null && h.waitStatus != 0)//头结点存在
                unparkSuccessor(h);//将没有取消的后继挂起的线程获取锁,并设置为头节点,激活运行
            return true;
        }
        return false;
}
//Sync类的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;
              //设置AQS的独占线程为null
                setExclusiveOwnerThread(null);
            }
            setState(c);//设置AQS的state状态为0
            return free;
}

release方法,首先先进行尝试去释放锁,如果释放锁仍然被占用则直接返回false,如果尝试释放锁时,发现锁已经释放,当前线程不在占用锁资源时,则会进入的下面进行一些列操作后返回true,接下来我们先来看一下ReentrantLockSync下的tryRelease方法,如下所示:

释放锁后,进入到if语句中,判断当前头节点不为空且waitStatus!=0,通过上图也可以发现头节点为-1,则进入到unparkSuccessor方法内:

private void unparkSuccessor(Node node) {
        /*
         * CAS设置当前节点的waitStatus状态为0
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        //找到头节点的下一个未被取消的结点
  //唤醒下一个节点,唤醒下一个节点之前需要判断节点是否存在或已经被取消了节点,如果没有节点则不需唤醒操作,如果下一个节点被取消了则一直一个没有被取消的节点。
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {//判断后继节点是否为空或者取消状态
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
            //从队列尾部向前遍历找到最前面的一个waitStatus小于0的节点,至于为什么从尾部开始向前遍历,因为在doAcqurieInterrupttibly.cancelAcquire方法的处理过程中只设置了next的变化,没有设置prev的变化,在最后有这样一行代码:node.next=node;如果这时执行了unparkSuccessor方法,并且向后遍历,就会发生死循环。所以,这时只有prev是稳定的
        }
        if (s != null)
            LockSupport.unpark(s.thread);//取消下个结点的挂起状态
    }

从代码执行操作来看,这里主要作用是用unpark()唤醒同步队列中最前边未放弃线程(也就是状态为CANCELLED的线程结点s)。此时,回忆前面分析进入自旋的函数acquireQueued(),s结点的线程被唤醒后,会进入acquireQueued()函数的if (p == head && tryAcquire(arg))的判断,然后s把自己设置成head结点,表示自己已经获取到资源了,最终acquire()也返回了,这就是独占锁释放的过程。

可以看到它是现将头节点的状态更新为0,然后再唤醒下一个节点,如果下一个节点为空则直接返回不唤醒任何节点,如果下一个节点被取消了,那么它会从尾节点往前进行遍历,遍历与头节点最近的没有被取消的节点进行唤醒操作,在唤醒前看一下队列状态.

在这里插入图片描述

在这里插入图片描述

ReentrantLock的获取锁和释放锁到这里就讲完了,总的来说还是比较清晰的一个流程,通过AQS的state状态来控制锁获取和释放状态,AQS内部用一个双向链表来维护挂起的线程。在AQS和ReentrantLock之间通过状态和行为来分离,AQS用管理各种状态,并内部通过链表管理线程队列,ReentrantLock则对外提供锁获取和释放的功能,具体实现则在AQS中。下面我通过两张流程图总结了公平锁和非公平锁的流程。

公平锁FairSync

公平锁获取锁

可以看到,非公平锁的加锁与公平锁的加锁有一点不同,那就是如果当前锁状态是空闲状态,那么就可以直接获取锁,而公平锁显然是不能这么做的,因为公平锁必须要先判断 AQS 同步队列中是否有先入队的正在等待获取锁的线程,否则直接获取就打破了公平锁的原则了。

和非公平锁很相似,主要差别lock()的时候不是直接去获取锁,而是先看锁是否可用并且没有前节点,有前节点的话,即使锁是空闲也不会获取锁。

在这里插入图片描述

AQS通过最简单的CAS和LockSupport的park,设计出了高效的队列模型和机制:

1、AQS结构其实是在第二个线程获取锁的时候再初始化的,就是lazy-Init的思想,最大程度减少不必要的代码执行的开销

2、为了最大程度上提升效率,尽量避免线程间的通讯,采用了双向链表的Node结构去存储线程

3、为了最大程度上避免CPU上下文切换执行的消耗,在设计排队线程时,只有头结点的下一个的线程在一直重复执行获取锁,队列后面的线程会通过LockSupport进行休眠。

公平锁释放锁

公平锁和非公平锁的释放过程是一样的,其实现都是在Sync父类中。

总结

await与signal/signalAll的结合思考

文章开篇提到等待/通知机制,通过使用condition提供的await和signal/signalAll方法就可以实现这种机制,而这种机制能够解决最经典的问题就是“生产者与消费者问题”,关于“生产者消费者问题”之后会用单独的一篇文章进行讲解,这也是面试的高频考点。await和signal和signalAll方法就像一个开关控制着线程A(等待方)和线程B(通知方)。它们之间的关系可以用下面一个图来表现得更加贴切:

img

condition下的等待通知机制.png

如图,线程awaitThread先通过lock.lock()方法获取锁成功后调用了condition.await方法进入等待队列,而另一个线程signalThread通过lock.lock()方法获取锁成功后调用了condition.signal或者signalAll方法,使得线程awaitThread能够有机会移入到同步队列中,当其他线程释放lock后使得线程awaitThread能够有机会获取lock,从而使得线程awaitThread能够从await方法中退出执行后续操作。如果awaitThread获取lock失败会直接进入到同步队列

独占式lock流程(unlock同理):

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功就返回。

  2. 没成功,则addWaiter()将线程加入等待队列的尾部,并标记为独享模式。

  3. acquireQueued()使线程在等待队列中休息,有机会时会去尝试获得资源。获得资源后返回。如果整个过程有中断过返回true,否则返回false。

  4. 如果线程在等待过程中中断过,它是不响应的。只是获得资源后才再进行自我中断selfInterrupt(),将中断补上。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vrAEs2Oq-1607175685241)(file:///C:/Users/ADMINI~1/AppData/Local/Temp/enhtmlclip/721070-20151102145743461-623794326.png)]

img

通过这篇文章基本将AQS队列的实现过程做了比较清晰的分析,主要是基于非公平锁的独占锁实现。在获得同步锁时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

在这里插入图片描述

AQS使用一个FIFO队列表示排队等待锁的线程,队列头结点称作“哨兵节点”或者“哑结点”,它不与任何线程关联。其他的节点与等待线程关联,每个阶段维护一个等待状态waitStatus。

这样设计的结构有几点好处:

  • 首先为什么要有Sync这个内部类呢?

因为无论是NonfairSync还是FairSync,他们解锁的过程是一样的,不同只是加锁的过程,Sync提供加锁的模板方法让子类自行实现.

  • AQS为什么要声明为Abstract,内部却没有任何abstract方法?

这是因为AQS只是作为一个基础组件,从上图可以看出countDownLatch等并发组件都依赖了它,它并不希望直接作为直接操作类对外输出,而更倾向于作为一个基础并发组件,为真正的实现类提供基础设施,如构建同步队列,控制同步状态等。

AQS是采用模板方法的设计模式构建的,它作为基础组件,封装的是核心并发操作,但是实现上分为两种模式,即共享模式(如Semaphore)与独占模式(如Reentrantlock,这两个模式的本质区别在于多个线程能不能共享一把锁),而这两种模式的加锁与解锁实现方式是不一样的,但AQS只关注内部公共方法实现并不关心外部不同模式的实现,所以提供了模板方法给子类使用:也就是说实现独占锁,如ReentrantLock需要自己实现tryAcquire()方法和tryRelease()方法,而实现共享模式的Semaphore,则需要实现tryAcquireShared()方法和tryReleaseShared()方法,这样做的好处是显而易见的,无论是共享模式还是独占模式,其基础的实现都是同一套组件(AQS),只不过是加锁解锁的逻辑不同罢了,更重要的是如果我们需要自定义锁的话,也变得非常简单,只需要选择不同的模式实现不同的加锁和解锁的模板方法即可。

在这里插入图片描述

我个人理解acquire方法不间断的尝试获取锁,如果锁没有获取到则现将节点加入到队列中,并将当前线程设置为独占锁资源,也就是独占了锁的意思,别的线程不能拥有锁,然后如果当前节点的前节点是头节点话,再去尝试争抢锁,则设置当前节点为头节点,并将原头节点的下一个节点设置为null,帮助GC回收它,如果不是头节点或争抢锁不成功,则会现将前面节点的状态设置直到设置为SIGNAL为止,代表下面有节点被等待了等待上一个线程发来的信号,然后就挂起当前线程。

AQS其他重要方法

如果线程等待的过程中抛出异常,则当前线程进入到finally中的时候failed为true,因为修改该字段只有获取到锁的时候才会修改为false,进来之后它会运行cancelAcquire来进行取消当前节点,下面我们先来分析下源码内容:

private void cancelAcquire(Node node) {
    // 如果节点为空直接返回,节点不存在直接返回
    if (node == null)
        return;
        // 设置节点所在的线程为空,清除线程操作
    node.thread = null;

    // 获取当前节点的前节点
    Node pred = node.prev;
      // 如果前节点是取消节点则跳过前节点,一直寻找一个不是取消节点为止
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

       // 获取头节点下一个节点
    Node predNext = pred.next;

    // 这里直接设置为取消节点状态,没有使用CAS原因是因为直接设置只有其他线程可以跳过取消的节点
    node.waitStatus = Node.CANCELLED;

    // 如果当前节点为尾节点,并且设置尾节点为找到的合适的前节点时,修改前节点的下一个节点为null
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        // 如果不是尾节点,则说明是中间节点,则需要通知后续节点,嘿,伙计你被唤醒了。
        int ws;
        if (pred != head &&                                                            //前节点不是头结点
            ((ws = pred.waitStatus) == Node.SIGNAL ||        // 前节点的状态为SIGNAL 
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) //或者前节点状态小于0而且修改前节点状态为SIGNAL成功 
               && pred.thread != null) {                                            //前节点线程不为空
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
              //唤醒下一个不是取消的节点
            unparkSuccessor(node);
        }

        node.next = node; // help GC
    }
}
  1. 首先找到当前节点的前节点,如果前节点为取消节点则一直往前寻找一个节点。
  2. 取消的是尾节点,则直接将前节点的下一个节点设置为null
  3. 如果取消的是头节点的下一个节点,且不是尾节点的情况时,它是唤醒下一个节点,唤醒之前并没有将其移除队列,而是在唤醒下一个节点的时候,shouldParkAfterFailedAcquire里面将取消的节点移除队列,唤醒之后,当前节点的下一个节点也设置成自己,帮助GC回收它。
  4. 如果取消节点是中间的节点,则直接将其前节点的下一个节点设置为取消节点的下下个节点即可。

第一种情况如果我们取消的节点是前节点是头节点,此时线程1的节点应该是被中断操作,此时进入到cancelAcquire之后会进入else语句中,然后进去到unparkSuccessor方法,当进入到这个方法之前我们看一下状态变化:

图片描述

我们发现线程1的Node节点的waitStatus变为1也就是Node.CANCELLED节点,然后运行unparkSuccessor方法,该方法上面就已经讲述了其中的源码,这里就不在贴源码了,就是要唤醒下一个没有被取消的节点,这里是Ref-695这个线程,当Ref-695被唤醒之后它会继续运行下面的内容:

private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {                //再一次循环发现还是没有争抢到锁
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&    //再一次循环之后有运行到这里了
                parkAndCheckInterrupt())                    //这里被唤醒了,又要进行循环操作了
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

发现再一次循环操作后,还是没有正抢到锁,这时候还是会运行shouldParkAfterFailedAcquire方法,这个方法内部发现前节点的状态是Node.CANCELLED这时候它会在内部先将节点给干掉,也就是这个代码:

if (ws > 0) {
    /*
     * Predecessor was cancelled. Skip over predecessors and
     * indicate retry.
     */
    do {
        node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;
}

最后还是会被挂起状态,因为没有释放锁操作,最后移除的节点如下所示:

图片描述

如果取消的事尾节点,也就是线程3被中断操作,这个是比较简单的直接将尾节点删除即可,其中会走如下代码:

if (node == tail && compareAndSetTail(node, pred)) {
    compareAndSetNext(pred, predNext, null);
}

图片描述

如果取消的节点是中间的节点,通过上例子中则是取消线程2,其实它内部只是将线程取消线程的前节点的下一个节点指向了取消节点的下节点,如下图所示:

图片描述

doAcquireNanos的流程简述为:线程先入等待队列,然后开始自旋,尝试获取锁,获取成功就返回,失败则在队列里找一个安全点把自己挂起直到超时时间过期。这里为什么还需要循环呢?因为当前线程节点的前驱状态可能不是SIGNAL,那么在当前这一轮循环中线程不会被挂起,然后更新超时时间,开始新一轮的尝试。

/**
 * 在有限的时间内去竞争锁
 * @return 是否获取成功
 */
private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    // 起始时间
    long lastTime = System.nanoTime();
    // 线程入队
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        // 又是自旋!
        for (;;) {
            // 获取前驱节点
            final Node p = node.predecessor();
            // 如果前驱是头节点并且占用锁成功,则将当前节点变成头结点
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            // 如果已经超时,返回false
            if (nanosTimeout <= 0)
                return false;
            // 超时时间未到,且需要挂起
            if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                // 阻塞当前线程直到超时时间到期
                LockSupport.parkNanos(this, nanosTimeout);
            long now = System.nanoTime();
            // 更新nanosTimeout
            nanosTimeout -= now - lastTime;
            lastTime = now;
            if (Thread.interrupted())
                //相应中断
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

结束语

ReentrantLock增加了一些高级特性如:等待可中断、可实现公平锁,以及锁可以绑定多个条件。
(1)等待可中断:等待的线程可选择放弃等待或改做其他处理。
(2)公平锁是指按申请锁的时间顺序获取锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但是可以通过带布尔值的构造函数要求使用公平锁。
(3)锁绑定多个条件。是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象通过wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但要多个条件又得多加一个锁。

ReentrantLock增加了一些高级特性如:等待可中断、可实现公平锁,以及锁可以绑定多个条件。这些内容将在第30节进行讲解。

(1)等待可中断:等待的线程可选择放弃等待或改做其他处理。等待可中断可提高CPU的利用率。当时阿里面试时,有问到一个问题。前提条件是:已经开启了多个线程组,怎样减少某个线程组中线程的等待时间,最大程度利用CPU。其中可以用等待可中断属性,先让出CPU资源。线程池中的线程可以先干其他操作。

(2)公平锁是指按申请锁的时间顺序获取锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但是可以通过带布尔值的构造函数要求使用公平锁。

(3)锁绑定多个条件。是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象通过wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但要多个条件又得多加一个锁。

https://cloud.tencent.com/developer/article/1092512

https://www.javarticles.com/category/java/java-concurrency

https://blog.csdn.net/Greek_xpf/article/details/79773406

https://www.cnblogs.com/qiuyong/p/7102779.html

https://blog.csdn.net/a1439775520/article/details/98471610

https://blog.csdn.net/qq_39662660/article/details/91558970
https://www.jianshu.com/p/28387056eeb4

在这里插入图片描述

xx

在这里插入图片描述

1、如果前一个节点的waitState 是-1(等待同步),直接返回,然后就是执行&&后面的操作,将当前线程挂起。

private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
}

2、如果前一个节点的waitState 大于0,即状态是cancelled ==1 从链表尾部开始查找,找到状态小于等于0 ,并且返回false

3、如果当前状态小于0,且不是signal状态,将pred节点的状态更新为signal

以上就是非共享模式下的AQS实现,整理下执行流程:

1、线程A和线程B同时去获取锁,如果线程A获取成功,会将AQS当中的state改为1,并且将AQS当中的线程指定为当前线程

2、线程B尝试去获取锁失败,这时候会去生成新的node节点(这个节点所有参数都不设置,不包含thread)作为head节点,然后将线程B放在head节点的后面作为tail

3、在放入对列之后,先进行自旋,判断前面的节点是否是head节点,以及当前线程再次尝试获取锁,如果这时候线程A已经执行结束,并且释放掉锁,线程B这时候就不需要进行挂起,直接去执行

4、如果线程B这时候没有获取到锁,判断它前一个节点的状态,如果前一个节点状态是SIGNAL, 将线程B通过LockSupport方式挂起,如果前一个节点状态是CANCELLED状态,那么就会从尾部开始查找,直到waitState小于0,然后将节点waitState改为是SIGNAL状态。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值