news 2026/10/15 4:38:49

动态线程池实战:从ThreadPoolExecutor参数调优到线上救火

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
动态线程池实战:从ThreadPoolExecutor参数调优到线上救火

前阵子看到某大厂分享的“动态线程池”实践,第一反应有点不以为然:线程池还能玩出花?参数不就是corePoolSize、maximumPoolSize、队列长度这些吗,把值改一改谁不会?直到后来自己负责的服务因为固定参数线程池在大促前夜翻车,连夜做了线程池动态化改造,才不得不承认——真香。

所谓动态线程池,简单说就是把线程池的参数从“写死在代码里”改成“运行期随时可调”,并且配上可观测的指标。调整一个线程池的核心线程数、最大线程数、队列容量、拒绝策略,不再需要改代码、走发布、重启应用。它能解决的核心问题只有两个:第一,线上出问题时你还能救;第二,你想知道线程池到底发生了什么。

这篇文章我会把自己从踩坑到改造,再到落地后的排查经验完整写出来。适合被线程池问题折磨过、想给服务加一层“呼吸感”的 Java 后端开发者。无论是想直接抄作业,还是先理解原理再因地制宜,都能在里面找到可用的东西。

1. 先看痛点:写死的线程池就像开盲盒

1.1 线程池参数真的能靠一次估算定下来吗

Java 里的ThreadPoolExecutor,本质上就是一个“线程管理者 + 任务队列”的组合。核心参数就那么几个:核心线程数控制常驻线程规模,最大线程数限制峰值线程规模,工作队列存放来不及处理的任务,空闲存活时间决定非核心线程什么时候退场,拒绝策略决定实在塞不下时怎么办。

问题在于,这几个参数是高度耦合的,而且和业务流量强相关。做技术方案的时候,很少有人能准确回答“下周这个接口的峰值 QPS 到底是多少”“下游服务的 P99 延迟会不会从 50ms 漂到 500ms”。绝大多数情况下的参数来源是:参考旧服务、问问隔壁团队,或者干脆按“感觉还够”来填。

于是线程池就变成一个只能靠上线后观察的黑盒。流量一涨,任务开始排队,接口 RT 指数级上升;流量一跌,几十个核心线程空转,白白占着内存和调度资源。如果你能接受这种状态,那确实不用动态线程池;但如果你经历过线上告警和手工救火,就会明白“可调”这两个字值多少钱。

1.2 三个让我想重构的翻车现场

先说第一个,大促流量下的“排队地狱”。某个核心服务配置了核心线程数 10、最大线程数 20、队列容量 2000。平时完全没事,活动预热后流量突然翻了 5 倍,线程池里的任务开始堆积。线程池的处理逻辑是:核心线程满了之后,先把任务塞队列,队列满了才会创建新线程到最大上限。当队列深度到 1800 的时候,接口 RT 已经从 40ms 涨到了 3s,用户体验几乎不可用。

当时我能做的只有临时改代码加线程数,然后走发布流程。发布五分钟,期间新任务继续堆,老任务和新调用方不断超时,超时重试又加重了线程池负载,这是非常典型的雪崩前兆。

第二个场景是下游慢调用拖垮整个线程池。有个外部服务偶尔抖动,每次调用要等 3 秒才返回。因为业务侧统一用了服务调用框架,默认超时时间设得比较长,一批慢请求就持续占着线程池里的线程不释放。很快线程池被打满,后续哪怕是很轻量的任务也要排队。静态线程池在这里毫无抵抗能力,既不能快速超时甩掉慢任务,也不能临时扩容,更没法针对某一类任务做隔离。

第三个场景是凌晨低峰期的资源浪费。很多服务按照白天峰值配置线程池,核心线程数常年保持在 50 左右。到了凌晨,实际活跃线程可能只有五六个,但 50 个核心线程谁也不会退。每个线程都有独立的调用栈、线程元数据,还要参与上下文切换,日积月累是一笔不小的资源开销。

1.3 静态线程池想调整一次有多痛

没有动态化之前,调线程池参数的链路是:改代码 → 代码评审 → 构建产物 → 走发布流程 → 重启应用。整个过程最快也要十几分钟,大一点的服务要排队申请发布窗口,等个半小时很正常。

