news 2026/9/29 4:00:29

内存泄漏排查实战:Android、Native与JVM工具链

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
内存泄漏排查实战:Android、Native与JVM工具链

内存泄漏这四个字,在客户端开发里几乎就是一句"我知道它在那儿,但一时半会儿抓不到它"。真到了线上,用户反馈"用久了就卡、切几个页面就闪退",你手上能用的检查工具其实就那么几类:看内存曲线的、抓堆转储的、自动找引用链的、还有直接盯 native 分配的。工具本身不难用,难的是搞清楚什么时候该用哪一个,以及拿到数据之后怎么读。这篇就把我这些年反复用过的几套工具串起来讲一遍,从 Android 端的 LeakCanary、Memory Profiler 一直到 MAT、ASan、Perfetto,再到服务端 JVM 的那套老办法,中间穿插大量实操步骤和踩坑记录。适合已经能写业务代码、但对内存问题还是一头雾水的同学,也适合想把手里的排查流程再规范一遍的老手。

1. 先把"涨得快"和"回不去"分开:判断是不是真泄漏

很多人一看到 Profiler 里那条内存曲线往上走,第一反应就是"泄漏了"。然后花两天时间抓堆、看引用链,最后发现是图片缓存没做上限。这种时间浪费得非常可惜,因为排查方向从一开始就定错了。真正的内存泄漏有一个非常明确的定义:对象已经不再被业务需要,但仍然被 GC Roots 可达,导致无法回收。注意这里的关键词是"不再被业务需要",而不是"内存占用高"。

1.1 三种内存曲线的形态差异

先把三种常见情况的表现说清楚,你对号入座一下,能省掉大量无谓的排查。

内存抖动的表现是曲线呈锯齿状,频繁上下大幅波动。典型的触发场景是在onDraw、onScroll或者循环里做字符串拼接、创建临时对象、装箱拆箱。这类问题的危害是频繁触发 GC,表现为滑动掉帧,而不是最终 OOM。它跟泄漏是两回事,处理方式是减少临时对象分配,比如把String拼接换成StringBuilder复用、把循环里的对象创建提到循环外。

缓存膨胀的表现是曲线一路缓涨,但在内存压力下能被系统回收一部分。图片库、列表数据、HashMap 缓存都属于这一类。它最后也会 OOM,但根因是"没有上限",而不是"该释放的没释放"。处理方式很直接:给 LruCache 设maxSize,用onTrimMemory分级清理。

真正的泄漏表现最典型:进一个页面内存涨一截,退出这个页面内存不降,反复进出十次,内存阶梯式上升,每次的增量基本一致。这种规律的阶梯曲线,基本可以直接判定为页面级对象泄漏,最常见的就是 Activity 或 Fragment 没被回收。

这三种曲线的区分不需要什么高级工具,Android Studio 的 Profiler、甚至adb shell dumpsys meminfo连续采样几次就能看出来。我一般的习惯是先手动触发一次 GC(Profiler 里那个垃圾桶形状的按钮),再进出一个可疑页面,再触发 GC,看内存能不能回到基线。回不去,才值得往下深挖。

注意:判断泄漏一定要在 GC 之后看。没触发 GC 的内存上涨可能只是对象还在新生代里待着,这不叫泄漏。

1.2 先定层再选工具:Java 堆、Native 堆、系统侧

确定了是真泄漏之后,第二件事是定层。Android 应用的内存分好几块,Profiler 里能看到 Java、Native、Graphics、Code、Stack、Others 这几类。Java 堆的泄漏用 LeakCanary 和 MAT 就能解决,Native 堆的泄漏这两样工具基本无能为力,得换 ASan、LSan 或者 Perfetto 的 heapprofd。

怎么快速分清是哪一层?看 Profiler 里各条曲线的增速。如果 Java 那条线明显阶梯上升,就是 Java 层。如果 Native 那条线一直涨而 Java 很平稳,大概率是 native 内存问题,比如图片解码后的 native bitmap、OpenGL 纹理没释放、音视频解码器没关。如果是 Graphics 涨,多半跟 Surface、Canvas、动画有关。

这个定型动作看起来简单,但它是整个排查流程里性价比最高的一步。我见过太多次,一群人对着 Java 堆转储文件看到眼睛发酸,结果问题出在 native 侧的一个解码器上。选错工具,后面所有努力都是白费。

