【JUC并发编程】深入浅出Java并发基石——AQS

news2024/11/16 7:28:23

【JUC并发编程】深入浅出Java并发基石——AQS

参考资料:

RedSpider社区——第十一章 AQS

深入剖析并发之AQS独占锁

1.5w字,30图带你彻底掌握 AQS!

深入浅出AbstractQueuedSynchronizer

我画了35张图就是为了让你深入 AQS

动画演示AQS的核心原理

文章目录

  • 【JUC并发编程】深入浅出Java并发基石——AQS
    • 一:AQS简介
    • 二:AQS的数据结构
    • 三:AQS资源共享模式
    • 四:AQS的主要方法源码解析
      • 以 ReentrantLock 举例
      • 获取资源
      • tryAcquire 剖析
      • acquireQueued 剖析
      • 释放资源
    • 五:ReetrantLock实现总结
    • 六:AQS总结

image-20230128202524755

一:AQS简介

AQSAbstractQueuedSynchronizer的简称,即抽象队列同步器,从字面意思上理解:

  • 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
  • 队列:使用先进先出(FIFO)队列存储数据;
  • 同步:实现了同步的功能。

那AQS有什么用呢?AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的同步器,比如我们提到的ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器,只要子类实现它的几个protected方法就可以了,在下文会有详细的介绍。

我们先看下AQS相关的UML图:

image-20230128202908596

二:AQS的数据结构

AQS内部使用了一个volatile的变量state来作为资源的标识。同时定义了几个获取和改变state的protected方法,子类可以覆盖这些方法来实现自己的逻辑:

getState()
setState()
compareAndSetState()

image-20230128203126284

这三种操作均是原子操作,其中compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法。

而AQS类本身实现的是一些排队和阻塞的机制,比如具体线程等待队列的维护(如获取资源失败入队/唤醒出队等)。它内部使用了一个先进先出(FIFO)的双端队列,并使用了两个指针head和tail用于标识队列的头部和尾部。其数据结构如图:

image-20230128203203774

另外 AQS 中实现的 FIFO 队列(CLH 队列)其实是双向链表实现的,由 head, tail 节点表示,head 结点代表当前占用的线程,其他节点由于暂时获取不到锁所以依次排队等待锁释放。

所以我们不难明白 AQS 的如下定义:

public abstract class AbstractQueuedSynchronizer
  extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    // 以下为双向链表的首尾结点,代表入口等待队列
    private transient volatile Node head;
    private transient volatile Node tail;
    // 共享变量 state
    private volatile int state;
    // cas 获取 / 释放 state,保证线程安全地获取锁
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    // ...
 }

三:AQS资源共享模式

资源有两种共享模式,或者说两种同步方式:

  • 独占模式(Exclusive):资源是独占的,一次只能一个线程获取。如ReentrantLock。

    独占锁:即其他线程只有在占有锁的线程释放后才能竞争锁,有且只有一个线程能竞争成功(医生只有一个,一次只能看一个病人)。

    image-20230128203459380

  • 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch。

    共享锁:即共享资源可以被多个线程同时占有,直到共享资源被占用完毕(多个医生,可以看多个病人),常见的有读写锁 ReadWriteLock, CountdownLatch。

    image-20230128203524026

一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock

AQS中关于这两种资源共享模式的定义源码(均在内部类Node中)。我们来看看Node的结构:

static final class Node {
    // 标记一个结点(对应的线程)在共享模式下等待
    static final Node SHARED = new Node();
    // 标记一个结点(对应的线程)在独占模式下等待
    static final Node EXCLUSIVE = null; 

    // waitStatus的值,表示该结点(对应的线程)已被取消
    static final int CANCELLED = 1; 
    // waitStatus的值,表示后继结点(对应的线程)需要被唤醒
    static final int SIGNAL = -1;
    // waitStatus的值,表示该结点(对应的线程)在等待某一条件
    static final int CONDITION = -2;
    /*waitStatus的值,表示有资源可用,新head结点需要继续唤醒后继结点(共享模式下,多线程并发释放资源,而head唤醒其后继结点后,需要把多出来的资源留给后面的结点;设置新的head结点时,会继续唤醒其后继结点)*/
    static final int PROPAGATE = -3;

