Mobile Development 12 min read

Debugging Performance Degradation of Android 14 Debug Builds: Root Cause and Workarounds

The severe jank observed in Android 14 debug builds stems from the DEBUG_JAVA_DEBUGGABLE flag triggering DeoptimizeBootImage, which forces boot‑image methods onto the slow switch interpreter; a temporary hook clearing the flag and a permanent fix using UpdateEntrypointsForDebuggable restore performance, with Google planning an official fix in Android 15.

DeWu Technology
DeWu Technology
DeWu Technology
Debugging Performance Degradation of Android 14 Debug Builds: Root Cause and Workarounds

Background : The author observed severe jank in a debug build running on Android 14. Initial systrace and dutrace analysis showed no CPU blockage, suggesting the slowdown was caused by pure method‑execution time.

Problem Investigation Record :

1. Conventional checks using systrace and dutrace revealed an idle CPU and no main‑thread stalls.

2. Suspicious point : dutrace highlighted an abnormal behavior in the ART interpreter.

3. Interpreter analysis : ART provides three interpreter implementations – the classic C++ switch‑based interpreter, the fast mterp (handler‑table based), and the optimized nterp (assembly‑only). Android 12 uses mterp , Android 13 uses nterp , but on Android 14 the switch interpreter is used during debugging, causing the slowdown.

Code snippet of dutrace principle :

dutrace uses inline hooks on ArtMethod entry/exit to insert atrace points, then visualizes them with Perfetto.

Debuggable flag investigation :

The DEBUG_JAVA_DEBUGGABLE flag triggers DeoptimizeBootImage , which forces methods in the boot image to run via the interpreter instead of AOT code.

Key runtime function:

void Runtime::SetRuntimeDebugState(RuntimeDebugState state) {
  if (state != RuntimeDebugState::kJavaDebuggableAtInit) {
    DCHECK(runtime_debug_state_ != RuntimeDebugState::kJavaDebuggableAtInit);
  }
  runtime_debug_state_ = state;
}

Modifying isJavaDebuggable to false did not eliminate the jank, while setting it true in a release build introduced slight lag, leading to the conclusion that the flag itself is not the sole cause.

Native‑time investigation using simpleperf confirmed that most of the time is spent in interpreter stacks.

Root cause :

When DEBUG_JAVA_DEBUGGABLE is set, the runtime executes DeoptimizeBootImage , which deoptimizes boot‑image methods to the switch interpreter. This regression appears only on Android 14.

Relevant source check:

if ((runtime_flags & DEBUG_JAVA_DEBUGGABLE) != 0) {
    runtime->AddCompilerOption("--debuggable");
    runtime_flags |= DEBUG_GENERATE_MINI_DEBUG_INFO;
    runtime->SetRuntimeDebugState(Runtime::RuntimeDebugState::kJavaDebuggableAtInit);
    {
      ScopedSuspendAll ssa(__FUNCTION__);
      runtime->DeoptimizeBootImage();
    }
    runtime_flags &= ~DEBUG_JAVA_DEBUGGABLE;
    needs_non_debuggable_classes = true;
}

By manually invoking DeoptimizeBootImage on a non‑debuggable build, the author reproduced the slowdown.

Temporary solution :

Hook the system process to clear the DEBUG_JAVA_DEBUGGABLE flag from runtimeFlags before the process starts. This eliminates the jank for the test package.

Hook example:

hookAllMethods(Process.class, "start", new XC_MethodHook() {
    @Override
    protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
        int runtimeFlags = (int) param.args[5];
        if (isDebuggable((String) param.args[1], user)) {
            param.args[5] = runtimeFlags & ~DEBUG_JAVA_DEBUGGABLE;
        }
    }
});

Community verification showed the issue is reproducible on multiple devices running Android 14, and a bug has been filed on the Google Issue Tracker.

Proposed permanent fix :

Android 14 adds UpdateEntrypointsForDebuggable , which can re‑patch methods to use the optimized entry points. By forcing CanRuntimeUseNterp to return true, then calling this API, the boot‑image methods can be switched back to nterp .

Key function:

void Instrumentation::UpdateEntrypointsForDebuggable() {
  Runtime* runtime = Runtime::Current();
  InstallStubsClassVisitor visitor(this);
  runtime->GetClassLinker()->VisitClasses(&visitor);
}

Implementation example (using android-inline-hook ):

Java_test_ArtMethodTrace_bootImageNterp(JNIEnv *env, jclass clazz) {
    void *handler = shadowhook_dlopen("libart.so");
    // Resolve symbols
    void (*UpdateEntrypointsForDebuggable)(void *) = (void(*)(void*))
        shadowhook_dlsym(handler, "_ZN3art15instrumentation15Instrumentation30UpdateEntrypointsForDebuggableEv");
    // Disable DEBUG_JAVA_DEBUGGABLE
    void (*setRuntimeDebugState)(void *, int) = (void(*)(void*,int))
        shadowhook_dlsym(handler, "_ZN3art7Runtime20SetRuntimeDebugStateENS0_17RuntimeDebugStateE");
    setRuntimeDebugState(instance_, 0);
    UpdateEntrypointsForDebuggable(instrumentation);
    setRuntimeDebugState(instance_, 2);
    LOGE("bootImageNterp success");
}

This workaround restores most of the performance, though a small residual lag remains because some app‑level code still falls back to the switch interpreter.

System‑level outlook : Google plans to fix the issue in Android 15 and, for overseas Android 14 devices, via an update to the com.android.artapex module. Chinese devices may need OEMs to back‑port the changes.

References: the author’s article on Juejin and the linked Issue Tracker entries.

debuggingNativePerformanceAndroidartRuntime
DeWu Technology
Written by

DeWu Technology

A platform for sharing and discussing tech knowledge, guiding you toward the cloud of technology.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.