2. Android 现场的轻量取证:adb 与 Memory Profiler 的分工

不是所有排查都需要一上来就抓几百 MB 的堆转储。在真机上做初步定位时,adb命令加上 Profiler 这套组合已经能覆盖八成场景,而且成本极低,不需要改动业务代码,也不需要调试包之外的特殊配置。这一节我把这两类手段的具体用法和看数据的重点拆开讲。

2.1 dumpsys meminfo 里最该看的那几行数字

dumpsys meminfo是我最常用的第一把刀,因为它输出快、信息密度高,而且在线上灰度包里也能通过日志系统采集。基本用法:

adb shell dumpsys meminfo com.example.app

输出的前半部分是内存分类统计,Pss Total、Private Dirty、Heap Size、Heap Alloc、Heap Free 这些数字。但对排查泄漏来说,真正关键的是后半部分那几个对象计数:

adb shell dumpsys meminfo com.example.app -d

加-d参数会打印更详细的对象统计,重点看这几项:

字段含义泄漏时的表现
Activities当前存活的 Activity 实例数退出页面后不减少,持续增长
ViewRootImpl当前存活的视图树根节点数与 Activities 同步异常增长
AppContextsApplication Context 实例数正常恒为 1,大于 1 说明有异常
Views当前存活的 View 实例数单页面反复进出后不回落
Cursors未关闭的游标数长期大于 0 说明有未关闭的查询
WebViews存活的 WebView 实例数与页面生命周期不匹配

我自己最常用的是Activities和ViewRootImpl这两个数。操作方法很简单:进页面之前记一次数,退出页面、触发 GC、再记一次数,如果 Activities 没有回到进页面之前的值,那基本可以坐实这个页面泄漏了。这个方法的好处是不需要任何工具链,一条命令就能定位到"哪个页面泄漏",然后再用后面讲的工具去查"为什么泄漏",排查范围一下子缩小了。

提示:不同厂商 ROM 对dumpsys meminfo的输出格式有细微差异,字段名可能略有不同,但 Activities、ViewRootImpl 这类关键计数基本一致。

2.2 Memory Profiler 抓堆的三个动作与时机

Profiler 的 Memory 面板比纯粹的堆转储更直观,因为它是实时曲线加上可交互的堆快照。但要用好它,得搞清楚三个动作的顺序,顺序错了数据就没意义。

第一个动作是等一下再抓。不要刚进页面就 dump,那时候对象还在创建过程中。等到页面完全展示稳定之后,再操作。

第二个动作是手动触发 GC。按下那个垃圾桶图标,等内存曲线掉到基线。这一步的目的是把可以回收的对象清掉,剩下的才是真正被持有的。

第三个动作是 Dump Java heap。抓完快照后,在 Profiler 的类列表里按 Retained Size 排序,看哪个类的实例数和内存占用异常。比如一个本该只有一个实例的 Presenter 类出现了几十个实例,或者某个页面的 View 类数量远大于当前打开的页面数,这些都是很明确的信号。

Profiler 里还有个常被忽略的功能:Record Java/Kotlin allocations。它会记录一段时间内所有对象的分配堆栈,适合排查短生命周期的对象滥用,比如在循环里反复创建 Bitmap。这个跟泄漏是两个场景,别搞混。

实测下来,Profiler 的优点是可视化好、上手快,缺点是它抓的快照分析能力弱于 MAT,没法方便地看引用链。所以我的流程通常是:Profiler 定位到可疑的类,然后决定是继续用 MAT 深挖,还是直接用 LeakCanary 在复现场景里自动抓引用链。

3. LeakCanary:把"找引用链"这件事自动化

如果说前两节讲的是人工排查,那 LeakCanary 就是把整个流程自动化了。它的价值不在于"发现泄漏"——前面说过,曲线和 Activities 计数已经能发现——而在于它直接把从 GC Roots 到泄漏对象的那条最短强引用路径打出来。这条路径,是人工用 MAT 一步步点出来的东西,LeakCanary 几秒钟就能给你。

3.1 弱引用加 ReferenceQueue 判断对象死活

