AbstractQueuedSynchronizer(AQS)类 源码学习笔记
2021/7/18 11:06:18
本文主要是介绍AbstractQueuedSynchronizer(AQS)类 源码学习笔记,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
前言
抽象队列同步器-AbstractQueuedSynchronizer(AQS)定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(status)的同步器。AQS是Java并发包下,大多数同步组件和同步工具类的实现基础。同步器状态status,对于使用者而言,是锁;对于自定义同步组件而言,如可重入锁ReentrantLock,是重入次数。
AQS内部基于Node节点定义了同步等待队列和条件等待队列,用于将获取同步状态或等待在某个条件上的线程以节点的方式连接起来。
同步等待队列:用于存储可以获取同步状态的线程节点
条件等待队列:用于存储等待在某个condition上的线程节点,当线程被signal信号唤醒后,便会被转移到同步等待队列。
AQS提供了共享式和独占式两种获取共享资源的方式。
独占式:同步状态只能被一个线程获取
共享式:同步状态可以被多个线程获取
Node节点类
static final class Node { static final Node SHARED = new Node(); // 标记节点为共享模式 static final Node EXCLUSIVE = null; // 标记节点为独占模式 // waitStatus为CANCELLED时 表示等待锁的线程,被取消 static final int CANCELLED = 1; // waitStatus为SIGNAL时 表示后继线程需要被唤醒 static final int SIGNAL = -1; // aitStatus为CONDITION时 表示线程正在等待条件 static final int CONDITION = -2; // 表示下一次共享模式下获取同步状态,会被无条件传播 static final int PROPAGATE = -3; /** * 字段状态值,只会取以下值: * SIGNAL: 当前节点的后继节点被阻塞(或将很快被阻塞),因此当前节点在释放锁或取消等待时, * 必须唤醒后继节点。为了避免竞争,acquire方法必须首先表明它们需要一个信号, * 然后以原子方式重试acquire,如果失败,则阻塞。 * CANCELLED: 由于超时或中断,该节点将被取消。节点永远不会离开这种状态。 * 特别是,取消节点的线程永远不会再阻塞。 * CONDITION: 该节点当前位于条件队列中。它在传输之前不会用作同步队列节点, * 此时状态将设置为0。(此处使用此值与该字段的其他用途无关,但简化了机制.) * PROPAGATE: 处于此模式下,释放共享锁具有传递性。头节点调用doReleaseShared方法, * 保证传递释放共享锁,即使有其他的操作干涉。 * 0: 节点被创建后的初始状态 * * 这些值按数字排列以简化使用。非负值意味着节点不需要发出信号。 * 因此,大多数代码不需要检查特定值,只需检查符号。 * * 对于正常同步节点,该字段初始化为0,对于条件节点,该字段初始化为CONDITION。 * 它使用CAS(或者在可能的情况下,使用无条件的volatile写入)进行修改。 */ volatile int waitStatus; /** * 当前节点用prev指针代表的前驱节点检查等待状态。 * prev指针在节点入队期间赋值,并仅在出队时赋值为null(方便GC)。 * 此外,取消前驱节点时,我们会使用一个短暂的循环找到一个未取消的节点, * 这肯定存在,因为头节点永远不会被取消。只有成功获取到同步状态的节点,才能成为 * 头结点,一个被取消的线程节点永远不会成功获取到同步状态, * 并且一个线程只会取消自身对应的节点,而不是任何其他节点。 */ volatile Node prev; /** * 当前节点/线程释放时,next指针指向的后继节点。 * 将非取消的前驱节点的next指针指向入队的节点,出队时,赋值为null(方便GC)。 * 新的节点入队后, 前驱节点的next指针才会被赋值,指向这个新节点, * 因此next指针为null并不意味着该节点在队尾。 * 但是,如果next指针为null时,我们可以从队尾扫描prev指针做双重检查。 * 被取消节点的next指针被设置为指向节点本身,而不是null,以便在同步队列更容易处理。 */ volatile Node next; // 入队节点的线程,在节点的构造函数中初始化,使用完后被赋值为null volatile Thread thread; /** * 链接到下一个等待条件的节点,或特殊值 SHARED。 * 因为条件队列只有在独占模式下才会被访问,所以我们只需要一个简单的链接队列来保存节点, * 因为它们正在等待条件。 然后将它们转移到队列以重新获取。 * 并且因为条件只能是独占的,所以我们通过使用特殊值保存该字段以表明这是共享模式。 */ Node nextWaiter; // 如果节点在共享模式下等待,在返回TRUE final boolean isShared() { return nextWaiter == SHARED; } /** * 返回前一个节点,如果为空则抛出 NullPointerException。 * 当前驱节点不能为空时使用。 可以省略空检查,但存在以便帮助VM。 */ final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } // 无参构造用于初始化头结点或者标记共享模式 Node() { // Used to establish initial head or SHARED marker } // 用于同步队列CLH添加节点 Node(Thread thread, Node mode) { // Used by addWaiter this.nextWaiter = mode; this.thread = thread; } // 用于条件队列 Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; } }
访问同步状态的三种方式
getState() :获取同步器当前的同步状态
setState() :设置同步器的同步状态
compareAndSetState() :CAS设置同步器的同步状态,在存在多线程竞争的状态下使用
自定义同步组件可重写的方法
自定义同步组件,通过自定义同步器(继承于AQS的静态内部类)实现其获取共享资源的方式。自定义同步器在实现时只需要实现共享资源status的获取与释放方式即可,AQS已经维护好了线程的等待队列,如获取资源失败入队/唤醒出队等。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
源码分析
独占式获取同步状态(不可中断)
public final void acquire(int arg) { // tryAcquire(arg) 由子类实现其获取同步状态的逻辑 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
- 如果尝试获取同步状态失败,即tryAcquire(arg)返回false,则执行后面的判断逻辑,继续步骤2;如果尝试获取同步状态成功,则方法执行结束。
- 为当前获取状态的线程,构造一个独占模式的,且节点的等待状态waitStatus为0的节点,并将其添加到队列尾部。
- 以死循环的方式,获取同步状态;如果线程在阻塞过程中,检测到被中断(检测方法会擦除中断标记),则在获取同步状态成功后,返回中断结果。
- 如果线程被中断,则进行自我中断。
tryAcquire(arg)
tryAcquire(arg): // 子类重写的竞争获取同步状态的逻辑(锁竞争逻辑)
addWaiter(Node mode)
tryAcquire失败后,将当前竞争锁的线程构造为一个独占模式下的队列节点(该节点的waitstatus为初始状态0)
// 将节点入队 // 以CAS方式,设置尾节点,因为入队也存在竞争,为了保证所有阻塞线程对象能够被唤醒, // 必须保证每个竞争锁的线程都能够入队成功(自旋+CAS) private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // 构造同步等待队列的入队节点 // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { // 如果尾节点不为空,将入队节点设置为新的尾结点 node.prev = pred; // 将入队节点的前驱指针指向尾节点 if (compareAndSetTail(pred, node)) { // CAS将入队节点设置为新的尾结点 pred.next = node; // 设置成功后,将旧尾结点的next指针指向新的尾结点 return node; // 返回入队节点 } } enq(node); // 如果尾结点为空,或者“CAS将入队节点设置为新的尾结点”失败,则再次入队 return node; } private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 如果尾结点为空,则表明队列不存在,必须初始化头结点和尾结点 if (compareAndSetHead(new Node())) // 先new一个新节点作为头结点 tail = head; // 将尾结点指向头结点 } else { // 如果尾结点不为空,将入队节点作为新的尾结点添加到队列尾部 node.prev = t; // 将入队节点前驱指针指向旧尾结点 if (compareAndSetTail(t, node)) { // CAS将入队节点设置为新的尾结点 t.next = node; // 设置成功后,将旧尾结点的next指针指向新的尾结点 return t; // 注意:此处返回的是入队节点的前驱节点,即旧的尾结点 } } } }
acquireQueued(final Node node, int arg)
在acquireQueued方法中中再次尝试同步状态,失败后,将线程阻塞
// 因为阻塞操作,会调用系统函数,属于内核态下的操作,阻塞后再唤醒线程,系统会做内核态到用户态的切换, // 比较耗费CPU的资源。因此在阻塞之前会再尝试获取一次同步状态 // 再获取同步状态成功的条件:当前节点的前驱节点为头结点,且头结点已经释放锁(将同步状态设为了0) 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)) { // 当前节点的前驱节点为头结点,尝试获取同步状态 setHead(node); // 获取同步状态成功(即头结点释放了同步状态),则将当前节点设置为头结点(头结点为成功获取同步状态的节点) p.next = null; // 原头结点的next指针置为null,即断开该节点的链接关系,便于GC failed = false; // 获取同步状态成功 return interrupted; // 返回线程被中断标记,若线程是通过被中断唤醒的,则为true; 若线程是被头结点唤醒的,则为false } // 上一个if失败,即尝试获取同步状态失败,则根据前驱节点的waitStatus,决定当前节点是否应该阻塞(park); // shouldParkAfterFailedAcquire根据前驱节点的waitStatus是否为SIGNAL,返回true/false // 如果前驱节点的waitStatus为SIGNAL,则当前节点可以被阻塞;否则,当前节点还不可以阻塞,应该继续自旋 // 如果前驱节点的waitStatus为SIGNAL,则shouldParkAfterFailedAcquire()方法返回true, 即当前节点应该被阻塞,则会调用 // parkAndCheckInterrupt()方法阻塞当前线程,在线程被唤醒后,检查线程是否被中断过,返回检查结果 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; // 如果parkAndCheckInterrupt()返回true,表明线程是通过中断唤醒的,则线程被中断标记为true } } finally { // 如果自旋获取同步状态失败 if (failed) cancelAcquire(node); // 则取消该节点获取同步状态的资格 } } // 将成功获取到同步状态的节点设置为头结点 private void setHead(Node node) { head = node; // 将node节点设置为头结点 node.thread = null; // 头结点的thread置为null,便于GC node.prev = null; // 头结点的前驱指针置为null,便于GC } private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 拿到前驱节点的等待状态 if (ws == Node.SIGNAL) // 如果等待状态为SIGNAL return true; // 返回true,表明node节点可以被阻塞 if (ws > 0) { // 如果前驱节点的等待状态 >0,则表明该前驱节点的等待状态为CANCELLED do { node.prev = pred = pred.prev; // 剔除同步等待队列中状态为CANCELLED的所有节点 } while (pred.waitStatus > 0); pred.next = node; // 将正常状态的节点的后继指针指向node节点 } else { // 走到此处,表明前驱节点的等待状态为初始状态0,则将其状态CAS更新为SIGNAL // 第1轮循环,将head的waitStatus的状态由 0 -> -1 // 非公平锁情况下,unparkSuccessor将headwaitStatus的状态由 -1 -> 0,这时,head便可以再次将状态改为-1,以便 // 能够唤醒后继节点 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; // 返回false,表明node节点还不可以被阻塞,需要继续自旋;因为只有node节点的前驱节点确保是SIGNAL时,node节点才能被阻塞 }
selfInterrupt()
中断当前线程
static void selfInterrupt() { Thread.currentThread().interrupt(); }
unparkSuccessor(Node node)
当前获取同步状态的节点,唤醒后继节点
private void unparkSuccessor(Node node) { // 当前节点的等待状态 int ws = node.waitStatus; // 如果小于0,对于独占模式,表明是SIGNAL if (ws < 0) // CAS将其更新为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; } // 找到后 唤醒该节点的线程 if (s != null) LockSupport.unpark(s.thread); }
Condition条件等待机制解析
AQS定义了内部类ConditionObject,基于Node节点及其相关属性维护一个条件等待队列,实现Condition接口的相关方法,以实现等待-通知机制。
ConditionObject中定义了2个节点指针,便于维护等待条件队列中的节点。
// 条件等待队列的第一个节点 private transient Node firstWaiter; // 条件等待队列的最后一个节点 private transient Node lastWaiter;
条件等待
阻塞当前持有独占锁的线程,在其释放锁后,将其加入条件等待队列,等待另一个线程调用signal()方法唤醒它
await()
// 实现可中断的条件等待 public final void await() throws InterruptedException { if (Thread.interrupted()) // 测试当前线程是否被中断(检查中断标志),并清除中断状态 throw new InterruptedException(); // 若被中断,抛出中断异常 Node node = addConditionWaiter(); // 将当前线程构造为条件等待队列的节点,并加入条件等待队列尾部 // 调用此方法的线程,为持有独占锁的线程,因此需要完全地释放锁(“完全地” 的意思是,若该锁重入过,则status会>1) // 线程释放锁后,在同步队列中,等待锁的线程便可以去抢占锁了 int savedState = fullyRelease(node); int interruptMode = 0; // 0表示线程没有发生中断 // 判断线程对应的节点是否已经在同步队列中,若在,则表明线程已经被唤醒 // 调用signal方法: 1.会将节点从条件队列移动到同步队列,2. 唤醒节点,LockSupport.unpark(node.thread); // 因此就可以跳出while循环 while (!isOnSyncQueue(node)) { LockSupport.park(this); // 若线程还未被唤醒,则当前线程在此处一值阻塞 // 走到此处,证明线程已经被唤醒,存在两种情况: // 1. 通过 signal方法唤醒了线程 // 2. 通过 中断 唤醒了线程 // 该方法就是用来检查线程在waiting时,是否被 以中断方式唤醒 if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) // 若interruptMode != 0 表明线程确实是被 以中断方式唤醒 // 如果节点线程是在被signal之前中断,则为THROW_IE // 如果节点线程是在被signal之后中断,则为REINTERRUPT break; } // 线程被唤醒后,尝试获取锁,acquireQueued方法返回线程是否被中断 // 若线程已被中断,且被中断方式为 REINTERRUPT if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; // 如果节点的nextWaiter不为null,则表明节点在signal之前发生了中断, if (node.nextWaiter != null) // clean up if cancelled unlinkCancelledWaiters(); if (interruptMode != 0) reportInterruptAfterWait(interruptMode); }
addConditionWaiter()
// 向条件等待队列添加一个新的等待节点 private Node addConditionWaiter() { Node t = lastWaiter; // If lastWaiter is cancelled, clean out. 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; return node; }
fullyRelease(Node node)
// 释放锁 final int fullyRelease(Node node) { boolean failed = true; try { int savedState = getState(); // 获取同步器的同步状态值,对于ReentrantLock,就是重入次数 if (release(savedState)) { // 释放同步状态 failed = false; return savedState; } else { throw new IllegalMonitorStateException(); } } finally { // 如果释放锁失败,将该节点的状态标记为取消 if (failed) node.waitStatus = Node.CANCELLED; } }
isOnSyncQueue(Node node)
// 检查当前节点是否在同步等待队列中 final boolean isOnSyncQueue(Node node) { // 如果节点状态为-2 或 节点的前驱节点为null 则节点一定还在条件等待队列中 // 因为在signal中,将节点从条件等待队列转移到同步等待队列时,首先会将节点的状态改为0 // 而将节点加入到同步等待队列中时,即enq(node)方法,节点的prev指向当前队列的尾结点 if (node.waitStatus == Node.CONDITION || node.prev == null) return false; // 同理,若节点的next指针不为null,则一定在同步等待队列中; // 因为node被放入Sync队列的最后一步是设置node的next if (node.next != null) return true; /* * 当我们执行完两个if而仍未返回时,node的prev一定不为null,next一定为null, * 这个时候可以认为node正处于放入Sync队列的CAS操作过程中。 * 而这个CAS操作有可能失败,因此我们再给node一次机会,调用findNodeFromTail来检测: */ return findNodeFromTail(node); // 从同步队列尾部向前遍历,查找节点是否在同步等待队列中 }
findNodeFromTail(Node node)
// 查找节点是否位于同步等待对列中 private boolean findNodeFromTail(Node node) { Node t = tail; for (;;) { if (t == node) return true; if (t == null) return false; t = t.prev; } }
checkInterruptWhileWaiting(Node node)
检查阻塞在条件等待队列中的节点,是否有被中断,以及被中断的方式。
/** * 如果节点线程是在被signal之前中断,则返回THROW_IE * 如果节点线程是在被signal之后中断,则返回REINTERRUPT * 如果没有中断,则返回0 */ private int checkInterruptWhileWaiting(Node node) { return Thread.interrupted() ? (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) : 0; }
transferAfterCancelledWait(Node node)
// 将节点从条件等待队列转移到同步等待队列 final boolean transferAfterCancelledWait(Node node) { // 如果节点状态能够被设置为0,则表明是在signal之前被中断,即在中断发生时,还没有执行signal if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) { enq(node); // 因为还没有执行signal,所以将线程重新放入同步等待队列 return true; // 返回true, 表明点线程是在被signal之前中断,则返回THROW_IE } // 上述CAS失败,说明signal先于中断发生,因此自旋等待,直到节点被添加到同步等待队列中 while (!isOnSyncQueue(node)) Thread.yield(); return false; // 返回false, 表明点线程是在被signal之后中断,则返回 REINTERRUPT }
unlinkCancelledWaiters()
// 解除条件队列中取消等待的节点的链接关系,即移除队列中状态非CONDITION的节点 private void unlinkCancelledWaiters() { Node t = firstWaiter; // 从条件队列的第一个开始判断,t为节点指针 Node trail = null; // trail指向上一个状态为CONDITION的节点 while (t != null) { Node next = t.nextWaiter; // 取当前节点的后继节点 if (t.waitStatus != Node.CONDITION) { // 如果当前节点的状态不为CONDITION,则表明需要将其从队列中移除 t.nextWaiter = null; // 将当前节点的nextWaiter置为null,即将其从队列中移除 if (trail == null) // 走到此处,表明条件等待队列还未找到第一个状态是CONDITION的节点 firstWaiter = next; // 将firstWaiter指向下一个状态可能为CONDITION的节点 else trail.nextWaiter = next; // 将上一个状态为CONDITION的节点的nextWaiter指向下一个节点 if (next == null) // 走到此处,表明当前节点是条件等待队列的最后一个节点 lastWaiter = trail; // 将lastWaiter指向最后一个节点 } else // t指向的当前节点状态为CONDITION,即trail指向上一个状态为CONDITION的节点,是为了 if条件里的 trail.nextWaiter = next;做准备 trail = t; t = next; // 取下一个节点检查 } }
reportInterruptAfterWait(int interruptMode)
THROW_IE: 如果节点线程是在被signal之前中断
REINTERRUPT : 如果节点线程是在被signal之后中断
// 根据中断模式,执行相应的中断方式 private void reportInterruptAfterWait(int interruptMode) throws InterruptedException { // 如果是THROW_IE,则抛出中断异常 if (interruptMode == THROW_IE) throw new InterruptedException(); // 如果是REINTERRUPT,则执行自我中断 else if (interruptMode == REINTERRUPT) selfInterrupt(); }
条件通知
唤醒等待在condition上的条件等待队列中的节点
signal()
// 将等待时间最长的线程(如果存在)从此条件等待队列 移动到拥有锁的等待队列。 public final void signal() { if (!isHeldExclusively()) // 当前线程是否为独占锁的线程 throw new IllegalMonitorStateException(); Node first = firstWaiter; // 取条件等待队列的首节点,即等待时间最长的线程节点 if (first != null) doSignal(first); // 唤醒首节点 }
isHeldExclusively()
// 子类实现,判断当前线程是否为独占锁的线程 protected boolean isHeldExclusively() { throw new UnsupportedOperationException(); } // ReentrantLock中自定义同步器的实现 protected final boolean isHeldExclusively() { return getExclusiveOwnerThread() == Thread.currentThread(); }
doSignal(Node first)
// 从条件等待队列中将头结点移除,并将其加入到同步等待队列中 private void doSignal(Node first) { do { if ( (firstWaiter = first.nextWaiter) == null) // 如果节点的next节点为空 lastWaiter = null; // 将尾部节点置空 first.nextWaiter = null; // 将节点的nextWaiter置空,即将节点从条件等待队列中移除 } while (!transferForSignal(first) && (first = firstWaiter) != null); // 将条件队列的首节点加入到同步等待队列中,若添加失败,则取条件队列的下一个节点 }
transferForSignal(Node node)
// 将条件等待队列中的节点转移到同步等待队列中 final boolean transferForSignal(Node node) { // 首先,需要将节点的状态由-2 改为 0 初始状态 if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) return false; // CAS失败,表明该节点已经被取消 Node p = enq(node); // 走到这里,证明节点状态为0,将节点加入到同步等待队列中,该方法返回该节点的前驱节点 int ws = p.waitStatus; // 拿到前驱节点的状态 // 如果前驱节点状态大于0,表明节点为CANCELLED(1)已经被取消, // 而要唤醒node节点的前提是 其前驱节点的状态必须为SIGNAL 因此需要将该前驱节点的状态CAS改为 SIGNAL if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) // CAS成功,唤醒node节点,此时在await()方法中 在while循环中LockSupport.park(this);处阻塞的线程会解除阻塞,开始 // 执行if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)的判断逻辑 LockSupport.unpark(node.thread); return true; // 返回true,表明节点已经被唤醒 }
这篇关于AbstractQueuedSynchronizer(AQS)类 源码学习笔记的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-06TypeScript面试真题详解与实战攻略
- 2024-11-06TypeScript大厂面试真题解析与实战教程
- 2024-11-05Snowflake Cortex大语言模型函数:让AI数据查询更简单高效
- 2024-11-05Azure开发更轻松:VS Code中的GitHub Copilot for Azure公测版
- 2024-11-05Databricks与Snowflake:数据处理实力大比拼
- 2024-11-05Sealos Devbox 使用教程:使用 Cursor 开发一个高仿苹果官网
- 2024-11-05在 etcd 中怎么看有多少客户端在监视特定键 (watch)-icode9专业技术文章分享
- 2024-11-05uniapp 怎么实现返回上一页效果-icode9专业技术文章分享
- 2024-11-05UniApp 中则么实现检查一个 URL 是否以 "http://" 开头功能-icode9专业技术文章分享
- 2024-11-05怎么使用nslookup指定dns解析?-icode9专业技术文章分享