更要命的是重启是有副作用的。Java 线程池里的任务队列属于内存态,应用一停,排队中的任务可能直接消失。如果正在执行的任务还没写完数据库事务,重启也会造成不完整状态。调用方一旦发现超时,会继续重试,重试流量会在重启窗口内打进来,把新启动的应用再次压垮。所以只要还能忍,没人愿意为了调几个参数去重启生产服务。

动态线程池的本质,就是把“调参”从发布流程里剥离出来,变成一个实时控制面。你不需要动代码,不需要重启,只需要改配置,线程池自己会调整。这种能力在关键时刻就是“能救火”和“只能看着”的区别。

2. 动态线程池的思路:把调参数变成调配置

2.1 其实 JDK 已经为“动态”留了入口

很多人以为动态线程池需要做很重的事,实际上ThreadPoolExecutor本身就提供了运行时修改参数的接口:

  • setCorePoolSize(int)可以调整核心线程数
  • setMaximumPoolSize(int)可以调整最大线程数
  • setKeepAliveTime(long, TimeUnit)可以调整空闲线程存活时间
  • setRejectedExecutionHandler(RejectedExecutionHandler)可以替换拒绝策略

这几个 setter 方法内部都做了对应的状态维护。比如setCorePoolSize在调大时会尝试创建新线程,调小时轮询等待空闲 worker 退出。所以“动态调整”并不是什么黑科技,JDK 在设计之初就给运行期干预留了通道。

唯一麻烦的是队列容量。LinkedBlockingQueue这类常用队列的capacity字段是构造时定死的,对外没有修改入口。如果你想动态改队列长度,就得自定义一个可调整容量的阻塞队列,或者整体替换队列。整体替换队列风险较大,因为已有任务还在旧队列里,所以多数方案都会选择自定义队列。

2.2 动态线程池具体要“动”什么

拿一个线上线程池举例,建议动态化的配置项可以先用这张表理清:

配置项是否支持动态修改方式
核心线程数支持setCorePoolSize
最大线程数支持setMaximumPoolSize
空闲存活时间支持setKeepAliveTime
队列容量需要自定义队列队列内部capacity字段使用 volatile 并加锁保护
拒绝策略支持setRejectedExecutionHandler
线程工厂不建议运行期改影响新线程创建逻辑,风险较高

还要注意参数之间的约束。核心线程数不能大于最大线程数,否则调整后会触发校验或产生不符合预期的行为。队列容量也不是越小越好,如果一边把核心线程调大,一边把队列容量缩到很小,可能导致线程频繁创建销毁,任务被拒绝的概率也会上升。动态化不是让你随便改,而是给你一个线上矫正的机会。

2.3 整条变更链路是怎么串起来的

动态线程池的架构核心可以概括成一张“注册表 + 监听器”的模型。

所有需要动态调整的线程池,启动时都要注册到一个全局管理组件里,每个线程池有一个唯一名称。管理组件内部维护一个Map<线程池名称, 线程池实例>。当我们在配置中心修改了某个线程池的参数,配置中心会触发变更事件,监听器拿到变更后的 JSON 配置,解析出线程池名称。

然后监听器去注册表里找到对应的线程池实例,调用前面说的那些 setter 方法,把新参数逐个应用进去。应用完成后,统一打印一条变更日志:修改前是什么值,修改后是什么值,谁在什么时间触发的。这样整个操作链路是有迹可循的。

整个过程不涉及业务代码改动。线程池的创建和注册还是走原有初始化流程,只是额外加了一步“告诉注册表我有这么个线程池”。业务方提交任务的代码完全不用动,这就是动态线程池能快速落地的原因。

2.4 为什么我不建议一上来就自己造轮子

自研动态线程池的难点不在 setter 调用,而在周边体系。比如配置中心挂掉怎么办,本地是不是要缓存最近一次配置;多个实例同时收到变更,如何保证最终一致;变更过程中队列刚缩容、后续又来了任务,怎么避免拒绝风暴;变更日志和监控指标怎么打点,才能快速回溯问题。