LeakCanary 判断一个对象是否泄漏的机制,说白了就是"等 GC 回收它,等不到就说明有人抱着不放"。具体实现上,它用ObjectWatcher来登记需要观察的对象,登记时创建的是一个KeyedWeakReference(带 key 的弱引用),同时把这个弱引用注册到一个ReferenceQueue上。

流程是这样的:调用watch登记对象后,LeakCanary 会主动触发一次 GC。GC 之后,如果这个对象真的被回收了,那么对应的弱引用会被放入ReferenceQueue,程序从队列里 poll 到这个引用,就知道对象已经正常释放了。如果等了一段时间(默认是 5 秒)队列里还是没出现它,说明对象仍然被强引用持有,LeakCanary 就会 dump 一次堆,交给内置的 Shark 分析引擎去算引用链。

注意:LeakCanary 触发 GC 这个动作本身是有成本的,所以它默认只在 debug 包里工作,线上包不要带。

理解了这个机制,你就能明白为什么 LeakCanary 有时会报"误报"——如果对象只是暂时被某个短命的对象持有,5 秒窗口过后就释放了,它其实不是泄漏。LeakCanary 对这种情况的处理是标记为UNKNOWN而不是LEAKING,这在后面 3.3 里展开讲。

3.2 接入姿势与自定义关注对象

LeakCanary 2.x 的接入简单到有点反直觉,只需要在模块的 gradle 文件里加一行依赖,不需要在 Application 里写任何初始化代码:

dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' }

它会通过一个 ContentProvider 在应用启动时自动完成初始化。自动监控的范围包括 Activity、Fragment、Fragment 的 View、ViewModel 和 Service。这里有个细节值得注意:Service 的监控在旧版本里是默认关闭的,因为 Service 的生命周期判断需要额外调用,如果你的项目里 Service 泄漏高发,需要手动在 Service 的onDestroy里调用AppWatcher.objectWatcher.watch(this)。

另外,如果你有自定义的对象需要监控,比如自己写的单例管理器、编译期生成的组件持有者,可以手动登记:

AppWatcher.objectWatcher.watch( watchedObject = myObject, description = "MyManager instance" )

这套机制我一般在两种场景下用:一是怀疑某个全局注册的监听器没注销,二是排查自定义 View 被谁持有时。手动watch加上业务复现路径,比漫无目的地抓堆高效得多。

3.3 leak trace 里 Leaking: YES 与 UNKNOWN 的区别

LeakCanary 输出的 leak trace 是从 GC Roots 一路画到泄漏对象的引用链,每一层都会标注对象的存活状态。这里最关键的一点是:要看链上每一个节点,找到那个"不该存在"的引用。

典型的输出会长这样:一个 Activity 被一个匿名内部类持有,这个内部类又被一个 MessageQueue 持有的 Message 持有,Message 最后被主线程持有。看到这条链,结论就很清楚了——某个 Handler 发了延时消息,页面销毁时没有removeCallbacksAndMessages(null)。

链上标注Leaking: YES的对象是确定泄漏的,Leaking: NO说明它本身是应该长期存活的(比如主线程、Application),Leaking: UNKNOWN表示 LeakCanary 无法判断它的生命周期。排查的重点是那些 Leaking: YES 但本不该长期存活的对象,通常泄漏的"断点"就在它附近。

还有一个提高信噪比的技巧:LeakCanary 支持配置referenceMatchers,把已知的系统框架持有行为(比如某些 ROM 上InputMethodManager短暂持有 View)标记为可忽略。不配置的话,你会被大量系统层面的噪音淹没。这部分配置需要在自定义的AppWatcher.Config或者LeakCanary.Config里做,具体做法是往LeakCanary.config里追加自己的引用匹配规则。

4. 堆转储到手之后:MAT 里的分析路径

不是所有环境都能跑 LeakCanary。线上包、用户设备、没有调试权限的场景,你手里只有一份从崩溃平台或者用户那里捞回来的堆转储文件。这时候 MAT 就是主力。MAT 的分析思路和 LeakCanary 自动生成引用链不太一样,它是给你一堆视图让你自己找线索,所以更需要方法。

4.1 hprof-conv 什么情况下还需要

Android 早期导出的 hprof 文件带的是 Android 特有的格式(包含了 zygote 堆的一些信息),标准 MAT 打不开,需要用 SDK 里的hprof-conv转换:

hprof-conv input.hprof output.hprof

