Zygote 的预加载优化,对 App 启动的影响

Zygote 的预加载优化,对 App 启动的影响

Zygote 的预加载优化,对 App 启动的影响


Zygote 的预加载优化,对 App 启动的影响


Android 应用的冷启动速度,很大程度上取决于 Zygote 进程的分叉效率。这个结论在官方文档里被反复提及,但具体到实际项目中,Zygote 预加载的类列表到底该怎么调整、能省多少毫秒、会不会引发奇怪的崩溃,这些细节在中文技术社区里讨论得并不深。我去年参与一个启动优化专项时,花了两周时间跟 Zygote 的预加载机制较劲,记录了一些实测数据和踩坑经历。


Zygote 预加载的基本机制与观测方法


Zygote 进程在系统启动时会执行预加载,把框架层常用的类和资源加载到内存中,后续 fork 出应用进程时通过写时复制共享这些页。Android 10 引入了 ZygotePreload 接口,允许厂商在 ZygoteInit 的预加载流程中注入自定义逻辑,但普通应用开发者能直接干预的入口非常有限。


要观测 Zygote 预加载的实际效果,最直接的方式是看 /system/etc/zygote 目录下的预加载配置文件。在 Android 12 的 AOSP 代码里,ZygoteInit.javapreload() 方法会调用 preloadClasses()preloadResources(),前者读取的是 /system/etc/preloaded-classes 这个文本文件,每行一个全限定类名。Android 13 开始这个文件的位置有所调整,部分厂商迁移到了 /apex/com.android.art/etc/preloaded-classes,但格式没变。


我在 Pixel 4 的 Android 13 系统上抓了一份预加载类列表,总共 4000 多行,从 `android.app.Activity` 到 `android.graphics.drawable.VectorDrawable` 都在里面。用 `adb shell cat /proc/$(pidof zygote)/maps grep preloaded` 可以看到这些类对应的内存映射,但页是否被实际共享,需要用 `showmap` 或者自己解析 `/proc/<pid>/smaps` 来计算 PSS 的共享比例。

一个更实用的观测手段是 dumpsys meminfo-c 选项,对比 Zygote 子进程和 Zygote 本身的内存占用。我在测试机上写了个简单的 Native 工具,通过 process_vm_readv 读取指定地址范围,配合 mincore 系统调用检查页是否驻留内存。这个方法的精度有限,但能快速验证某个类是否真的被预加载到了物理内存中。


预加载类列表的裁剪实验


厂商定制 ROM 通常会对预加载列表做裁剪,低端机型尤其激进。我拿到一台某国产厂商的 Android 12 设备,其预加载类列表只有 AOSP 的 60% 左右,缺失的主要是 android.animation 包下的十几个类和一些不常用的 android.widget 子类。这带来了一个诡异的问题:该设备上冷启动我们的应用时,ObjectAnimator 的首次实例化耗时比 Pixel 设备长了 3-4 倍,因为类加载和 JIT 编译发生在应用进程而非 Zygote 预加载阶段。


为了量化这个差异,我用 Trace.beginSectionTrace.endSection 在应用代码里埋点,配合 Perfetto 抓 trace。具体代码片段如下:


Trace.beginSection("ObjectAnimatorFirstCreate");
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f);
Trace.endSection();

在 Pixel 4(完整预加载)上,这段代码首次执行耗时约 2.3ms;在裁剪过的低端设备上,首次执行耗时 11.7ms,后续实例化回落到 1.8ms 左右。差距主要来自 ClassLoader.loadClass 的耗时和解释执行阶段的开销。这个 9ms 的差异在启动链路上会被放大,因为 Choreographer 的帧调度可能因此错过一个 vsync,导致首帧渲染延迟 16ms。


我尝试在应用侧做补偿:把 ObjectAnimator 的类加载提前到 Application.attachBaseContext 中,通过 Class.forName("android.animation.ObjectAnimator") 触发加载。实测这能把首次实例化耗时降到 4.5ms,但无法完全消除差距,因为 Zygote 预加载还会做额外的初始化工作,比如静态字段的赋值和 <clinit> 的执行,这些在应用进程的类加载中同样会发生,但缺少了 Zygote 阶段的内存共享优势。


Android 14 的 Zygote 新特性与兼容问题


Android 14 引入了一个值得关注的改动:Zygote 的 selective preload 机制,允许根据即将启动的应用类型选择不同的预加载配置。这个特性在 ZygoteArguments 中通过 --preload-profile 参数控制,但目前在 AOSP 代码里默认关闭,只有部分参与早期访问计划的设备启用。


