Skip to content

预加载优化

追踪 preloaded-classes 的 profile 生成、双重消费者、denylist、Zygote 初始化成本与私有脏页评估。

基于android-17.0.0_r1
AndroidZygoteARTpreloaded-classesCOW性能源码阅读

预加载优化 ​

本文面向已经读过 Zygote预加载、preloadClasses、Zygote fork准备、fork与COW 和 启动时间分析 的读者。前面的文章回答“预加载如何执行”;本文回答“类表应该怎样产生、改动会影响谁、怎样判断改动是否真的更好”。

预加载优化不是简单地缩短 /system/etc/preloaded-classes。少加载一个类可以缩短 Zygote 初始化,却可能让数十个应用重复承担类解析、静态初始化和缺页;多加载一个类可以共享对象,也可能扩大 Zygote heap、执行错误的全局静态初始化,并增加每个子进程的私有脏页。本文以当前 release 的 profile 生成链、ART 编译链、运行时消费和测试为主线,不沿用旧 tools/preload 的历史阈值结论。

1. 优化对象 ​

源码文件:frameworks/base/config/preloaded-classes

当前类表的文件头直接声明了它的双重作用:类会进入 boot image,并由 Zygote 强制初始化,以虚拟地址空间换取应用间共享。该 tag 中有 18784 条非注释记录,其中还包括数组类描述符;它不是常见 API 的手写清单。

text
Preloaded-classes filter file for phones.

Classes in this file will be allocated into the boot image, and forcibly
initialized in the zygote during initialization. This is a trade-off,
using virtual address space to share common heap between apps.

一次类表改动至少要同时观察四个结果:

指标主要消费者改善方向典型反作用
PreloadClasses 耗时Zygote 启动更短应用首次使用成本上升
boot image/Zygote 内存ART、Zygote更小共享对象减少
应用冷启动Zygote child更短Zygote 初始化可能变长
private dirty pages每个 child更少为布局优化增加构建复杂度

因此优化目标应写成“在指定启动终点和应用集合下,减少总时间或每进程私有内存”,而不是“类表行数减少”。owner 也不只有 Zygote:构建系统、dex2oat、profman 和运行时分别拥有不同状态。

2. 双重消费者 ​

同一份 frameworks/base/config/preloaded-classes 沿两条路径生效。第一条在构建 boot image 时进入 dex2oat;第二条被复制到 system 分区,供 Zygote 逐行读取。

2.1 产品安装 ​

源码文件:

  • build/make/target/product/base_system.mk
  • frameworks/base/config/Android.bp
make
PRODUCT_COPY_FILES += $(call add-to-product-copy-files-if-exists,\
    frameworks/base/config/preloaded-classes:system/etc/preloaded-classes)

Make 产品配置给出最终分区路径;同目录的 Soong 模块则声明该文件作为可引用的 prebuilt_etc 构建输入:

make
prebuilt_etc {
    name: "preloaded-classes",
    src: "preloaded-classes",
    filename: "preloaded-classes",
    no_full_install: true,
}

产品复制链决定 Zygote 最终看到的文件。add-to-product-copy-files-if-exists 允许源文件不存在时跳过复制;运行时 preloadClasses() 对目标文件缺失也只是记录错误并返回。因此“系统能启动”不能证明类表已经安装,A/B 实验必须先检查设备文件及构建来源。

2.2 Boot image ​

源码文件:build/soong/java/dexpreopt_bootjars.go

go
if !ctx.Config().GetBuildFlagBool(
        "RELEASE_ART_NOPRELOAD_CLASSES_IN_PROFILE") &&
        len(image.preloadedClassesFile) > 0 {
    preloadedClassesPath := android.ExistentPathForSource(
            ctx, image.preloadedClassesFile)
    if preloadedClassesPath.Valid() {
        cmd.FlagWithInput("--preloaded-classes=",
                preloadedClassesPath.Path())
    }
}