这个工具在platform-tools目录下,Android Studio 用户的 SDK 路径里都有。需要注意,从 Android Studio 3.0 开始,Profiler 导出的 hprof 已经是标准格式,可以直接拖进 MAT,不再需要转换。如果你拿到的 hprof 打开时报格式错误,先检查一下来源——自己用am dumpheap抓的、或者从某些内存监控 SDK 导出的,通常还是要转。

adb shell am dumpheap com.example.app /data/local/tmp/heap.hprof adb pull /data/local/tmp/heap.hprof

用am dumpheap抓的文件默认是 Android 格式,记得转。

4.2 Histogram 和 Dominator Tree 各自解决什么问题

MAT 的两个核心视图经常让人搞混,我把它们的分工说清楚。

Histogram是按类聚合的实例统计,显示每个类的实例数、Shallow Size(对象自身占用)、Retained Size(回收它之后能释放的总内存)。它的强项是发现异常数量。比如Activity类下有 5 个实例但当前只有一个页面,那就有问题;又比如某个自定义Callback类有 200 个实例,明显超出了预期。用正则过滤类名能很快缩小范围。

Dominator Tree是按"支配关系"组织的,显示的是如果回收某个对象,能连带释放多少内存。它的强项是发现内存大户。一个巨大的 Bitmap 或者一个超大的数组,在 Dominator Tree 里会非常显眼,你能看到它下面支配了一大片内存。有些情况下泄漏的对象本身不大,但它持有了几十 MB 的数据,这种就必须用 Dominator Tree 才看得出来。

我的一般顺序是:先用 Histogram 按实例数找异常类,确定可疑类后再在 Dominator Tree 里看它实际占了多少内存、持有了什么。两个视图配合着看,比单看一个效率高得多。

表格对比一下更容易记:

视图排序依据主要用途典型场景
Histogram类名分组,看实例数找数量异常的类Activity 多实例、Listener 堆积
Dominator Tree支配关系,看 Retained Size找内存大户大图未释放、超大缓存
Top Consumers综合占用快速定位占用前几名初步摸底
Duplicate Classes类重复加载排查多 ClassLoader插件化、热修复场景

4.3 Path to GC Roots 一定要排除的引用类型

找到可疑对象之后,右键它,选择Path to GC Roots,然后务必选择exclude weak/soft references。这一步是新手最容易出错的地方。

原因很简单:弱引用和软引用在 GC 发生时会被回收,它们本身不构成泄漏。如果不排除这两类引用,你看到的引用链上会出现大量来自 WeakHashMap、缓存容器、监听器注册表的链路,这些都不是真正的持有者。排除之后,剩下的强引用路径才是问题所在。

还有一个需要排除的是 Finalizer 相关的引用。某些对象在被回收前会进入 FinalizerQueue,如果看到链路上有FinalizerReference,那说明对象其实正在被回收流程处理,不是泄漏。这两个排除项做完,引用链往往会变得干净很多,一眼就能看出是谁在持有。

顺带提一个很多人不知道的点:MAT 里可以看对象的"被支配者"和"支配者"两种关系。右键选Immediate Dominators能看到谁支配了它,反过来看Retained Set能看到它支配了哪些对象。排查一个复杂对象图的时候,这两个视角能帮你快速理清谁持有谁。

5. Native 与跨语言场景:ASan、LSan、Perfetto 补位

前面整条链路都是围绕 Java 堆展开的。但现实中的 OOM 有很大一部分发生在 native 侧,尤其是涉及音视频、图像处理、自研引擎的应用。这时候 LeakCanary 和 MAT 全部失效,因为它们看不到 native 内存的分配和释放。这一节讲 native 侧该怎么查。

5.1 Native 泄漏的证据链和 Java 完全不同

Native 内存泄漏的排查逻辑跟 Java 是不同的:Java 侧你有完整的对象图和引用关系,能顺着引用链找持有者;Native 侧你面对的是裸的内存地址,没有对象概念,你只能看到"某处分配了 4MB,从来没有释放"。

Android NDK 提供了AddressSanitizer(ASan)和LeakSanitizer(LSan)。ASan 的定位是检测越界访问和释放后使用,LSan 则是专门检测内存泄漏的——它在程序退出时扫描还有哪些分配没有释放,并打印出分配时的调用栈。这个调用栈就是 native 泄漏排查里最有价值的信息。

