Skip to content

JVM tunables

The Picodroid JVM exposes five compile-time knobs that let a board choose its own CPU↔memory tradeoff without touching JVM source. Override them in your board’s [jvm] section; leave them out to keep the defaults.

TL;DR

KeyDefaultRangeTradesLower when…Raise when…
gc_alloc_threshold25616..=8192GC pause frequency ↔ peak heapRAM is tight; tolerable pause costRAM is plentiful; pauses hurt latency
slot_chunk_shift63..=8Worst-case contiguous alloc ↔ slot metadata overheadHeap fragments under FreeRTOS pressureHeap is contiguous and large
inline_array_data80..=32Per-array struct size ↔ arena churnArrays are mostly largeMost arrays are short (≤16 ints)
activity_stack_depth81..=32UI nav nesting ↔ RAMSingle-screen appDeep modal/wizard flows
pending_op_queue81..=64Lifecycle-op throughput ↔ RAMQuiet UIApp fires many startService/startActivity per frame
prereserve_obj_chunks00..=64Boot-claimed slot storage ↔ boot-time heap useDefault (off)Nav churn fragments the native heap
prereserve_arr_chunks00..=64(as above, array slots)Default (off)(as above)
prereserve_str_chunks00..=64(as above, dyn-string slots)Default (off)(as above)
prereserve_fields_values00..=65536Boot-claimed fields-arena capacity (Value slots)Default (off)(as above)
prereserve_arena_values00..=65536Boot-claimed array-arena capacity (i32 slots)Default (off)(as above)
prereserve_arena8_bytes00..=65536Boot-claimed packed byte-array arena capacity (bytes; backs byte[]/boolean[] at 1 B per element)Default (off)(as above)

The first five are compile-time pub consts, inlined at every use site — zero RAM cost; changing them changes the binary, not the running JVM. The prereserve_* keys instead size a one-shot allocation at app start.

Why these knobs exist

Each tunable started as a hardcoded constant tuned to one board; over time the project hit cases where the right value diverged per target.

  • gc_alloc_threshold was the literal const GC_THRESHOLD: u16 = 256 in the interpreter. Commit ceeae97 lowered it to 128 for an RP2350 OOM, then 93c5297 reverted after a 15% sim regression in string workloads. The value is genuinely board-dependent.
  • slot_chunk_shift falls out of the ChunkedSlots refactor (commit b43c413) that unblocked picoenvmon on hardware. Before that, Vec<Option<JvmObject>> doubled its capacity on growth, eventually demanding a 90 KB contiguous block that the FreeRTOS heap could not serve once fragmented. Fixed-size chunks cap the worst-case request at 1 << shift × sizeof::<Option<JvmObject>>(). Smaller chunks survive harsher fragmentation.
  • inline_array_data is the inline-vs-arena threshold in ArrayHeap. Small arrays live in the slot struct; larger arrays go to a shared arena. Raising the threshold pulls more arrays inline (fewer arena allocations, faster access for short arrays) at the cost of a bigger slot struct. Lowering it is the opposite trade.
  • activity_stack_depth and pending_op_queue size two fixed-capacity arrays on the RP native handler (Activity LIFO + lifecycle-op FIFO). The defaults cover any realistic Android-shaped UI; raise them only if you have nested modal flows or a single Activity that queues many startService/startActivity calls per frame.
  • prereserve_* came out of the 2026-07-23 memory-stress run (PEM-3): the JVM’s permanent storage — slot chunks and arena capacity, which are never freed once grown — was being allocated mid-heap during Activity churn, splitting the largest free block roughly in half even though the storage itself is only tens of KB. Claiming the app’s measured steady-state footprint at boot, while the heap is still contiguous, keeps it packed low. Size the values from the [memmon] storage line (mem-diag builds print it whenever a chunk count or arena capacity changes) after a representative navigation soak, plus one growth step of margin. Reservation is best-effort: on a heap too small to grant it, the app silently falls back to on-demand growth.

Each one is a pure board-vs-board policy choice. The defaults match what the original hardcoded source said, so nothing changes for a board that doesn’t opt in.

How values reach the binary

The JVM crate is no_std and cannot read board.toml directly, so values flow through environment variables that jvm/build.rs snapshots at compile time. The two platform-side knobs take a shorter path because platforms/rp/build.rs already parses board.toml.

