ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

数据结构实战:从内存布局到Redis/Kafka工业级选型

数据结构实战:从内存布局到Redis/Kafka工业级选型 1. 这不是教材摘抄而是一份能让你真正“用起来”的数据结构实战笔记我带过三届校招实习生也给五家中小企业的后端团队做过技术内训最常听到的一句话是“书看了代码写了面试官一问‘这个结构在Redis里怎么用’‘为什么Java线程池选ArrayBlockingQueue而不是LinkedBlockingQueue’就卡壳。”——问题不在学没学而在学得“不落地”。这份《数据结构学习笔记全》就是从这个痛点长出来的。它不按教科书顺序罗列定义而是以真实系统中的高频使用场景为锚点把线性表、栈、队列、二叉树这些概念还原成你每天调试时看到的redisTemplate.opsForZSet().add()、ThreadPoolExecutor构造参数、git log --graph的输出结构、甚至Linux内核task_struct链表的真实模样。关键词里反复出现的“王道数据结构电子版”“二叉树C”“栈内存溢出”恰恰说明大家缺的不是资料而是能把抽象结构和具体代码、内存布局、性能瓶颈一一对应起来的“翻译器”。适合两类人一类是刚写完链表插入却不知道ArrayList扩容机制背后是动态数组的初学者另一类是能手写红黑树但说不清为什么Kafka用跳表不用AVL树的进阶者。笔记里所有示例都来自我线上排查过的真问题比如某次支付回调超时根源是消息队列消费者用Stack做任务暂存导致LIFO顺序错乱又比如某次JVM频繁GC最后发现是日志模块用LinkedList做缓冲区每次get(i)触发O(n)遍历。它不承诺“三天速成”但保证每一页笔记都能直接映射到你的IDE、你的监控面板、你的错误日志里。2. 整体设计逻辑从“内存里的字节”出发拒绝空中楼阁2.1 为什么放弃传统教学路径——因为生产环境从不按章节出题传统教材从“什么是数据结构”开始讲逻辑结构、存储结构、算法复杂度像在解剖标本。但实际开发中你遇到的第一个问题永远是“这个接口响应慢jstackdump里显示300个线程卡在BlockingQueue.take()该换哪种队列”或者“MySQL索引B树的非叶子节点只存键值那为什么我们查SELECT * FROM user WHERE id100还要回表”——这些问题的答案藏在数据在内存中的物理排布方式、CPU缓存行对齐规则、操作系统调度策略的交界处。所以这份笔记的骨架是倒过来搭的先锁定6个高频生产场景缓存、消息队列、内存管理、编译器符号表、文件系统目录树、网络协议栈再反向拆解每个场景依赖的核心数据结构最后才回归到结构本身的数学定义。比如讲“栈”不从“后进先出”定义切入而是先看Java方法调用栈帧在内存中的连续布局再对比Redis ZSet底层跳表的“栈式”分层索引设计最后才推导出栈的抽象特性。这种设计让每个概念都有“落脚点”你知道std::stack在C里默认用deque而非vector实现是因为deque的头尾插入O(1)且内存不连续避免大对象拷贝你也知道Linux内核用struct list_head双向链表而非数组管理进程是因为链表能动态增删且list_head本身不存数据复用性极强。2.2 结构选型背后的硬约束CPU缓存、内存带宽与原子操作所有数据结构的选择本质是在三个物理约束间做权衡CPU缓存局部性连续内存访问比随机访问快10-100倍。ArrayList遍历快LinkedList遍历慢根本原因不是“数组vs指针”而是前者数据在缓存行里批量加载后者每次跳转都可能触发缓存未命中。内存带宽瓶颈现代CPU计算速度远超内存读写。HashMap扩容时rehash本质是把散列分布的数据重新聚集成连续块就是为了减少内存访问次数。原子操作开销ConcurrentLinkedQueue用CAS实现无锁但每次CAS失败都要重试高并发下反而不如ArrayBlockingQueue的synchronized稳定——因为后者一次锁住整个数组避免了反复争抢。这些约束直接决定技术选型。比如“线程池阻塞队列选择”这个热搜词答案不是“LinkedBlockingQueue好”而是如果任务提交频率远高于消费频率如秒杀预热选ArrayBlockingQueue——固定大小数组连续内存友好避免链表指针遍历开销如果任务突发性强且内存充足选LinkedBlockingQueue——动态扩容链表插入O(1)但要注意size()方法是O(n)遍历如果要求绝对无锁且允许一定延迟选SynchronousQueue——不存储元素纯传递但吞吐量最高。笔记里所有对比表格都标注了这些物理层指标。例如二叉树章节会明确写出满二叉树的数组存储第i个节点的左子节点索引是2*i1这不仅是公式更是CPU用位运算i1|1实现的硬件级优化比函数调用快两个数量级。2.3 真实世界的“数据结构混合体”单个系统绝不会只用一种结构教科书总把栈、队列、树分开讲但现实系统全是组合体。以Redis为例ZSet表面是“有序集合”底层却是跳表Skip List哈希表Hash Table的混合跳表提供O(log n)范围查询哈希表提供O(1)精确查找List命令用quicklist实现本质是双向链表压缩列表ziplist小列表用连续内存的ziplist节省空间大列表自动转为链表避免扩容开销Hash类型当字段少时用ziplist多时转为dict切换阈值由hash-max-ziplist-entries参数控制。这种混合设计源于单一结构无法兼顾所有场景。笔记中每个核心结构章节都会配一个“工业级实现案例”比如讲完二叉搜索树立刻分析MySQL B树如何通过“非叶子节点只存键值叶子节点双向链表”解决范围查询和顺序扫描问题讲完栈马上拆解Chrome V8引擎的CallStack如何用固定大小数组溢出检测避免栈内存溢出。这种写法逼着你思考“如果我要设计一个支持百万QPS的日志聚合服务该用什么结构存时间窗口数组循环队列还是基于时间轮的哈希桶”——答案在笔记的“消息队列”实战章节里用环形缓冲区Ring Buffer时间轮Timing Wheel组合既保证写入O(1)又支持毫秒级定时触发。3. 核心细节解析从定义到内存布局的穿透式解读3.1 线性表不只是数组和链表更是内存管理的基石线性表的抽象定义是“n个数据元素的有限序列”但它的价值在于如何把逻辑上的“先后”映射到物理内存的“位置”。ArrayList和LinkedList的区别本质是连续内存 vs 非连续内存的哲学分歧。ArrayList的底层是Object数组扩容机制是关键当size capacity时新容量 oldCapacity (oldCapacity 1)即1.5倍。这个数字不是拍脑袋定的——太小如1.1倍导致频繁扩容拷贝开销大太大如2倍浪费内存且大数组GC压力陡增。实测中若初始容量设为1000插入10000个元素仅扩容4次若设为10需扩容13次耗时增加37%。更隐蔽的是内存对齐JVM对象头占12字节数组长度占4字节之后才是元素。所以new int[1]实际占用16字节124new int[2]占24字节1248但new int[3]占32字节12412→补齐到8字节对齐。这种对齐让CPU能一次性读取8字节提升遍历速度。LinkedList则用Node对象串联每个Node含prev、next、item三个引用64位JVM下至少占24字节对象头12引用8引用4对齐后24。插入O(1)的代价是遍历get(i)必须从头/尾开始跳转平均O(n/2)。但很多人忽略一个事实LinkedList的addFirst/addLast比ArrayList的add(0)/add(size)快得多——后者要移动所有后续元素。所以当你需要高频头尾插入如LRU缓存淘汰LinkedList是合理选择但若只是偶尔插删中间ArrayList配合System.arraycopy反而更稳。Linux内核的struct list_head是线性表的极致简化它只有两个指针next和prev不存数据。所有业务结构体如task_struct都嵌入一个list_head成员。这样做的好处是内存零冗余task_struct的tasks链表头直接指向下一个task_struct的tasks字段无需额外Node对象类型安全list_entry(ptr, type, member)宏通过地址偏移计算出宿主结构体地址避免强制类型转换风险。提示面试常问“ArrayList和Vector区别”答案不是“Vector线程安全”而是Vector扩容是2倍且所有方法加synchronized而ArrayList推荐用Collections.synchronizedList或CopyOnWriteArrayList——前者适合读多写少后者适合写极少读极多因为写操作要复制整个数组。3.2 栈从函数调用到分布式事务的LIFO哲学栈的“后进先出”特性在计算机体系中无处不在。最典型的是函数调用栈每次方法调用JVM在栈内存分配栈帧Stack Frame存局部变量、操作数栈、返回地址。栈帧大小在编译期确定所以递归过深会触发StackOverflowError。但注意StackOverflowError和OutOfMemoryError有本质区别——前者是栈空间耗尽默认1MB后者是堆内存不足。redisTemplate.opsForZSet().add引发的“栈内存溢出”热搜其实是个误导。ZSet操作本身不涉及JVM栈但若你在ZSet添加时嵌套调用大量递归方法如自定义比较器里做深度遍历就会真爆栈。更常见的是Redis客户端连接池耗尽每个连接独占一个线程线程栈默认1MB1000个连接就是1GB栈内存加上堆内存直接OOM。解决方案不是调大栈而是用连接池如Lettuce的StatefulRedisConnection复用连接。栈在分布式系统中演化为事务协调器。Saga模式里每个服务执行本地事务后记录补偿操作如订单服务扣库存后记下“补库存”SQL。当全局事务失败协调器按逆序执行补偿——这正是栈的LIFO语义。Kafka事务ID管理也用栈Producer发送消息时Broker为每个PID分配单调递增的epoch旧epoch被压入栈新epoch成为栈顶确保幂等性。C语言的stack实现常被误解。malloc分配的堆内存也能模拟栈push/pop指针移动但真正的栈内存由OS管理不可手动free。c栈热搜指向GCC的-fstack-protector编译选项它在栈帧中插入canary值函数返回前校验防止栈溢出攻击。注意java中堆和栈的区别是经典误区。堆存对象实例栈存局部变量和引用——但引用本身是栈上4/8字节指向堆中对象。String str new String(hello)中str变量在栈hello字符串对象在堆JDK7后字符串常量池移到堆中。3.3 队列从FIFO到优先级再到无锁并发队列的FIFO特性看似简单但工业实现千差万别。ArrayBlockingQueue和LinkedBlockingQueue的差异远不止“数组vs链表”ArrayBlockingQueue是单锁双条件队列一个ReentrantLock保护takeIndex和putIndexnotEmpty和notFull两个Condition分别唤醒等待线程。这种设计保证了生产者/消费者互斥但高并发下锁争抢严重LinkedBlockingQueue是双锁队列putLock保护last节点插入takeLock保护head节点删除两把锁独立大幅提升吞吐量。但size()方法需同时获取两把锁是O(n)遍历慎用ConcurrentLinkedQueue是无锁队列用CAS操作head/tail指针但存在ABA问题指针值相同但中间被修改过所以用AtomicMarkableReference标记位规避。“阻塞队列”热搜背后是线程池配置陷阱。ThreadPoolExecutor构造函数中workQueue参数决定任务排队策略SynchronousQueue不存储任务直接移交空闲线程。若无空闲线程则创建新线程直到maxPoolSize。适合任务执行快、不想积压的场景PriorityBlockingQueue基于堆的优先队列任务按Comparable排序。但注意poll()是O(log n)且不保证公平性高优先级任务可能饿死低优先级任务自定义队列如用DelayQueue实现定时任务元素必须实现Delayed接口getDelay(TimeUnit)返回剩余延迟时间。Linux的bqueues命令查看队列权限本质是读取/proc/sys/kernel/msgmax等内核参数。msgmax限制单个消息队列最大字节数msgmni限制系统消息队列总数。这些参数影响IPC性能运维需根据业务消息大小调整。实操心得消息队列重复消费问题根源常在“消费确认机制”。RabbitMQ的ack模式、Kafka的enable.auto.commitfalse手动提交offset都是用队列思想保证Exactly-Once。但注意手动commit有风险——若消费成功但commit失败下次重启会重复消费。最佳实践是消费逻辑幂等化如用数据库唯一索引防重。3.4 二叉树从遍历到平衡穿透内存与算法的双重迷雾二叉树是数据结构中最易入门也最难精通的部分。“二叉树的遍历”“二叉树C”“写二叉树程序时为什么总是报运行时错误”这些热搜暴露了三个共性痛点指针空解引用、递归栈溢出、内存泄漏。先看基础遍历。前序、中序、后序的递归实现简洁但隐含风险void inorder(Node* root) { if (root nullptr) return; inorder(root-left); // 若left为野指针此处崩溃 cout root-val; inorder(root-right); }安全写法是加assert(root)或if (!root) return;。更健壮的是迭代版用显式栈替代系统栈void inorder_iterative(Node* root) { stackNode* s; Node* curr root; while (curr || !s.empty()) { while (curr) { // 一路向左到底 s.push(curr); curr curr-left; } curr s.top(); s.pop(); cout curr-val; curr curr-right; // 转向右子树 } }这段代码揭示了中序遍历的本质访问顺序 左子树全部访问完 → 根节点 → 右子树全部访问完。迭代栈的作用就是模拟递归调用栈保存“待返回的父节点”。“完全二叉树和满二叉树”常被混淆。满二叉树要求所有层都填满完全二叉树只要求最后一层靠左对齐。关键价值在于数组存储效率完全二叉树用数组tree[i]存节点左子节点tree[2*i1]右子节点tree[2*i2]父子关系用位运算i1快速计算。堆Heap就是完全二叉树的典型应用PriorityQueue底层就是此结构。平衡二叉树AVL/红黑树的旋转操作本质是维持树高平衡的局部重构。AVL要求左右子树高度差≤1插入后可能需四类旋转LL/RR/LR/RL红黑树用颜色标记5条规则保证最长路径不超过最短路径2倍。STL的map/set用红黑树因为其插入/删除平均O(log n)且常数因子小而unordered_map用哈希表平均O(1)但最坏O(n)。“二叉树的深度”计算递归版public int maxDepth(TreeNode root) { if (root null) return 0; return Math.max(maxDepth(root.left), maxDepth(root.right)) 1; }但此写法有隐患若树退化为链表如只有右子节点递归深度达10^5必然栈溢出。优化方案是层序遍历BFSpublic int maxDepth(TreeNode root) { if (root null) return 0; QueueTreeNode q new LinkedList(); q.offer(root); int depth 0; while (!q.isEmpty()) { int size q.size(); depth; for (int i 0; i size; i) { TreeNode node q.poll(); if (node.left ! null) q.offer(node.left); if (node.right ! null) q.offer(node.right); } } return depth; }BFS用队列避免递归空间复杂度O(w)w为最大宽度通常远小于树高。常见问题搜索二叉树BST的insert操作新手常写成if (val root.val) insert(root.left, val); // 忘记接返回值正确写法必须返回新根节点root.left insert(root.left, val);——因为插入可能改变子树根不赋值会导致链接丢失。4. 实操过程从手写代码到线上问题定位的完整闭环4.1 环境准备与工具链让学习脱离“Hello World”陷阱学习数据结构环境搭建是第一道坎。很多人卡在“C编译报错”或“Java泛型警告”其实是工具链没配好。我的建议是C放弃Dev-C用CLion或VS Code CMake。重点配置CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(DataStructures) set(CMAKE_CXX_STANDARD 17) add_executable(bst main.cpp bst.cpp) # 显式列出所有源文件关键是CMAKE_CXX_STANDARD 17启用结构化绑定让auto [key, value] *it;成为可能。Java用IntelliJ IDEA禁用-Xlint:unchecked警告泛型擦除不可避免但开启-ea启用断言用于调试public void add(int val) { assert val 0 : Value must be positive; // 开发期检查上线可关闭 // ... }Python用PyCharm安装memory_profiler插件。分析list.append()扩容时的内存变化from memory_profiler import profile profile def test_append(): l [] for i in range(100000): l.append(i)运行后生成内存快照直观看到扩容瞬间的内存峰值。调试工具比代码更重要。gdb调试C栈溢出gdb ./bst (gdb) run # 程序崩溃后 (gdb) bt # 查看调用栈 (gdb) info registers # 查看寄存器状态 (gdb) x/10x $rsp # 查看栈顶10个字Java用jstack看线程阻塞jstack -l pid thread_dump.txt # 搜索BLOCKED定位锁竞争点提示linux的内存管理子系统中有哪些重要的数据结构答案是struct page页描述符、struct zone内存区、struct mem_map页数组。page结构体里_count字段记录引用计数_mapcount记录映射次数这些字段直接影响OOM Killer决策。用cat /proc/meminfo可查看当前统计。4.2 线性表实战手写LRU缓存与内存泄漏检测LRULeast Recently Used缓存是线性表综合应用的经典。要求O(1)插入、删除、查找标准解法是哈希表双向链表哈希表存key→Node*映射实现O(1)查找双向链表按访问时间排序头为最新尾为最久get(key)查哈希表找到Node后移至链表头put(key, value)查哈希表存在则更新值并移至头不存在则新建Node插入头并检查容量超限则删尾部Node及哈希表项。C实现关键点class LRUCache { private: struct Node { int key, val; Node* prev; Node* next; Node(int k, int v) : key(k), val(v), prev(nullptr), next(nullptr) {} }; unordered_mapint, Node* cache; Node* head; // dummy head Node* tail; // dummy tail int capacity; void moveToHead(Node* node) { // 摘下node node-prev-next node-next; node-next-prev node-prev; // 插入头后 node-next head-next; node-prev head; head-next-prev node; head-next node; } public: LRUCache(int capacity) : capacity(capacity) { head new Node(0, 0); tail new Node(0, 0); head-next tail; tail-prev head; } int get(int key) { if (cache.find(key) cache.end()) return -1; Node* node cache[key]; moveToHead(node); return node-val; } void put(int key, int value) { if (cache.find(key) ! cache.end()) { Node* node cache[key]; node-val value; moveToHead(node); } else { Node* node new Node(key, value); cache[key] node; moveToHead(node); if (cache.size() capacity) { Node* tail_node tail-prev; cache.erase(tail_node-key); tail_node-prev-next tail; tail-prev tail_node-prev; delete tail_node; // 关键释放内存 } } } };这段代码暴露了C内存管理核心delete tail_node必须执行否则每次put都泄露一个Node。Java版用LinkedHashMap重写removeEldestEntry方法即可因GC自动回收。实操心得用Valgrind检测C内存泄漏valgrind --leak-checkfull --show-leak-kindsall ./lru_test输出会精确定位new未delete的行号。这是比“程序跑通”更重要的能力。4.3 栈与队列协同实现一个简易消息中间件用栈和队列组合可以构建消息中间件核心。需求支持发布/订阅、消息持久化、消费者ACK。架构如下发布端用ConcurrentLinkedQueue接收生产者消息异步写入磁盘模拟Kafka日志段分发端用PriorityQueue按消息时间戳排序确保有序投递消费者端每个消费者维护一个ArrayBlockingQueue作为本地缓冲区避免网络抖动影响处理ACK机制消费者处理完消息向服务端发送ACK服务端用HashSet记录已确认消息ID超时未ACK则重发。Java伪代码// 消息队列核心 public class SimpleMQ { private final BlockingQueueMessage publishQueue new LinkedBlockingQueue(); private final PriorityQueueMessage dispatchQueue new PriorityQueue((a,b)-Long.compare(a.timestamp, b.timestamp)); private final MapString, BlockingQueueMessage consumerQueues new ConcurrentHashMap(); private final SetString ackedIds ConcurrentHashMap.newKeySet(); public void publish(Message msg) { publishQueue.offer(msg); // 异步线程从publishQueue取消息写磁盘再入dispatchQueue } public void subscribe(String consumerId) { consumerQueues.putIfAbsent(consumerId, new ArrayBlockingQueue(1000)); } public Message poll(String consumerId) throws InterruptedException { BlockingQueueMessage queue consumerQueues.get(consumerId); return queue ! null ? queue.poll(1, TimeUnit.SECONDS) : null; } public void ack(String msgId) { ackedIds.add(msgId); } }这个设计体现了栈/队列的分工publishQueue是生产者缓冲FIFOdispatchQueue是时间排序优先级队列consumerQueues是消费者本地队列FIFO。注意“消息队列重复消费问题”的终极解法不是技术而是业务设计。比如支付回调用数据库唯一约束INSERT IGNORE INTO callback_log (order_id, status) VALUES (?, success)即使重复插入也只生效一次。4.4 二叉树工程化从遍历到B树索引优化二叉树的工程落地绕不开数据库索引。以MySQL InnoDB为例其B树索引结构是二叉搜索树的工业化演进非叶子节点只存键值指针节省空间提高扇出fan-out减少树高叶子节点存完整数据双向链表支持范围查询WHERE id BETWEEN 100 AND 200和顺序扫描聚簇索引主键索引的叶子节点存整行数据二级索引叶子节点存主键值回表查询需二次查找。优化案例某电商订单表orders(id, user_id, status, create_time)高频查询SELECT * FROM orders WHERE user_id ? AND status ?。若只建user_id单列索引status过滤需全表扫描。正确做法是建联合索引(user_id, status)利用最左前缀原则且status区分度高时索引效率接近user_id单列索引。手写B树过于复杂但可模拟其核心思想。用Java实现一个简化版class BPlusTreeK extends ComparableK, V { static final int ORDER 4; // 每个节点最多ORDER个键 private NodeK, V root; private static class NodeK, V { ListK keys; ListV values; // 叶子节点存值 ListNodeK, V children; // 非叶子节点存子节点 boolean isLeaf; Node(boolean isLeaf) { this.keys new ArrayList(); this.children new ArrayList(); this.isLeaf isLeaf; } } public void insert(K key, V value) { if (root null) { root new Node(true); root.keys.add(key); root.values.add(value); return; } // 插入逻辑分裂节点、提升键值... } }重点不是实现细节而是理解B树的ORDER越大树越矮IO次数越少但单次IO读取数据越多需权衡。InnoDB默认ORDER约1000取决于页大小16KB和键大小。实操技巧用EXPLAIN分析SQL执行计划关注typeALL/index/range/ref、key使用的索引、rows扫描行数。若typeALL说明没走索引需检查WHERE条件是否符合最左前缀。5. 常见问题与排查技巧实录来自线上事故的血泪总结5.1 “栈内存溢出”的10种真实场景与诊断路径“栈内存溢出”常被误认为代码写错实则是资源规划问题。以下是我在生产环境见过的典型场景场景表象诊断命令解决方案递归过深StackOverflowError堆栈trace显示同一方法反复调用jstack -l pid | grep -A 10 methodName改为迭代增加JVM参数-Xss512k谨慎线程数过多OutOfMemoryError: unable to create new native threadps -eLf | grep java | wc -l用线程池复用降低-Xss值如256kJNI本地栈溢出JVM崩溃hs_err_pid.log显示Native framesgdb加载core dumpbt查看C栈检查JNI代码避免大数组局部变量Lambda捕获大对象方法调用栈异常长javap -c显示合成方法jmap -histo pid看对象分布改用静态方法引用避免闭包持有Spring AOP代理过深Transactional嵌套调用栈帧爆炸jstack看CglibAopProxy调用链减少AOP切面用TransactionTemplate特别提醒“你计算机上一个有效的策略使你无法连接到此打印队列”这类Windows错误表面是网络问题实则可能是打印后台程序spoolsv.exe的线程栈耗尽需重启服务或增大HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Printers\DefaultSpoolDirectory权限。5.2 “二叉树程序报运行时错误”的根因分析表新手写二叉树90%的崩溃源于指针/引用操作。以下是最常见的5类错误及修复错误类型典型代码根本原因修复方案空指针解引用if (root-left-val 0)未检查root-left访问野指针或nullptrif (root root-left root-left-val 0)内存未初始化Node* node new Node; node-val 10;next/prev指针为随机值构造函数初始化Node(): left(nullptr), right(nullptr) {}递归无终止条件void dfs(Node* root) { dfs(root-left); }忘写if (root nullptr) return;所有递归函数首行加空指针检查深拷贝缺失Node* copy original;直接赋值浅拷贝导致析构时double free实现拷贝构造函数递归复制子树迭代器失效for (auto it map.begin(); it ! map.end(); it) { map.erase(it); }erase后it失效it map.erase(it);或用erase返回值独家技巧用AddressSanitizer检测C内存错误g -fsanitizeaddress -g bst.cpp -o bst ./bst它能精准定位use-after-free、heap-buffer-overflow等顽疾比valgrind更快。5.3 “阻塞队列选择困难症”的决策树面对ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue如何选择按此流程判断是否需要存储任务否 →SynchronousQueue纯传递适合任务执行快是 → 进入2任务大小是否固定内存是否紧张是 →ArrayBlockingQueue数组连续内存友好否
返回列表