framework boot image 配置把 frameworks/base/config/preloaded-classes 交给 dex2oat。构建 flag 开启时,denylist 会被写入 binary profile 的 classes-no-preload section,dex2oat 改为优先消费 profile,不再依赖文本文件判断 no-preload class。

源码文件:art/dex2oat/dex2oat.cc

cpp
bool PreparePreloadedClasses() {
  if (preloaded_classes_files_.empty() && preloaded_classes_fds_.empty()) {
    // We don't have any preloaded-classes files: handle all the classes as no-preload.
    // TODO(b/383506474): deprecate this behavior and rely only on the profile.
  } else if (profile_compilation_info_ != nullptr &&
             profile_compilation_info_->HasNoPreloadSection()) {
    compiler_options_->ignore_preloaded_classes_ = true;
  } else if (!preloaded_classes_fds_.empty()) {
    for (int fd : preloaded_classes_fds_) {
      if (!ReadCommentedInputFromFd(
              fd, nullptr, &compiler_options_->preloaded_classes_)) {
        return false;
      }
    }
  } else {
    for (const std::string& file : preloaded_classes_files_) {
      if (!ReadCommentedInputFromFile(
              file.c_str(), nullptr,
              &compiler_options_->preloaded_classes_)) {
        return false;
      }
    }
  }
  return true;
}

没有类表时,编译器把所有类当成 no-preload;有 classes-no-preload section 时,文本文件被忽略。这个选择影响静态方法是否需要生成 class-initialization check,而不是只影响 boot image 里“放几个类”。

cpp
// art/compiler/driver/compiler_options.cc
bool CompilerOptions::ShouldCompileWithClinitCheck(
        ArtMethod* method) const {
  if (method != nullptr &&
      Runtime::Current()->IsAotCompiler() &&
      method->IsStatic() &&
      !method->IsConstructor() &&
      !method->IsNative()) {
    ScopedObjectAccess soa(Thread::Current());
    ObjPtr<mirror::Class> cls =
        method->GetDeclaringClass<kWithoutReadBarrier>();
    return cls->IsInBootImageAndNotInPreloadedClasses();
  }
  return false;
}

所以修改类表后只 push /system/etc/preloaded-classes 不能完整模拟正式构建:运行时初始化集合变了,boot image 的类状态和编译代码却仍来自旧输入。可靠实验必须重建并刷入匹配的 boot image。

3. Profile生成 ​

当前主流程由设备真实 profile 驱动,而不是旧 WritePreloadedClassFile 的“加载时间超过 1250 微秒”规则。输入是 boot classpath/system server dex、多个设备 profile 和 denylist;输出包括 boot image profile、preloaded classes 与 system_server profile。

3.1 数据采集 ​

源码文件:

  • art/tools/boot-image-profile-configure-device.sh
  • art/tools/boot-image-profile-extract-profile.sh
bash
# 配置阶段:停止 framework,打开 boot classpath/system_server profiling,
# 清空旧 profile 后重新启动。输入是已刷入的测试构建。
adb root
adb shell stop
adb shell setprop dalvik.vm.profilebootclasspath true
adb shell setprop dalvik.vm.profilesystemserver true
adb shell 'find /data/misc/profiles -name "*.prof" -exec truncate -s 0 {} \;'
adb shell start

# 运行目标产品的关键用户流程后,快照 android profile 并拉到 host。
adb shell cmd package snapshot-profile android
adb pull /data/misc/profman/android.prof android.prof

关键用户流程决定 profile 的覆盖面。只启动到桌面会偏向 SystemServer/SystemUI;加入相机、设置、输入法和常用应用后,跨包覆盖比例会改变。profile owner 是设备上 ART/package profile 状态,生成脚本只消费快照,不会替你补齐未执行的场景。

ZygoteInit.preloadClasses() 在 profile boot classpath 模式下会在预加载结束后重置 JIT counters,避免“因为类表已经强制加载”被误认为后续用户流程真实使用。

