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 (8-byte 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, which also claims the collector’s 4 KB compaction buffer.

Two boards set a [jvm] block today, pico_enviro_mon and pico_enviro_mon_w, both for the prereserve_* keys.

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 crates/jvm/build.rs snapshots at compile time. The two platform-side knobs and the prereserve_* keys take a shorter path because crates/picodroid-core/build.rs already parses board.toml (through crates/build_support/board_cfg.rs). Defaults and ranges for all of them live in one file, crates/build_support/jvm_defaults.rs, which both build scripts include.

board.toml [jvm]
│
├─ JVM-side (3 knobs)
│ │
│ │ scripts/board-lib.sh::apply_jvm_env
│ ▼
│ PICODROID_JVM_GC_ALLOC_THRESHOLD
│ PICODROID_JVM_SLOT_CHUNK_SHIFT
│ PICODROID_JVM_INLINE_ARRAY_DATA
│ │
│ │ crates/jvm/build.rs (validates ranges)
│ ▼
│ $OUT_DIR/tunables.rs ───► crates/jvm/src/tunables.rs ───► pub const at use site
│
└─ Platform-side (2 knobs)
│
│ build_support/board_cfg.rs::emit_jvm_state_config (validates ranges)
▼
$OUT_DIR/jvm_state_config.rs ───► native_handler/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 crates/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 — a small price for not having startActivity / finish / startService throw IllegalStateException (“too many pending Activity/Service transitions in one frame”) during a heavy lifecycle burst.

Four more build-time choices shape the JVM. None of them is a [jvm] key, and the first two must agree with a Cargo feature or the build stops.

SwitchWhereWhat it does
jvm_loop_ram_kbMCU toml (36 on rp2350 and rp2350b, absent on rp2040)RAM for the copy of the interpreter loop that runs from SRAM (pico-jvm’s loop-in-ram feature, which chip-rp2350 enables). Comes out of the FreeRTOS arena.
hot_ram_kbboard.toml (48 on every RP2350 board)RAM for the other hot sets: the JVM’s invoke, field and constant-pool helpers (hot-in-ram), and the LVGL and FreeRTOS functions listed in mcus/rp/hot-ram-*.txt. Comes out of the arena too.
line-numbersCargo feature(File.java:39) frames in stack traces instead of (pc=9). The scripts enable it for the simulator; no firmware build has it by default (PICODROID_LINE_NUMBERS=1 asks for it, ~70 KB on a W board).
mem-diagCargo featureThe memory monitor and offensive heap checks (./scripts/sim.sh --mem-diag, or PICODROID_EXTRA_FEATURES=mem-diag for firmware). Compiled out when off.

The arena each board links after the first two is in System limits.

Limits and pitfalls

  • Out-of-range values fail the build. Both crates/jvm/build.rs and emit_jvm_state_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 crates/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 crates/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.
  • The two caps fail differently. A full pending-op queue is Java-visible: the startActivity / finish / startService / stopService / bindService / unbindService call that would have been dropped throws IllegalStateException and the log says pending-op queue full, op dropped. A full Activity stack is not: the push is dropped, the log says activity stack overflow on push: <class>, the previous Activity’s view is restored and the app keeps running on it. The defaults are conservative on purpose; if your UI legitimately needs more depth, raise these explicitly.

See also