Analyzing Java memory

Java and Kotlin applications manage memory through a garbage-collected heap. When objects are no longer reachable, the garbage collector (GC) eventually reclaims their space. Memory leaks occur when objects that are no longer needed are still held by "GC roots," preventing them from being reclaimed.

Core concepts

GC roots

A GC Root is a special type of object that the garbage collector treats as always reachable. Examples include:

  • Active threads (and objects referenced from their currently executing Java stack frames).
  • Classes with actively running methods.
  • JNI references (global or local references held by native code).

Path to GC root

As long as there is a chain of references from a GC Root to an object, that object is "reachable" and cannot be garbage collected. This chain is called the Path to GC Root. To fix a memory leak, you must identify and break this chain.

Path to GC Root

Dominator trees

While the path to GC root tells you why an object is alive, it doesn't tell you how much memory would be reclaimed if that reference was broken. For this, we use Dominator Trees.

Object A is said to dominate object B if every path from any GC root to B must pass through A. If A dominates B, then reclaiming A will also guarantee that B can be reclaimed, because there are no other paths from any root to B.

The following diagram shows an object graph and its corresponding dominator tree. Notice how object D is reached by both A and B in the graph, so neither A nor B dominates D; instead, the GC Root is its nearest dominator.

Dominator Tree

Obtaining Java heap dumps

A heap dump is a snapshot of all objects in the Java heap at a specific point in time.

Using ADB

To capture a heap dump from a running process, you can pass the package name directly to am dumpheap. To run this command, you must build your app with <profileable android:shell="true"/> or <debuggable>.

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

Using Perfetto

Perfetto can also capture Java heap dumps as part of a system-wide trace by enabling the android.java_hprof data source in your Perfetto config. This is useful for correlating heap state with other system events.

To capture a heap dump for the MemoryLab app using Perfetto, you can use the following command:

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

See: Java heap dumps on Perfetto docs.

Analyzing with AHAT

AHAT (Android Heap Analysis Tool) is the recommended tool for viewing .hprof files in a web browser.

Starting AHAT

If you have ahat installed on your path, launch it with:

ahat heap.hprof

Or run the standalone jar:

java -jar ahat.jar heap.hprof

Then open your browser to http://localhost:7100.

For details on obtaining or building AHAT, see the AHAT source repository.

Key analysis workflows

Finding leaks

Search for your Activity class (MainActivity) in the Allocations view.

AHAT view showing instances

Click the class to find all Instances.

AHAT view showing MainActivity
instances Click on the MainActivity instance to inspect it.

AHAT showing an instance details

In the instance view, you can find the Sample Path from GC Root, which shows the chain of references preventing the object from being garbage collected, and the Object Size, which shows how much memory is being retained by this specific instance.

AHAT Sample Path from GC Root and Object Size

Analyzing bitmaps

AHAT has special support for viewing android.graphics.Bitmap objects, which are often large memory consumers. Click on a Bitmap instance to see a rendered preview of its contents.

AHAT Bitmap Preview

Activity leaks page

AHAT has a specialized view for identifying leaked Activities, which are one of the most common and impactful memory leaks in Android.

  1. Action: In MemoryLab, tap Leak an Activity. This launches LeakedActivity which intentionally leaks itself.
  2. Dump: Take a heap dump.
  3. Analyze: Click on Activity Leaks in the AHAT sidebar.
  4. Verify: AHAT will list com.android.memorylab.LeakedActivity as leaked because its mDestroyed field is true (indicating the Activity lifecycle has ended) but it is still reachable from a GC root.

AHAT Activity Leaks Page

Diffing heap dumps

Comparing two heap dumps is one of the most powerful ways to identify memory issues. By comparing a "clean" baseline dump with a dump taken after performing some actions, you can immediately see which objects have accumulated.

Exercise: Identifying Leaks through Diffing

  1. Baseline: Launch MemoryLab and take a baseline heap dump:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. Action: Tap Allocate Java Memory(10MB) several times in the app.

  3. Final: Take a second heap dump:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. Compare: Start AHAT with the second dump as primary and the first as the baseline:

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. Analyze Overview: The Overview page now includes a Δ (Delta) column. You will see a large positive delta for the app heap, indicating significant memory growth.

AHAT Overview with Delta

  1. Drill Down: Click on rooted in the menu. This page shows objects reachable from GC roots, sorted by their retained size. You'll see MainActivity at the top with a large positive delta.

AHAT Rooted View with Delta

Recording allocation stack traces

While the Sample Path from GC Root tells you why an object is still alive, it doesn't tell you how it was created. Allocation stack traces provide the exact line of code that allocated an object.

Concept & Trade-offs: Recording every allocation's stack trace is computationally expensive and consumes significant memory. In a large production app, this can make the app nearly unusable. However, MemoryLab is a small enough application that we can safely enable this tracking to pinpoint the source of allocations.

Exercise: Identifying the source of Byte Arrays

  1. Start with Tracking: Force-stop MemoryLab and restart it with the --track-allocation flag. Increase the default stack depth to capture more context.

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. Action: Tap Allocate Java Memory(10MB) a few times.

  3. Dump: Take a heap dump and pull it.

  4. Analyze: Open the dump in AHAT. Navigate to a large byte[] instance. (e.g., inspect MainActivitymJavaAllocations (ArrayList) → elementData (Object[]) → array element [0]).

  5. Verify: In the instance view, look at the Allocation Site section. It will show the full stack trace leading to MainActivity.allocateJava.

AHAT Allocation Site

Analyzing Java memory dynamics (combined profile)

To get a complete picture of an application's memory behavior, you can combine memory counters, thread activity, and callstack-based allocation profiling into a single Perfetto trace. This allows you to correlate system-wide memory metrics (like RSS and heap size) with specific code execution and allocation sites.

We will use a combined configuration that enables:

  • Memory Counters (linux.process_stats): Polls RSS and other memory metrics.
  • ATrace (dalvik, memory, sched categories): Captures thread states and GC events.
  • Heapprofd (android.heapprofd): Targets both com.android.art (Java) and libc.malloc (native) heaps with continuous dumps every 5 seconds.

Exercise: combined memory analysis

In this exercise, we will run the MemoryLab app and perform a sequence of memory operations to observe different patterns in the trace:

  1. Baseline: Idle state.
  2. Java Churn: Temporary allocations that are immediately garbage collected.
  3. Persistent Java Allocation: Allocating Java objects that remain in memory.
  4. Bitmap Allocation: Allocating large graphics assets (which sit in the native heap/graphics memory).
  5. Reclamation: Freeing all allocated resources.

1. Launch and prepare

  1. Force-stop and restart the app to ensure a clean state:

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. Start tracing and trigger sequence

We will start a 40 second trace and trigger the memory events using am broadcast commands.

  1. Start the trace:

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 65536
        fill_policy: