ART Runtime Explained

ANDROID › System

When an interviewer asks about ART, they're really asking whether you understand what happens between writing Kotlin and a frame actually rendering on screen, because that gap explains why cold start takes what it takes and why some code runs faster than other code. The engine underneath all of it is **ART**, the Android Runtime, which replaced an older runtime called **Dalvik** back in Android 5.0.

Neither runtime executes your Kotlin or Java directly. The build tool D8 compiles your compiled .class bytecode into **DEX** (Dalvik Executable), a register-based bytecode format, and DEX is what actually gets executed on the device by both runtimes. What changed between Dalvik and ART is *how* that DEX gets executed, not the bytecode format itself.

Dalvik was JIT-only: it interpreted DEX and compiled hot methods to native code at runtime, starting over on every run. ART adds **ahead-of-time (AOT) compilation** through a tool called dex2oat, which can turn DEX into native machine code before the app even runs, stored in an OAT file. ART keeps interpretation and JIT around too, so it ends up a hybrid: on any given run, some code executes already-compiled, some gets JIT-compiled as it heats up, and some just gets interpreted.

Compiling the whole app ahead of time at install would remove JIT warmup entirely, so why doesn't ART just do that for every app, every install? Because full AOT is slow to run and wastes storage compiling code paths that may never execute. Since Android 7, ART instead defaults to **profile-guided compilation**: the app installs quickly and runs mostly interpreted or JIT at first, while ART quietly records which methods actually get hot into a profile. Later, when the device is **idle and charging**, dex2oat reads that profile and AOT-compiles just those methods in the background.

adb shell cmd package compile -m speed-profile -f com.example.app
adb shell dumpsys package com.example.app | grep compiler-filter
# → compiler-filter=speed-profile

speed-profile is the resulting default filter: only the methods the profile says are hot get compiled to native code, everything else keeps running interpreted or JIT-compiled. That's the deliberate tradeoff, compile time and storage spent only on the code that's actually worth it.

Profile-guided compilation has a bootstrapping problem: a fresh install has no usage history yet, so the very first sessions after install or update run mostly interpreted, exactly when you want cold start fastest. **Baseline Profiles** close that gap by shipping a list of known-hot methods and classes *inside the app itself*, at assets/dexopt/baseline.prof. ART AOT-compiles those methods **at install time**, before the app has ever run once.

@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect("com.example.app") {
        startActivityAndWait()   // records which methods are hot here
    }
}

You generate the profile with BaselineProfileRule and Macrobenchmark, exercising the app's critical journeys, cold start, key first-screen navigation, and it produces human-readable rules the Gradle plugin compiles into the binary baseline.prof. Shipping one typically cuts cold start by roughly 30%.

Bundling baseline.prof inside the APK isn't enough by itself, something on the device still has to copy it into the location ART reads for dexopt. That's the job of androidx.profileinstaller: it runs automatically via Jetpack App Startup the first time the app launches, and writes the bundled profile into ART's profile store so those methods actually get picked up for AOT compilation.

dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
}

Without it, a bundled Baseline Profile can sit inside the APK and simply never get read. The profile data existing in the package doesn't automatically mean ART has installed it into its own profile store.

Baseline Profiles cover the journeys you thought to benchmark. **Cloud Profiles** cover everything else: Google Play aggregates profile data server-side from real usage across many installs, Android 9 and up, then pushes the result down to new installs of your app. They complement Baseline Profiles rather than replace them. A Baseline Profile is bundled with the app and applies immediately at install; a Cloud Profile needs a large enough user base to aggregate from and typically doesn't arrive until hours or days after a release. Ship a Baseline Profile for the paths you control and can benchmark, and let Cloud Profiles pick up the longer tail of behavior your own test journeys didn't cover.

DEX has a structural limit worth knowing: a single DEX file can hold at most **65,536 method references**, the so-called "64K limit", because DEX indexes methods with a 16-bit value. Apps that pull in enough libraries cross that count easily. **Multidex** solves it by splitting an app's code across more than one DEX file:

android {
    defaultConfig {
        minSdk = 21
        multiDexEnabled = true   // splits the app across multiple DEX files
    }
}

On minSdk 21+, this is essentially automatic: ART was built to load multiple DEX files natively, so there's little extra work. Only pre-21, Dalvik-era devices needed the androidx.multidex support library to stitch the extra DEX files together manually at app startup.

Getting from your compiled .class files to DEX runs through one of two tools. D8 is the dexer: it converts .class bytecode into DEX, nothing more. R8 is a superset that replaced the older ProGuard-plus-D8 pipeline: it dexes too, but in the same pass it also **shrinks** unused classes and methods, **optimizes** by inlining calls and stripping dead branches, and **obfuscates** by renaming classes and members to short, meaningless names.

android {
    buildTypes {
        release {
            isMinifyEnabled = true   // turns on R8 for this build type
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
        debug {
            isMinifyEnabled = false  // D8 only, no shrinking or renaming
        }
    }
}

This matters for Baseline Profiles too. You generate a profile against a non-minified build, so the method and class names in it match your source, and then, on AGP 8.2 and later, R8 rewrites those rules to match the obfuscated, optimized names in your actual release build. Skip that step and the profile's names simply won't match what R8 shipped.

Compilation is one half of runtime performance; garbage collection is the other. ART's collector is **mostly concurrent**: it does the bulk of its scanning and copying while your app's threads keep running, with typically just one short pause, and that pause length doesn't grow with heap size the way a naive stop-the-world collector's would. Android 10 added a **generational** mode on top of that: it collects short-lived, young objects, the common case of temporary allocations during a frame, more often and more cheaply than a full-heap pass.

None of that makes the heap unlimited, though. ART enforces a **per-app heap ceiling** regardless of how much RAM the device physically has, and allocating past it throws OutOfMemoryError, the same as a leak or a run of oversized bitmaps would:

// android:largeHeap is NOT set
val bitmaps = mutableListOf<Bitmap>()
while (true) {
    bitmaps.add(Bitmap.createBitmap(4000, 4000, Bitmap.Config.ARGB_8888))
}

android:largeHeap="true" in the manifest raises that ceiling for apps with a genuine need, heavy image processing, for instance, but it doesn't remove the cap. It's still finite, and leaning on it instead of fixing an excessive allocation pattern is explicitly discouraged.

Starting every app from a completely cold process, loading the entire framework's classes and initializing a fresh heap from nothing, would make every launch slow. Android avoids that with **Zygote**, a single process started at boot that preloads and initializes the common framework classes and resources exactly once.

adb shell ps -A | grep zygote
# zygote64 is the parent; every app process below it is one of its forks

When a new app launches, the system doesn't build a process from scratch, it **forks** Zygote. fork() gives the child its own memory space, but nothing is actually duplicated at fork time, both processes share the same physical pages **copy-on-write**, and a page only gets copied once either process writes to it. So every app process starts already holding warm, preloaded framework classes, and only pays memory for the pages it actually changes.

One more piece of context ties the runtime to how fast fixes reach real devices. Historically, a bug fix inside ART itself had to wait for the next full OS or vendor firmware update, which on plenty of devices arrives slowly or never. Since Android 12, ART ships as an updatable **Mainline module**, packaged as com.android.art, an APEX file, rather than being baked directly into the read-only system image.

Because it's a Mainline module, Google can push runtime and compiler fixes, even new language support, straight through **Play system updates**, the same mechanism used for several other core system components, independent of a full OS release or manufacturer rollout. That's why ART improvements now reach most active devices far faster than they could when the runtime was locked to whatever OS version a device originally shipped with.

Pull all of this together and here's what actually matters for an interview answer: there are three ways ART can run the same DEX bytecode, and each has a different speed story. Interpreted code is decoded instruction by instruction on the fly, slowest, but zero warmup cost. JIT-compiled code pays a warmup cost the first time a method gets hot, then runs at native speed for the rest of that process's life. AOT-compiled code, whatever triggered it, a Baseline Profile at install, or a profile-guided background dexopt, skips warmup entirely, because the native code already exists in the OAT file before the app even launches.

adb shell dumpsys package com.example.app | grep compiler-filter
# compiler-filter=speed          → fully AOT-compiled
# compiler-filter=speed-profile  → hot paths AOT, rest interpreted/JIT
# compiler-filter=quicken        → interpreter only, slowest

So the answer that actually lands: DEX is always the bytecode both runtimes execute, dex2oat is always the AOT compiler, and a profile, baseline, cloud, or runtime-recorded, is simply what tells dex2oat which methods are worth compiling ahead of time. Startup time and jank both trace back to how much of the hot path was already compiled before your code needed to run it.

Back to ART Runtime