源码文件:frameworks/base/core/java/com/android/internal/os/ZygoteInit.java

java
runtime.preloadDexCaches();

if (shouldProfileBootClasspath()) {
    Trace.traceBegin(Trace.TRACE_TAG_DALVIK,
            "ResetJitCounters");
    VMRuntime.resetJitCounters();
    Trace.traceEnd(Trace.TRACE_TAG_DALVIK);
}

3.2 阈值筛选 ​

源码文件:art/profman/boot_image_profile.h

cpp
struct BootImageOptions {
  uint32_t preloaded_class_threshold = 10;
  uint32_t image_class_threshold = 10;
  uint32_t image_class_clean_threshold = 5;
  uint32_t method_threshold = 10;
  SafeMap<std::string, uint32_t>
      special_packages_thresholds;
  std::set<std::string> preloaded_classes_denylist;
};

预加载阈值默认是 10%,含义是类的 origin package 注解数量占最大聚合数的比例达到阈值。它不是方法调用次数,也不是单次耗时。生成脚本默认再给 android 和 com.android.systemui 设置 1% special-package 阈值,让关键包使用的类更容易入选。

源码文件:art/profman/boot_image_profile.cc

cpp
static bool IncludeItemInProfile(uint32_t max_aggregation_count,
                                 uint32_t item_threshold,
                                 const FlattenProfileData::ItemMetadata& metadata,
                                 const BootImageOptions& options) {
  float item_percent = metadata.GetAnnotations().size() /
      static_cast<float>(max_aggregation_count);
  for (const auto& annotIt : metadata.GetAnnotations()) {
    const auto& thresholdIt = options.special_packages_thresholds.find(
        annotIt.GetOriginPackageName());
    if (thresholdIt != options.special_packages_thresholds.end()) {
      if (item_percent >= (thresholdIt->second / 100.f)) {
        return true;
      }
    }
  }
  return item_percent >= (item_threshold / 100.f);
}

static bool IncludeInPreloadedClasses(
    const std::string& class_name,
    uint32_t max_aggregation_count,
    const FlattenProfileData::ItemMetadata& metadata,
    const BootImageOptions& options) {
  bool denylisted = options.preloaded_classes_denylist.find(
      class_name) != options.preloaded_classes_denylist.end();
  return !denylisted && IncludeItemInProfile(
      max_aggregation_count,
      options.preloaded_class_threshold,
      metadata, options);
}

阈值降低会扩大类表,优先照顾关键包;阈值升高会保留跨更多包复用的类。denylist 是硬条件,即使一个类覆盖率足够高也不能进入输出。

3.3 输出顺序 ​

profman 把候选放入 SafeMap<std::string, FlattenProfileData::ItemMetadata>,而 SafeMap 等价于 std::map。输出按类名 key 迭代,不按 hotness、加载耗时或 package 权重排序。

cpp
// art/profman/boot_image_profile.cc
SafeMap<std::string, FlattenProfileData::ItemMetadata>
    preloaded_classes;

候选通过阈值和 denylist 后,以类名作为 map key 写入;最终输出直接遍历同一个 map:

cpp
for (const auto& it : preloaded_classes) {
  preloaded_content += ClassToProfileFormat(
      it.first, it.second,
      options.append_package_use_list) + "\n";
}

因此当前实现里的“优先级”体现在入选阈值,不体现在 Zygote 加载顺序。手工把高频类移动到文件顶部不会改变候选集合,还可能被依赖类的静态初始化提前加载所抵消。若要研究顺序,必须用每个类名对应的 trace 做独立实验,不能把它当成现行生成策略。

4. Denylist边界 ​

源码文件:frameworks/base/config/preloaded-classes-denylist

text
android.content.AsyncTaskLoader$LoadTask
android.net.ConnectivityThread$Singleton
android.os.FileObserver
android.provider.MediaStore
android.widget.Magnifier
com.android.internal.jank.InteractionJankMonitor$InstanceHolder
java.util.concurrent.ThreadLocalRandom

