AQS源码解读之ReentrantLock-图解
1.背景
1.AQS简介
AQS全称为AbstractQueuedSynchronizer(抽象队列同步器)。AQS是一个用来构建锁和其他同步组件的基础框架,
使用AQS可以简单且高效地构造出应用广泛的同步器,例如ReentrantLock、Semaphore、ReentrantReadWriteLock和FutureTask等等。
2.原理
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,
即将暂时获取不到锁的线程加入到队列中。
3.CLH队列
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。
AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
4.博客配套视屏教程
网易云课堂在线学习:
https://study.163.com/course/courseMain.htm?share=2&shareId=400000000332026&courseId=1213602801
视屏课程目录:
2.重要成员变量介绍
2.1.state 表示锁状态
a.值为0表示资源空闲可用,int型默认为0;
b.值大于0表示资源忙,不可用,有线程持有这把锁;
c.如果发生锁重入则值+1,可以结合代码分析;
d.注意不要把节点的等待状态混淆在一起了,代码示例:volatile int waitStatus;
state状态变动的情况:
1.获取锁时,使用cas将0改为1,代码示例:compareAndSetState(0, 1)
2.锁从入时,将累加锁的状态值,代码示例:setState(nextc);
3.设置节点为下一个获取的节点,compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
2.2.head 和 tail 分别表示指向队列的头节点和尾节点的指针
AQS中的队列是先进先出的双向链表;
队列中的头节点并不是要获取锁的节点,只是占位而已,真正要获取锁的节点是第二个节点,第二个节点获取到锁之后成为头节点;
某个线程没有获取到锁则需要进入队列中等候,持有锁的线程一定不会在队列中,可以结合后面的代码分析
2.3.Node节点
队列中的每个Node节点由主要由4个成员变量组成:
1.前一个节点指针
2.后一个节点指针
3.当前线程
4.当前线程的等待状态(WaitStatus)
对于 waitStatus 枚举值,记录当前线程的等待状态,
int型默认值为0
CANCELLED (1)表示线程被取消了
SIGNAL (-1)表示线程需要被唤醒,处于等待状态,即下一个获取资源的线程
CONDITION (-2)表示线程在条件队列里面等待
PROPAGATE (-3)表示释放共享资源时需要通知其他节点
注意:在后面的代码分析中一定要注意waitStatus的状态时如何修改的
简要代码如下:
/** * 双向链表队列节点 */ class Node { /** * 对于 waitStatus 枚举值,记录当前线程的等待状态, * int型默认值为0 * CANCELLED (1)表示线程被取消了 * SIGNAL (-1)表示线程需要被唤醒,处于等待状态,即下一个获取资源的线程 * CONDITION (-2)表示线程在条件队列里面等待 * PROPAGATE (-3)表示释放共享资源时需要通知其他节点 */ static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; static final int PROPAGATE = -3; /** * 等待获取资源的状态 */ volatile int waitStatus; /** * 前一个节点 */ volatile Node prev; /** * 下一个节点 */ volatile Node next; /** * 节点对应的线程 */ volatile Thread thread; }
3.继承体系
4.获取锁源码分析
在分析源码之前,我们再次回顾一下原理:
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,
即将暂时获取不到锁的线程加入到队列中。
用通俗的话说就是:
获取锁时如果资源空闲,就立即执行,执行完成后唤醒队列中等待的线程;如果资源被占用,就进入队列等待;(知道了这个大体方向后,对代码的理解非常有帮助)
源码分析开始:
1.创建锁对象调用lock方法
2.进入lock方法
3.公平锁和非公平锁选择
大家在这里可留意一下,公平锁和非公平锁是怎么样实现的的?
简单补充一下:
公平锁就是先到先获得资源的意思,在获取锁时会判断一下队列中有没有处于等待的线程(具体的代码实现后面看,这里简单提一下);
非公平锁是,只要能抢到锁都可以;
由此可见,非公平锁效率要高一点,实际生产中也是经常采用非公平锁,
那么问题又来了,我们在使用的时候怎么设置使用公平锁还是非公平锁呢?(详见后面代码解读)
4.调用acquire方法
传入参数1,表示将AQS中的资源状态state修改为1,加锁成功.
5.调用tryAcquire方法
6.进入公平锁重写的方法
注意:如果FairSync对象没有重写tryAcquire方法就会抛出异常
4.1.tryAcquire方法逻辑(尝试获取锁)
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 尝试获取锁 * * @param acquires * @return */ protected final boolean tryAcquire(int acquires) { // 获取当前线程对象 final Thread current = Thread.currentThread(); // 获取当前锁的状态 int c = getState(); if (c == 0) { // 状态为0表示资源可用 //解读一: hasQueuedPredecessors()方法的作用,检查对队列中是否有需要等待执行的线程,这是公平锁的体现,队列中有待执行的线程,优先让队列中的线程执行; //解读二: 如果hasQueuedPredecessors()这个方法返回false表示队列中没有等待执行的线程,那么使用方法compareAndSetState(0, acquires) 进行cas机制修改资源状态,修改成功表示获取锁成功,方法返回true。 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { // 设置当前线程对象为 锁拥有的线程对象 // 思考一下:为什么这个里设置线程对象时不使用cas的方式修改呢?难道不怕线程并发引起的问题么? // 答曰:不需要,因为这里线程已经获取到锁了,只有获取到锁的线程才可以执行这里的代码,而当前获取到锁的线程只有一个线程,故无需使用cas的方式,即:不存在并发 setExclusiveOwnerThread(current); // 返回获取锁成功 return true; } // 如果 current == getExclusiveOwnerThread() 相等,说明当前线程与锁对象拥有的线程是是同一个线程,则也可以认为获取锁成功,即:重入锁 } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); // 记录锁重入次数,大家可以对同一个线程在还没有释放锁的时候多次获取锁,然后通过断点的方式,观察这里的值变化。 setState(nextc); // 返回获取锁成功 return true; } // 返回获取锁失败 return false; }
4.2.hasQueuedPredecessors方法逻辑(判定队列中是否有待执行节点)
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 队列中是否有等待处理的节点 * 根据AQS原理,在公平锁的情况下,头节点的下一个节点的线程不等于当前线程(s.thread != Thread.currentThread()),则表示队列中有待执行的节点,返回true * * @return 返回true表示队列中有等待处理的节点, 返回false表示没有等待处理的节点 */ public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; /** * h != t,如果头结点等于尾节点,说明队列中无数据,返回false,队列中没有等待处理的节点, * (s = h.next) == null,头节点的下一个节点为空 返回true * s.thread != Thread.currentThread() 头结点的下一个节点(即将执行的节点)所拥有的线程不是当前线程,返回true-->队列中有即将执行的节点 */ return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }
4.3.addWaiter方法解读(添加一个节点)
代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 将无法获取锁的线程加入队列中 * * @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared * @return the new node */ 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; // 更新尾几点,期望尾节点为pred,更新为node if (compareAndSetTail(pred, node)) { // 将尾节点的下一个节点的值设置为 node,这里是双向链表 node.prev = pred; pred.next = node; 构成双向链表 pred.next = node; // 设置成功返回节点 node return node; } } // 没有等待的线程,将节点插入队列,必要时进行初始化 enq(node); return node; }
图解:
第一步:Node pred=tail; 将尾节点赋值给前置节点,意思结束pred指向t4;
第二步:node.pred=pred;设置新增节点t5的前置节点为pred,意思就是说设置t5的前置节点为t4;
注意:这里新增一个节点的时候,是先设置前置节点,如果在并发的情况下,
cpu发生切换,双向链表只能从后往前遍历,这个在面试中经常问到,如下图:
4.4.enq方法详解(初始化节点)
代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 将节点插入队列,必要时进行初始化 */ private Node enq(final Node node) { // 死循环,直到插入队列成功 for (;;) { Node t = tail; // Must initialize , // 整理思路就是:当队列还是空的时候,初始化一个头结点和尾节点, // 注意实际上头结点和尾节点中没有线程,只是占位 if (t == null) { // 尾节点为空 // compareAndSetHead(new Node()) 设置头结点 if (compareAndSetHead(new Node())) // 尾节点与头结点是同一个节点 tail = head; } else { // 构成双向链表 node.prev = t;t.next = node; node.prev = t; // 设置尾节点 if (compareAndSetTail(t, node)) { // 设置t节点的下一个节点为node,此时的t节点其实就是空节点 t.next = node; return t; } } } }
图解:
4.4.acquireQueued方法详解
这个方法比较难理解
1.需要结合for自旋逻辑阅读代码
2.需要理解这三个方法的含义
shouldParkAfterFailedAcquire(p, node)检查当前节点是否应该被,阻塞等待park
parkAndCheckInterrupt() 当前线程进入阻塞状态
cancelAcquire(node); 取消节点
代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * acquireQueued()用于队列中的线程自旋地以独占且不可中断的方式获取同步状态(acquire),直到拿到锁之后再返回。 * 该方法的实现分成两部分:如果当前节点已经成为头结点,尝试获取锁(tryAcquire)成功,然后返回; * 否则检查当前节点是否应该被park(等待), * 然后将该线程park并且检查当前线程是否被可以被中断。 */ final boolean acquireQueued(final Node node, int arg) { boolean failed = true; // 是否需要被取消的标记,true表示需要取消获取资源 try { boolean interrupted = false; // 是否需要中断标记,这个中断标记的作用 for (; ; ) { final Node p = node.predecessor(); // node 节点的前一个节点 // p == head 表示除节点node外,没有等待的节点 // tryAcquire(arg) 当前线程 尝试获取锁 if (p == head && tryAcquire(arg)) { // 将刚才已经获取了锁的线程设置为头结点 // setHead(node),为什么这里设置头节点 不使用 cas的方式 setHead(node); p.next = null;// help GC // failed = false 说明,只有在出异常的情况下才会执行 cancelAcquire(node); failed = false; return interrupted; } // shouldParkAfterFailedAcquire(p, node)检查当前节点是否应该被,阻塞等待park // parkAndCheckInterrupt() 当前线程进入阻塞状态 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) // 只有在出异常的情况下才会执行到这里 cancelAcquire(node); } }
4.5.shouldParkAfterFailedAcquire方法详解
代码解读:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 检查并更新无法获取的节点的状态。 * 如果线程应该阻塞,则返回true。这是所有采集回路中的主要信号控制。要求pred==node.prev。 * Node.SIGNAL=-1, waitStatus值,用于指示后续线程需要取消连接 * * 对于 waitStatus 枚举值,记录当前线程的等待状态, * int型默认值为0 * CANCELLED (1)表示线程被取消了 * SIGNAL (-1)表示线程需要被唤醒,处于等待状态,即下一个获取资源的线程 * CONDITION (-2)表示线程在条件队列里面等待 * PROPAGATE (-3)表示释放共享资源时需要通知其他节点 */ private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) /* * This node has already set status asking a release * to signal it, so it can safely park. * 翻译: 当前节点状态是等待唤醒状态(-1),后面的节点可以安全暂停(park) */ return true; if (ws > 0) { /* * Predecessor was cancelled. Skip over predecessors and * indicate retry. * 翻译:前置节点已经被取消(ws=1>0),跳过前置节点,并且重新设置前置节点 * 注意:这里的思路是如果前置节点已经是取消状态,就跳过当前的前置节点继续向前找有效的前置节点 */ do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { /* * waitStatus must be 0 or PROPAGATE. Indicate that we * need a signal, but don't park yet. Caller will need to * retry to make sure it cannot acquire before parking. * 翻译: 状态是0或者-3,的情况下线,设置前置节点为 -1 需要唤醒状态 */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } // 返回false会产生自旋,在下一次进入当前函数的时候就会返回true, // 这个方法一定要结合方法acquireQueued一起看 return false; }
图解:
这个方法每次执行节点状态是动态改变的,单用静态图不好画,这里只是画出最核心的逻辑,
假设新增节点时node,前置节点时n3,并且n3是一个取消节点
4.6.parkAndCheckInterrupt方法详解
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 该方法让线程去休息,真正进入等待状态。 * park()会让当前线程进入waiting状态。 * 在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。 * 需要注意的是,Thread.interrupted()会清除当前线程的中断标记位。 * 预备知识补充======================= * 2.预备知识 * 2.1.park、unpark、interrupt、isInterrupted、interrupted方法的理解 * 一:park、unpark * 1.park、unpark它不是Thread中的方法,而是LockSupport.park(),LockSupport是JUC中的对象; * 2.park可以让线程暂停 (只有在isInterrupted状态为false的情况下才有效),unpark可以让暂停的线程继续执行; * <p> * 二:interrupt、isInterrupted、interrupted * 1.interrupt、isInterrupted、interrupted 它是Thread中的方法; * 2.interrupt 设置一个中断标记,interrupt()方法可以让暂停的方法继续执行,通俗直观的理解是,设置打断标记后,处于暂停状态的线程就会被唤醒,如果是睡眠的就抛出中断异常; * 3.isInterrupted 这个好理解就是查看当前线程的中断标记; * 4.interrupted 这是一个静态方法,只能这样调用Thread.interrupted(),这个方法会返回当前interrupt状态,如果interrupt=true会将其改变为 false,反之则不成立; */ private final boolean parkAndCheckInterrupt() { // 让线程处于等待状态,前提是无中断标记,这里可以思考一下,暂停后什么时候被唤醒呢? LockSupport.park(this); // Thread.interrupted() 返回当前的interrupted状态,如果之前是true,即有中断标记,修改为false,即清除中断标记 return Thread.interrupted(); }
4.7.cancelAcquire方法详解
图解:
这里假设当前取消的节点时N4,且N3是已经取消的节点,其他节点都是正常节点
代码解读:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 节点取消获取资源 * @param node */ private void cancelAcquire(Node node) { // Ignore if node doesn't exist if (node == null) return; node.thread = null; // Skip cancelled predecessors Node pred = node.prev; // 找到有效的前置节点,从后往前找,这里会跳过已经取消的节点 while (pred.waitStatus > 0) node.prev = pred = pred.prev; // predNext is the apparent node to unsplice. CASes below will // fail if not, in which case, we lost race vs another cancel // or signal, so no further action is necessary. // 找到有效前置节点的下一个节点 Node predNext = pred.next; // Can use unconditional write instead of CAS here. // After this atomic step, other Nodes can skip past us. // Before, we are free of interference from other threads. // 设置节点状态为取消状态 node.waitStatus = Node.CANCELLED; // If we are the tail, remove ourselves. // compareAndSetTail(node, pred) 设置尾节点为有效前置节点 if (node == tail && compareAndSetTail(node, pred)) { // 如果取消节点时尾节点,有效前置节点的下一个节点设置为空, compareAndSetNext(pred, predNext, null); } else { // If successor needs signal, try to set pred's next-link // so it will get one. Otherwise wake it up to propagate. int ws; if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {//有效前置节点不是头结点的情况下: 设置有效前置节点的下一个节点为当前节点的下一个节点 compareAndSetNext(pred, predNext, next); Node next = node.next; if (next != null && next.waitStatus <= 0) // 设置有效前置节点的下一节点为取消节点的下一个节点 compareAndSetNext(pred, predNext, next); } else {//有效前置节点[是]头结点的情况下: 唤醒下一个应该执行的线程 unparkSuccessor(node); } node.next = node; // help GC } }
5.锁释放
5.1.release方法详解
代码解读:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 锁释放 并唤醒下一个节点 * * @param arg * @return */ public final boolean release(int arg) { // tryRelease(arg) 释放锁 if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) // 唤醒头结点的下一个节点 unparkSuccessor(h); return true; } return false; }
5.2.tryRelease方法详解
代码解读:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * 已执行完成的线程,释放资源 * * @param releases * @return */ protected final boolean tryRelease(int releases) { // 获取当前资源状态值 int c = getState() - releases; // 如果当前线程不是获得资源的线程,抛出异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); // 资源释放成功还是失败标记 boolean free = false; if (c == 0) { // 等于0的情况下表示资源释放成功,考虑到锁重入的情况,就是说同一个线程多次获取了同一把锁 free = true; // 资源释放成功 setExclusiveOwnerThread(null); } // 设置当前资源状态 setState(c); return free; }
5.3.unparkSuccessor方法详解
代码解读:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
/** * Wakes up node's successor, if one exists. * 唤醒节点的后续节点 * * @param node the node */ private void unparkSuccessor(Node node) { /* * If status is negative (i.e., possibly needing signal) try * to clear in anticipation of signalling. It is OK if this * fails or if status is changed by waiting thread. * 这句代码的意思:如果当前节点状态小于0(等待执行状态),对节点进行初始化 */ int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); /* * Thread to unpark is held in successor, which is normally * just the next node. But if cancelled or apparently null, * traverse backwards from tail to find the actual * non-cancelled successor. */ Node s = node.next; // 如果当前节点的下一个节点是null 或者 状态大于0,说明当前节点的下一个节点不是有效节点 if (s == null || s.waitStatus > 0) { s = null; // 这个for循环大家得认真理解,含义是从尾节点开始向前找,找到最前面的状态小于0的节点 // 提问这里为什么要从后往前找,而不是从前往后找?从前往后找不是更简单么? for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) // 让当前节点的下一个节点,继续执行 // 这里相当于是线程间通信,与之前方法中的parkAndCheckInterrupt()->LockSupport.park(this); 进行了唤醒操作,使流程完整 LockSupport.unpark(s.thread); }
6.加锁与释放锁总结
6.1.代码核心流程
6.2.核心流程图解
6.3.debug断点调试
测试代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
package com.ldp.demo01; import org.junit.Test; import java.util.concurrent.locks.ReentrantLock; /** * @author ldp */ @SuppressWarnings("all") public class AqsTest { /** * 测试:2 */ @Test public void test01() throws InterruptedException { ReentrantLock reentrantLock = new ReentrantLock(); Thread t0 = new Thread(() -> { System.out.println(Thread.currentThread().getName() + "-准备获取锁"); reentrantLock.lock(); try { System.out.println(Thread.currentThread().getName() + "-获取所成功"); System.out.println(Thread.currentThread().getName() + "-业务处理中................."); } finally { reentrantLock.unlock(); System.out.println(Thread.currentThread().getName() + "-已释放锁"); } }); Thread t1 = new Thread(() -> { System.out.println(Thread.currentThread().getName() + "-准备获取锁"); reentrantLock.lock(); try { System.out.println(Thread.currentThread().getName() + "-获取所成功"); System.out.println(Thread.currentThread().getName() + "-业务处理中................."); } finally { reentrantLock.unlock(); System.out.println(Thread.currentThread().getName() + "-已释放锁"); } }); Thread t2 = new Thread(() -> { System.out.println(Thread.currentThread().getName() + "-准备获取锁"); reentrantLock.lock(); try { System.out.println(Thread.currentThread().getName() + "-获取所成功"); System.out.println(Thread.currentThread().getName() + "-业务处理中................."); } finally { reentrantLock.unlock(); System.out.println(Thread.currentThread().getName() + "-已释放锁"); } }); Thread t3 = new Thread(() -> { System.out.println(Thread.currentThread().getName() + "-准备获取锁"); reentrantLock.lock(); try { System.out.println(Thread.currentThread().getName() + "-获取所成功"); System.out.println(Thread.currentThread().getName() + "-业务处理中................."); } finally { reentrantLock.unlock(); System.out.println(Thread.currentThread().getName() + "-已释放锁"); } }); t0.setName("T0"); t1.setName("T1"); t2.setName("T2"); t3.setName("T3"); t0.start(); // 让其按顺序进入队列 Thread.sleep(500L); t1.start(); Thread.sleep(500L); t2.start(); Thread.sleep(500L); t3.start(); System.out.println("线程已启动,等待结束"); t0.join(); t1.join(); t2.join(); t3.join(); System.out.println("执行结束"); } }
测试预期结果:
设置断点:
线程状态图:
t0释放锁唤醒t1:
t1释放锁唤醒t2
...后面的流程就以此类推,直到所有的节点都执行完成
7.总结
AQS的核心思想是当没有获取到资源的线程就在队列中等待,已经获取到资源的线程执行完成后就唤醒队列中的下一个节点去获取资源,以此循环;
根据这个设计思想,就涉及到:
如何让一个Java线程暂停,
如何唤醒Java线程,
在并发的情况又涉及到,如何原子操作修改资源状态
等待的线程是采用双向链表作为队列的,因此涉及到数据结构双向链表的应用
双向链表就涉及到添加节点,删除节点,遍历节点等操作
因此AQS的实现是Java基础知识的一个综合应用,
如果我们能深入的理解,不仅仅是可以应付面试的提问,更重要的是我们可以应用其思想解决我们在实际开中遇到的业务问题;
举一个我个人在实际开发中借鉴AQS思想解决的业务问题,
业务场景是这样的,提供一个接口去查询某个商品是否有货,这个商品由多个供货商供货的,只要我查询到有一个供货商能够供货就表示该商品可以出售的.
最简单的做法,当然循环查询(http请求)每一个供货商,直到找可以供货的商家,就停止查询,很明显这样的查询效率是很低的...
我希望的是同时去查询所有的供货商,只要有一家供货商返回了有货,该接口就可以返回商品可售了
完美!