并发容器全家桶:选择正确的“交通工具”

我们复习了很多,日常敲代码感觉是不是不一样了已经,渐入佳境、登堂入室!咱们已经聊了 ConcurrentHashMap这辆"跑车"(CAS + 分段/节点锁),但现实世界的交通场景千变万化:

  • 有时候你需要排队上车(生产者 - 消费者)。
  • 有时候车上全是乘客,几乎没人下车(读多写少)。
  • 有时候你需要按顺序到站(有序 Map)。

如果选错了交通工具(比如让几万人挤一辆跑车,或者让赶时间的人坐大巴),系统就会拥堵(性能低下)甚至瘫痪(死锁/OOM)

今天让我们拆解 BlockingQueueCopyOnWriteArrayListConcurrentSkipListMap,从底层源码到选型策略,构建一个接近完美的并发交通网【得意 努力】

第一章:BlockingQueue ------ "智能地铁系统" (生产者 - 消费者)

BlockingQueue 是一个线程安全 的队列,保证安全就是比较慢:核心特性是阻塞 (Blocking)

  1. 队列为空时 :消费者线程尝试取元素,会被阻塞,直到有元素入队。
  2. 队列已满时 :生产者线程尝试存元素,会被阻塞,直到有空间腾出。

比喻 :这就是地铁站台容量有限(队列有界)

  • 人满了,外面的人(生产者)必须在闸机口等着(阻塞),不能硬挤。
  • 车走了,里面的人(消费者)必须等着下一站**(阻塞),不能跳下去跑。
  • 完美解决 :生产者和消费者速度不匹配的问题,无需手动 wait/notify
四大金刚实现及选型
1. ArrayBlockingQueue (ABQ) ------ "固定车厢的地铁"
  • 底层结构数组 + ReentrantLock + Condition
  • 数组,长度固定:有界:创建时必须指定容量,满了就是满了,不能扩容,上面没人走不了;put时必须阻塞等待消费者腾出来位置!
  • 公平性可选 :构造函数可传 fair=true,保证先排队的先上车(FIFO),但性能略低
  • 内存连续:数组结构,CPU 缓存友好(Cache Locality),但在高并发下锁竞争可能略大

适用场景 :对内存敏感,需要严格控制队列大小,防止 OOM 的场景

复制代码
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {
    final Object[] items; // 数组
    int takeIndex, putIndex; // 头尾指针
    final ReentrantLock lock;
    private final Condition notEmpty; // 消费者等待条件
    private final Condition notFull;  // 生产者等待条件

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0) // 队列为空,等待
                notEmpty.await(); 
            return dequeue(); // 出队
        } finally {
            lock.unlock();
        }
    }
    
    public void put(E e) throws InterruptedException {
        // ... 类似逻辑,满时 await notFull
    }
}
2. LinkedBlockingQueue (LBQ) ------ "无限延伸的列车"

底层结构链表 + 两把锁 (putLocktakeLock) 在上面put和take还是两个指针哈哈

  • 可选有界 :默认容量 Integer.MAX_VALUE(相当于无界,小心 OOM!),也可指定容量
  • 读写分离锁:入队和出队使用不同的锁,并发度通常比 ABQ 高(因为入队和出队操作互不干扰)
  • 内存不连续:链表节点分散,缓存命中率略低

适用场景:吞吐量要求高,能接受潜在内存风险(或设置了合理上限)的场景。咱们Tomcat 的线程池默认就用它!