    // 等待状态,取值范围,-3,-2,-1,0,1
    volatile int waitStatus;
    volatile Node prev; // 前驱结点
    volatile Node next; // 后继结点
    volatile Thread thread; // 结点对应的线程
    Node nextWaiter; // 等待队列里下一个等待条件的结点


    // 判断共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    Node(Thread thread, Node mode) {     // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    // 其它方法忽略,可以参考具体的源码
}

// AQS里面的addWaiter私有方法
private Node addWaiter(Node mode) {
    // 使用了Node的这个构造函数
    Node node = new Node(Thread.currentThread(), mode);
    // 其它代码省略
}

注意:通过Node我们可以实现两个队列,一是通过prev和next实现CLH队列(线程同步队列,双向队列),二是nextWaiter实现Condition条件上的等待线程队列(单向队列),这个Condition主要用在ReentrantLock类中。

四:AQS的主要方法源码解析

以 ReentrantLock 举例

首先我们先来看下 ReentrantLock 的使用方法

        ReentrantLock lock = new ReentrantLock(false);
        lock.lock();
        //...
        lock.unlock();

我们来分析下非公平锁是如何加锁的

image-20230128205828764

image-20230128205910453

image-20230128205923978

可以看到 lock 方法主要有两步

  1. 使用 CAS 来获取 state 资源,如果成功设置 1,代表 state 资源获取锁成功,此时记录下当前占用 state 的线程 setExclusiveOwnerThread(Thread.currentThread());
  2. 如果 CAS 设置 state 为 1 失败(代表获取锁失败),则执行 acquire(1) 方法,这个方法是 AQS 提供的方法,如下
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

获取资源

获取资源的入口是acquire(int arg)方法。arg是要获取的资源的个数,在独占模式下始终为1。我们先来看看这个方法的逻辑:

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

tryAcquire 剖析

首先调用tryAcquire(arg)尝试去获取资源。前面提到了这个方法是在子类具体实现的。

image-20230128210257773

先来看下 tryAcquire 方法,这个方法是 AQS 提供的一个模板方法,最终由其 AQS 具体的实现类(Sync)实现,由于执行的是非公平锁逻辑,执行的代码如下:

image-20230128210328237

此段代码可知锁的获取主要分两种情况

  1. state 为 0 时,代表锁已经被释放,可以去获取,于是使用 CAS 去重新获取锁资源,如果获取成功,则代表竞争锁成功,使用 setExclusiveOwnerThread(current) 记录下此时占有锁的线程,看到这里的 CAS,大家应该不难理解为啥当前实现是非公平锁了,因为队列中的线程与新线程都可以 CAS 获取锁啊,新来的线程不需要排队
  2. 如果 state 不为 0,代表之前已有线程占有了锁,如果此时的线程依然是之前占有锁的线程(current == getExclusiveOwnerThread() 为 true),代表此线程再一次占有了锁(可重入锁),此时更新 state,记录下锁被占有的次数(锁的重入次数),这里的 setState 方法不需要使用 CAS 更新,因为此时的锁就是当前线程占有的,其他线程没有机会进入这段代码执行。所以此时更新 state 是线程安全的。

假设当前 state = 0,即锁不被占用,现在有 T1, T2, T3 这三个线程要去竞争锁

image-20230128210557025

假设现在 T1 获取锁成功,则两种情况分别为 1、 T1 首次获取锁成功

image-20230128210610614

2、 T1 再次获取锁成功,state 再加 1,表示锁被重入了两次,当前如果 T1一直申请占用锁成功,state 会一直累加

image-20230128210650784

如果 tryAcquire(arg) 执行失败,代表获取锁失败,则执行 acquireQueued 方法,将线程加入 FIFO 等待队列

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