该 tag 的 denylist 共 12 条。配置说明将它们定义为“具有 app-specific behavior、不应在 Zygote 初始化的类”。常见风险包括启动线程、建立进程级 singleton、缓存调用进程环境或提前固定随机/媒体/网络状态。这里首先是正确性边界,其次才是内存优化。

host 测试把 denylist 作为资源逐行读取,为每个类启动独立 app_process,要求类存在但尚未初始化。

源码文件:frameworks/base/tools/preload-check/src/com/android/preload/check/PreloadCheck.java

java
@Test
public void testDenyList() throws Exception {
    StringBuilder sb = new StringBuilder();
    try (BufferedReader br = new BufferedReader(
            new InputStreamReader(getClass().getResourceAsStream(
                    "/preloaded-classes-denylist")))) {
        String s;
        while ((s = br.readLine()) != null) {
            s = s.trim();
            if (s.startsWith("#") || s.isEmpty()) {
                continue;
            }
            try {
                run("com.android.preload.check.NotInitialized", s);
            } catch (Throwable t) {
                if (sb.length() > 0) {
                    sb.append('\n');
                }
                sb.append(t.getMessage());
            }
        }
    }
    if (sb.length() > 0) {
        throw new RuntimeException(sb.toString());
    }
}

device helper 使用 Class.forName(name, false, null) 获取 boot classpath class,再读取 ART 的 class status,确认状态未达到 initialized。测试输入是完整 denylist,断言是每个独立进程最终打印 OK;它证明这些类没有被 Zygote 提前初始化,但不证明类表中其他类一定值得预加载。

profman 还有另一层测试:构造一个在多个 profile 中都出现的 denylisted java.lang.Thread,设置 100% preload threshold,期望输出仍只有满足阈值且不在 denylist 的 java.lang.Object。这验证了 denylist 优先于覆盖率。

5. 运行时成本 ​

源码文件:frameworks/base/core/java/com/android/internal/os/ZygoteInit.java

java
while ((line = br.readLine()) != null) {
    line = line.trim();
    if (line.startsWith("#") || line.equals("")) {
        continue;
    }

    Trace.traceBegin(Trace.TRACE_TAG_DALVIK, line);
    try {
        Class.forName(line, true, null);
        count++;
    } catch (ClassNotFoundException e) {
        if (line.contains("$$Lambda$")) {
            if (LOGGING_DEBUG) {
                missingLambdaCount++;
            }
        } else {
            Log.w(TAG, "Class not found for preloading: " + line);
        }
    } catch (UnsatisfiedLinkError e) {
        Log.w(TAG, "Problem preloading " + line + ": " + e);
    } catch (Throwable t) {
        Log.e(TAG, "Error preloading " + line + ".", t);
        if (t instanceof Error) {
            throw (Error) t;
        } else if (t instanceof RuntimeException) {
            throw (RuntimeException) t;
        } else {
            throw new RuntimeException(t);
        }
    }
    Trace.traceEnd(Trace.TRACE_TAG_DALVIK);
}

initialize=true 会执行 <clinit>,所以成本不仅是读 dex metadata。每个成功类都可能创建字符串、数组、缓存、Binder proxy 或 native state;这些对象由 Zygote heap 持有,fork 后才成为共享候选。

失败语义也不同:

  • 文件不存在:记录错误并跳过整个类阶段,后续资源预加载仍继续;
  • 普通缺失类或 native library 缺失:记录 warning,继续下一类;
  • 静态初始化抛出 Error/RuntimeException:重新抛出,Zygote 初始化失败;
  • 读取中断:记录 IOException,但 finally 仍填充 dex cache 并恢复权限。

每个类名都有 DALVIK trace section,适合定位单类 <clinit>;但父类、字段类型和静态依赖可能在当前行之前已经被其他类隐式加载,单类 section 不能直接等同于“该类独占成本”。