复制代码
public class LinkedBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
    
    // 节点内部类
    static class Node<E> {
        E item;
        Node<E> next;
        Node(E x) { item = x; }
    }

    // 容量上限
    private final int capacity;
    // 当前元素个数 (AtomicInteger 保证可见性和原子计数)
    private final AtomicInteger count = new AtomicInteger();

    //  核心:两把锁!
    // putLock 专门保护入队操作 (tail)
    private final ReentrantLock putLock = new ReentrantLock();
    // takeLock 专门保护出队操作 (head)
    private final ReentrantLock takeLock = new ReentrantLock();

    //  核心:两个条件变量
    // notEmpty: 当队列空时,消费者等待此信号
    private final Condition notEmpty = takeLock.newCondition();
    // notFull: 当队列满时,生产者等待此信号
    private final Condition notFull = putLock.newCondition();

    // 头尾指针 (仅在持有对应锁时修改)
    transient Node<E> head;
    private transient Node<E> last;

    // --- 入队方法 (put) ---
    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<>(e);
        
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        
        putLock.lockInterruptibly(); // 1. 获取入队锁
        try {
            // 2. 如果队列满了,等待 notFull 信号
            while (count.get() == capacity) {
                notFull.await();
            }
            
            // 3. 执行入队 (enqueue)
            enqueue(node);
            
            // 4. 原子增加计数,并获取旧值
            c = count.getAndIncrement();
            
            // 5. 关键优化:如果加 1 后还是没满 (c + 1 < capacity),说明还有空间,唤醒其他等待的生产者
            // 注意:这里只唤醒一个,避免惊群效应
            if (c + 1 < capacity)
                notFull.signal(); 
        } finally {
            putLock.unlock();
        }
        
        // 6. 如果加 1 之前是 0 (c == 0),说明刚才队列是空的,现在有了,需要唤醒消费者
        if (c == 0)
            signalNotEmpty();
    }

    private void enqueue(Node<E> node) {
        last = last.next = node; // 链表追加 
        //last.next=node; 旧尾节点的next指针指向新节点
        //last=node;  更新last引用,指向新的尾节点
    }

    // --- 出队方法 (take) ---
    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        
        takeLock.lockInterruptibly(); // 1. 获取出队锁
        try {
            // 2. 如果队列空了,等待 notEmpty 信号
            while (count.get() == 0) {
                notEmpty.await();
            }
            
            // 3. 执行出队 (dequeue)
            x = dequeue();
            
            // 4. 原子减少计数
            c = count.getAndDecrement();
            
            // 5. 关键优化:如果减 1 后还大于 0,说明还有货,唤醒其他消费者
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        
        // 6. 如果减 1 之前等于容量 (c == capacity),说明刚才满了,现在有空位了,唤醒生产者
        if (c == capacity)
            signalNotFull();
            
        return x;
    }

    //出队逻辑:移除头节点,返回数据,并帮助垃圾回收
    private E dequeue() {
        Node<E> h = head;//获取当前的头节点 (注意:LBQ 的头节点通常是一个 dummy 空节点)
        Node<E> first = h.next;//获取真正的第一个元素 (头节点的下一个)
        h.next = h; //help GC,断开旧节点的引用 关键优化
        head = first;//移动头指针:让新的第一个元素成为新的 dummy 头节点
        E x = first.item;//取出数据
        first.item = null;//关键gc优化,清空原第一个节点的数据引用
        return x;
    }
    
    // 辅助方法:在持有 putLock 的情况下唤醒 takeLock 等待的线程
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }
}

警告 :如果使用默认构造函数(无界),在生产者速度 > 消费者速度时,内存会无限增长直到 OOM。生产环境务必指定容量

3. PriorityBlockingQueue ------ "VIP 通道"