所以接下来我们来看看 acquireQueued 的执行逻辑,首先会调用 addWaiter(Node.EXCLUSIVE) 将包含有当前线程的 Node 节点入队, Node.EXCLUSIVE 代表此结点为独占模式

acquireQueued 剖析

如果获取资源失败,就通过addWaiter(Node.EXCLUSIVE)方法把这个线程插入到等待队列中。其中传入的参数代表要插入的Node是独占式的。

所以接下来我们来看看 acquireQueued 的执行逻辑,首先会调用 addWaiter(Node.EXCLUSIVE) 将包含有当前线程的 Node 节点入队, Node.EXCLUSIVE 代表此结点为独占模式。

这个方法的具体实现:

private Node addWaiter(Node mode) {
    // 生成该线程对应的Node节点
    Node node = new Node(Thread.currentThread(), mode);
    // 将Node插入队列中
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // 使用CAS尝试,如果成功就返回
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 如果等待队列为空或者上述CAS失败,再自旋CAS插入
    enq(node);
    return node;
}

这段逻辑比较清楚,首先是获取 FIFO 队列的尾结点,如果尾结点存在,则采用 CAS 的方式将等待线程入队,如果尾结点为空则执行 enq 方法

// 自旋CAS插入等待队列
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

首先判断 tail 是否为空,如果为空说明 FIFO 队列的 head,tail 还未构建,此时先构建头结点,构建之后再用 CAS 的方式将此线程结点入队

使用 CAS 创建 head 节点的时候只是简单调用了 new Node() 方法,并不像其他节点那样记录 thread,这是为啥

因为 head 结点为虚结点,它只代表当前有线程占用了 state,至于占用 state 的是哪个线程,其实是调用了上文的 setExclusiveOwnerThread(current) ,即记录在 exclusiveOwnerThread 属性里。

执行完 addWaiter 后,线程入队成功,现在就要看最后一个最关键的方法 acquireQueued 了,这个方法有点难以理解,先不急,我们先用三个线程来模拟一下之前的代码对应的步骤

1、假设 T1 获取锁成功,由于此时 FIFO 未初始化,所以先创建 head 结点

image-20230128211049143

2、此时 T2 或 T3 再去竞争 state 失败,入队,如下图:

image-20230128211100280

好了,现在问题来了, T2,T3 入队后怎么处理,是马上阻塞吗,马上阻塞意味着线程从运行态转为阻塞态 ,涉及到用户态向内核态的切换,而且唤醒后也要从内核态转为用户态,开销相对比较大,所以 AQS 对这种入队的线程采用的方式是让它们自旋来竞争锁,如下图示

image-20230128211116450

不过聪明的你可能发现了一个问题,如果当前锁是独占锁,如果锁一直被被 T1 占有, T2,T3 一直自旋没太大意义,反而会占用 CPU,影响性能,所以更合适的方式是它们自旋一两次竞争不到锁后识趣地阻塞以等待前置节点释放锁后再来唤醒它。

另外如果锁在自旋过程中被中断了,或者自旋超时了,应该处于「取消」状态。

基于每个 Node 可能所处的状态,AQS 为其定义了一个变量 waitStatus,根据这个变量值对相应节点进行相关的操作,我们一起来看这看这个变量都有哪些值,是时候看一个 Node 结点的属性定义了

基于每个 Node 可能所处的状态,AQS 为其定义了一个变量 waitStatus,根据这个变量值对相应节点进行相关的操作,我们一起来看这看这个变量都有哪些值,是时候看一个 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;  // 节点阻塞(park)必须在其前驱结点为 SIGNAL 的状态下才能进行,如果结点为 SIGNAL,则其释放锁或取消后,可以通过 unpark 唤醒下一个节点,
    static final int CONDITION = -2;//表示线程在等待条件变量(先获取锁,加入到条件等待队列,然后释放锁,等待条件变量满足条件;只有重新获取锁之后才能返回)
    static final int PROPAGATE = -3;//表示后续结点会传播唤醒的操作,共享模式下起作用

    //等待状态:对于condition节点,初始化为CONDITION;其它情况,默认为0,通过CAS操作原子更新
    volatile int waitStatus;

