标签 内存泄漏 下的文章

我想让 AI 分析一下第三方 jar 包提供的一个类每次都 new 会不会造成内存泄漏,但是我发现无法把这个类添加到 cursor 对话框里。

最后我指明了这个类的名称发给 cursor ,它竟然把这个 jar 包解压缩到了我项目路径下再去做分析,并且分析完剩下的 tmp 文件都留着。。。。

有没有其他更优雅的办法呢?

在HarmonyOS应用开发中,性能问题直接决定用户体验——滑动卡顿、启动缓慢、内存泄漏等问题,往往成为应用上线的“拦路虎”。DevEco Profiler作为官方性能分析利器,提供了实时监控、深度录制、多场景专项分析能力,能精准定位从底层资源到上层UI的各类性能瓶颈。

本文将以“理论+实操+专项”三维视角,拆解基于DevEco Profiler的性能优化闭环流程,重点覆盖Frame(卡顿丢帧)与ArkUI(组件/状态)两大高频场景,提供可直接落地的分析方法与避坑指南,助力开发者高效解决性能难题。

一、核心认知:性能优化的闭环逻辑与指标基准

性能优化并非“头痛医头”,而是一套“识别-定界-定位-优化-验证”的闭环流程。在动手分析前,需先明确性能指标基准与工具分工,避免无方向调优。

1.1 关键性能指标基准

以用户可感知体验为核心,结合HarmonyOS应用特性,核心指标参考如下(可根据业务场景微调):

  • 流畅度:页面滑动、动画播放帧率稳定在60fps以上,无掉帧、卡顿;60fps对应Vsync周期16.6ms,单帧耗时需控制在该阈值内。
  • 启动速度:冷启动耗时≤2秒,热启动耗时≤500ms;启动阶段需重点监控初始化链路耗时。
  • 资源占用:无高负载操作时,CPU占用率≤30%;内存无持续上涨(排除泄漏);GPU使用率适配场景,无无效渲染。
  • 稳定性:无因性能过载导致的崩溃、闪退,正常使用无异常发烫。

1.2 DevEco Profiler核心工具分工

工具能力与优化流程深度绑定,核心分工如下,避免重复操作或无效录制:

工具模块

核心作用

适用阶段

Realtime Monitor(实时监控)

快速识别资源异常,定界问题类型与场景

识别-定界、验证阶段

场景化模板(Frame/ArkUI/Launch等)

深度录制数据,精准定位问题根因(代码级)

定位阶段

离线符号解析、源码跳转

还原Native函数栈,定位具体代码行

定位阶段(底层问题)

二、性能优化全流程实操(闭环落地)

本流程适用于所有性能问题场景,核心是“先快速定界,再精准定位”,避免盲目深度录制浪费资源。

步骤1:实时监控定界——快速锁定异常场景

核心目标:10分钟内排查是否存在性能问题、明确问题类型与触发场景,不深入底层细节。

实操步骤(零基础可照做):

  1. 环境准备:USB连接真机(不支持模拟器),开启开发者模式与USB调试;确保macOS 12+,DevEco Studio版本匹配(建议5.1.0+)。
  2. 启动工具与选目标:通过菜单栏(View→Tool Windows→Profiler)、底部工具栏“Profiler”或搜索启动工具,在左侧会话区依次选择“设备—应用—进程”。
  3. 复现场景并监控:会话列表默认加载Realtime Monitor,操作应用复现核心场景(冷启动、列表滑动、动画播放等),观察数据区泳道的CPU、内存、帧率、GPU数据。
  4. 标记异常并定界:用快捷键M标记异常时间点,记录核心信息——如“列表滑动时帧率降至40fps(卡顿)”“内存多次操作后只增不减(泄漏)”,明确问题类型与场景。

干货技巧:实时监控仅用于“筛问题”,无需长时间录制;重点关注帧率、CPU占用两大指标,可快速锁定80%的表层性能问题。

步骤2:深度录制定位——精准找到代码根因

核心目标:针对定界的问题,用场景化模板录制精细化数据,从宏观指标拆解至具体代码行,找到根本原因。