底层结构二叉堆 (Binary Heap) + ReentrantLock,优先级调度 ,而不是流量控制

  • 堆操作 + 单一锁 + 非阻塞 put

  • 无界队列: 动态扩容的数组 (Object[] queue),没有固定容量上限, 当数组满了,它会像 ArrayList 一样自动扩容 (grow):**Arrays.copyOf**换一个更大的数组!

  • put(E e) :因为可以无限扩容,所以理论上永远有空间放新元素。既然总有空间,就不需要让生产者线程等待(阻塞)。它只会因为内存不足抛出 OutOfMemoryError,而不会阻塞

  • take() :如果堆里没有元素(size == 0),消费者必须等待,直到有任务进来,所以它会阻塞

  • 有序 :出队顺序不是 FIFO,而是根据元素的自然排序Comparator 决定优先级

  • 非阻塞take() 在空时会阻塞,但 put() 永远不会阻塞(因为无界)

    public class PriorityBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E> {

    复制代码
      // 默认初始容量
      private static final int DEFAULT_INITIAL_CAPACITY = 11;
      
      // 存储元素的数组 (动态扩容)
      private Object[] queue;
      private int size;
      
      //  核心:只有一把锁,控制所有修改操作
      private final ReentrantLock lock;
      
      //  核心:只有一个条件变量,供消费者等待
      private final Condition notEmpty;
      
      // 用于比较优先级的 Comparator,或者元素自然排序
      private final Comparator<? super E> comparator;
    
      public PriorityBlockingQueue() {
          this(DEFAULT_INITIAL_CAPACITY, null);
      }
    
      public E take() throws InterruptedException {
          final ReentrantLock lock = this.lock;
          lock.lockInterruptibly();
          try {
              // 1. 如果为空,一直等待
              while (size == 0)
                  notEmpty.await();
              
              // 2. 取出堆顶元素 (index 0)
              E x = (E) queue[0];
              
              // 3. 将最后一个元素移到堆顶,然后下沉 (siftDown) 维护堆性质
              E end = (E) queue[size - 1];//取出堆顶最后一个元素,暂存为 end
              queue[size - 1] = null; // 帮助 GC
              size--;//堆大小减1
              if (size > 0)//如果堆中有元素,进行下沉操作
                  siftDown(0, end);
                  
              return x;
          } finally {
              lock.unlock();
          }
      }
    
      public void put(E e) {
          //  重点:直接调用 offer,因为永远不会失败(除非 OOM)
          offer(e); 
      }
    
      public boolean offer(E e) {
          if (e == null) throw new NullPointerException();
          final ReentrantLock lock = this.lock;
          lock.lock();
          try {
              // 1. 检查容量,如果不够,自动扩容 (grow)
              int i = size;
              if (i >= queue.length)
                  grow(i + 1);
              
              // 2. 放入数组末尾
              size = i + 1;
              if (i == 0)
                  queue[0] = e;
              else
                  // 3. 上浮 (siftUp) 维护堆性质
                  siftUp(i, e, comparator);
              
              // 4. 唤醒一个等待的消费者
              notEmpty.signal();
          } finally {
              lock.unlock();
          }
          return true;
      }
      
      // 扩容逻辑:类似 ArrayList,每次扩大 1 倍 (如果小于 64) 或 1.5 倍
      private void grow(int minCapacity) {
          int oldCapacity = queue.length;
          int newCapacity = oldCapacity + ((oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1));
          if (newCapacity - MAX_ARRAY_SIZE > 0)
              newCapacity = hugeCapacity(minCapacity);
          queue = Arrays.copyOf(queue, newCapacity);
      }

    }

  1. 无界扩容offer/put 中没有 while(size == capacity) 的等待逻辑,只有 grow()
  2. 堆维护 :利用 siftUp (插入) 和 siftDown (删除) 保证 queue[0] 始终是最小(或最大)元素。
  3. 单一锁:因为读写都涉及数组结构调整(扩容或堆平衡),无法像 LBQ 那样分离锁,所以性能在高并发写入时不如 LBQ,但读取(take)依然高效

适用场景:任务调度,紧急任务优先处理(如:报警日志优先于普通日志)

任务总量可控消费能力足够强

4. DelayQueue ------ "定时发车专列"

底层结构PriorityQueue + ReentrantLock

  • 无界

  • 延时 :元素必须实现 Delayed 接口。只有当元素的延迟时间到期后,才能被取出

  • 用途:缓存过期清理、定时任务调度、订单超时取消

    class DelayedTask implements Delayed {
    private long expireTime;
    // 实现 compareTo 和 getDelay 方法
    public long getDelay(TimeUnit unit) {
    long diff = expireTime - System.currentTimeMillis();
    return unit.convert(diff, TimeUnit.MILLISECONDS);
    }
    }
    // 使用
    DelayQueue<DelayedTask> queue = new DelayQueue<>();
    queue.put(new DelayedTask(5000)); // 5 秒后才能取出来

