一、简单使用

在聊它的源码之前,我们先来做个简单的使用说明。当我在IDEA中创建了一个简单的Demo之后,它会给出以下提示

提示文字

在使用阻塞等待获取锁的方式中,必须在try代码块之外,并且在加锁方法与try代码块之间没有任何可能抛出异常的方法调用,避免加锁成功后,在finally中无法解锁。

1、如果在lock方法与try代码块之间的方法调用抛出异常,那么无法解锁,造成其它线程无法成功获取锁。

2、如果lock方法在try代码块之内,可能由于其它方法抛出异常,导致在finally代码块中,unlock对未加锁的对象解锁,它会调用AQS的tryRelease方法(取决于具体实现类),抛出IllegalMonitorStateException异常。

3、在Lock对象的lock方法实现中可能抛出unchecked异常,产生的后果与说明二相同

还举了两个例子,正确案例如下:

Lock lock = new XxxLock();
// ...
lock.lock();
try {
doSomething();
doOthers();
} finally {
lock.unlock();
}

错误案例如下:

Lock lock = new XxxLock();
// ...
try {
// 如果在此抛出异常,会直接执行 finally 块的代码
doSomething();
// 不管锁是否成功,finally 块都会执行
lock.lock();
doOthers(); } finally {
lock.unlock();
}

二、AQS

Sync 是通过继承AbstractQueuedSynchronizer来实现的,没错,AbstractQueuedSynchronizer就是AQS的全称。AQS内部维护着一个FIFO的双向队列(CLH),ReentrantLock也是基于它来实现的,先来张图感受下。

三、Node 属性

  //此处是 Node 的部分属性
static final class Node { //排他锁标识
static final Node EXCLUSIVE = null; //如果带有这个标识,证明是失效了
static final int CANCELLED = 1; //具有这个标识,说明后继节点需要被唤醒
static final int SIGNAL = -1; //Node对象存储标识的地方
volatile int waitStatus; //指向上一个节点
volatile Node prev; //指向下一个节点
volatile Node next; //当前Node绑定的线程
volatile Thread thread; //返回前驱节点即上一个节点,如果前驱节点为空,抛出异常
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
}

对于里边的waitStatus属性,我们需要做个解释:

  • CANCELLED(1):如果在AQS同步队列中等待的线程等待超时或被中断,那么需要从AQS同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化

  • SIGNAL(-1):只要获取锁的线程释放锁,就会通知标识为SIGNAL状态的后续节点的线程

  • CONDITION(-2):Condition​中使用,当前线程阻塞在Condition​,如果其他线程调用了Condition的signal方法,这个结点将从等待队列转移到同步队列队尾,等待获取同步锁;

  • PROPAGATE(-3):共享模式,前置节点唤醒后面节点后,唤醒操作无条件传播下去;

  • 0:线程节点在队列的初始状态

四、AQS 属性

// 头结点
private transient volatile Node head; // 尾结点
private transient volatile Node tail; //0->1 拿到锁,大于0 说明当前已经有线程占用了锁资源
private volatile int state;

五、加锁

对AQS的结构有了基本了解之后,我们正式进入主题——加锁。从源码中可以看出锁被分为公平锁和非公平锁