实操核心步骤:

  1. 选对场景化模板(关键!):模板选错会导致数据无效,匹配关系如下:

问题类型

推荐模板

核心分析维度

页面滑动/动画卡顿

Frame/ArkUI

帧率丢帧、组件绘制、状态更新

应用启动慢

Launch

启动各阶段耗时、热点函数

ArkTS层内存泄漏

Snapshot

对象持有关系、内存分配节点

Native层问题

Allocation/CPU

Native内存分配、CPU热点函数

  1. 深度录制场景:选中模板后点击“Create Session”,点击录制按钮(▶),完整复现异常场景(如滑动卡顿需滑动3次以上),结束录制后等待数据解析。
  2. Top-Down逐层分析(高效方法):从宏观到微观拆解数据,以卡顿问题为例:

  • 顶层:Frame泳道查看丢帧时间点与类型(App侧/Render侧);
  • 中层:CPU/Callstack泳道查看耗时函数;
  • 底层:双击函数栈帧跳转至源码,定位耗时代码行。

干货技巧:用Alt+框选聚焦异常时段,可快速过滤无关数据;涉及Native层问题需导入离线符号表(工具控制栏按钮),还原函数名才能定位代码。

步骤3:代码优化+验证——形成闭环

核心原则:围绕“降负载”优化,分为永久降负载(彻底解决)与临时降负载(缓解体验),避免过度优化。

高频优化场景与方案:

  • 卡顿优化:简化UI层级(减少嵌套)、耗时计算移至子线程、避免滑动时执行复杂渲染。
  • 冗余刷新:拆分大型Object为小对象、避免子组件重复绑定同一状态变量。
  • 内存泄漏:释放无用对象引用、避免全局变量滥用、正确使用@Prop/@Link装饰器。

验证步骤:优化后重新用Realtime Monitor复现场景,对比指标——如卡顿场景帧率恢复至60fps、启动耗时缩短50%,即说明优化有效;未达标则重复“定位-优化”流程。

三、专项分析:Frame卡顿丢帧深度拆解

Frame模板是分析卡顿的核心工具,可覆盖GPU渲染、帧链路、异常操作等多维度,精准定位掉帧根源。

3.1 核心泳道解读(必懂)

展开Frame泳道后,重点关注以下子泳道,覆盖帧渲染全链路:

  • RS Frame/App Frame:分别对应Render Service侧与App侧帧数据,绿色为正常帧,红色为卡顿帧(耗时超16.6ms)。
  • Lost Frames/Hitch Time:直观展示丢帧数与卡顿时长,点选可查看具体时段数据。
  • Anomaly:检测图片解码超时(超8.3ms告警)、序列化/反序列化超时(默认8ms阈值),仅支持非上架应用。
  • User Events:查看用户操作(如点击)的处理耗时,定位交互卡顿原因。

3.2 实操分析流程(卡顿场景)

  1. 框选卡顿时段,查看RS Frame/App Frame泳道,判断卡顿来自App侧还是Render侧;
  2. 若为App侧卡顿:切换至ArkTS Callstack泳道,定位耗时最长的组件绘制或状态更新函数;
  3. 若为Render侧卡顿:查看GPU使用率,排查是否因硬件合成渲染过载;
  4. 通过“Statistics”区域统计卡顿率、次数,验证优化后的数据改善情况。

3.3 快捷键高效操作(提升50%效率)

  • 时间轴:W/S放大/缩小,A/D左右移动(需激活泳道区);
  • 标记:M添加单点标记,Shift+M添加时间段标记;
  • 标记切换:Ctrl+,/Ctrl+. 前后切换单点标记,Ctrl+[/Ctrl+] 切换时间段标记。

四、专项分析:ArkUI组件与状态卡顿定位

ArkUI层卡顿多源于组件布局、状态管理不当,通过ArkUI模板的专属泳道,可精准定位这类上层问题。

4.1 典型问题场景(高频踩坑点)

  1. 布局嵌套过多:组件层级超过5层,导致绘制链路冗长;
  2. 冗余刷新:更新大型Object部分属性,触发全对象刷新;
  3. 状态绑定异常:子组件重复绑定同一状态变量,更新时多次刷新;
  4. 装饰器误用:@Prop传递大型对象,引发不必要的深度拷贝。