需求 推荐容器 理由
严格限制内存,防止 OOM ArrayBlockingQueue 强制有界,内存紧凑
高吞吐,读写并发高 LinkedBlockingQueue (带容量) 读写分离锁,性能更好
任务有优先级 PriorityBlockingQueue 自动排序,VIP 优先
延时任务/超时处理 DelayQueue 基于时间轮询,自动到期

第二章:CopyOnWriteArrayList (COW) ------ "读多写少的大巴"

CopyOnWriteArrayList 是一个线程安全的 List,其核心思想是:写时复制 (Copy-On-Write)

比喻 :这是一辆老式大巴

  • 乘客上车(读):随时可以上,不需要检票(无锁),速度极快。
  • 司机改路线(写)
    1. 司机不能直接在行驶的车上改路线(会撞车)。
    2. 司机把全车人复制到一辆新车上。
    3. 在新车上修改路线。
    4. 修改完后,把车站的牌子指向新车
    5. 后续的新乘客直接上新车,旧车上的乘客坐完这一趟就下车(旧对象被 GC)。
底层原理:读写分离 + 最终一致性
复制代码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    //核心:volatile 修饰的数组,保证可见性
    private transient volatile Object[] array;

    public E get(int index) {
        //读操作:完全无锁!直接访问数组
        return get(getArray(), index);
    }

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            // 1. 复制数组 (System.arraycopy) -> 开销大!
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            // 2. 在新数组上修改
            newElements[len] = e;
            // 3. 原子替换引用 (volatile 写)
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
}
  • ✅ 优点
    • 读性能极高:无锁,适合高并发读------随便读。
    • 迭代安全 :遍历时不会抛出 ConcurrentModificationException,因为迭代器操作的是旧数组快照。
  • ❌ 缺点
    • 写性能差 :每次写都要复制整个数组,时间复杂度 O(N) ;如果列表很大,写入会导致 CPU 飙高和频繁 GC。
    • 内存占用高:写入瞬间,内存中同时存在两份数组!!!
    • 数据一致性弱 :只能保证最终一致性。读取时可能读到旧数据(刚写完还没切换引用时,其他线程可能还在读旧数组)。
适用场景(黄金法则)

"读多写少""写操作不频繁" 的场景。

  • 典型场景
    • 白名单/黑名单列表。
    • 配置信息列表(极少更新)。
    • 监听器列表(Listener List):Spring 事件机制、GUI 事件监听。
  • ❌ 禁用场景
    • 列表元素多且频繁增删(如:实时订单列表、高频日志缓冲)。这时候请用 Collections.synchronizedList 或者 ConcurrentLinkedQueue

第三章:ConcurrentSkipListMap ------ "有序的高铁"

ConcurrentSkipListMap 是 咱们Java 中唯一一个支持高并发Key 有序 的 Map 实现。它是 TreeMap 的并发版本。

比喻 :这是一列高铁

  • 有序:站点(Key)严格按顺序排列(自然排序或自定义 Comparator)。
  • 高速 :利用跳表 (Skip List) 结构,实现 O(log⁡N)O(logN) 的查找、插入和删除,且无需全局锁。
  • 对比ConcurrentHashMap 是"直升机",速度最快但不管顺序;TreeMap 是"绿皮火车",有序但慢(全局锁);ConcurrentSkipListMap 是"高铁",既快又有序。
底层原理:跳表 (Skip List) + CAS
1.为什么不用红黑树?

TreeMap 基于红黑树;红黑树在插入/删除时需要复杂的旋转操作来保持平衡。在并发环境下,给红黑树加细粒度锁非常困难,容易导致死锁或性能瓶颈。

2.跳表是什么?多层有序的链表

