---
title: "Reducing Android JVM allocations for less memory pressure and faster logs"
metaTitle: "Reducing Android JVM allocations: less memory pressure, faster logs"
slug: "reducing-android-jvm-allocations"
blurb: "We substantially reduced JVM allocations on common Android logging paths by removing map-shaped work from the SDK internals. The work started with a suggestion to replace HashMap with ScatterMap; we ended up changing the field representation, the JNI boundary, and several hot logging paths. The change affects every app using the Capture SDK on Android."
metaDescription: "Substantially reduce JVM allocations on common Android logging paths by removing map-shaped work from SDK internals and improve runtime along the way."
cover:
  url: "/assets/posts/reducing-android-jvm-allocations/feature-jvm-alloc-reduction-hero-image-desktop@1x.webp"
  alt: "Reducing Android JVM allocations"
socialThumbnail:
  url: "/assets/posts/reducing-android-jvm-allocations/feature-jvm-alloc-reduction-hero-image-desktop@1x.webp"
  alt: "Reducing Android JVM allocations"
author:
  - "fran"
publishedDate: "2026-07-02T00:00:00.000Z"
modifiedDate: "2026-06-30T00:00:00.000Z"
tags:
  - "mobile"
  - "android"

---

## The problem domain

[bitdrift's Capture SDK](https://bitdrift.io/feature/performance-centric) 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](https://www.romainguy.dev/posts/2024/a-better-hashmap/), 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](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](https://en.wikipedia.org/wiki/Parallel_array) 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](https://developer.android.com/topic/performance/benchmarking/microbenchmark-overview) 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 }
        }
    }
}
```

<Image alt="Android Studio benchmark output before the allocation reduction changes" altAsCaption large asset="/assets/posts/reducing-android-jvm-allocations/before-android-studio-benchmark@1x.webp" />

<Image alt="Android Studio benchmark output after the allocation reduction changes" altAsCaption large asset="/assets/posts/reducing-android-jvm-allocations/after-android-studio-benchmark@1x.webp" />

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

### Allocation Comparison Report

<figure style={{border: '1px solid var(--palette-divider)'}}>

| Test name | allocs on Main | allocs on PR | % allocs Reduction |
|------------------------------------------------------|----------------|--------------|-------------|
| trackSpansWithoutFields | 117 | 54 | 53.85% |
| trackSpansWithFields | 3754 | 266 | 92.91% |
| logNotMatchedNoFields | 2 | 0 | 100.00% |
| logNotMatched5Fields | 34 | 14 | 58.82% |
| logNotMatched10Fields | 48 | 17 | 64.58% |
| logNotMatched500Fields | 1148 | 34 | 97.04% |
| logNotMatched5000Fields | 10034 | 77 | 99.23% |

<figcaption style={{ padding: 8 }}>Allocation count dropped across every measured path, with the biggest reductions showing up once field count increased.</figcaption>

</figure>

### Duration Comparison Report

<figure style={{border: '1px solid var(--palette-divider)'}}>

| Test name | duration (ns) on Main | duration (ns) on PR | % duration Reduction |
|------------------------------------------------------|------------------|---------------|-------------|
| trackSpansWithoutFields | 39,358 ns | 9,924 ns | 74.77% |
| trackSpansWithFields | 1,173,969 ns | 661,361 ns | 43.62% |
| logNotMatchedNoFields | 12,958 ns | 621 ns | 95.21% |
| logNotMatched5Fields | 18,758 ns | 4,148 ns | 77.87% |
| logNotMatched10Fields | 23,928 ns | 7,566 ns | 68.37% |
| logNotMatched500Fields | 514,746 ns | 319,711 ns | 37.87% |
| logNotMatched5000Fields | 5,243,689 ns | 3,312,847 ns | 36.79% |

<figcaption style={{ padding: 8 }}>Runtime also improved across the same paths, which confirmed that this was not only an allocation win.</figcaption>

</figure>

### 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.

<Image alt="Before memory profile and timeline session" altAsCaption large asset="/assets/posts/reducing-android-jvm-allocations/add-before-memory-profile@1x.webp" />

<Image alt="After memory profile and timeline session" altAsCaption large asset="/assets/posts/reducing-android-jvm-allocations/add-after-memory-profile@1x.webp" />

## 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:

```
_status=success
```

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](https://docs.bitdrift.io/sdk/features/custom-logs) 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.