4.2 核心泳道实操

4.2.1 ArkUI Component泳道(组件绘制分析)

  1. 框选时段后,“Summary”列表展示组件绘制统计(次数、总耗时、最大耗时),快速锁定绘制耗时最长的组件;
  2. 点选泳道条块,“More”区域展示组件树,直观查看布局嵌套层级,优化冗余组件。

4.2.2 ArkUI State泳道(状态更新分析)

  1. 录制状态更新场景(如点击按钮更新数据),“Summary”区域展示状态变量的变化次数、所属组件;
  2. 选中状态变量变化记录,开启“Delivery Chain”开关,图形化查看状态影响的组件链路,定位冗余刷新组件;
  3. 关联ArkUI Component泳道,验证状态更新是否触发组件过度刷新。

注意事项

因隐私政策,已上架应用不支持录制ArkUI Component/State泳道,需在开发测试阶段完成全量性能验证。

五、实战避坑与优化建议(干货总结)

结合大量项目实践,整理以下高频避坑点与优化技巧,帮你少走弯路:

  • 录制时务必完整复现场景:如卡顿需重复触发3次以上,避免数据碎片化导致定位失败;
  • 优先优化“耗时占比最高”的函数:这类函数往往是性能瓶颈的核心,优化后收益最明显;
  • 版本适配:页面布局查看、Component Animation等能力需DevEco Studio 5.1.0+,提前升级避免功能缺失;
  • 避免过度优化:如为简化布局牺牲功能扩展性,需平衡性能与代码可维护性;
  • 数据备份:解析完成后导出会话数据,便于团队共享分析或后续回溯问题。

六、总结

DevEco Profiler的核心价值的是“让性能问题可量化、可定位”,其优化流程的本质是“用数据驱动决策”——而非凭经验猜测。通过“实时监控定界→深度录制定位→优化验证闭环”的标准化流程,结合Frame与ArkUI专项分析,可高效解决HarmonyOS应用的各类性能问题。

建议在开发阶段就融入性能测试,每完成一个核心功能就用Realtime Monitor排查,避免上线前集中“救火”。

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/ 

InheritableThreadLocal相比ThreadLocal多一个能力:在创建子线程Thread时,子线程Thread会自动继承父线程的InheritableThreadLocal信息到子线程中,进而实现在在子线程获取父线程的InheritableThreadLocal值的目的。

关于ThreadLocal详细内容,可以看这篇文章:史上最全ThreadLocal 详解

和 ThreadLocal 的区别

举个简单的栗子对比下InheritableThreadLocal和ThreadLocal:

public class InheritableThreadLocalTest {    
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();    
    private static final InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();    

    public static void main(String[] args) {        
        testThreadLocal();        
        testInheritableThreadLocal();    
    }    

    /**     * threadLocal测试     */    
    public static void testThreadLocal() {       
         // 在主线程中设置值到threadLocal        
         threadLocal.set("我是父线程threadLocal的值");        
         // 创建一个新线程并启动        
         new Thread(() -> {            
                 // 在子线程里面无法获取到父线程设置的threadLocal,结果为null            
                 System.out.println("从子线程获取到threadLocal的值: " + threadLocal.get());           }
         ).start();    
     }    
 
     /**     * inheritableThreadLocal测试     */  
    public static void testInheritableThreadLocal() {        
        // 在主线程中设置一个值到inheritableThreadLocal        
        inheritableThreadLocal.set("我是父线程inheritableThreadLocal的值");        
        // 创建一个新线程并启动        
        new Thread(() -> {            
                // 在子线程里面可以自动获取到父线程设置的inheritableThreadLocal    
                System.out.println("从子线程获取到inheritableThreadLocal的值: " + inheritableThreadLocal.get());        
            }).start();    
        }
    }

执行结果:

从子线程获取到threadLocal的值:null
从子线程获取到inheritableThreadLocal的值:我是父线程inheritableThreadLocal的值

可以看到子线程中可以获取到父线程设置的inheritableThreadLocal值,但不能获取到父线程设置的threadLocal值