跳表是一种基于链表 的概率数据结构,通过建立多层索引来加速查找:以空间换时间

  • 第 0 层:包含所有元素的标准链表

  • 第 1 层:随机抽取部分元素作为索引,指向下层

  • 第 N 层:索引越来越少,查找跨度越来越大

  • 这保证了高层节点稀疏,低层节点密集,维持整体的平衡性

    Level 2: 1 -----------------> 9 -----------------> 15 -> null
    | | |
    Level 1: 1 -------> 5 -------> 9 -------> 13 -------> 15 -> null
    | | | | |
    Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> null

查找过程:从最高层开始向右找,发现大于目标值就向下沉一层。就像坐高铁:先坐快车(高层索引)跨越大距离,快到站了换乘慢车(低层)精准停靠。

特性 红黑树 (Red-Black Tree) 跳表 (Skip List)
结构 严格的平衡二叉树 概率平衡的多层链表
插入/删除 需要复杂的旋转操作来维持平衡 只需修改指针,无需旋转
并发难度 极难。旋转操作涉及多个节点引用变更,很难拆分锁粒度,容易死锁。 容易。插入/删除只影响局部相邻节点,很容易通过 CAS 或 细粒度锁 实现无锁/低锁并发。
范围查询 需要中序遍历,稍麻烦 天然链表结构,范围遍历极其高效
JDK 选择 TreeMap (单线程), CHM (桶内冲突解决) ConcurrentSkipListMap (全并发有序 Map)
3. 并发控制:CAS + 节点锁
  • 无锁读取:读操作完全无锁,利用 volatile 保证可见性。

  • 局部锁/CAS 写入 :插入/删除时,只锁定相邻的节点,而不是整个 Map。利用 CAS 操作更新指针。

  • 随机层级 :新节点插入时,通过随机算法决定它出现在哪几层索引中(概率通常为 0.5)

    // 简化的跳表节点结构
    static final class Node<K,V> {
    final K key;
    volatile Object value;
    volatile Node<K,V> next;
    // 其他字段...

    复制代码
      Node(K key, Object value, Node<K,V> next) {
          this.key = key;
          this.value = value;
          this.next = next;
      }

    }

特性 ConcurrentHashMap ConcurrentSkipListMap
底层结构 数组 + 链表/红黑树 跳表 (Skip List)
Key 顺序 无序 (JDK8 后部分有序但不保证) 严格有序 (自然排序/Comparator)
性能 ⭐⭐⭐⭐⭐ (最快) ⭐⭐⭐⭐ (略慢于 CHM,但优于同步 TreeMap)
Null Key/Value 不允许 不允许
适用场景 绝大多数 KV 存储,不关心顺序 需要范围查询 (subMap, headMap) 或有序遍历
复制代码
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.NavigableMap;

public class SkipListDemo {
    public static void main(String[] args) {
        // 创建一个有序的并发 Map
        ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
        
        // 插入数据
        for (int i = 10; i >= 1; i--) {
            map.put(i, "Value-" + i);
        }
        
        // 核心优势:高效的范围查询 (Range Query)
        // 获取 key 在 [5, 8] 之间的所有数据
        NavigableMap<Integer, String> subMap = map.subMap(5, true, 8, true);
        
        System.out.println("范围 [5, 8] 的数据:");
        for (var entry : subMap.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
        // 输出: 5->Value-5, 6->Value-6, 7->Value-7, 8->Value-8
        // 注意:输出是有序的!CHM 做不到这一点。
        
        // 查找第一个大于等于 6 的元素
        var ceiling = map.ceilingEntry(6);
        System.out.println(">=6 的第一个元素: " + ceiling); 
        
        // 并发安全测试
        // 多个线程同时 put 和 subMap 遍历,不会报错
    }
}

综合选型决策树

面对并发容器,请问自己三个问题:

  1. 需要有序吗?

    • →→ ConcurrentSkipListMap (Map) 或 ConcurrentSkipListSet (Set)。
    • →→ 继续问。
  2. 是生产者 - 消费者模型吗?需要阻塞等待吗?

