尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

线程池从原理到实战:参数、队列、拒绝策略与压测监控全解析

线程池从原理到实战:参数、队列、拒绝策略与压测监控全解析 线程池这个东西Java面试基本必考项目里也是遍地都用但很多同学对它的理解停留在“用Executors.newFixedThreadPool(10)就完事了”。说实话这么用早晚要出事。我前前后后在高并发IM、AI Agent调度这类场景里折腾过不少线程池踩过坑也填过坑这篇就结合我的实际经验把线程池从参数到原理、从配置到压测、从监控到排查一次讲透希望能帮你把这块真正吃进脑子里。1. 线程池到底解决了什么问题1.1 不是所有地方都该手动new线程先想一个问题如果一个接口内部要执行多个独立任务你是不是会下意识地写new Thread(...).start()看起来没啥毛病但一旦请求量上来了问题就全暴露了。第一是线程创建销毁开销大。Java线程的创建要经过操作系统分配内核资源、初始化栈空间这一步是实打实的高成本操作一个线程大概要几十到上百微秒的创建时间。第二个问题是线程数量失控。没有线程池约束并发量一冲上来线程数可能瞬间飙升到几千甚至上万每个线程默认栈大小是1MB内存直接被吃光CPU也全耗在线程切换上。第三是缺少统一的生命周期管理和监控手段线程生命周期散落在业务代码里排查问题的时候连个抓手都没有。线程池解决的就是这三件事复用线程降低创建销毁成本、限制并发资源上限、提供统一的任务调度与管理入口。用一个生活化的类比就是线程池像一家公司的固定工位工位数量固定任务来了排队等工位干完活的员工继续留在工位上等下一个任务而不是干完就直接辞职走人。话说回来我之前在美团那边的同事分享过一个观点线程池绝不能只用默认的Executors方法因为newFixedThreadPool用的是无界队列newCachedThreadPool允许创建Integer.MAX_VALUE数量的线程。前者会导致任务无限堆积最后OOM后者会导致线程数失控直接拖垮机器。后面细说。1.2 Executor框架的处理逻辑JUC里线程池的核心是ThreadPoolExecutor它实现了ExecutorService接口Executors只是快捷键和便捷工厂真正干活的还是底层的ThreadPoolExecutor。理解线程池的关键是先理解它的分层设计Executor最顶层接口只有一个execute(Runnable)方法语义就是“把任务递过去执行”。ExecutorService扩展了Executor增加了生命周期管理能力比如submit、shutdown、invokeAll等。ThreadPoolExecutor落地实现线程池的全部核心逻辑都在这里。ScheduledThreadPoolExecutor在ThreadPoolExecutor基础上加了定时和延迟执行能力。我自己在实际项目中的使用习惯是除非是简单到不能再简单的场景否则一律直接new ThreadPoolExecutor(...)把参数显式写明。这样写看起来啰嗦但每一个参数都暴露在明面上任何接手代码的人一眼就能看出这个线程池的容量设计、队列策略和拒绝行为排查问题时省太多事了。2. 深入ThreadPoolExecutor的七个参数2.1 核心参数逐一拆解ThreadPoolExecutor最完整的构造函数有七个参数每一个都决定线程池的行为边界。先列个表后面逐一展开参数作用我的理解corePoolSize核心线程数线程池存活线程的底线数量就算空闲也不一定会回收maximumPoolSize最大线程数线程池最多能扩张到多少线程keepAliveTime空闲线程存活时间超过核心线程数的线程空闲这么久就会被回收unit时间单位keepAliveTime的单位workQueue任务队列线程都在忙时新任务先排队threadFactory线程工厂控制线程名、是否daemon、异常处理等handler拒绝策略队列也满了、线程也到上限了新任务怎么办corePoolSize和maximumPoolSize的区别常常被误解。线程数不是从0直接跳到maximumPoolSize的它有一个递增路径任务来了先让线程数增长到corePoolSize全部在干活后继续来任务任务就进队列队列满了才会把线程数往maximumPoolSize扩张。这个“先队列后扩线程”的顺序是很多配置问题的根源后面细聊。2.2 任务从提交到执行经历了什么一个任务通过execute()提交后ThreadPoolExecutor内部的判定顺序是这样的如果当前线程数小于corePoolSize直接创建一个新线程来执行这个任务不会复用空闲线程。如果当前线程数达到corePoolSize任务尝试放入阻塞队列能放进去就排队等待。如果队列已满且当前线程数小于maximumPoolSize则创建新线程来执行任务。如果线程数已经到了maximumPoolSize且队列也满了交给拒绝策略处理。这个顺序非常关键。我记得有个经典误区以为核心线程都被占满了就应该先把线程数拉满再去排队。实际上源码里的execute方法写的很清楚是先addWorker(command, false)尝试入队队列满了才addWorker(command, true)增加线程。很多线上事故就是队列配得太大线程数一直没机会增长结果请求在队列里堆积等消费者处理时数据早过期了。看一段简化的源码逻辑public void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); if (workerCountOf(c) corePoolSize) { // 第一阶段启动新线程执行任务 if (addWorker(command, true)) return; c ctl.get(); } if (isRunning(c) workQueue.offer(command)) { // 第二阶段入队 int recheck ctl.get(); if (!isRunning(recheck) remove(command)) reject(command); else if (workerCountOf(recheck) 0) addWorker(null, false); } else if (!addWorker(command, false)) { // 第三阶段队列满了则尝试扩张线程失败则拒绝 reject(command); } }这里还要多说一句ctl这个变量它是个AtomicInteger高32位只存一份线程池状态低32位存worker线程数。一个int同时存两个信息靠的就是位操作。这个设计很巧妙但也导致直接读线程数时要用workerCountOf(c)做位运算。理解了ctl的存在以后看线程池监控就不会对着一个整型数字发懵了。2.3 线程池的五种运行状态线程池不是只有“运行中”和“关闭”两种状态严格来说是五种RUNNING接受新任务也处理队列中已有任务。SHUTDOWN不接受新任务但会继续处理队列中已排队的任务。STOP不接受新任务不处理队列中已有任务正在执行的线程会被中断。TIDYING所有任务都结束了线程数为0即将执行terminated()。TERMINATEDterminated()执行完毕的最终状态。状态流转里最容易出问题的就是shutdown和shutdownNow的区别。shutdown()只是把状态改成SHUTDOWN正在跑的任务和队列里排队的任务都会继续处理完毕。shutdownNow()则会把状态改成STOP然后尝试用Thread.interrupt()中断所有正在执行的线程并返回还没执行的任务列表。实际项目里我见过有人在线程池用完以后调shutdownNow()结果在跑的长任务被中断了也没感知数据写到一半丢了这个坑在消息推送场景尤其危险。3. 阻塞队列和拒绝策略选错就出事3.1 四种常见阻塞队列对比与选择队列是线程池的缓冲地带也是配置里最容易选错的地方。Java里常用的有这几类队列特点是否有界适用场景LinkedBlockingQueue基于链表默认容量Integer.MAX_VALUE可有界可无界任务相对均匀的默认场景ArrayBlockingQueue基于数组必须指定容量有界需要精确控制队列长度的场景SynchronousQueue不存任务直接交给线程无容量适合CachedThreadPool要求线程数弹性大PriorityBlockingQueue按优先级出队无界需要优先处理关键任务DelayQueue延迟到达才可取无界延迟任务调度Executors.newFixedThreadPool默认用无界LinkedBlockingQueue这是最大的坑。无界意味着队列永远满不了maximumPoolSize参数形同虚设线程数永远维持在corePoolSize任务全在队列里堆着。堆积到内存溢出只是时间问题。我个人的经验是生产环境一律用有界队列而且要主动估算容量上限。比如接口QPS是5000核心线程16个每个任务耗时50ms那每秒能处理320个任务多余的4680个任务就进队列。如果队列容量是5000排队时长大约是一秒出头还能接受如果队列容量是10000排队时间拉长到两三秒用户体验就开始恶化了。这个账一定要自己算而不是拍脑袋选个1000或者100000。ArrayBlockingQueue和LinkedBlockingQueue的区别除了底层数据结构还有一个细节ArrayBlockingQueue底层是数组有界容量必须指定LinkedBlockingQueue如果不传容量就是无界传了容量才是有界。很多人用LinkedBlockingQueue只new不传容量埋下隐患代码review时一定要盯这个细节。3.2 拒绝策略四件套与自定义策略当线程数到顶了、队列也满了新任务就要走拒绝逻辑。JUC内置了四种策略AbortPolicy直接抛RejectedExecutionException这是默认策略。CallerRunsPolicy不抛弃任务也不抛异常而是由提交任务的线程自己去执行。这个策略能天然起到慢速降级的作用提交者自己干完活自然就降低了提交速率。DiscardPolicy静默丢弃任务不看代码根本发现不了丢任务。DiscardOldestPolicy丢弃队列中最老的任务然后重新提交新任务。我之前的项目里用过一个自定义拒绝策略把被拒绝的任务持久化到本地文件然后由后台定时任务重新提交这样既不阻塞主流程也不丢数据RejectedExecutionHandler logAndRetryHandler (r, executor) - { // 序列化任务信息到消息队列或文件 System.err.println(任务被拒绝: r.toString()); // 例如放到Redis延迟队列后续再重试 };自定义策略的好处是完全可控代价是要自己做补偿。对于IM推送场景丢一条消息可能会引起用户投诉所以我后来都是自定义策略把拒绝的任务转到消息队列异步补偿。3.3 线程工厂为什么值得自定义大部分人对threadFactory直接忽略用默认的就好。但线上排查问题时线程池抛出来的异常栈里线程名全部长一个样根本定位不到是哪个池子出了问题。自定义线程工厂的核心就是为了给线程起一个有意义的名字同时可以设置是否daemon、设置异常处理ThreadFactory factory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(im-push-thread- seq.getAndIncrement()); t.setUncaughtExceptionHandler((thread, throwable) - log.error(线程执行异常, thread{}, thread.getName(), throwable)); return t; } };线程名的价值在压测和事故排查时会无限放大。之前有个生产事故两个线程池都叫“pool-1-thread-1”日志里根本分不清是哪个业务池子在报错后来全量规范线程名才把问题定位清楚。3.4 队列里的ThreadLocal要小心这块不算队列本身的问题但和任务排队有强关联。线程池里的线程是复用的ThreadLocal的值会在线程上残留A订单的数据可能被B订单读到。经典解决方案是每次任务执行完手动清理或者在调用链路上用InheritableThreadLocal的值初始化后立即清除。如果你用了一些RPC框架它们内部往往有上下文传递线程池的钩子但也不完全可靠最稳的办法还是代码里显式清理try { // 业务逻辑 } finally { threadLocal.remove(); }这个坑我在做多租户数据隔离时踩过某个租户的数据串到另一个租户的报表里排查了好久才发现是ThreadLocal没有在线程池场景下清理到位。4. 线程池大小怎么配从“16C32G”说起4.1 CPU密集还是IO密集计算公式先弄明白线程池大小没有标准答案但有一个业界通行的估算公式。CPU密集型任务线程数一般配置为CPU核数1因为CPU密集型任务几乎没有等待多一点线程也只能在切换时浪费资源。IO密集型任务线程数一般为CPU核数乘以2或者按公式核心线程数 CPU核数 / (1 - 阻塞系数)来推阻塞系数在IO场景下通常取0.8到0.9。举个例子16核机器跑一个IO密集型任务阻塞系数按0.8算线程数大约是16 / (1-0.8) 80。但这个公式假设单任务CPU占用很低实际还要参考自己压测出来的数据修正。我通常的做法是先用公式算一个基准值再通过JMeter压测慢慢调整而不是一次配死。4.2 16C32G服务器到底能扛多少并发这是一个被问烂了的问题。先说结论单看机器参数无法直接回答并发数因为瓶颈取决于下游响应时间、任务类型和业务复杂度。但可以做一个粗略推演。假设一台16C32G的服务器跑一个纯Java服务每个请求平均CPU耗时约20ms不考虑GC停顿和外部IO等待时单核每秒能处理50个请求16核理论上限就是800 QPS。如果请求有IO等待比如访问数据库需要30ms而CPU计算只要10ms那么请求总耗时是40ms单线程每秒能处理25个请求用80个IO密集型线程来跑QPS理论上可以到2000左右。线程数多不等于QPS高因为线程数一旦超过CPU核心数上下文切换本身就要消耗CPU。有人误以为把线程池配到1000就能让QPS打满实际反而因为CPU全耗在上下文切换上QPS可能比配200个线程还低。压测出来的拐点才是真实的答案机器参数只是个起点。4.3 高并发IM与AI Agent场景的配置思路高并发IM场景比如推送系统核心特征是消息量大、单条消息逻辑不重、依赖下游长连接网关。这里线程池的队列不宜太长因为推送消息追求时效性排队超过一定阈值就失去意义。我当时的配置是核心线程数64最大线程数128队列容量2000拒绝策略自定义保证高峰期宁可拒绝也不能无限堆积。AI Agent场景更特殊。Agent的典型动作是调用大模型API获取结果一次调用可能耗时几秒到几十秒不等几乎完全是IO密集型。这时候线程池配小了用户的请求一个个排队体验极差配大了大模型接口的限流又会把请求打回来。我见过的合理做法是根据上游模型接口的QPS上限来反推线程数比如模型接口限流100 QPS单次调用平均3秒那线程池大小就定在300左右队列再给一些缓冲。线程数定了之后剩下的交给背压机制去处理也就是拒绝策略。另外AI Agent场景里往往有多个模型调用互相依赖这时候单个线程池往往不够还得配合CompletableFuture做异步编排。把几个无依赖的模型调用分别扔到不同线程池里并行执行比串行调用能快好几倍。JUC里的CompletableFuture底层也是走ForkJoinPool的但它支持自定义Executor强烈建议传一个独立线程池进去避免所有异步任务都挤在公共池里。4.4 数据库并发锁与线程池的矛盾很多业务场景下线程池扩容能提升吞吐但数据库却扛不住。比如一个库存扣减接口线程池从20扩到100QPS上来了但数据库行锁的竞争也随之激烈起来最终系统瓶颈从应用线程池转移到了数据库行锁上。这给我们的启示是线程池参数不是单独定的它要和下游系统的容量配套。比如下游数据库连接池最大连接数是50那么应用线程池的并发线程也不宜远超50否则大量线程会阻塞在等待数据库连接上白白占用线程资源。数据库连接池本身也是一个信号量它往往比线程池更先打满。生产上我遇到过线程池倒是一切正常但数据库连接池满了导致接口大面积超时的故障根因就是线程池配得太大把连接池拖垮了。5. 线程池的监控与动态调优5.1 自研一个可监控的线程池JDK自带的ThreadPoolExecutor没有直接的池子使用率监控方法但可以通过继承重写beforeExecute、afterExecute、terminated来采集线程执行情况。线上生产环境我建议封装一个MonitorThreadPoolExecutor把核心指标定时上报到监控平台public class MonitorThreadPoolExecutor extends ThreadPoolExecutor { private final AtomicLong executeTaskCount new AtomicLong(); private final AtomicLong totalExecuteTime new AtomicLong(); public MonitorThreadPoolExecutor(...) { super(...); } Override protected void beforeExecute(Thread t, Runnable r) { // 记录任务开始的线程名、时间配合压测数据做对比 } Override protected void afterExecute(Runnable r, Throwable t) { // 记录耗时和异常注意ThreadPoolExecutor的FutureTask异常要单独处理 } Override protected void terminated() { // 线程池关闭时的清理逻辑比如输出最终统计 } }最简单的监控指标是活跃线程数、队列积压数、任务执行耗时分布。用定时任务每秒抓一次线程池状态输出到日志或者监控系统就能看到线程池跑到哪个水位了。不需要很复杂的框架一个ScheduledExecutorService就够了ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(() - { int activeCount pool.getActiveCount(); int poolSize pool.getPoolSize(); long taskCount pool.getTaskCount(); long completedCount pool.getCompletedTaskCount(); int queueSize pool.getQueue().size(); log.info(线程池监控 active{}, pool{}, queue{}, completed{}, activeCount, poolSize, queueSize, completedCount); }, 1, 5, TimeUnit.SECONDS);通过这个监控就能发现一些平时肉眼看不到的问题。比如队列持续不减说明消费速度跟不上生产速度活跃线程数永远等不到corePoolSize说明线程数配置偏大completedTaskCount增长缓慢但CPU却跑到100%这个时候就要看一下是不是任务里有死循环。5.2 动态调整corePoolSize和队列阈值线程池不是配好就不动了。ThreadPoolExecutor提供了setCorePoolSize、setMaximumPoolSize和setKeepAliveTime方法允许我们在运行期动态调参。我做过一个方案根据监控数据每5分钟自动评估一次当队列积压持续超过阈值时自动把最大线程数上调20%并同步告警让运维介入。需要注意的是调高maximumPoolSize时线程并不是立刻创建而是等新任务进来才创建调低corePoolSize时空闲线程也不是立刻销毁要等到超过keepAliveTime才会回收。所以动态调整的效果有滞后性用于应对突发流量时不能指望立竿见影。5.3 线程池优雅关闭关闭线程池的正确姿势是先shutdown()再awaitTermination轮询等待任务结束最后调用shutdownNow()兜底。我看到很多代码直接在finally里调shutdownNow()正在执行的任务说中断就中断了后果很难预料。一个推荐的写法pool.shutdown(); if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // 强制中断 pool.shutdownNow(); if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { log.error(线程池未能终止); } }线程池关闭更像是一个停机流程顺序是先停止接收新任务再等存量任务跑完最后对顽固任务强制执行。Spring的PreDestroy注解里也支持这个逻辑别忘了在应用重启时要保证线程池正确关闭否则容器重启的时候旧线程池还在占用资源。6. JMeter压测验证线程池配置是否合理6.1 并发十个参数不同的POST请求怎么测配置好了线程池怎么验证它扛不扛得住JMeter是一个常用的压测工具。热词里提到的“十个参数不同的POST请求”做法通常是把不同参数写入CSV文件在JMeter里用CSV Data Set Config组件读取配合线程组模拟并发请求。例如新建一个CSV文件第一行是参数名后面每行是一组参数。在线程组里添加CSV Data Set Config设置文件名、变量名、分隔符。在HTTP Request的Body Data里用${param1}引用变量。线程组里设置并发线程数和循环次数。这样每个线程取到的参数都不一样比较贴近真实业务场景。压测启动后重点关注吞吐量和响应时间分布不要只看平均响应时间P99和P95更有参考价值。6.2 从压测结果反推线程池问题压测过程中出现以下特征大概率是线程池配置出了问题现象大概率原因调整方向P99急剧上升但吞吐平坦队列过长任务排队时间增加缩小队列容量或增大最大线程数线程数增至maximum后P99反而恶化CPU上下文切换过多适当调低最大线程数报RejectedExecutionException线程和队列都打满了调大参数或优化拒绝策略CPU未打满但接口耗时高下游IO等待或锁竞争排查数据库连接池、下游依赖线程数长期不动但队列深不见底核心线程处理不过来调大核心线程数或优化任务耗时之前我在一个服务压测时200并发用户QPS只有300P99却到了2000ms。直觉以为是线程池太小调大之后P99没有改善反而CPU飙到99%。后来一查才发现是数据库连接池满了大量线程阻塞在获取连接上。这次教训很直接线程池背锅之前先确认下游的容量。6.3 几个容易忽略的压测细节压测不是开一两分钟就完事了有几个细节特别影响结果可信度第一要预留JIT预热时间。Java程序跑一会儿之后字节码会被JIT编译成机器码性能会有明显提升压测至少持续5到10分钟前面的数据要剔除。第二要注意线程池队列积压的观察时间窗口。很多压测工具在刚发起请求时队列还没满数据看起来很理想跑了2分钟后队列开始积压P99才一路飙升。只看前1分钟结果完全会误判。第三压测机的网络带宽和负载也是变量。分布式压测服务器和被测机器在同一机房网络延迟才接近真实线上。我之前压测时发现QPS上不去后来发现是压测机自己先扛不住了线程和CPU都满了。7. 线程池排查工具与实操记录7.1 用jstack和jstat快速定位线程池状态线上排查线程池问题第一步不是看代码而是把现场抓下来。最常用的就是jstack。把进程内的线程栈dump下来搜索线程名前缀比如自定义的”im-push-thread-“能看到这些线程现在卡在哪个方法上。如果大量线程都堵在LinkedBlockingQueue.take()说明队列为空线程在等待如果大量线程堵在LockSupport.park可能和同步逻辑有关。jstack命令jstack pid thread_dump.txt配合top -Hp pid查看线程CPU占用找到消耗CPU最高的线程号再转成十六进制在jstack里匹配线程栈大概率就能定位到热点方法。查AJDK的线程池状态还可以用jstat -gcutil配合看GC线程池把内存打爆之前GC一般已经异常了。7.2 一次真实的事故排查复盘我之前负责过一个报表系统每天晚上8点跑批某天开始频繁告警。jstack一看发现大部分线程都阻塞在同一个锁的等待处。顺着线程栈发现原来是多个线程池共用了一个全局锁对象其中一个线程池的某个任务卡在下游接口等待响应持锁不放其他线程池的任务全在锁上排队。这个问题的根源其实是两个线程池共享了锁不属于线程池本身的问题但暴露了线程池任务串行化的隐性风险。排查时一定要把线程栈里的BLOCKED/WATTING状态和业务逻辑串起来看不能只看线程池参数和指标。8. JUC并发工具和线程池的组合8.1 CountDownLatch与线程池的经典配合需要将一个大任务拆成多个子任务并行执行时用CountDownLatch控制主线程等待所有子线程完成。线程池负责执行CountDownLatch负责对齐。CountDownLatch latch new CountDownLatch(10); for (int i 0; i 10; i) { pool.submit(() - { try { // 执行子任务 } finally { latch.countDown(); } }); } boolean finished latch.await(30, TimeUnit.SECONDS); if (!finished) { // 超时处理避免主线程长时间等待 }这里有个小细节countDown()必须放在finally里否则子任务异常时latch永远等不到清零主线程直接挂起。而且await一定要带超时网络抖动或其他意外情况无法预料。8.2 CompletableFuture自定义线程池CompletableFuture默认使用ForkJoinPool公共池但公共池会被其他任务占满最好显式传线程池CompletableFuture.supplyAsync(() - callModelApi(), aiAgentThreadPool) .thenApplyAsync(result - parseResult(result), aiAgentThreadPool) .exceptionally(e - { ... });在AI Agent编排大模型调用时多个独立的模型请求并行执行再用组合API汇聚结果这套模式我已经用了很长时间。每个thenApply阶段都改走独立线程池避免公共ForkJoinPool被阻塞式调用拖住。8.3 Semaphore给线程池上一道保险线程池自己有余量但下游系统可能有更高的并发限制。这时候用Semaphore做第二道闸门在提交任务之前先获取许可获取不到就说明下游已经过载直接快速失败或者进入降级逻辑比让线程在队列里排队等死更合理。Semaphore semaphore new Semaphore(50); boolean acquired semaphore.tryAcquire(); if (!acquired) { throw new BizException(下游过载请稍后重试); } try { pool.submit(() - { try { // 调用下游 } finally { semaphore.release(); } }); } catch (RejectedExecutionException e) { semaphore.release(); throw e; }这种两层限流的模式在实际高并发接入第三方系统时非常有用外层线程池控制应用自身资源内层信号量控制下游额度。9. 一个让你少走弯路的线程池配置建议最后总结一下线程池不是配置一次就一劳永逸的它要跟着业务流量、下游状态、机器规格变化持续调整。我个人建议每个团队沉淀一份“线程池配置标准”包括参数填写模板、监控面板、压测流程三个部分。配置模板至少包含核心线程数、最大线程数、队列类型与容量、拒绝策略、线程名前缀、是否启用监控。在并发场景中的任何一个项目我都不建议直接用Executors的快捷方法。把参数写明白比什么都重要。我在每次上线前都会做一遍压测验证压测时盯住队列积压和P99两个指标。队列积压是线程池容量是否匹配流量的信号灯P99则是用户真实体感的探测器。只要这两个指标在预期范围内线程池的配置就不会出大问题。线程池这块内容我前前后后整理了好几轮这篇已经涵盖了从原理到实战的大部分核心点。如果你正在处理高并发系统或者面试前抱佛脚希望这篇能给到一些可落地的参考。
返回列表