实现原理

InheritableThreadLocal 的实现原理相当精妙,它通过在创建子线程的瞬间,“复制”父线程的线程局部变量,从而实现了数据从父线程到子线程的一次性、创建时的传递 。

其核心工作原理可以清晰地通过以下序列图展示,它描绘了当父线程创建一个子线程时,数据是如何被传递的:

sequenceDiagram
    participant Parent as 父线程
    participant Thread as Thread构造方法
    participant ITL as InheritableThreadLocal
    participant ThMap as ThreadLocalMap
    participant Child as 子线程

    Parent->>Thread: 创建 new Thread()
    Note over Parent,Thread: 关键步骤:初始化
    Thread->>Thread: 调用 init() 方法
    Note over Thread,ITL: 检查父线程的 inheritableThreadLocals
    Thread->>+ThMap: createInheritedMap(<br/>parent.inheritableThreadLocals)
    ThMap->>ThMap: 新建一个ThreadLocalMap
    loop 遍历父线程Map中的每个Entry
        ThMap->>+ITL: 调用 key.childValue(parentValue)
        ITL-->>-ThMap: 返回子线程初始值<br/>(默认返回父值,可重写)
        ThMap->>ThMap: 将 (key, value) 放入新Map
    end
    ThMap-->>-Thread: 返回新的ThreadLocalMap对象
    Thread->>Child: 将新Map赋给子线程的<br/>inheritableThreadLocals属性
    Note over Child: 子线程拥有父线程变量的副本

下面我们来详细拆解图中的关键环节。

### 核心实现机制

  1. **数据结构基础:Thread类内部维护了两个 ThreadLocalMap类型的变量 :

    • threadLocals:用于存储普通 ThreadLocal设置的变量副本。
    • inheritableThreadLocals:专门用于存储 InheritableThreadLocal设置的变量副本 。InheritableThreadLocal通过重写 getMapcreateMap方法,使其所有操作都针对 inheritableThreadLocals字段,从而与普通 ThreadLocal分离开 。
  2. 继承触发时刻:子线程的创建。继承行为发生在子线程被创建(即执行 new Thread())时。在 Thread类的 init方法中,如果判断需要继承(inheritThreadLocals参数为 true父线程(当前线程)的 inheritableThreadLocals不为 null,则会执行复制逻辑 。
  3. 复制过程的核心:createInheritedMap。这是实现复制的核心方法 。它会创建一个新的 ThreadLocalMap,并将父线程 inheritableThreadLocals中的所有条目遍历拷贝到新 Map 中。

    • Key的复制:Key(即 InheritableThreadLocal对象本身)是直接复制的引用。
    • Value的生成:Value 并非直接复制引用,而是通过调用 InheritableThreadLocalchildValue(T parentValue)方法来生成子线程中的初始值。默认实现是直接返回父值return parentValue;),这意味着对于对象类型,父子线程将共享同一个对象引用 。

关键特性与注意事项

  1. 创建时复制,后续独立:继承只发生一次,即在子线程对象创建的瞬间。此后,父线程和子线程对各自 InheritableThreadLocal变量的修改互不影响 。
  2. 在线程池中的局限性:这是 InheritableThreadLocal最需要警惕的问题。线程池中的线程是复用的,这些线程在首次创建时可能已经从某个父线程继承了值。但当它们被用于执行新的任务时,新的任务提交线程(逻辑上的“父线程”)与工作线程已无直接的创建关系,因此之前继承的值不会更新,这会导致数据错乱(如用户A的任务拿到了用户B的信息)或内存泄漏​ 。对于线程池场景,应考虑使用阿里开源的 TransmittableThreadLocal (TTL)​ 。
  3. 浅拷贝与对象共享:由于 childValue方法默认是浅拷贝,如果存入的是可变对象(如 MapList),父子线程实际持有的是同一个对象的引用。在一个线程中修改该对象的内部状态,会直接影响另一个线程 。若需隔离,可以重写 childValue方法实现深拷贝 。
  4. 内存泄漏风险:与 ThreadLocal类似,如果线程长时间运行(如线程池中的核心线程),并且未及时调用 remove方法清理,那么该线程的 inheritableThreadLocals会一直持有值的强引用,导致无法被GC回收。良好的实践是在任务执行完毕后主动调用 remove()