我在 Android 14 Beta 3 的模拟器上尝试启用这个特性,修改了 init.rc 中 Zygote 的启动参数,加了 --preload-profile=apps。结果系统启动后,所有应用进程都 fork 失败,logcat 里报 Zygote: failed to load preloaded-classes file for profile apps。排查后发现是预加载配置文件的路径解析逻辑有 bug,ZygoteInit 在拼接路径时漏掉了 apex 模块的目录前缀,导致文件找不到。这个 bug 在 Android 14 Beta 4 中被修复,issue 编号是 AOSP 的 #2839472,我在 Google Issue Tracker 上跟踪了整个过程。


这个经历让我意识到,Zygote 层面的改动对应用开发者来说基本是黑盒。我们能做的不是直接修改 Zygote,而是理解其机制后,在应用层做出适配。比如 Android 14 的 selective preload 如果普及,意味着不同应用的 Zygote 子进程可能拥有不同的预加载类集合,应用启动时不能假设某个框架类一定已经被预加载和初始化。


应用层能触及的优化:ClassLoader 预加载


虽然不能直接修改 Zygote 的预加载列表,但应用可以在 ApplicationContentProvider 的早期阶段主动触发关键类的加载,模拟一种"应用级预加载"的效果。这个技巧在头条、快手等大厂的启动优化方案里很常见,但实现细节有很多坑。


我尝试在 attachBaseContext 中用一个后台线程批量加载启动路径上的类:


new Thread(() -> {
    try {
        Class.forName("androidx.recyclerview.widget.RecyclerView");
        Class.forName("androidx.constraintlayout.widget.ConstraintLayout");
        Class.forName("com.google.android.material.button.MaterialButton");
    } catch (ClassNotFoundException e) {
        Log.e(TAG, "preload failed", e);
    }
}).start();

第一个坑是线程安全问题。Class.forName 最终会走到 BaseDexClassLoaderfindClass,而 DexPathListdexElements 数组虽然在构造时已经确定,但类解析过程中的 defineClass 需要获取 ClassLoader 的锁。如果主线程在启动路径上也触发了同类加载,两个线程会竞争同一个 monitor,反而可能拖慢启动。我用 Systrace 抓到过这样的场景:预加载线程和主线程同时加载 androidx.fragment.app.Fragment,主线程阻塞了 8ms。


第二个坑更隐蔽:预加载的类如果引用了尚未初始化的依赖类,会触发连锁加载,可能把不在启动路径上的类也拉进内存,增加不必要的 PSS。我用 Debug.MemoryInfo 对比过,激进的预加载策略能让启动时间减少 15ms,但 PSS 增加了 2.3MB,在低端设备上可能触发更频繁的 GC。


比较稳妥的做法是只预加载那些确定会在主线程首帧前用到、且没有复杂依赖链的类。我最终的方案是结合 R8 的 -printseeds 输出和启动路径的 Trace 数据,用脚本生成一个预加载白名单,大约 40 个类,效果比较均衡。


Zygote fork 的写时复制开销


预加载的类能被共享,前提是应用进程不修改这些内存页。一旦修改,就会触发写时复制(COW),页变成私有,不再共享 Zygote 的物理内存。框架层的类通常被认为是只读的,但实际上有很多隐藏的修改点。


一个典型的例子是 Typeface 的预加载。Zygote 会预加载系统默认字体,但应用进程如果调用 Typeface.create 传入自定义字体,相关的内部状态会被修改。我在 Android 12 上跟踪过 Typeface.sSystemFontMap 这个静态字段,Zygote 预加载后它是一个包含系统字体信息的 Map。当应用首次加载自定义字体时,Typeface 的 native 代码会修改这个 Map 的底层结构,导致整个 Map 所在的页变为私有。用 showmap 观察,这个页的共享标志从 sh 变成了 pr,PSS 计算时不再分摊到 Zygote。


更隐蔽的是 Resources 类的预加载。Zygote 预加载的是框架资源,但应用自己的资源是在 ResourcesManager 中动态创建的。Resources 内部有一个 TypedArray 的缓存池,这个池子在 Resources 构造时初始化,会修改预加载阶段已经分配好的数组对象。我写过一段测试代码,在 Application.onCreate 前后分别打印 TypedArray 缓存池的内存状态:


TypedArray cache = Resources.getSystem().obtainTypedArray(android.R.array.emailAddressTypes);
Log.d(TAG, "cache before: " + cache);

这段代码本身不关键,但配合 adb shell dumpsys meminfo <pid> 可以看到,调用 obtainTypedArray 后,应用进程的 Resources 相关内存页共享比例从 78% 降到了 62%。差距不大,但在资源密集的应用里会累积。


厂商定制带来的不可预期性


Zygote 的预加载策略在厂商定制 ROM 中差异极大。我测试过三台设备:Pixel 4(Android 13)、某国产旗舰(Android 12)、某海外品牌的低端机(Android 11 Go 版)。三台设备的预加载类数量分别是 4230、2890、1980,差距超过一倍。