board.toml [jvm]
├─ JVM-side (3 knobs)
│ │
│ │ scripts/lib.sh::apply_jvm_env
│ ▼
│ PICODROID_JVM_GC_ALLOC_THRESHOLD
│ PICODROID_JVM_SLOT_CHUNK_SHIFT
│ PICODROID_JVM_INLINE_ARRAY_DATA
│ │
│ │ jvm/build.rs (validates ranges)
│ ▼
│ $OUT_DIR/tunables.rs ───► jvm/src/tunables.rs ───► pub const at use site
└─ Platform-side (2 knobs)
│ platforms/rp/build.rs::emit_jvm_config (validates ranges)
$OUT_DIR/jvm_state_config.rs ───► state.rs ───► pub const at use site

Both paths end at a pub const. Constants are inlined; no value is ever stored in RAM, and no runtime indirection happens at the use site. A cargo:rerun-if-env-changed=PICODROID_JVM_* directive in jvm/build.rs re-compiles when you switch boards mid-session.

When you invoke cargo build directly (without ./scripts/sim.sh / flash.sh), the wrapper scripts don’t run and the env vars are unset. In that case the JVM picks the documented defaults — the same values you would get from a board with no [jvm] block.

Tuning workflow

perfbench is the measurement instrument. Its composite SCORE folds wall time, GC cycle count, and peak heap into a single number you can grep for.

The formula (from examples/perfbench/java/perfbench/PerfBench.java):

score = wall_ms + W_GC × gc_cycles + peak_kb / W_PEAK_DIV
where W_GC = 1 and W_PEAK_DIV = 10

Lower SCORE means a better tradeoff. The loop:

  1. Baseline./scripts/sim.sh --app perfbench --board <board>. Note the SCORE ... line.
  2. Change one knob in that board’s board.toml. Just one — interactions between knobs are real, and isolating the effect of each move is what makes future maintainers trust the chosen value.
  3. Re-run the same command. Compare the new SCORE.
  4. Decide — keep the change if SCORE drops and per-test lines tell a coherent story (peak heap down without GC count exploding, etc.). Document the rationale next to the value in board.toml so the next maintainer knows why.

Repeat on hardware (./scripts/flash.sh) before declaring a tuning final — the sim heap is generous and rarely fragments the way FreeRTOS does on an MCU.

Recipes

Three concrete starting points for common shapes of board:

Heap-constrained board (RP2040-class)

[jvm]
gc_alloc_threshold = 128 # collect more often, shrink the high-water mark
slot_chunk_shift = 4 # 16-slot chunks; halves the worst-case contiguous request

This mirrors the picoenvmon heap-budget work — small heap, fragmenting allocator. The drawback is more GC pauses, which is acceptable because the board’s bottleneck is RAM, not CPU.

CPU-constrained board with plenty of RAM (RP2350 / RP2350W with display thread)

[jvm]
gc_alloc_threshold = 512 # halve the GC rate; trade peak heap for wall time
inline_array_data = 16 # short array operations stay in the slot struct

Trades a higher peak heap for shorter wall time and fewer pauses. Watch peak heap with perfbench; if it climbs above what your display + framework leave free, back off.

Deep-nav UI (modal dialogs over a wizard)

[jvm]
activity_stack_depth = 16
pending_op_queue = 16

Doubles two fixed-size buffers in the platform native handler. Each entry is small (≤ 24 bytes), so the RAM cost is roughly 200 extra bytes — well under what the freed heap gains from avoiding silent false-returning enqueue failures during a heavy lifecycle burst.

Limits and pitfalls

  • Out-of-range values fail the build. Both jvm/build.rs and platforms/rp/build.rs::emit_jvm_config validate against the declared range and panic! with a clear citation. The accepted bounds are designed so that even the extremes are safe — the upper bound on gc_alloc_threshold (8192) does not OOM any board the project has tested, and the lower bound on slot_chunk_shift (3 = 8-slot chunks) does not measurably slow index math.
  • const_asserts in jvm/src/tunables.rs are a second line of defence against a corrupted generated file. They fire at type-check time with a clear message.
  • Direct cargo build skips the script bridge. If you’re not using ./scripts/sim.sh or ./scripts/flash.sh, the PICODROID_JVM_* env vars are unset and the JVM picks defaults. Either export them yourself or stick with the wrapper scripts.
  • Cache invalidation is automatic. cargo:rerun-if-env-changed directives in jvm/build.rs mean a board switch (different env values exported by the wrapper script) re-compiles just the affected crates.
  • One knob at a time. Interaction effects exist — e.g. lowering gc_alloc_threshold while also lowering slot_chunk_shift overweights memory at the cost of CPU. Tune one, measure, then move on.
  • Activity-stack and pending-op caps are not Java-visible errors. Enqueue overflows return false and are logged but do not throw a RuntimeException. The defaults are conservative on purpose; if your UI legitimately needs more depth, raise these explicitly.

See also