线程池中局限性

一般来说,在真实的业务场景下,没人会直接 new Thread,而都是使用线程池的,因此InheritableThreadLocal在线程池中的使用局限性要额外注意

首先,我们先理解 InheritableThreadLocal的继承前提

  • InheritableThreadLocal的继承只发生在 新线程被创建时(即 new Thread()并启动时)。在创建过程中,子线程会复制父线程的 InheritableThreadLocal值。
  • 在线程池中,线程是预先创建或按需创建的,并且会被复用。因此,继承只会在线程池创建新线程时发生,而不会在复用现有线程时发生。

再看线程池创建新线程的条件,对于标准的 ThreadPoolExecutor,新线程的创建遵循以下规则:

  1. 当前线程数 < 核心线程数:当提交新任务时,如果当前运行的线程数小于核心线程数,即使有空闲线程,线程池也会创建新线程来处理任务。此时,新线程会继承父线程(提交任务的线程)的 InheritableThreadLocal
  2. 当前线程数 >= 核心线程数 && 队列已满 && 线程数 < 最大线程数:当任务队列已满,且当前线程数小于最大线程数时,线程池会创建新线程来处理任务。同样,新线程会继承父线程的 InheritableThreadLocal

不会继承的场景

  • 线程复用:当线程池中有空闲线程时(例如,当前线程数 >= 核心线程数,但队列未满),任务会被分配给现有线程执行。此时,没有新线程创建,因此不会发生继承。现有线程的 InheritableThreadLocal值保持不变(可能是之前任务设置的值),这可能导致数据错乱(如用户A的任务看到用户B的数据)。
  • 线程数已达最大值:如果线程数已达最大线程数,且队列已满,新任务会被拒绝(根据拒绝策略),也不会创建新线程,因此不会继承。

不只是线程池污染,线程池使用 InheritableThreadLocal 还可能存在获取不到值的情况。例如,在执行异步任务的时候,复用了某个已有的线程A,并且当时创建该线程A的时候,没有继承InheritableThreadLocal,进而导致后面复用该线程的时候,从InheritableThreadLocal获取到的值为null:

public class InheritableThreadLocalWithThreadPoolTest {    
    private static final InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();    
    // 这里线程池core/max数量都只有2    
    private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(            
        2,            
        2,            
        0L,            
        TimeUnit.MILLISECONDS,            
        new LinkedBlockingQueue<Runnable>(3000),            
        new ThreadPoolExecutor.CallerRunsPolicy()    
    );    
    
    public static void main(String[] args) {        
    // 先执行了不涉及InheritableThreadLocal的子任务初始化线程池线程 
           testAnotherFunction();        
           testAnotherFunction();        
           // 后执行了涉及InheritableThreadLocal
           testInheritableThreadLocalWithThreadPool("张三");        
           testInheritableThreadLocalWithThreadPool("李四");        
           threadPoolExecutor.shutdown();    
     }    
     
     /**     * inheritableThreadLocal+线程池测试     */    
        public static void testInheritableThreadLocalWithThreadPool(String param) {        
            // 1. 在主线程中设置一个值到inheritableThreadLocal        
             inheritableThreadLocal.set(param);        
            // 2. 提交异步任务到线程池        
            threadPoolExecutor.execute(() -> {            
            // 3. 在线程池-子线程里面可以获取到父线程设置的inheritableThreadLocal吗?            
                System.out.println("线程名: " + Thread.currentThread().getName() + ", 父线程设置的inheritableThreadLocal值: " + param + ", 子线程获取到inheritableThreadLocal的值: " + inheritableThreadLocal.get());        
            });        
            // 4. 清除inheritableThreadLocal        
            inheritableThreadLocal.remove();    
       }    
                   
       /**     * 模拟另一个独立的功能     */   
       public static void testAnotherFunction() {        
           // 提交异步任务到线程池        
           threadPoolExecutor.execute(() -> {            
           // 在线程池-子线程里面可以获取到父线程设置的inheritableThreadLocal吗?            
               System.out.println("线程名: " + Thread.currentThread().getName() + ", 线程池-子线程摸个鱼");        
           });    
       }
}