这些问题每一项单独拆开都不算难,合在一起却要投入不少人力和时间。如果团队本身没有现成的配置中心、监控平台和告警通道,我更建议先基于开源的动态线程池组件做二次封装,或者先抄一个最小可用的内部版本,只覆盖最核心的三四个线程池。

最小闭环的做法是:先给线程池加名称、加监控、加配置中心监听。只要这三个能力有了,就已经解决 80% 的线上痛苦了。后面再逐步补 UI、操作审计、自动告警,不要一上来就追求大而全。

3. 把一个固定参数线程池改成动态线程池的实操记录

3.1 定义可动态调整的线程池封装

新建一个独立的线程池配置模块时,我建议先定义一个可动态调整的执行器类。它内部持有两个关键对象:一个ThreadPoolExecutor,一个自定义的可调容量队列。

public class DynamicThreadPoolExecutor extends ThreadPoolExecutor { private final DynamicBlockingQueue<Runnable> dynamicQueue; public DynamicThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, DynamicBlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler); this.dynamicQueue = workQueue; } public void setQueueCapacity(int newCapacity) { this.dynamicQueue.setCapacity(newCapacity); } public int getQueueCapacity() { return this.dynamicQueue.getCapacity(); } }

这里的关键点是工作队列必须从一开始就是自定义队列。如果构造时传入的是LinkedBlockingQueue,后续再想改容量就非常别扭。自定义队列的capacity我们放在后面展开。

与此同时,还需要一个线程池注册管理组件。线上通常会有多个线程池,每个线程池都要有唯一名称。注册表就是一个并发安全的 Map 结构:

@Component public class ThreadPoolManager { private final Map<String, DynamicThreadPoolExecutor> registry = new ConcurrentHashMap<>(); public void register(String poolName, DynamicThreadPoolExecutor executor) { DynamicThreadPoolExecutor old = registry.putIfAbsent(poolName, executor); if (old != null) { throw new IllegalStateException("线程池名称重复: " + poolName); } } public DynamicThreadPoolExecutor get(String poolName) { return registry.get(poolName); } public void refresh(String poolName, ThreadPoolConfig config) { DynamicThreadPoolExecutor executor = registry.get(poolName); if (executor == null) { return; } // 参数校验 if (config.getMaxPoolSize() < config.getCorePoolSize()) { throw new IllegalArgumentException("最大线程数不能小于核心线程数"); } // 优先调整队列和最大线程数,最后收紧核心线程数 executor.setQueueCapacity(config.getQueueCapacity()); executor.setMaximumPoolSize(config.getMaxPoolSize()); executor.setCorePoolSize(config.getCorePoolSize()); executor.setKeepAliveTime(config.getKeepAliveTimeSeconds(), TimeUnit.SECONDS); executor.setRejectedExecutionHandler( RejectedPolicyBuilder.build(config.getRejectedPolicyType())); log.info("动态线程池 {} 参数已更新: {}", poolName, config); } }

注册表的职责要单一,只负责保存实例和触发变更,不要在里面混业务逻辑。这样后续想接告警、接操作审计,都可以通过扩展注册表的接口来实现。

3.2 用配置中心监听实现参数下发

假设你的项目里已经有配置中心,那监听器写起来并不复杂。配置中心里放一个 JSON 配置,key 对应线程池名称,value 是线程池参数。

{ "corePoolSize": 20, "maxPoolSize": 40, "queueCapacity": 2000, "keepAliveTimeSeconds": 60, "rejectedPolicyType": "CALLER_RUNS" }

监听器的伪代码长这样:

@Component public class ThreadPoolConfigListener { @Resource private ThreadPoolManager threadPoolManager; public void onConfigChange(String poolName, String newConfigJson) { ThreadPoolConfig config = JsonUtil.parse(newConfigJson, ThreadPoolConfig.class); threadPoolManager.refresh(poolName, config); } }

这里面最容易踩的坑是“变更顺序”。我的经验是:先扩容队列,再扩大最大线程数,再调核心线程数,最后换拒绝策略。这么做的原因很简单,先把“容器”变大,避免在参数调整的瞬间出现任务放不进去的情况。如果把拒绝策略先改成直接丢弃,或者先把队列缩得很小,那在变更窗口期进来的任务就会被无辜拒绝。

setCorePoolSize这个方法有个比较隐蔽的行为:调大核心线程数时,它会尝试创建新的 worker 线程;调小时,它会等待现有 worker 空闲后逐步回收。所以核心线程数的变化通常不是瞬间完成的,监控图上看到的是一条渐变曲线,这是正常现象,不要以为没生效。

3.3 队列容量动态化的实现细节

队列容量动态化是整个改造里最需要小心的地方。JDK 自带队列不支持修改容量,所以用了一个可调容量队列。核心思路是:把capacity从final int改成volatile int,在offer和put方法里用新的容量做判断。

public class DynamicBlockingQueue<E> extends LinkedBlockingQueue<E> { private volatile int capacity; public DynamicBlockingQueue(int capacity) { super(capacity); this.capacity = capacity; } public int getCapacity() { return capacity; } public synchronized void setCapacity(int newCapacity) { if (newCapacity < 1) { throw new IllegalArgumentException("队列容量必须大于0"); } this.capacity = newCapacity; } @Override public boolean offer(E e) { if (e == null) { throw new NullPointerException(); } if (size() >= capacity) { return false; } return super.offer(e); } }

这段代码不是完整实现,但抓住了动态容量最核心的一点:每次判断是否满员时,都使用实时容量,而不是构造时的旧容量。需要注意size()方法在高并发下返回的是近似值,如果业务对精确性要求极高,可以引入显式的计数器,不过绝大多数场景下近似值足够。

还有一个容易被忽略的细节:容量调小后,队列中已经积压的任务并不会消失,新任务依然会尝试入队,直到size()达到新容量才会失败。所以缩容是“软缩容”,不会立刻清空历史堆积。如果有清空队列的需求,一定要经过业务评估,否则会造成任务丢失。

3.4 监控和告警是动态线程池的标配

如果只做了动态调整,却没有监控,那相当于只给了自己一个不知道什么时候该用的遥控器。线程池的问题往往不是瞬间爆发的,是排队数和活跃线程数一点点爬升的。没有监控,等你接到业务告警的时候,可能已经晚了。

我落地时做了一个定时采样任务,每 15 秒采集一次线程池的核心指标,输出成结构化日志:

@Scheduled(fixedDelay = 15000) public void collectThreadPoolMetrics() { for (DynamicThreadPoolExecutor executor : threadPoolManager.getAll()) { log.info("pool={}, core={}, max={}, active={}, poolSize={}, queueSize={}, queueCapacity={}, " + "taskCount={}, completedTaskCount={}, rejectCount={}", executor.getPoolName(), executor.getCorePoolSize(), executor.getMaximumPoolSize(), executor.getActiveCount(), executor.getPoolSize(), executor.getQueue().size(), executor.getQueueCapacity(), executor.getTaskCount(), executor.getCompletedTaskCount(), executor.getRejectCount()); } }

拒绝数这个指标建议单独统计。可以在动态线程池里重写beforeExecute或维护一个RejectedExecutionHandler包装器,每次拒绝任务时计数并告警。告警阈值可以参考这几个经验值:

  • 排队任务数连续 3 次超过队列容量的 80%
  • 活跃线程数连续 3 次超过最大线程数的 80%
  • 单位时间拒绝数大于 0
  • 任务执行耗时超过业务容忍的 P99 阈值

这些都是参考值,不同业务差别很大。比如削峰填谷的场景,排队本来就是设计的一部分;而核心接口场景,哪怕排队任务超过 500 都已经不可接受了。

3.5 上线前怎么做验证

动态线程池的改造风险其实不高,但不代表可以跳过验证。我是这样做的:先在测试环境把核心线程数配置成 1,模拟一个需要 10 个线程才能扛住的流量,观察任务排队;然后通过配置中心把核心线程数改成 10,确认排队数下降、活跃线程数上升。整个过程不重启应用,验证动态调整链路是否通。

再验证缩容。把核心线程数从 10 改回 2,观察池内线程数是否逐步下降。要注意核心线程默认不会因为空闲而退出,只有把allowCoreThreadTimeOut(true)打开之后,核心线程才会在超过keepAliveTime后退出。如果想让“缩容也动态”,需要在线程池配置里显式开启这个特性。

验证的最重要一步是观察配置中心推送失败时的表现。可以人为把变更 JSON 写坏,确认监听器不会因为解析异常影响原有线程池。一个好的动态线程池设计,在配置错误时应该保持上一次可用配置,而不是直接拒绝所有任务。

4. 常见问题与排查技巧实录

4.1 明明配置了,线程池为什么纹丝不动

这是接入动态线程池最常见的疑问。配置中心里改好了参数,日志里也看到监听器被触发,但线程池的监控数据没有任何变化。按照这个顺序排查基本能命中 90% 的问题:

先确认线程池名称是否完全一致。很多团队线程池名称用手写字符串维护,配置中心里多一个空格,或者大小写不一致,注册表就找不到目标实例。第二,看refresh方法有没有被拦截或吞掉异常。如果注册表在某个@PostConstruct阶段没有完成初始化,监听器调用时可能拿到空对象,但又没有上报错误。第三,检查是不是 Ioc 容器中同一线程池被创建了多个副本。某些框架增强逻辑会生成代理对象,注册表里存的是代理,配置监听器改的是另一个裸实例。

最实用的排查手段是:在监听器里打一个“进方法”日志,在refresh里打一个“出方法”日志,再对比监控曲线。看到“进”没看到“出”,说明配置解析或参数校验出了问题;看到“出”没看到“曲线变化”,说明修改的实例和实际工作的实例不是同一个。

4.2 动态调大 max 线程数,任务为什么还是在排队

这个问题基本是ThreadPoolExecutor执行机制导致的。提交一个任务时,线程池的决策顺序是这样的:如果当前工作线程数小于核心线程数,创建核心线程执行;否则尝试把任务放进队列;如果队列放不进去,再看当前线程数是不是小于最大线程数,是才创建非核心线程。

也就是说,只要队列还有空间,线程池就不会创建超出核心线程数的新线程。你把最大线程数从 20 调到 200,但如果队列容量是 5000,任务只需要塞进队列,那这 200 个线程永远只是“纸面数字”。

遇到这种情况,单纯调大maximumPoolSize没用。你需要根据场景选择:要么把队列容量同步调小,让任务更快触发拒绝扩容;要么在提交任务时做一次预判,当排队任务数超过一定水位,主动新建线程。也可以直接调用prestartAllCoreThreads()把核心线程预先拉满,但这个方法会立即占用线程资源,不适合高峰期之后使用。

4.3 动态缩容后线程没回收

这里要注意核心线程和非核心线程的差别。默认情况下,只有非核心线程才会在空闲达到keepAliveTime后退出,核心线程即使空闲也会一直存活。你把核心线程数从 30 调到 5,那 25 个多余的核心线程并不会立刻消失。

解决方式是在初始化线程池时调用allowCoreThreadTimeOut(true)。这个方法打开后,核心线程空闲超过keepAliveTime也会被回收。但它有一个副作用:核心线程也会因为超时被回收,这意味着线程数可能在低峰期降到 0,下一次有任务时需要重新创建线程,会带来少量额外开销。

缩容尽量不要太激进。我在生产环境踩过一次性从 50 缩到 5 导致变更后几分钟内线程创建频繁的坑,后来改为“每次缩到目标值的中间档,观察五分钟再继续”。动态化给了你操作空间,但你没必要每次都一把梭。

4.4 动态线程池不是万能的解药

线程池参数的动态调整解决的是“容量配置跟不上流量变化”的问题,但解决不了“业务代码本身慢”的问题。比如线程池里的任务在跑一个慢 SQL,或者循环调用了外部接口,你把线程数从 20 调到 200,本质上是把压力从线程池转移到了下游数据库或服务上,下游不一定扛得住。

更合理的做法是:先用动态线程池救急,保证任务不积压;同时用监控数据定位慢任务来源,通过限流、熔断、异步化、缓存等手段把根因处理掉。动态线程池更像是一个“呼吸阀”,而不是“发动机”。把它当万能药乱调的人,最后往往会在别的地方付出代价。

4.5 变更记录、回滚与“先调整队列再动线程数”

我在真实线上变更中习惯遵守两个原则:一是每次变更必须有记录,二是尽量让变更可回滚。

记录内容至少包括变更前后的完整参数、发起人、变更时间、当时线程池的活跃线程数和排队数。这些信息能帮助你在出问题后判断“是参数设置不对,还是业务流量变化导致的”。回滚则靠配置中心本身,把上一次生效的配置重新推送一遍就行。这个看起来简单,但如果没有提前把上一次配置备份好,真出问题时你可能会手忙脚乱。

变更顺序上面说过,先改队列和最大线程数,再动核心线程数。这样即使配置有问题,线程池也不会因为队列太小而在变更瞬间拒绝大量任务。我见过有人把队列从 2000 直接改成 50,核心线程从 10 改成 100,结果配置刚推送,线程数还没拉起来,任务已经被拒绝了一波。这种坑不是动态化的问题,是操作姿势的问题。

动态线程池给我的整体感觉,不是多了一个“高级功能”,而是把线程池从“黑盒”变成了“可观测、可干预、可回滚”的基础设施。从一开始觉得花里胡哨,到后来真香,核心变化在于它把线上调优的主动权拿回到了自己手里。最后分享一个小技巧:每次变更配置后,别只盯着监控曲线,有条件的话抓一次线程 dump,亲眼确认线程都停在了你预期的状态,这个习惯比任何告警规则都靠谱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 4:38:44

做钣金10年,我们只干一件事:搞定高难度小型精密异形件

做钣金10年&#xff0c;我们只干一件事&#xff1a;搞定高难度小型精密异形件在钣金行业&#xff0c;有一个很现实的现象&#xff1a;简单大件&#xff0c;无数工厂抢着做复杂小件、精密异形件、难折弯、难成型的工件&#xff0c;没人愿意做。但高新设备、医疗、AI、无人机、汽…

作者头像 李华
网站建设 2026/10/15 4:37:24

Kibana安装实战:版本匹配与故障排查全解析

说起安装Kibana&#xff0c;很多人的第一反应是"下载解压有什么好写的"。确实&#xff0c;从官方仓库把tar包拖下来、解压、跑一个bin/kibana&#xff0c;熟练的话三分钟就能看到5601端口起来了。但真正让人头疼的从来不是这个动作本身&#xff0c;而是起来之后的一连…

作者头像 李华
网站建设 2026/10/15 4:37:17

数开面试高频题与避坑指南:SQL、建模、实时链路全解析

最近好几个准备投数据开发岗的候选人来找我复盘面试。聊下来发现&#xff0c;大家共同的困惑不是题目有多难&#xff0c;而是面试官明明给了很常规的题&#xff0c;追问起来却一环套一环&#xff0c;答着答着就露了怯。尤其是面到某出行平台这类业务复杂度高的公司&#xff0c;…

作者头像 李华
网站建设 2026/10/15 4:37:08

HSO高灵敏度观测器从原理到调参:无感电机控制避坑指南

简介&#xff1a;这份PDF资料来自ST&#xff08;意法半导体&#xff09;2024年工业峰会&#xff0c;面向电机控制系统开发工程师与相关科研人员&#xff0c;重点讲解高灵敏度观测器&#xff08;HSO&#xff09;技术在PMSM电机控制中的应用。内容从与传统STO观测器的对比切入&am…

作者头像 李华
网站建设 2026/10/15 4:36:42

realloc和malloc区别

一句话区别malloc&#xff1a;新建一块堆内存&#xff0c;只负责分配&#xff0c;不能修改已经分配好的内存大小realloc&#xff1a;重新调整一块已经存在的堆内存大小&#xff08;扩大、缩小都可以&#xff09;1. 函数原型// 分配 size 字节&#xff0c;返回起始地址&#xff…

作者头像 李华
网站建设 2026/10/15 4:36:13

IEEE论文投稿格式全攻略:模板选用、公式图表与自查清单

简介&#xff1a;IEEE Transactions 系列是 IEEE 旗下最具权威性的学术期刊集群之一&#xff0c;其论文格式要求细致且严格。这份模板源自 IEEE 官网&#xff0c;提供了 2021 年版本的完整投稿格式规范&#xff0c;适合准备向 Transactions、Journals 或 Letters 投稿的科研人员…

作者头像 李华