/**
* 公平锁代码
*/
final void lock(){
acquire(1);
} /**
* 非公平锁代码
*/
final void lock(){
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

六、非公平锁

final void lock(){
//通过 CAS 的方式尝试将 state 从0改为1,
//如果返回 true,代表修改成功,获得锁资源;
//如果返回false,代表修改失败,未获取锁资源
if (compareAndSetState(0, 1))
// 将属性exclusiveOwnerThread设置为当前线程,该属性是AQS的父类提供的
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

compareAndSetState():底层调用的是unsafe的compareAndSwapInt,该方法是原子操作;

假设有两个线程(t1、t2)在竞争锁资源,线程1获取锁资源之后,执行setExclusiveOwnerThread操作,设置属性值为当前线程t1

此时,当t2想要获取锁资源,调用lock()方法之后,执行compareAndSetState(0, 1)返回false,会走else执行acquire()方法。

1、方法查看

public final void accquire(int arg){
// tryAcquire 再次尝试获取锁资源,如果尝试成功,返回true,尝试失败返回false
if (!tryAcquire(arg) &&
// 走到这,代表获取锁资源失败,需要将当前线程封装成一个Node,追加到AQS的队列中
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 线程中断
selfInterrupt();
}

accquire()中涉及的方法比较多,我们将进行拆解,一个一个来分析,顺序:tryAcquire() -> addWaiter() -> acquireQueued()

2、查看 tryAcquire() 方法

//AQS中
protected boolean tryAcquire(int arg){
//AQS 是基类,具体实现在自己的类中实现,我们去查看“非公平锁”中的实现
throw new UnsupportedOperationException();
} //ReentrantLock 中
protected final boolean tryAcquire(int acquires){
return nonfairTryAcquire(acquires);
} final boolean nonfairTryAcquire(int acquires){ // 获取当前线程
final Thread current = Thread.currentThread(); //获取AQS 的 state
int c = getState(); // 如果 state 为0,代表尝试再次获取锁资源
if (c == 0) { // 步骤同上:通过 CAS 的方式尝试将 state 从0改为1,
//如果返回 true,代表修改成功,获得锁资源;
//如果返回false,代表修改失败,未获取锁资源
if (compareAndSetState(0, acquires)) {
//设置属性为当前线程
setExclusiveOwnerThread(current);
return true;
}
} //当前占有锁资源的线程是否是当前线程,如果是则证明是可重入操作
else if (current == getExclusiveOwnerThread()) { //将 state + 1
int nextc = c + acquires; //为什么会小于 0 呢?因为最大值 + 1 后会将符号位的0改为1 会变成负数(可参考Integer.MAX_VALUE + 1)
if (nextc < 0) // overflow
//加1后小于0,超出锁可重入的最大值,抛异常
throw new Error("Maximum lock count exceeded"); //设置 state 状态
setState(nextc);
return true;
}
return false;
}

因为线程1已经获取到了锁,此时state为1,所以不走nonfair非公平的TryAcquire()方法的if。又因为当前是线程2,不是占有当前锁的线程1,所以也不会走else if,即tryAcquire()方法返回 false

3、查看 addWaiter() 方法

走到本方法中,代表获取锁资源失败。addWaiter()将没有获取到锁资源的线程甩到队列的尾部

private Node addWaiter(Node mode){

	 //创建 Node 类,并且设置 thread 为当前线程,设置为排它锁
Node node = new Node(Thread.currentThread(), mode); // 获取 AQS 中队列的尾部节点
Node pred = tail; // 如果 tail == null,说明是空队列,
// 不为 null,说明现在队列中有数据,
if (pred != null) { // 将当前节点的 prev 指向刚才的尾部节点,那么当前节点应该设置为尾部节点
node.prev = pred; // CAS 将 tail 节点设置为当前节点
if (compareAndSetTail(pred, node)) { // 将之前尾节点的 next 设置为当前节点
pred.next = node; // 返回当前节点
return node;
}
}
enq(node);
return node;
}

当tail不为空,即队列中有数据时,我们来图解一下 pred!=null 里面中的代码。初始化状态如下,pred指向队列的尾节点,node指向新的节点。

de.prev = pred;将node的前驱指针pred指向队列中尾部节点pred = tail

compareAndSetTail(pred, node) 通过CAS的方式尝试将node设置为尾结点,此处我们假设设置成功,则FIFO队列的tail指向node节点

pred.next = node;将队列中尾节点pred的后驱指针next指向node节点,此时node节点成功进入FIFO队列尾部

而当pred为空,即队列中没有节点或将node节点设置为尾结点失败时,会走enq()方法。我们列举的例子就符合pred为空的情况,就让我们以例子为基础继续分析吧

//现在没人排队,我是第一个 || 前边CAS失败也会进入这个位置重新往队列尾巴去塞
private Node enq(final Node node){ //死循环
for (;;) { //重新获取tail节点
Node t = tail; // 没人排队,队列为空
if (t == null) { // 初始化一个 Node 为 head,而这个head 没有意义
if (compareAndSetHead(new Node()))
// 将头尾都指向了这个初始化的Node,第一次循环结束
tail = head;
} else { // 有人排队,往队列尾巴塞
node.prev = t; // CAS 将 tail 节点设置为当前节点
if (compareAndSetTail(t, node)) {
//将之前尾节点的 next 设置为当前节点
t.next = node;
return t;
}
}
}
}

进入死循环,首先会走if方法的逻辑,通过CAS的方式尝试将一个新节点设置为head节点,然后将tail也指向新节点。可以看出队列中的头节点只是个初始化的点【哨兵节点】,没有任何意义

继续走死循环中的代码,此时t不为null,所以会走else方法。将node的前驱节点指向t,通过CAS方式将当前节点node设置为尾结点,然后将t的后继节点指向node。此时线程2的节点就被成功塞入FIFO队列尾部。

4、查看 acquireQueued()方法

将已经在队列中的node尝试去获取锁否则挂起

final boolean acquireQueued(final Node node, int arg){

	 // 获取锁资源的标识,失败为 true,成功为 false
boolean failed = true; try { // 线程中断的标识,中断为 true,不中断为 false
boolean interrupted = false; for (;;) { // 获取当前节点的上一个节点
final Node p = node.predecessor(); //p为头节点,尝试获取锁操作
if (p == head && tryAcquire(arg)) { setHead(node);
p.next = null; // 将获取锁失败标识置为false
failed = false; // 获取到锁资源,不会被中断
return interrupted;
} // p 不是 head 或者 没拿到锁资源,基于 Unsafe 的 park方法,挂起线程
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

这里又出现了一次死循环,首先获取当前节点的前驱节点p,如果p是头节点(哨兵节点没有意义),说明node是head后的第一个节点,此时当前获取锁资源的线程1可能会释放锁,所以线程2可以再次尝试获取锁。

假设获取成功,证明拿到锁资源了,将node节点设置为head节点,并将node节点的pre和thread设置为null。因为拿到锁资源了,node节点就不需要排队了。

将头节点p的next置为null,此时p节点就不在队列中存在了,可以帮助GC回收(可达性分析)。failed设置为false,表明获取锁成功;interrupted为false,则线程不会中断。

如果p不是head节点或者没有拿到锁资源,会执行下边的代码,因为我们的线程1没有释放锁资源,所以线程2获取锁失败,会继续往下执行

//该方法的作用是保证上一个节点的waitStatus状态为-1(为了唤醒后继节点)
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node){ //获取上一个节点的状态,该状态为-1,才会唤醒下一个节点。
int ws = pred.waitStatus; // 如果上一个节点的状态是SIGNAL即-1,可以唤醒下一个节点,直接返回true
if (ws == Node.SIGNAL)
return true; // 如果上一个节点的状态大于0,说明已经失效了
if (ws > 0) {
do {
// 将node 的节点与 pred 的前一个节点相关联,并将前一个节点赋值给 pred
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0); // 一直找到小于等于0的 // 将重新标识好的最近的有效节点的 next 指向当前节点
pred.next = node;
} else { // 小于等于0,但是不等于-1,将上一个有效节点状态修改为-1
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

只有节点的状态为-1,才会唤醒后一个节点,如果节点状态未设置,默认为0

图解一下 ws>0 的过程,因为 ws>0 的节点为失效节点,所以 do...while 中会重复向前查找前驱节点,直到找到第一个 ws<=0 的节点为止,将node节点挂到该节点上。

我们的pred是头结点且未设置状态,所以状态为0,会走else。通过CAS尝试将pred节点的waitStatus设置为-1,表明node节点需要被pred唤醒。

shouldParkAfterFailedAcquire() 返回false,继续执行acquireQueued()中的死循环。

步骤和上边一样,node的前驱节点还是head,继续尝试获取锁。如果线程1释放了锁,线程2就可以拿到,返回true;否则继续调用shouldParkAfterFailedAcquire(),因为上一步已经将前驱结点的ws设置为-1了,所以直接返回true。

执行 parkAndCheckInterrupt()方法,通过 UNSAFE.park();方法阻塞当前线程2。等以后执行 unpark方法的时候,如果node是头节点后的第一个节点,会进入 acquireQueued()方法中走if (p == head && tryAcquire(arg))的逻辑获取锁资源并结束死循环

5、查看cancelAcquire()方法

该方法执行的机率约等于0,为什么这么说呢?因为针对failed属性,只有JVM内部出现问题时,才可能出现异常,执行该方法。

    // node 为当前节点
private void cancelAcquire(Node node){ if (node == null)
return; node.thread = null; // 上一个节点
Node pred = node.prev; // 节点状态大于0,说明节点失效
while (pred.waitStatus > 0) node.prev = pred = pred.prev; // 将第一个不是失效节点的后继节点声明出来
Node predNext = pred.next; // 节点状态变为失效
node.waitStatus = Node.CANCELLED; // node为尾节点,cas设置pred为尾节点
if (node == tail && compareAndSetTail(node, pred)) { //cas将pred的next设置为null
compareAndSetNext(pred, predNext, null);
} else {
int ws;
// 中间节点
// 如果上一个节点不是head 节点 if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
// 前边已经判断了大于0的操作,
// pred 是需要唤醒后继节点的,所以当 waitStatus 不为 -1 时,需要将 pred 节点的 waitStatus 设置为 -1
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
// CAS 尝试将 pred 的 next 指向当前节点的 next
compareAndSetNext(pred, predNext, next);
} else { // head 节点,唤醒后继节点
unparkSuccessor(node);
}
node.next = node; // help GC
}
}

执行到while时找到前驱节点中最近的有效节点,把当前节点node挂到有效节点后边,可以过滤掉当前节点前的失效节点。声明出有效节点的第一个后继无效节点predNext,并把当前的node节点状态设置为失效状态。

if中的操作:如果当前节点是尾节点,CAS尝试将最近的有效节点设置为尾节点,并将尾节点的next设置为null。

else中的操作:

如果pred节点不是头结点即中间节点,并且pred的waitStatus为-1或者waitStatus<=0,为了让pred节点能唤醒后继节点,需要设置为-1,并且pred节点的线程不为空。获取node节点的后继节点,如果后继节点有效,CAS尝试将pred的next指向node节点的next

当其他节点来找有效节点的时候走当前node的prev这条线,而不是再一个一个往前找,可以提高效率。

如果是头结点则唤醒后继节点。

最后将node节点的next指向自己。

七、解锁

释放锁是不区分公平锁和非公平锁的,释放锁的核心是将state由大于 0 的数置为 0。废话不多说,直接上代码

//释放锁方法
public void unlock(){
sync.release(1);
} public final boolean release(int arg){
//尝试释放锁资源,如果释放成功,返回true
if (tryRelease(arg)) {
Node h = head;
// head 不为空且 head 的 ws 不为0(如果为0,代表后边没有其他线程挂起)
if (h != null && h.waitStatus != 0)
// AQS的队列中有 node 在排队,并且线程已经挂起
// 需要唤醒被挂起的 Node
unparkSuccessor(h);
return true;
}
// 代表释放一次没有完全释放
return false;
}

如果释放锁成功,需要获取head节点。如果头结点不为空且waitStatus不为0,则证明有node在排队,执行唤醒挂起其他node的操作。

1、查看tryRelease()方法

 protected final boolean tryRelease(int releases) {
// 获取当前锁的状态,先进行减1操作,代表释放一次锁资源
int c = getState() - releases;
// 如果释放锁的线程不是占用锁的线程,直接抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果 c 为0 ,代表锁完全释放了,如果不为0,代表锁之前重入了,一次没释放掉,等待下次再次执行时,再次判断
if (c == 0) {
// 释放锁标志为 true,代表完全释放了
free = true;
// 将占用互斥锁的标识置为 null
setExclusiveOwnerThread(null);
}
// 设置 state 状态
setState(c);
return free;
}

我们的例子中线程1占用锁资源,线程1释放锁之后,state为0。进入if操作,将释放标志更新为true,将FIFO队列的 exclusiveOwnerThread 标志置为null

2、查看unparkSuccessor()方法

用于唤醒AQS中被挂起的线程

 // 注意当前的 node 节点是 head 节点
private void unparkSuccessor(Node node) {
//获取 head 的状态
int ws = node.waitStatus;
// CAS 将 node 的 ws 设置为0,代表当前 node 接下来会舍弃
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); // 获取头节点的下一个节点
Node s = node.next;
// 如果下一个节点为null 或者 下一个节点为失效节点,需要找到离 head 最近的有效node
if (s == null || s.waitStatus > 0) {
s = null;
// 从尾节点开始往前找不等于null且不是node的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
// 如果该节点有效,则将s节点指向t节点
s = t;
}
// 找到最近的node后,直接唤醒
if (s != null)
LockSupport.unpark(s.thread);
}

问题解析:为什么要从尾结点往前查找呢 ?

因为在addWaiter方法中是先给prev指针赋值,最后才将上一个节点的next指针赋值,为了避免防止丢失节点或者跳过节点,必须从后往前找。

我们举例中head节点的状态为-1,通过CAS的方式将head节点的waitStatus设置为0

我们的头结点的后继节点是线程2所在的节点,不为null,所以这边会执行unpark操作,从上边的acquireQueued()内的parkAndCheckInterrupt()方法继续执行。

private final boolean parkAndCheckInterrupt(){
LockSupport.park(this);
//返回目标线程是否中断的布尔值:中断返回true,不中断返回false,且返回后会重置中断状态为未中断
return Thread.interrupted();
}

因为线程2未中断,所以返回false。继续执行acquireQueued()中的死循环

  for (;;) {

      // 获取当前节点的上一个节点
final Node p = node.predecessor(); // p为头节点,尝试获取锁操作
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // 将获取锁失败标识置为false
failed = false; // 获取到锁资源,不会被中断
return interrupted;
}
// p 不是 head 或者 没拿到锁资源,
if (shouldParkAfterFailedAcquire(p, node) &&
// 基于 Unsafe 的 park方法,挂起线程
parkAndCheckInterrupt())
interrupted = true;
}

此时p是头节点,且能获取锁成功,将exclusiveOwnerThread设置为线程2,即线程2 获取锁资源。

将node节点设置为head节点,并将node节点的pre和thread设置为null。因为拿到锁资源了,node节点就不需要排队了。

将头节点p的next置为null,此时p节点就不在队列中存在了,可以帮助GC回收(可达性分析)。failed设置为false,表明获取锁成功;interrupted为false,则线程不会中断。

为什么被唤醒的线程要调用Thread.interrupted()清除中断标记

从上边的方法可以看出,当parkAndCheckInterrupt()方法返回true时,即Thread.interrupted()方法返回了true,也就是该线程被中断了。为了让被唤醒的线程继续执行后续获取锁的操作,

就需要让中断的线程像没有被中断过一样继续往下执行,所以在返回中断标记的同时要清除中断标记,将其设置为false。

清除中断标记之后不代表该线程不需要中断了,所以在parkAndCheckInterrupt()方法返回true时,要自己设置一个中断标志interrupted = true,为的就是当获取到锁资源执行完相关的操作之后进行中断补偿,

故而需要执行selfInterrupt()方法中断线程。

八、公平锁和非公平锁的区别

前边已经说过了,似乎非公平锁包含了公平锁的全部操作。打开公平锁的代码,我们发现accquire()方法中只有该方法的实现有点区别

hasQueuedPredecessors()返回false时才会尝试获取锁资源。该方法代码实现如下:

public final boolean hasQueuedPredecessors(){
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}

h==t时,队列为空,表示没人排队,可以获取锁资源;

队列不为空,头结点有后继节点不为空且s节点获取锁的线程是当前线程也可以获取锁资源,代表锁重入操作;

ReentrantLock底层源码分析的更多相关文章

  1. List-LinkedList、set集合基础增强底层源码分析

    List-LinkedList 作者 : Stanley 罗昊 [转载请注明出处和署名,谢谢!] 继上一章继续讲解,上章内容: List-ArreyLlist集合基础增强底层源码分析:https:// ...

  2. List-ArrayList集合基础增强底层源码分析

    List集合基础增强底层源码分析 作者:Stanley 罗昊 [转载请注明出处和署名,谢谢!] 集合分为三个系列,分别为:List.set.map List系列 特点:元素有序可重复 有序指的是元素的 ...

  3. LInkedList总结及部分底层源码分析

    LInkedList总结及部分底层源码分析 1. LinkedList的实现与继承关系 继承:AbstractSequentialList 抽象类 实现:List 接口 实现:Deque 接口 实现: ...

  4. Vector总结及部分底层源码分析

    Vector总结及部分底层源码分析 1. Vector继承的抽象类和实现的接口 Vector类实现的接口 List接口:里面定义了List集合的基本接口,Vector进行了实现 RandomAcces ...

  5. JAVA ArrayList集合底层源码分析

    目录 ArrayList集合 一.ArrayList的注意事项 二. ArrayList 的底层操作机制源码分析(重点,难点.) 1.JDK8.0 2.JDK11.0 ArrayList集合 一.Ar ...

  6. 分布式缓存技术之Redis_Redis集群连接及底层源码分析

    目录 1. Jedis 单点连接 2. Jedis 基于sentinel连接 基本使用 源码分析 本次源码分析基于: jedis-3.0.1 1. Jedis 单点连接   当是单点服务时,Java ...

  7. Servlet和Tomcat底层源码分析

    Servlet 源码分析   Servlet 结构图 Servlet 和 ServletConfig 都是顶层接口,而 GenericServlet 实现了这两个顶层接口,然后HttpServlet ...

  8. 持久层Mybatis3底层源码分析,原理解析

    Mybatis-持久层的框架,功能是非常强大的,对于移动互联网的高并发 和 高性能是非常有利的,相对于Hibernate全自动的ORM框架,Mybatis简单,易于学习,sql编写在xml文件中,和代 ...

  9. Java——HashMap底层源码分析

    1.简介 HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的. HashMap 最多只允许一条记录的key为 nu ...

  10. java ThreadLocal线程设置私有变量底层源码分析

    前面也听说了ThreadLocal来实现高并发,以前都是用锁来实现,看了挺多资料的,发现其实还是区别挺大的(感觉严格来说ThreadLocal并不算高并发的解决方案),现在总结一下吧. 高并发中会出现 ...

随机推荐

  1. Linux系统安全限制:禁用或限制用户ssh登录(sshd_config、hosts.allow、hosts.deny、nologin、passwd)

    我们知道Linux系统安全性设置有很多方式.常见的有通过firewall防火墙.或者iptables规则实现放行.拦截屏蔽某些特征的网络请求.示例:iptables限制ssh链接服务器     还有一 ...

  2. linux服务器通过rpm包安装nginx案例

    [rpm安装nginx] 普通用户执行安装命令:sudo rpm -ivh nginx-1.19.5-1.el7.ngx.x86_64.rpm 安装过程很简单,如下: 显示信息 nginx-1:1.1 ...

  3. Quackerjack pg walkthrough

    nmap ┌──(root㉿kali)-[~] └─# nmap -p- -A 192.168.159.57 Starting Nmap 7.94SVN ( https://nmap.org ) at ...

  4. 1.某道翻译js逆向sign值

    首先找到这个请求接口 这个接口就是我们请求翻译的接口 发现有个sign值,这就是我们需要逆向的值 再看看这个接口的响应 可以发现这个响应是被加密的,我们还需要去逆向解密这个被加密的响应,这篇就单纯讲一 ...

  5. HTML布局常用标签——div和span

    HTML布局常用标签--div和span 在HTML的世界里,div和span是两位不可或缺的老朋友,它们虽然看似简单,却在网页布局和样式设计中发挥着举足轻重的作用.今天,我们就来聊聊这两位" ...

  6. npm i 下载太慢

    1.在项目内部进入终端 2.输入:npm config set registry https://registry.npmmirror.com 修改npm下载地址为淘宝 3.然后再执行 npm i 进 ...

  7. Keepalived学习,双机主备高可用

    一.主机安装 1.解压 tar -zxvf keepalived-2.0.18.tar.gz 2.解压后进入到解压出来的目录,看到会有configure,那么就可以做配置了 3.使用configure ...

  8. STM32实战——ESP8266 WIFI模块

    ESP8266 硬件介绍 ESP8266系列模组有哪些: 在本实验中,ESP8266与ESP-01不做区分. ESP-01引脚介绍: 引脚 功能 3.3 3.3V供电,避免使用5V供电 RX UART ...

  9. 【Unit4】UML解析器(模型化设计)-作业总结 & 【BUAA-OO】课程总结

    第四单元作业总结 1.题目概述 UML类图建模与查询(8) + UML顺序图/状态图建模与查询(3+3) + 模型错误检查(9),三次迭代共23条命令 2.构架设计 一开始以为和第三单元差不多,稍微用 ...

  10. 一些Qt样式设计的小积累

    QRadioButton 的设计 QRadioButton分有两个部分,由按钮和背景文字组成. QRadioButton::indicator { ...; // 设置你想要的属性 } QRadioB ...