通过状态的定义,我们可以猜测一下 AQS 对线程自旋的处理:如果当前节点的上一个节点不为 head,且它的状态为 SIGNAL,则结点进入阻塞状态。来看下代码以映证我们的猜测:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 如果前一个节点是 head,则尝试自旋获取锁
            if (p == head && tryAcquire(arg)) {
                //  将 head 结点指向当前节点,原 head 结点出队
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果前一个节点不是 head 或者竞争锁失败,则进入阻塞状态
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            // 如果线程自旋中因为异常等原因获取锁最终失败,则调用此方法
            cancelAcquire(node);
    }
}

先来看第一种情况,如果当前结点的前一个节点是 head 结点,且获取锁(tryAcquire)成功的处理

image-20230128211552985

可以看到主要的处理就是把 head 指向当前节点,并且让原 head 结点出队,这样由于原 head 不可达, 会被垃圾回收。

注意其中 setHead 的处理

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

将 head 设置成当前结点后,要把节点的 thread, pre 设置成 null,因为之前分析过了,head 是虚节点,不保存除 waitStatus(结点状态)的其他信息,所以这里把 thread ,pre 置为空,因为占有锁的线程由 exclusiveThread 记录了,如果 head 再记录 thread 不仅多此一举,反而在释放锁的时候要多操作一遍 head 的 thread 释放。

如果前一个节点不是 head 或者竞争锁失败,则首先调用 shouldParkAfterFailedAcquire 方法判断锁是否应该停止自旋进入阻塞状态:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
        
    if (ws == Node.SIGNAL)
       // 1. 如果前置顶点的状态为 SIGNAL,表示当前节点可以阻塞了
        return true;
    if (ws > 0) {
        // 2. 移除取消状态的结点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 3. 如果前置节点的 ws 不为 0,则其设置为 SIGNAL,
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

这一段代码有点绕,需要稍微动点脑子,按以上步骤一步步来看

1、 首先我们要明白,根据之前 Node 类的注释,如果前驱节点为 SIGNAL,则当前节点可以进入阻塞状态。

image-20230128213425763

如图示:T2,T3 的前驱节点的 waitStatus 都为 SIGNAL,所以 T2,T3 此时都可以阻塞。

2、如果前驱节点为取消状态,则前驱节点需要移除,这些采用了一个更巧妙的方法,把所有当前节点之前所有 waitStatus 为取消状态的节点全部移除,假设有四个线程如下,T2,T3 为取消状态,则执行逻辑后如下图所示,T2, T3 节点会被 GC。

image-20230128213453006

3、如果前驱节点小于等于 0,则需要首先将其前驱节点置为 SIGNAL,因为前文我们分析过,当前节点进入阻塞的一个条件是前驱节点必须为 SIGNAL,这样下一次自旋后发现前驱节点为 SIGNAL,就会返回 true(即步骤 1)

shouldParkAfterFailedAcquire 返回 true 代表线程可以进入阻塞中断,那么下一步 parkAndCheckInterrupt 就该让线程阻塞了

private final boolean parkAndCheckInterrupt() {
    // 阻塞线程
    LockSupport.park(this);
    // 返回线程是否中断过,并且清除中断状态(在获得锁后会补一次中断)
    return Thread.interrupted();
}

这里的阻塞线程很容易理解,但为啥要判断线程是否中断过呢,因为如果线程在阻塞期间收到了中断,唤醒(转为运行态)获取锁后(acquireQueued 为 true)需要补一个中断,如下所示:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 如果是因为中断唤醒的线程,获取锁后需要补一下中断
        selfInterrupt();
}

至此,获取锁的流程已经分析完毕。

上面的两个函数比较好理解,就是在队列的尾部插入新的Node节点,但是需要注意的是由于AQS中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过CAS自旋的方式保证了操作的线程安全性。