接入方式是在build.gradle或者 CMake 配置里开启 sanitizer,重新编译一个 debug 包:

android { defaultConfig { externalNativeBuild { cmake { arguments "-DANDROID_STL=c++_shared" } } } buildTypes { debug { // ASan 需要配合 android:debuggable 和特定配置 } } }

实际配置比这复杂一些,需要在 Application 里加载 ASan 的运行库,或者通过wrap.sh脚本启动应用。官方文档里有完整的步骤。关键点在于:ASan 会显著拖慢应用运行速度,并且内存占用会翻好几倍,所以只能在小规模的复现场景里用,不能拿它做长时间压测。

LSan 的局限也需要知道:它只能检测"程序退出时仍未释放"的内存。如果泄漏的指针被某个全局变量持有,LSan 就认为它"还是可达的",不会报出来。这种情况下需要一个能持续观察 native 内存增长的工具。

5.2 Perfetto/heapprofd 在系统级归因里的位置

当 LSan 报不出来但 native 内存确实在涨时,Perfetto 的heapprofd就是下一个选择。它做的是 native 堆采样分析,通过周期性采样和拦截 malloc/free 调用,记录每个分配点的调用栈和累计分配量。

它的使用方式是配置 trace config 文件,指定要采集的进程和采样参数,然后通过命令行启动:

adb shell perfetto -c /data/misc/perfetto-configs/heap.cfg --txt -o /data/misc/perfetto-traces/heap.pftrace

抓完之后把 trace 文件拉到本地,在 Perfetto 的网页界面里打开,就能看到按调用栈聚合的 native 分配视图。heapprofd 的优势是采样开销低,可以在接近真实的运行环境中用,这是 ASan 做不到的。

另外,Perfetto 也能做系统级的内存归因,比如查看各个进程的 Pss 变化、分析内存回收行为、观察 lmkd 的杀进程时机。当你的应用被系统杀掉,但你怀疑不是自己的问题而是系统内存压力导致的,Perfetto 的系统级 trace 能给出证据。

6. 服务端 JVM 与工具选型对照

前面的内容偏 Android,但内存泄漏这个问题在服务端同样普遍,尤其是长时间运行的应用。服务端排查的工具体系跟 Android 有重叠也有差异,这里单独拎出来说,因为很多人从客户端转到服务端后会发现,原来那套东西不全适用。

6.1 jstat、jmap 与 MAT 的配合流程

服务端 JVM 的第一步永远是看 GC 情况,用jstat:

jstat -gcutil <pid> 1000 10

它会每秒打印一次各个分代的占用百分比和 GC 次数、耗时。如果老年代(O)的占用率一直上升,Full GC 之后也降不下来,那就是典型的服务端内存泄漏。

确认之后用jmap抓堆:

jmap -dump:live,format=b,file=heap.hprof <pid>

注意live这个参数,它表示只 dump 存活对象,会先触发一次 Full GC。加上它能让 dump 出来的文件小很多,也更能反映真实泄漏情况。代价是这次 Full GC 会让服务停顿几秒到几十秒,具体取决于堆大小。

抓到的 hprof 直接用 MAT 打开,分析思路跟前面讲的一样:Histogram 找异常实例数,Dominator Tree 找内存大户,Path to GC Roots 排除弱引用和软引用之后看持有者。

还有一个服务端特有的工具:JProfiler 和 YourKit这类商业分析器。它们相比 MAT 的优势是能实时连接正在运行的 JVM、能看 CPU 和内存的关联、能追踪线程状态,适合排查那些"内存涨的同时 CPU 也在飙"的复合问题。如果预算允许,这两个工具在服务端排查效率上确实比 MAT 高一个档次。

6.2 各工具适用场景对照表

把这些工具按场景整理一下,方便你遇到问题时快速决策:

工具运行环境核心能力开销适用阶段
dumpsys meminfoAndroid 真机对象计数、内存分类极低初步定位页面
Memory ProfilerAndroid Studio实时曲线、堆快照中复现与初步分析
LeakCanaryAndroid debug 包自动引用链分析中高(触发 GC)开发期日常排查
MAT桌面深度堆分析无(离线)拿到 hprof 后
ASan / LSanAndroid debug 包native 泄漏检测极高小规模复现
Perfetto (heapprofd)Android 真机native 采样分析低接近真实环境
jstat / jmap服务端 JVMGC 观察、堆 dump低 / 高(停顿)服务端排查
JProfiler / YourKit服务端 JVM实时综合分析中复杂复合问题

这张表我建议存下来。很多人的问题不是工具不会用,而是手里有一堆工具但不知道该抽哪一把。

7. 几个反复踩到的坑

工具用熟了之后,剩下的问题基本都集中在"数据怎么读"和"环境怎么配"上。这一节讲几个我自己反复踩过、也见过很多人踩的坑。

7.1 混淆后的 hprof 与类名还原

Release 包抓出来的 hprof,类名全是a.a.a这种,看引用链跟看天书一样。解决办法是用抓包时对应版本的混淆映射文件(mapping.txt)还原。MAT 本身不带这个功能,需要用工具把 hprof 里的类名替换回去。

常见的做法有两个:一是用官方提供的retrace工具,先把 hprof 里的类名提取出来做映射;二是用社区里的一些 hprof 混淆还原脚本。我自己更常用的是简单粗暴的方式——在 MAT 里打开文件后,把 mapping.txt 里的映射关系手动对照着看关键的那几个类,因为真正需要看清楚的类通常就那么三五个,没必要全量还原。

这里有个必须注意的点:mapping.txt 必须和抓包时的包版本严格对应。混淆映射在不同版本之间是会变化的,用错版本还原出来的类名同样是错的,而且错得很隐蔽,容易误导排查方向。线上抓包时一定要把版本号和 mapping 文件对应存档。

7.2 弱引用链、Finalizer 造成的误报

这个坑在 4.3 里提过,但值得单独强调一遍,因为它太常见了。

用 MAT 查引用链时如果忘了排除弱引用和软引用,你会看到很多"看起来泄漏"的链路,比如某个对象被WeakHashMap持有、被ThreadLocal的内部结构持有。这些实际上都不会阻止 GC 回收对象。我有一个同事曾经花了整整一天追一条ThreadLocalMap的链路,最后发现是排查方法错了。

另一个容易误判的是 Finalizer。如果引用链上出现FinalizerReference或者FinalizerWatchdogDaemon,说明这个对象正在等待执行finalize方法,它最终还是会被回收的。这种链路也不代表泄漏。

一个简单的验证方法:找到可疑对象后,手动触发几次 GC(LeakCanary 会自动触发,MAT 里没这个功能,需要在应用侧触发),然后再抓一次堆对比。如果两次抓的堆里这个对象的实例数明显减少了,那之前的链路就是误报。这个方法虽然笨,但在关键判断上非常可靠。

7.3 dump 带来的卡顿与线上风险

最后说一个运维层面的坑:堆转储本身会暂停应用。

在 Android 上,早期的 dump 机制会挂起所有线程,一个几百 MB 的堆转储能让应用卡死好几秒甚至更久,用户很可能以为应用崩了。Android 11 之后引入了 fork 子进程来做 dump,暂停时间大幅缩短,但依然有明显开销。

服务端这边更严重。前面提过,jmap加上live参数会触发 Full GC,大堆的情况下停顿几十秒完全有可能,对于在线服务来说这是一次实打实的可用性风险。所以线上环境抓堆一定要走审批和限流流程,最好在流量低峰期做,并且提前准备好降级方案。

如果不想承担这种风险,替代方案是用采样式的监控:定期采集dumpsys meminfo的关键计数、或者用轻量的内存监控组件持续观察增长趋势,只在确认必要时才做完整 dump。牺牲一部分精确度换取线上稳定性,这个权衡在实际生产环境里通常是值得的。

我个人这些年的体会是,内存泄漏排查真正的门槛从来不是工具本身,而是判断力——判断是不是真泄漏、判断在哪一层、判断哪条引用链是噪音哪条是线索。工具只负责把数据摆到你面前,读数据这件事没人能替你做。所以我现在的习惯是,每排查完一个泄漏问题,都会把当时的曲线截图、引用链、修复方式记到一个自己的案例库里,攒到几十条之后,你会发现大部分泄漏其实就那么几个套路,看一眼曲线形态心里就有数了。

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

夜莺监控实战:从采集器接入到告警通知的完整配置指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华