lazy preload 也只是移动生效时机。--preload-default 命令由 ZygoteConnection 调用同一个 ZygoteInit.lazyPreload();它不会缩小类表或减少总初始化工作,还不能交给 USAP 处理。优化前必须明确是希望缩短 Zygote 启动,还是希望降低首次 fork 请求延迟。

6. COW收束 ​

类加载完成不等于已经形成适合 fork 的共享堆。Zygote 随后填充 dex cache、执行 GC/finalization;第一次 fork 前 ART 还会做 full GC、trim 和 Zygote space compaction。

源码文件:art/runtime/gc/heap.cc

cpp
if (!HasZygoteSpace()) {
  CollectGarbageInternal(collector::kGcTypeFull,
      kGcCauseBackground, false, GC_NUM_ANY);
  non_moving_space_->Trim();
}

第一次进入时,ART 随后切换 post-fork linear alloc,并冻结当前 intern/class table:

cpp
  runtime->SetupLinearAllocForPostZygoteFork(self);
  runtime->GetInternTable()->AddNewTable();
  runtime->GetClassLinker()->MoveClassTableToPreZygote();

移动 GC 配置下,当前空间再按 bin 进行 compaction:

cpp
  ZygoteCompactingCollector zygote_collector(
      this, is_running_on_memory_tool_);
  zygote_collector.BuildBins(non_moving_space_);
  zygote_collector.Run(kGcCauseCollectorTransition,
      false);

full GC 删除预加载期间的临时垃圾,trim 归还尾部页面,class/intern table 被切分成 pre/post-Zygote 区域,compaction 再建立 Zygote space。若只比较 Class.forName 时间,就会漏掉类表扩大导致的 PostZygoteInitGC 和第一次 preFork 成本。

boot image 还使用 dirty-image-objects 把预计会写脏的对象集中到相同页面。

源码文件:art/dex2oat/linker/image_writer.cc

cpp
Bin bin = Bin::kRegular;
if (kBinObjects) {
  ObjPtr<mirror::Class> klass =
      object->GetClass<kVerifyNone, kWithoutReadBarrier>();
  if (klass->IsStringClass<kVerifyNone>()) {
    bin = Bin::kString;
  } else if (dirty_objects_.find(object) != dirty_objects_.end()) {
    bin = Bin::kKnownDirty;
  } else if (klass->IsClassClass()) {
    bin = Bin::kClassVerified;
    ObjPtr<mirror::Class> as_klass =
        object->AsClass<kVerifyNone>();
    if (as_klass->IsVisiblyInitialized<kVerifyNone>()) {
      bin = Bin::kClassInitialized;
    }
  }
}

类表决定哪些类和静态对象会在 fork 前存在;dirty-image-objects 决定 boot image 中易脏对象如何聚合。二者共同影响 COW,但 owner 不同,不能用“删类”代替页面布局优化。

7. 对照实验 ​

一轮可解释的优化应固定设备、build variant、profile 场景、启动终点和应用集合,再只改变候选类表及由它生成的 boot image。

7.1 生成候选 ​

源码脚本:art/tools/boot-image-profile-generate.sh

bash
# 运行位置:完成 envsetup/lunch 的 AOSP 根目录。
# 输入:boot.zip、denylist 和多份执行过关键用户流程的 android.prof。
# 输出:candidate/boot-image-profile.txt、preloaded-classes、art-profile。
art/tools/boot-image-profile-generate.sh \
  candidate \
  out/dist/boot.zip \
  frameworks/base/config/preloaded-classes-denylist \
  profiles/home.prof \
  profiles/camera.prof \
  profiles/settings.prof \
  --boot-image-profman-arg --preloaded-class-threshold=10

# 输出候选规模及相对当前类表的新增/删除项,不把总行数当最终结论。
awk '!/^#/ && NF' candidate/preloaded-classes | wc -l
diff -u frameworks/base/config/preloaded-classes \
  candidate/preloaded-classes > candidate/preloaded-classes.diff || true

