Reducing Android JVM allocations: less memory pressure, faster logs

bitdrift Blog ·

9 min read Original article ↗

The problem domain

bitdrift's Capture SDK runs inline with your application as a mobile observability layer. Even small per-event costs show up quickly on paths like:

  • regular logs

  • spans

  • HTTP instrumentation

  • field provider expansion

The allocation pressure on those paths came up in customer feedback, including a suggestion to replace HashMap with ScatterMap. Romain Guy wrote a good post on why HashMap can be a poor fit in some cases, and that was a useful reference point while looking into this. The first profiling passes showed that the allocation stream was mostly made of small objects:

  • HashMap instances

  • map entries

  • intermediate collections used while reshaping data for JNI

PR: https://github.com/bitdriftlabs/capture-sdk/pull/805

Examples from the code

The original design was convenient: fields were carried around as maps, enriched a few times, then converted again at the JNI boundary. For small payloads that looks harmless in code review:

kotlin


val fields =
    buildMap {
        put("_method", method)
        put("_host", host)
        put("_path", path)
    }

logger.log(level, fields) { message }

What that hides is that we allocate the map itself, allocate entries, copy again into another internal structure later, and then reshape the same data again before crossing JNI.

Why HashMap was the wrong fit

Our field handling leaned heavily on maps, but the logging path does not really need most of what a map gives us. We did not need:

  • direct/non-sequential access

  • hashing

  • bucket management

  • entry wrappers

  • full map semantics during intermediate processing

What we mostly needed was an ordered collection of key/value pairs that could be combined and passed down. The problem with the original shape was not just HashMap itself. It was that map-shaped work kept propagating through the whole path.

The ScatterMap step

The first direction was to replace internal HashMap usage with ScatterMap. That was worth trying. It confirmed part of the hypothesis, but it did not remove the rest of the cost:

  • We were still constructing map-like objects in hot paths.

  • We were still converting those objects again before crossing JNI.

  • We were still carrying more semantics than we needed.

That is where the work shifted from “use a better map” to “stop carrying maps through this path at all.”

The broader fix

The key change was introducing our own internal ArrayFields type, backed by parallel arrays of keys and values. Instead of carrying fields around as maps, hot paths now carry our internal field container:

kotlin


class ArrayFields internal constructor(
    internal val keys: Array<String>,
    internal val values: Array<String>,
)

// Internal helper we added for building ArrayFields
fun fieldsOf(vararg pairs: Pair<String, String>): ArrayFields {
    if (pairs.isEmpty()) return ArrayFields.EMPTY
    val keys = Array(pairs.size) { pairs[it].first }
    val values = Array(pairs.size) { pairs[it].second }
    return ArrayFields(keys, values)
}

We built the arrays once with the final size in the common paths. When multiple groups of fields needed to be composed incrementally, we used a small builder and only materialized the final arrays once at the end. At the call site, that changed code from this:

kotlin


// Before
val fields =
    buildMap {
        put("_method", method)
        put("_host", host)
        put("_path", path)
    }

logger.log(level, fields) { message }

to this:

kotlin


// After
val fields =
    fieldsOf(
        "_method" to method,
        "_host" to host,
        "_path" to path,
    )

logger.log(level, fields) { message }

And for a single field we added a matching helper as well:

kotlin


val field = fieldOf("_method", method)

logger.log(level, field) { message }

Once that representation changed, a lot of internal APIs moved with it. Logging gained an overload that accepts ArrayFields directly:

kotlin


fun log(
    level: LogLevel,
    arrayFields: ArrayFields,
    throwable: Throwable? = null,
    message: () -> String,
)

The same pattern was pushed through LoggerImpl, span creation, HTTP request/response field building, resource utilization logging, and Timber integration. On Android there was a second part to this: the JNI boundary. Previously, the main log path passed maps across JNI and native code unpacked those Java objects field by field:

rust


let fields = jobject_map_to_fields(&mut env, &fields, LogFieldKind::Ootb)?;

The writeLog path now passes parallel string arrays instead:

kotlin


CaptureJniLibrary.writeLog(
    loggerId = loggerId,
    logType = type.value,
    logLevel = level.value,
    log = message(),
    fieldKeys = arrayFields.keys,
    fieldValues = arrayFields.values,
    usePreviousProcessSessionId = false,
    overrideOccurredAtUnixMilliseconds = occurredAt,
    blocking = false,
)

rust


let fields = string_arrays_to_annotated_fields(
    &mut env,
    &field_keys,
    &field_values,
    LogFieldKind::Ootb,
)?;

Benchmarks

We used AndroidX microbenchmarks to measure both runtime and allocation count for the logging paths we cared about. At a high level, the benchmark setup is simple: initialize the logger, exercise one hot path repeatedly, and let the benchmark library handle warmup, iteration count, and measurement.

kotlin


@RunWith(AndroidJUnit4::class)
class LogBenchmarkTest {
    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @Test
    fun logNotMatched5Fields() {
        startLogger()
        val fields = buildFieldsMap(5)

        benchmarkRule.measureRepeated {
            Capture.Logger.logInfo(fields) { LOG_MESSAGE }
        }
    }
}