OK,现在回到最开始的aquire(int arg)方法。现在通过addWaiter方法,已经把一个Node放到等待队列尾部了。而处于等待队列的结点是从头结点一个一个去获取资源的。具体的实现我们来看看acquireQueued方法

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 自旋
        for (;;) {
            final Node p = node.predecessor();
            // 如果node的前驱结点p是head,表示node是第二个结点,就可以尝试去获取资源了
            if (p == head && tryAcquire(arg)) {
                // 拿到资源后,将head指向该结点。
                // 所以head所指的结点,就是当前获取到资源的那个结点或null。
                setHead(node); 
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果自己可以休息了,就进入waiting状态,直到被unpark()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

这里parkAndCheckInterrupt方法内部使用到了LockSupport.park(this),顺便简单介绍一下park。

LockSupport类是Java 6 引入的一个类,提供了基本的线程同步原语。LockSupport实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数:

  • park(boolean isAbsolute, long time):阻塞当前线程
  • unpark(Thread jthread):使给定的线程停止阻塞

所以结点进入等待队列后,是调用park使它进入阻塞状态的。只有头结点的线程是处于活跃状态的

当然,获取资源的方法除了acquire外,还有以下三个:

  • acquireInterruptibly:申请可中断的资源(独占模式)
  • acquireShared:申请共享模式的资源
  • acquireSharedInterruptibly:申请可中断的资源(共享模式)

可中断的意思是,在线程中断时可能会抛出InterruptedException

总结起来的一个流程图:

image-20230128205316940

释放资源

释放资源相比于获取资源来说,会简单许多。不管是公平锁还是非公平锁,最终都是调的 AQS 的如下模板方法来释放锁

public final boolean release(int arg) {
    // 锁释放是否成功
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

private void unparkSuccessor(Node node) {
    // 如果状态是负数,尝试把它设置为0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 得到头结点的后继结点head.next
    Node s = node.next;
    // 如果这个后继结点为空或者状态大于0
    // 通过前面的定义我们知道,大于0只有一种可能,就是这个结点已被取消
    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;
    }
    // 如果后继结点不为空,
    if (s != null)
        LockSupport.unpark(s.thread);
}

五:ReetrantLock实现总结

基础组件:

  • 同步状态标识:对外显示锁资源的占有状态
  • 同步队列:存放获取锁失败的线程
  • 等待队列:用于实现多条件唤醒
  • Node节点:队列的每个节点,线程封装体

基础动作:

  • cas修改同步状态标识
  • 获取锁失败加入同步队列阻塞
  • 释放锁时唤醒同步队列第一个节点线程

加锁动作:

  • 调用tryAcquire()修改标识state,成功返回true执行,失败加入队列等待
  • 加入队列后判断节点是否为signal状态,是就直接阻塞挂起当前线程
  • 如果不是则判断是否为cancel状态,是则往前遍历删除队列中所有cancel状态节点
  • 如果节点为0或者propagate状态则将其修改为signal状态
  • 阻塞被唤醒后如果为head则获取锁,成功返回true,失败则继续阻塞

解锁动作:

  • 调用tryRelease()释放锁修改标识state,成功则返回true,失败返回false
  • 释放锁成功后唤醒同步队列后继阻塞的线程节点
  • 被唤醒的节点会自动替换当前节点成为head节点

六:AQS总结

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。

AbstractQueuedSynchronizer 是一个比较复杂的实现,要完全理解其中的细节还需要慢慢琢磨。

这篇文章也只能起到一个抛砖引玉的作用,将AbstractQueuedSynchronizer的设计思想,核心数据结构已经核心实现代码展示给大家。希望对大家理解AbstractQueuedSynchronizer的实现,以及理解重入锁,信号量,读写锁有一定帮助。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/184814.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

遍历 “可变参数模板” 的模板参数

类模板和函数模板&#xff0c;只能包含固定数量的模板参数&#xff0c;C11支持模板参数可变&#xff0c;那么在不知道模板参数有多少个的情况下&#xff0c;如何遍历模板参数&#xff1f; 目录 一、可变参数模板的声明 二、可变参数模板的遍历 1、递归遍历 2、非递归遍历 …

idea使用DataBase连接数据库 Free MyBatis Tool自动生成 实体类工具使用

DataBase DataBase连接数据库 设置DataSources Host 》IP地址Port 》端口号User 》用户名Password 》密码Database 》连接的数据库 设置驱动 Drives tables 文件夹中即所连接数据库中表 Free MyBatis Tool自动生成 实体类&#xff0c;Mapper &#xff0c;以及mapper.xml 选…

CleanMyMac4.12.3最新版本Mac系统清理工具

CleanMyMac可以为Mac腾出空间&#xff0c;软件已经更新到CleanMyMac X 支持最新版Macos 10.14系统。CleanMyMac具有一系列巧妙的新功能&#xff0c;可让您安全&#xff0c;智能地扫描和清理整个系统&#xff0c;删除大量未使用的文件&#xff0c;减小iPhoto图库的大小&#xff…

非类型模板参数/模板的特化/模板的分离编译

上一篇文章中&#xff0c;我们对模板有了初步的认识&#xff0c;接下来我们便对模板进一步地学习&#xff01; 1.非类型模板参数 模板参数分为类型形参与非类型形参&#xff1a; ①类型形参即&#xff1a;出现在模板参数列表中&#xff0c;跟在class或者typename之类的参数类…

20 个杀手级的 JavaScript 单行代码,可以节省你的编码时间

使用这些基本的单行代码将您的 JavaScript 技能提升到一个新的水平&#xff0c;这也将节省您的编码时间 &#x1f680;1) 查找数组中的最大值&#xff1a;Math.max(...array)2&#xff09;从数组中删除重复项&#xff1a;[...newSet(array)]3&#xff09;生成一个1到100之间的随…

Java开发环境搭建实践

前言 刚刚弄完python的环境搭建&#xff0c;今年打算也要好好学习Java&#xff0c;所以把Java的环境弄起来 搭建过程 jdk下载和安装 下载 官网&#xff1a;Oracle 甲骨文中国 | 云应用和云平台 打开官网 点击产品后下拉找到Java点进去。 下载Java 我就下载最新的jdk把…

Spring Boot学习之任务学习【异步、定时、邮件】

文章目录一 异步任务1.1 创建spring Boot项目&#xff0c;选择Spring Web1.2 创建AsyncService类1.3 编写controller类1.4 在启动类上开启异步功能1.5 测试结果二 定时任务2.1 基础知识2.2 项目创建2.3 创建一个ScheduledService2.4 在主程序上增加EnableScheduling 开启定时任…

Hbuilder打包成苹果IOS-App的详解

本文相关主要记录一下使用Hbuilder打包成苹果IOS-App的详细步骤。介绍一下个人开发者账号&#xff1a;再说下什么是免费的苹果开发者账号&#xff0c;就是你没交688年费的就是免费账号&#xff0c;如果你想变成付费开发者账号&#xff0c;提交申请付费就行&#xff0c;账号都是…

【C++】priority_queue使用模拟实现

priority_queue使用 http://www.cplusplus.com/reference/queue/priority_queue/ 文档介绍 优先级队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的(大堆为例) 在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)…

应用系统基于OAuth2实现单点登录的解决方案

1、OAuth2单点认证原理 基于OAuth2的认证方式包含四种&#xff0c;其中单点登录最常用的是授权码模式&#xff0c;其基本的认证过程如下&#xff1a; 用户访问业务应用&#xff0c;业务应用进行登录检查&#xff1b;业务应用重定向到OAuth2认证服务器&#xff0c;调用获取授权…

米哈伊年终奖是32万,我的年终奖是彩虹屁!

数据来源沉默王二 | 数据报表小熊绘制 年都过完了&#xff0c;年终奖结果也都出来了&#xff0c;我这个年没有过好&#xff0c;每次想到就难受&#xff0c;在看王二整理出来的年终奖&#xff0c;整个人都不好了。 本次统计基于49条数据的不准确统计&#xff0c;仅抽取部分公司部…

Lesson 4.4 随机梯度下降与小批量梯度下降

文章目录一、损失函数理论基础二、随机梯度下降&#xff08;Stochastic Gradient Descent&#xff09;1. 随机梯度下降计算流程2. 随机梯度下降的算法特性3. 随机梯度下降求解线性回归4. 随机梯度下降算法评价三、小批量梯度下降&#xff08;Mini-batch Gradient Descent&#…

SpringMVC执行流程和原理

1、用户发送出请求到前端控制器DispatcherServlet。 2、DispatcherServlet收到请求调用HandlerMapping&#xff08;处理器映射器&#xff09;。 3、HandlerMapping找到具体的处理器(可查找xml配置或注解配置)&#xff0c;生成处理器对象及处理器拦截器 (如果有)&#xff0c;再…

51单片机学习笔记-3模块化编程

3 模块化编程 [toc] 注&#xff1a;笔记主要参考B站江科大自化协教学视频“51单片机入门教程-2020版 程序全程纯手打 从零开始入门”。 3.1 模块化编程 传统方式编程&#xff1a;所有的函数均放在main.c里&#xff0c;若使用的模块比较多&#xff0c;则一个文件内会有很多的…

1604_linux环境下使用命令行把网页转换成pdf

全部学习汇总&#xff1a; GreyZhang/toolbox: 常用的工具使用查询&#xff0c;非教程&#xff0c;仅作为自我参考&#xff01; (github.com) 使用的工具很容易在彼此之间产生隔离性障碍&#xff0c;比如我最近使用的墨水屏阅读的最合适的文件格式我觉得是pdf&#xff0c;但是我…

路由工具之路由策略router-policy、acl列表与ip-prefix前缀列表的区别、过滤列表filter-policy

3.0.0 路由工具之路由策略router-policy、acl列表与ip-prefix前缀列表的区别、过滤列表filter-policy 目录IP-Prefix前缀列表前缀列表与ACLrouter-policy路由策略应用路由策略过滤路由1、环境介绍2、配置OSPF3、过滤路由&#xff08;1&#xff09;ACL匹配路由方式过滤&#xff…

带死区的PID控制算法及仿真

在计算机控制系统中&#xff0c;某些系统为了避免控制作用过于频繁&#xff0c;消除由于频繁动作所引起的振荡&#xff0c;可采用带死区的PID控制算法&#xff0c;控制算式为&#xff1a;式中&#xff0c;e(k)为位置跟踪偏差;e为一个可调参数&#xff0c;其具体数值可根据实际控…

软件测试职场六年,一个女测试工程师的自我认知

微软自动化测试二年&#xff0c;而后转入阿里做自动化测试三年&#xff0c;经历了入行时的迷茫&#xff0c;而后的笃定&#xff0c;转入移动后对自身定位和价值的怀疑&#xff0c;继而对自动化测试的重新认识&#xff0c;职场六年&#xff0c;终于敢对自动化测试有所论述了。 先…

五个好用的PDF软件推荐!

我们在工作中经常需要选择一款好用的办公软件来转换PDF文件&#xff0c;如果选择的软件不好用&#xff0c;那就回影响我们工作的效率&#xff0c;如果选对了软件&#xff0c;就可以让我们的效率越来越高&#xff0c;足以证明软件的在我们办公中的重要性&#xff0c;下面小编就来…

win远程桌面连接无显示器Ubuntu(22.04.1 LTS)

1、安装ssh server 安装虚拟显示器会导致物理显示器无法使用&#xff0c;为防止虚拟显示出现问题无法连接Ubuntu&#xff0c;在必要时可以使用SSH连接系统。 # Ubuntu Terminal sudo apt-get install openssh-server在Windows中尝试连接 # Windows PowerShell ssh UsernameU…