生成失败时先检查 profile 是否与 boot.zip 中 dex 匹配、denylist 路径是否存在,以及 ANDROID_BUILD_TOP 是否已设置。阈值不是越高越好;应先观察新增/删除类属于哪些关键路径,再构建候选镜像。

7.2 时间与内存 ​

bash
# 输入:刷入基线或候选完整构建后的冷启动事件。
# 输出:同一Zygote PID的preload起止时间;重复多轮再比较分布。
adb logcat -b events -v threadtime -d \
  | rg 'boot_progress_preload_(start|end)'

# 输入:已经运行相同用户流程、具有相同目标进程集合的设备。
# 输出:目标进程相对其Zygote的boot image private dirty对象报告。
art/imgdiag/run_imgdiag.py \
  com.android.systemui com.android.settings \
  --zygote zygote64 \
  --host-out-dir imgdiag-candidate

# 从报告中提取每个进程的private dirty页和对象,和基线同名进程比较。
rg -n 'total private dirty pages|Application dirty entries' \
  imgdiag-baseline imgdiag-candidate

时间改善但 private dirty 增加,说明工作可能从 Zygote 移到了每个 child;private dirty 降低但 preload/first fork 显著变慢,则需要评估进程数量能否摊销成本。进程集合不一致时,均值也不可直接比较。

8. 验证边界 ​

源码测试:art/profman/profile_assistant_test.cc 的 TestBootImageProfile 构造四个 origin package 的 class/method profile,设置 clean、dirty、method、preloaded 和 special-package 阈值,并把 java.lang.Thread 放入 denylist。测试执行真正的 profman,最后断言 boot profile 和 preloaded classes 文件与期望完全相同。

源码文件:art/profman/profile_assistant_test.cc

cpp
std::vector<std::string> expected_preloaded_data = {
    DescriptorToDot(kDirtyClass)
};

测试把阈值、special package 和 denylist 作为真实 profman 参数:

cpp
args.push_back("--preloaded-class-threshold=" +
               std::to_string(kPreloadedThreshold));
args.push_back(
    "--special-package=" + kSpecialPackage + ":" +
    std::to_string(kSpecialThreshold));
args.push_back("--profile-file=" + profile.GetFilename());
args.push_back("--out-profile-path=" + out_profile.GetFilename());
args.push_back("--out-preloaded-classes-path=" +
               out_preloaded_classes.GetFilename());
args.push_back("--apk=" + core_dex);
args.push_back("--dex-location=" + core_dex);
args.push_back("--preloaded-classes-denylist=" +
               preloaded_class_denylist.GetFilename());

执行完成后读取输出文件并与上面的期望逐字节比较:

cpp
ASSERT_EQ(ExecAndReturnCode(args, &error), 0) << error;
ASSERT_TRUE(android::base::ReadFileToString(
    out_preloaded_classes.GetFilename(),
    &output_preloaded_contents));
ASSERT_EQ(output_preloaded_contents,
          expected_preloaded_content);

这个测试证明阈值和 denylist 的组合筛选生效,也证明输出内容是确定的;它没有衡量真实设备上的启动时间和 COW 收益。

PreloadCheck.testDenyList() 则从另一个方向验证运行时状态:输入是当前 denylist,动作是在 Zygote child 中以 initialize=false 查类,断言是所有类仍未初始化。它能发现“生成器排除了类,但其他预加载路径又把它隐式初始化”的问题;这正是只 diff 文本文件无法覆盖的条件路径。

检查自己是否理解这套机制,可以回答两个问题:把 preloaded-class-threshold 从 10 提到 30 后,哪些类会被删除、special package 为什么仍可能保留低覆盖类;以及只 push 新类表不重建 boot image 时,为什么 Zygote trace 看似改变,AOT clinit check 和 private dirty 结论却仍不可信。