最麻烦的是预加载类列表的不透明性。AOSP 的列表是公开的,但厂商很少披露自己的裁剪逻辑。我遇到过一个崩溃:应用在 Android 11 Go 版设备上启动时,android.graphics.drawable.AnimatedVectorDrawablecreate 方法抛 NoClassDefFoundError。查了很久才发现,该设备的 Zygote 预加载列表里直接删掉了这个类,而 AOSP 的 preloaded-classes 文件里明明有它。应用代码里之前假设这个类一定存在(毕竟是框架类),没有做保护。


修复方案是在调用处加 try-catch,或者改用 AppCompatResources.getDrawable 做兼容层。但更深层的教训是:Zygote 预加载的类列表不能作为运行时的保证,应用代码里对框架类的引用仍然需要防御性编程。


另一个厂商相关的坑是预加载的时机。部分厂商在系统启动后会延迟加载某些类,以缩短开机时间。这导致应用如果在系统刚启动完就冷启动,可能遇到 Zygote 子进程还没继承到完整预加载状态的情况。我在一台测试机上用自动化脚本循环重启系统和应用,有约 3% 的概率抓到启动时间异常(比正常慢 40ms 以上),对应的 logcat 里能看到 Zygote: late preload of class XXXX 的警告。这个比例不高,但对追求启动时间 P99 指标的应用来说不可忽视。


与 App Startup 库的交互


Jetpack 的 App Startup 库设计初衷是简化 ContentProvider 的初始化流程,但它和 Zygote 预加载的交互有些微妙的冲突。App Startup 1.1.1 版本的 InitializationProvider 会在 onCreate 中同步执行所有 Initializer,这意味着它的执行时机非常早,甚至早于 Application.onCreate


问题出在部分 Initializer 会触发类加载,而这些类可能不在 Zygote 的预加载列表中。我在一个项目里用 App Startup 初始化网络库,该库的 Initializer 内部构造了一个 OkHttpClientOkHttpClient 的类加载和初始化发生在 InitializationProvider.onCreate 中,此时应用进程刚刚 fork 出来,很多基础类可能还没被主线程触及。结果是这个初始化耗时比预期多了 5-7ms,因为类加载和 JIT 编译串行执行,没有享受到后台线程预加载的并行优势。


解决方案是把耗时的初始化从 App Startup 移出来,或者至少把类加载部分提前。我修改后的做法是在 attachBaseContext 中用一个 CountDownLatch 控制的后台线程先完成 OkHttpClient 的类加载,App Startup 的 Initializer 里只做实例取用。这个改动让启动时间减少了约 4ms,代价是代码结构变复杂了一些。


App Startup 1.2.0 版本引入了一个 InitializationProvider 的延迟初始化选项,但它是通过 manifest 中的 tools:node="remove" 实现的,不够灵活。我个人觉得 Google 应该提供一个更细粒度的控制接口,比如允许 Initializer 声明自己的执行线程和优先级,而不是一刀切的同步执行。


native 层的预加载:so 库的加载时机


Zygote 预加载主要是 Java 类和资源,但现代应用越来越依赖 native 库。System.loadLibrary 的调用时机对启动时间的影响,和 Zygote 预加载有间接关系。


Android 的 linker 在加载 so 库时,需要解析 ELF 符号、重定位、执行 JNI_OnLoad。这些操作发生在应用进程的私有内存中,无法通过 Zygote 预加载共享。但有一个优化点:如果 so 库已经在 Zygote 进程中被加载过(比如框架层的 native 库),linker 的页缓存可能还在,应用进程加载同名库时能省去部分磁盘 I/O。


我在 Android 13 上测试了 libandroid.so 的加载。这个库是 Zygote 预加载阶段就加载的框架库,应用进程通过 System.loadLibrary("android") 引用它时,实际不会重复加载,而是增加引用计数。但应用自己的 so 库,比如 libmyapp.so,必须在应用进程中首次加载。我尝试把 so 库的加载提前到 Application 的一个后台线程中,和主线程的 UI 初始化并行。代码片段如下:


new Thread(() -> System.loadLibrary("myapp")).start();

这个并行化在高端设备上效果不明显,因为 CPU 核数多,主线程本来就不会被 so 加载阻塞。但在一款四核的 Android 11 低端设备上,并行加载能把启动时间减少 12ms,因为 so 加载的耗时从主线程关键路径上移除了。