After the optimization changes described above, these were the results against the latest main branch.

Allocation Comparison Report

Test nameallocs on Mainallocs on PR% allocs Reduction
trackSpansWithoutFields1175453.85%
trackSpansWithFields375426692.91%
logNotMatchedNoFields20100.00%
logNotMatched5Fields341458.82%
logNotMatched10Fields481764.58%
logNotMatched500Fields11483497.04%
logNotMatched5000Fields100347799.23%
Allocation count dropped across every measured path, with the biggest reductions showing up once field count increased.

Duration Comparison Report

Test nameduration (ns) on Mainduration (ns) on PR% duration Reduction
trackSpansWithoutFields39,358 ns9,924 ns74.77%
trackSpansWithFields1,173,969 ns661,361 ns43.62%
logNotMatchedNoFields12,958 ns621 ns95.21%
logNotMatched5Fields18,758 ns4,148 ns77.87%
logNotMatched10Fields23,928 ns7,566 ns68.37%
logNotMatched500Fields514,746 ns319,711 ns37.87%
logNotMatched5000Fields5,243,689 ns3,312,847 ns36.79%
Runtime also improved across the same paths, which confirmed that this was not only an allocation win.

Same app comparison

We also compared the same app flow before and after the change while looking at the memory profile and the resulting timeline session. One nice side effect here is that we can use bitdrift to measure bitdrift, using the same product view we expect customers to use when validating behavior in a real app flow.

Validation

One important case was field ordering and overwrite semantics. When fields are combined, the last value for the same key still needs to win. In simplified form:

kotlin


val fields =
    combineFields(
        fieldOf("_status", "pending"),
        fieldOf("_status", "success"),
    )

logger.log(level, fields) { message }

The expected result is still:


That was verified in gradle-test-app, along with:

  • field overwrite behavior when combining fields

  • timeline rendering

  • spans

  • regular log ingestion

  • workflow matching

Conclusion and lessons learned

The useful part of this work was not really the final container type. It was getting stricter about what the logging pipeline actually needs. We started from a narrow suggestion, replacing HashMap with ScatterMap, and ended up removing map-shaped work from several layers of the path instead. That was the part that moved the numbers. We learned several lessons from this work that can be applied more broadly:

  1. Use the right tool for the job. It's easy to reach for a standard library structure because it looks close enough, but it's worth looking at other data structures or simpler representations when the path is sensitive enough.

  2. Start with measurement, not assumptions. The ScatterMap hypothesis was reasonable, but benchmarking early showed it would not be enough on its own.

  3. Allocation pressure is invisible in functional tests. Everything still passes, but the SDK quietly does more work per event. CI benchmarks are the only reliable way to catch this class of regression.

  4. Public API ergonomics and internal representation can be decoupled. Callers kept the same interface while the internals stopped paying for unnecessary collection churn.

  5. In SDK code, "small" allocations are rarely small. Multiply any per-event cost across every log call on every customer device and the numbers change fast.

Future work

This change removed a meaningful amount of allocation pressure, but our work isn't done yet. Here are our next steps to continue improving the Capture SDK's JVM allocation.

  • expose more public or semi-public APIs that can accept ArrayFields directly where appropriate

  • remove more remaining map-to-array conversion points

  • continue shrinking work done at JNI boundaries

  • expand benchmark coverage for more real-world logging combinations

  • keep validating changes against both synthetic benchmarks and timeline sessions from real apps


Frequently asked questions

What are JVM allocations and why do they matter for Android SDKs?

Every time Android code creates a new object, the Java Virtual Machine (JVM) allocates memory for it. In most application code, that cost is negligible. In SDK code that runs on every log event, every span, and every HTTP call, small per-event allocations add up quickly, potentially multiplying across millions of calls. Sustained allocation pressure forces the garbage collector to run more frequently, competing with the app for CPU time and pausing execution. On Android, those pauses contribute directly to ANRs (Application Not Responding errors) when they occur on the main thread, and sustained memory pressure from uncollected objects can push the process toward OOM (out of memory) termination.

What is ArrayFields and why does it reduce allocation pressure?

ArrayFields is an internal representation we introduced in the Capture SDK that backs field collections with parallel arrays of keys and values rather than a HashMap. On the common logging path, the arrays are built once at the final size. This removes the cost of hash bucket management, entry wrappers, and intermediate map construction that the previous design paid on every event.

How does bitdrift measure JVM allocations?

The Capture SDK uses AndroidX microbenchmarks to measure both allocation count and wall clock duration across representative logging paths, including logs with no fields, small and large field counts, spans, and HTTP instrumentation. These benchmarks run on every Android PR and compare results against a baseline, making allocation regressions visible before they ship.

Why does allocation count affect logging latency?

Fewer allocations means less work for the garbage collector. When GC pressure drops, the JVM spends less time reclaiming memory on hot paths, which reduces the duration of the logging calls themselves. In the benchmark results from this change, runtime improvements tracked closely with allocation reductions across every measured path.

Does this change affect the public API for Capture SDK users?

No. The public APIs kept its existing ergonomics. The changes were internal: the field representation, the JNI boundary, and the internal logging overloads. Existing call sites continue to work without modification.