执行结果:

线程名:pool-1-thread-2,线程池-子线程摸个鱼
线程名:pool-1-thread-1,线程池-子线程摸个鱼
线程名:pool-1-thread-1,父线程设置的inheritableThreadLocal值:李四,子线程获取到inheritableThreadLocal的值:null
线程名:pool-1-thread-2,父线程设置的inheritableThreadLocal值:张三,子线程获取到inheritableThreadLocal的值:null

当然了,解决这个问题可以考虑使用阿里开源的 TransmittableThreadLocal (TTL),​或者在提交异步任务前,先获取线程数据,再传入。例如:

// 1. 在主线程中先获取inheritableThreadLocal的值
String name = inheritableThreadLocal.get();    
    
// 2. 提交异步任务到线程池        
threadPoolExecutor.execute(() -> {            
// 3. 在线程池-子线程里面直接传入数据  
System.out.println("线程名: " + Thread.currentThread().getName() + ", 父线程设置的inheritableThreadLocal值: " + param + ", 子线程获取到inheritableThreadLocal的值: " + name);        
            });        

与 ThreadLocal 的对比

特性ThreadLocalInheritableThreadLocal
数据隔离线程绝对隔离线程绝对隔离
子线程继承不支持支持(创建时)
底层存储字段Thread.threadLocalsThread.inheritableThreadLocals
适用场景线程内全局变量,避免传参父子线程间需要传递上下文数据

今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

为什么要用ETCD

先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

另外,Easegress最核心的设计主要有三个:

  • 一是无第三方依赖的自己选主组集群的能力
  • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
  • 三是内置一个Data Store用于集群控制和数据共享。

对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

背景问题

好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

用户报告的问题是——

在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

// DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
// to catch-up after compacting the raft storage entries.
// We expect the follower has a millisecond level latency with the leader.
// The max throughput is around 10K. Keep a 5K entries is enough for helping
// follower to catch up.
DefaultSnapshotCatchUpEntries uint64 = 5000

另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

另外还有下面几项也会导致etcd的内存会增加

  1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
  2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
  3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

(很明显,etcd这么做就是为了一个高性能的考虑)

Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

总结

要用好 etcd,有如下的实践

  • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
  • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
  • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
  • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

最后,欢迎大家关注我们的开源软件! https://github.com/megaease/ 

起因

今天打开活动监视器,发现内存压力已经黄了。

一看进程列表,好家伙,满屏的 node

排查

ps aux | grep node

输出让我沉默了:

node auggie --mcp -m default -w /Users/xxx/project
node auggie --mcp -m default -w /Users/xxx/project
node auggie --mcp -m default -w /Users/xxx/project
... (x34)

34 个 auggie --mcp 进程,全是 Augment 官方的 CLI 工具。

有些从 12月27日 就开始跑了,已经跑了两周了...

问题分析

Auggie 作为 MCP server 被 Claude Code / Cursor 等客户端调用时:

  1. 每次新会话都 fork 新进程 —— 这没问题
  2. 但是客户端断开后,进程不退出 —— 这就有问题了
  3. 没有任何清理机制 —— 进程就这么一直挂着
  4. 静默吃内存 —— 没有任何提示,用户完全无感知

结果就是:用一天,攒一堆僵尸进程,内存越来越少,直到你发现电脑开始卡了。

临时解决方案

# 一键清理所有 auggie 进程, 普通的 `pkill -f "auggie --mcp"` 居然杀不掉,必须用 `kill -9` 强制终止:
pkill -9 -f "auggie --mcp"

或者写个 alias 放到 .zshrc:

alias kill-auggie='pkill -9 -f "auggie --mcp" && echo "已清理 auggie 僵尸进程"' 

作为一个官方出品的 CLI 工具,进程生命周期管理这种基本功都没做好,属实有点说不过去。

ps: 可以用佬友 开发的 优化版… 官方这个属实有点坑了…


📌 转载信息
原作者:
cao_Jacker
转载时间:
2026/1/12 10:33:17