需要注意的是,JNI_OnLoad 里如果做了 Java 层的回调,会触发类加载,可能和主线程竞争 ClassLoader 锁。我在一个 so 库的 JNI_OnLoad 里调用了 FindClass 来获取 MyApplication 的引用,结果后台线程和主线程死锁了 200ms。根因是 FindClass 走到 Java 层后,需要获取 ClassLoader 的锁,而主线程正在同一个锁上执行 Application 的构造。这个 bug 在 Android 的不同版本上表现不一致,Android 12 的 ClassLoader 实现改了锁粒度,同样的代码不再死锁,但 Android 11 上必现。最终方案是把 FindClass 延迟到第一次 JNI 调用时,避开 JNI_OnLoad 的初始化阶段。


启动时间的测量陷阱


讨论 Zygote 预加载对启动时间的影响,必须先明确测量口径。Android 的启动时间有多种定义:TotalTime(从 AMS 收到启动意图到首帧绘制)、ThisTime(当前应用的启动耗时,排除前一个应用的暂停时间)、WaitTime(用户感知的总等待时间,包括系统调度开销)。adb shell am start -W 输出的是 WaitTime,很多开发者误把它当作应用自身的启动耗时。


我更关注 TotalTime,但它不包含 Zygote fork 的耗时。Zygote fork 发生在 AMS 调度之后、ActivityThread 初始化之前,属于系统层面的开销。要测量完整的 fork 耗时,需要看 systraceZygote: forkAndSpecialize 的区间,或者解析 logcatActivityManager 的日志:


I ActivityManager: Start proc 1234:com.example.app/u0a123 for activity {...}

从这条日志到 ActivityThread.main 的入口,就是 Zygote fork 加上进程初始化的耗时。我在 Pixel 4 上测得这个区间通常在 35-50ms,低端设备上能到 120ms 以上。Zygote 预加载优化得好的设备,fork 后需要复制的内存页少,COW 开销低,这个区间会更短。


但这里有个反直觉的现象:预加载类越多,Zygote 进程的内存占用越大,fork 时的页表复制开销反而可能增加。我在 Android 13 的模拟器上做过实验,把预加载类列表从 4000 行增加到 6000 行(手动注入了一些类),fork 耗时从 42ms 增加到 51ms,但应用进程的启动后类加载耗时减少了 15ms。总启动时间还是优化的,但 Zygote fork 本身变慢了。这说明预加载的优化收益不是线性的,需要综合评估。


一个未解决的疑问:ART 的 profile-guided preload


Google 在 Android 14 的开发者预览版中提到了 profile-guided Zygote preload,即根据应用的实际运行 profile 动态调整 Zygote 预加载的类。这个特性如果落地,理论上能让预加载列表更贴近真实应用需求,减少无用预加载的内存浪费。


但我在 Android 14 Beta 的代码里没找到完整的实现。ArtProfiles 相关类在 com.android.art 的 apex 模块中,但和 ZygoteInit 的集成似乎还在开发中。有个 ProfilePreloader 类,注释里写着 TODO: integrate with Zygote selective preload,代码是 stub 状态。


我尝试手动触发 profile 收集,用 cmd activity memory-factor critical 模拟内存压力,然后看是否有 profile 数据被写入 /data/misc/profiles 目录。确实有文件生成,但格式是私有的,没有公开工具可以解析。用 profman 命令行工具能看到一些统计信息,但和 Zygote 预加载的关联不明确。


这个方向我觉得值得持续跟踪。如果未来 Android 能根据应用的热启动路径动态优化 Zygote 预加载,应用开发者可能就不需要自己维护那份类预加载白名单了。但目前来看,这还是个未兑现的承诺。


实际项目中的权衡


回到我去年那个启动优化专项,最终方案没有激进地追求 Zygote 层面的优化,而是接受了厂商设备的差异性,在应用层做补偿。核心策略是三条:


第一,用 Trace 数据驱动决策。不是凭经验猜测哪些类该预加载,而是抓 100 次启动的 Systrace,统计 ClassLoader.loadClass 的耗时分布,只优化 top 20 的类。


第二,区分设备等级。在运行时读取 Build.BOARDBuild.DEVICE,结合预加载类列表的可用性(通过反射检查 ClassLoader 的已加载类集合),动态调整预加载策略。高端设备少预加载,低端设备多预加载。


第三,监控线上数据。启动时间的 P50、P90、P99 分别上报,特别关注 Zygote fork 区间的异常值。我们埋了一个自定义指标,从 ActivityManager 的日志时间戳到 Application.onCreate 的耗时,这个区间能间接反映 Zygote 和系统层的稳定性。


最终成果是冷启动时间从 1.2s 优化到 0.9s,其中和 Zygote 预加载相关的优化贡献了约 80ms。这个数字看起来不大,但启动优化到了后期,每 10ms 都很难挤出来。Zygote 预加载不是银弹,理解它的机制后,在应用层做精细化的适配,才是更务实的做法。

JetBrains Fleet 对 Android 开发的支持现状 2026-06-06
Dagger 到 Hilt 的迁移检查清单 2026-06-06

评论区