3/28: Your Android App Is a Fork of the Same Process Every Time
Zygote, preloading, and copy-on-write startup
3/28: Your Android App Is a Fork of the Same Process Every Time
Zygote, preloading, and copy-on-write startup

One warm Zygote, preloaded with the framework, forks into every app — all sharing the same memory copy-on-write.
Your app’s first Activity appears in a few hundred milliseconds. It shouldn’t.
Think about what “launching” a managed-runtime app should cost: spin up a virtual machine, load and verify thousands of framework classes, initialize the resource system, link native libraries — a genuinely cold start. On a server, standing up a fresh JVM with a large framework loaded is measured in seconds, not milliseconds. Yet you tap an icon and your UI is on screen before you’ve lifted your finger.
The reason isn’t that Android is magically fast at cold starts. It’s that your app never has a cold start at all. It isn’t started — it’s cloned. Every Android app process is a fork() of one warm, pre-baked template process that came up with the phone and already has the entire framework loaded. That template is the Zygote, and once you understand it, "why is cold start so fast" and "why can't I just use a different framework version" become the same answer.
You never get a fresh process. Every Android app is a fork of one warm template that booted with the phone — your app starts life already knowing the whole framework.
(We met the Zygote on the boot timeline last episode, where init started it and it forked system_server. Now we're back for the part that touches your app directly: how it forks you.)
Why is cold start faster than it has any right to be?
Because the expensive work — loading the runtime, the framework classes, the common resources — happens once, at boot, in a single process. Every app launch after that reuses that work instead of repeating it.
Here’s the trick in one sentence: the Zygote loads everything common to all apps into itself, then sits idle waiting. When you launch an app, the system doesn’t build a new process from scratch — it fork()s the Zygote. The child inherits the already-loaded framework. Your "cold" start is really a warm fork of a process that did the cold start hours ago.
So the speed isn’t an optimization bolted onto app launch. It’s structural: app launch was designed to skip the expensive part by cloning a process that already paid for it.

Why launch is cheap: a true cold runtime start (load VM + framework + resources) is long; forking the warm Zygote and specializing is a sliver — the framework is already in memory.
What does the Zygote actually preload?
When the Zygote starts, ZygoteInit runs a preload(...) step that front-loads everything shareable:

The Zygote pays the expensive loading cost exactly once, into one template process — so no app ever has to.

ZygoteInit.preload() on cs.android.com — classes, resources, HALs, shared libraries, text resources, WebView, and more, each traced and loaded into the template.
// frameworks/base/core/java/com/android/internal/os/ZygoteInit.java (preload)
preloadClasses();
cacheNonBootClasspathClassLoaders();
Resources.preloadResources();
nativePreloadAppProcessHALs();
maybePreloadGraphicsDriver();
preloadSharedLibraries();
preloadTextResources();
// ...
WebViewFactory.prepareWebViewInZygote();
preloadClasses() is the headline act. It reads a checked-in list at /system/etc/preloaded-classes and loads — and initializes — every class on it. How big is that list? See for yourself:

frameworks/base/config/preloaded-classes — thousands of framework classes, loaded into the template at boot. Read the header: "allocated into the boot image, and forcibly initialized in the zygote ... to share common heap between apps."
That header comment is the whole mechanism stated in AOSP’s own words. These classes are loaded and initialized once — baked into ART’s memory-mapped boot image and forcibly initialized in the Zygote — specifically so they can be shared across every app. Resources.preloadResources() does the same for common drawables and colors. By the time the Zygote is ready, it's a fat, warm process holding the parts of Android that every app needs.
What does fork() really give your app?
This is the part that makes it cheap. When the Zygote forks, your new process doesn’t get a copy of all that preloaded memory — it gets a copy-on-write view of it.
fork() on Linux hands the child an address space that looks identical to the parent's, but the physical pages are shared and marked read-only. Nothing is actually duplicated at fork time. Only when one side writes to a page does the kernel copy that single page — hence "copy-on-write." So the tens of megabytes of preloaded framework classes and resources cost your freshly-forked app essentially nothing up front; pages only materialize as your app dirties them.

Copy-on-write: every forked app shares the Zygote’s preloaded pages physically, read-only, until it writes — only then does that one page get its own copy.
This is why a phone can run dozens of apps without dozens of independent copies of the framework in RAM. They’re all forks of one Zygote, physically sharing the preloaded baseline, each diverging only where it actually differs. The framework you call all day is, at the page level, the same memory across every app on the device.
What does “specialize” mean — and where does your app diverge?
A bare fork() gives you a clone, but a clone is still pretending to be the Zygote. To become your app, the child has to specialize. That happens in one call — Zygote.forkAndSpecialize(...) — whose parameter list reads like a checklist of "everything that makes this process a specific app instead of a generic template":

Zygote.forkAndSpecialize(...) on cs.android.com — the fork and the specialization happen together; the parameters are how a generic clone becomes a specific, sandboxed app.
// frameworks/base/core/java/com/android/internal/os/Zygote.java
static int forkAndSpecialize(int uid, int gid, int[] gids, int runtimeFlags,
int[][] rlimits, int mountExternal, String seInfo, String niceName, int[] fdsToClose,
int[] fdsToIgnore, boolean startChildZygote, String instructionSet, String appDataDir,
boolean isTopApp, String[] pkgDataInfoList, String[] allowlistedDataInfoList,
boolean bindMountAppDataDirs, boolean bindMountAppStorageDirs,
boolean bindMountSyspropOverrides) {
// ... ultimately calls nativeForkAndSpecialize(...)
}
Read those parameters as the specialization steps:
**uid/gid/gids* — the childsetuids to your app's* unique Linux user ID. This is the foundation of the app sandbox (its own episode later) — the same template becomes a thousand different isolated users.**seInfo** — the SELinux context applied to the new process.**niceName** — the process name you see inadb shell ps(your package name), instead of "zygote".**appDataDir** and the bind-mount flags — wiring up your app's private data directory.**fdsToClose** — slamming shut file descriptors the child shouldn't inherit.
So the request that launches your app is: take this warm clone, give it my UID, my SELinux label, my name, my data dir — now it’s my process. (Mechanically, when ActivityManagerService wants to start your app, it sends a command over the Zygote's socket; ZygoteConnection reads it and calls forkAndSpecialize. On modern Android there's even a pool of pre-forked, not-yet-specialized processes — the USAP pool — to shave the fork cost further. Same idea either way: fork a warm template, then specialize.)
After specialization, the child stops being a Zygote and runs your app’s real entry point — and that path, from the fork to Application.onCreate and your first Activity, is the next stop in the launch story (Episode 8).
Why can’t you just ship a different framework version per app?
Now the constraint falls out for free. There is exactly one Zygote, preloaded with exactly one build of the framework, and every app on the device is its child. Your app doesn’t load its own private copy of android.app, android.view, and friends — it inherits them from the shared template.
That’s why you can’t run app A on framework version 1 and app B on version 2 on the same device; why all apps share an identical baseline of preloaded classes; and why a framework change is a whole-system affair, not a per-app dependency you can bump in Gradle. The thing that makes cold start cheap — one shared, preloaded template — is the same thing that makes the framework un-swappable per app. You don’t get to choose your parent.
The payoff for you as an app developer: stop picturing app launch as “build a process, load the framework, run my code.” Picture it as “clone a warm process, relabel it as mine, run my code.” Your real cold-start budget isn’t the framework — that’s already in memory. It’s whatever you add on top: your Application.onCreate, your eager initializers, your first frame's work. That's the part you own (and the subject of Episode 25).
Your app is alive now — a clone among hundreds, all sharing the same warm baseline. And the instant it’s alive, Android starts doing something quietly ruthless: ranking it against every other process on a list of who to kill first. Next episode, we read that list.
메타데이터
- post_id
- ba8ee506d99c
- slug
- your-android-app-is-a-fork-of-the-same-process-every-time-ba8ee506d99c
- url
- https://medium.com/@promode7/your-android-app-is-a-fork-of-the-same-process-every-time-ba8ee506d99c
- canonical_url
- https://medium.com/@promode7/your-android-app-is-a-fork-of-the-same-process-every-time-ba8ee506d99c
- author_url
- https://medium.com/@promode7
- status
- ok
- fetched_at
- 2026-07-16 02:05:13