    • →→ BlockingQueue 系列。
      • 要限流/省内存? →→ ArrayBlockingQueue.
      • 要高吞吐? →→ LinkedBlockingQueue (记得设容量!).
      • 要优先级? →→ PriorityBlockingQueue.
      • 要延时? →→ DelayQueue.
    • →→ 继续问。
  3. 读写的比例是多少?

    • 读 >> 写 (如 99% 读,1% 写),且写操作不频繁 →→ CopyOnWriteArrayList.
    • 读写均衡写较多 →→ ConcurrentHashMap (首选) 或 ConcurrentLinkedQueue (无锁队列).

Collections.synchronizedList (包装器模式)

本质:它是一个装饰器 (Decorator) 。它接收一个普通的 ArrayListLinkedList,然后在每个方法外部包裹一把全局锁 (synchronized)

复制代码
List<String> list = Collections.synchronizedList(new ArrayList<>());

//  正确用法:遍历时必须手动同步!
// 因为 iterator() 返回的迭代器不是线程安全的
synchronized(list) { 
    Iterator<String> it = list.iterator();
    while(it.hasNext()){
        System.out.println(it.next());
    }
}

//  错误用法:直接遍历,可能抛 ConcurrentModificationException
// for(String s : list) { ... } 

ConcurrentLinkedQueue (无锁队列)

基于 CAS (Compare-And-Swap) 算法实现的无锁 (Lock-Free) 单向链表队列

复制代码
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

queue.offer("A");
queue.offer("B");

//  遍历无需加锁!
// 它的迭代器是 Weakly Consistent (弱一致性)
// 意味着:它能保证遍历到创建迭代器时已存在的元素,但不保证看到之后的修改,也不会抛异常
for(String s : queue) {
    System.out.println(s);
}

// 常用方法
String head = queue.poll(); // 获取并移除头元素,空则返回 null (非阻塞)
String peek = queue.peek(); // 获取不移除
  • 适用场景
    • 高并发生产者 - 消费者模型,且不需要阻塞等待(允许轮询)。
    • 作为高性能的任务缓冲区。
    • 替代 synchronizedList 获得更高吞吐。

容器 比喻 核心特性 适用场景 避坑指南
ArrayBlockingQueue 🚇 固定车厢地铁 有界、数组、单锁 严格控内存、任务队列 容量设太小会阻塞生产者
LinkedBlockingQueue 🚄 无限列车 可选有界、链表、读写分离锁 高吞吐任务队列 默认无界,易 OOM,务必设容量
CopyOnWriteArrayList 🚌 读多写少大巴 写时复制、读无锁、最终一致 白名单、配置列表、监听器 严禁用于写频繁场景,否则 CPU/GC 爆炸
ConcurrentSkipListMap 🚅 有序高铁 跳表、有序、CAS 范围查询、有序遍历、排行榜 性能略低于 CHM,不需要顺序时别用
ConcurrentHashMap 🚁 并发直升机 哈希、CAS+synchronized、无序 通用 KV 存储 无法进行范围查询,Key 无序
相关推荐
w1225h2 小时前
Tomcat10下载安装教程
java
一方热衷.2 小时前
YOLO26-OBB ONNXruntime部署 python/C++
开发语言·c++·python
NikoAI编程2 小时前
AI实战第一课:从项目配置到功能开发的完整流程
java·ai编程
啦啦啦_99992 小时前
6. AI面试题之 MCP
java
SimonSkywalke2 小时前
鸟哥的Linux私房菜快速阅读笔记(二) 多用户系统的目录结构
后端·面试
安姌2 小时前
京东社招——Java后端开发面试复盘
面试·职场和发展
小曹要微笑2 小时前
C#的运算符重载
开发语言·c#·运算符重载·c#运算符重载
我是唐青枫2 小时前
C#.NET Channel 深入解析:高性能异步生产者消费者模型实战
开发语言·c#·.net
飞天小猪啊2 小时前
Mybatis
java·spring·mybatis