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
| Key | Default | Range | Trades | Lower when… | Raise when… |
|---|---|---|---|---|---|
gc_alloc_threshold | 256 | 16..=8192 | GC pause frequency ↔ peak heap | RAM is tight; tolerable pause cost | RAM is plentiful; pauses hurt latency |
slot_chunk_shift | 6 | 3..=8 | Worst-case contiguous alloc ↔ slot metadata overhead | Heap fragments under FreeRTOS pressure | Heap is contiguous and large |
inline_array_data | 8 | 0..=32 | Per-array struct size ↔ arena churn | Arrays are mostly large | Most arrays are short (≤16 ints) |
activity_stack_depth | 8 | 1..=32 | UI nav nesting ↔ RAM | Single-screen app | Deep modal/wizard flows |
pending_op_queue | 8 | 1..=64 | Lifecycle-op throughput ↔ RAM | Quiet UI | App fires many startService/startActivity per frame |
prereserve_obj_chunks | 0 | 0..=64 | Boot-claimed slot storage ↔ boot-time heap use | Default (off) | Nav churn fragments the native heap |
prereserve_arr_chunks | 0 | 0..=64 | (as above, array slots) | Default (off) | (as above) |
prereserve_str_chunks | 0 | 0..=64 | (as above, dyn-string slots) | Default (off) | (as above) |
prereserve_fields_values | 0 | 0..=65536 | Boot-claimed fields-arena capacity (8-byte Slots) | Default (off) | (as above) |
prereserve_arena_values | 0 | 0..=65536 | Boot-claimed array-arena capacity (i32 slots) | Default (off) | (as above) |
prereserve_arena8_bytes | 0 | 0..=65536 | Boot-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_thresholdwas the literalconst GC_THRESHOLD: u16 = 256in the interpreter. Commitceeae97lowered it to 128 for an RP2350 OOM, then93c5297reverted after a 15% sim regression in string workloads. The value is genuinely board-dependent.slot_chunk_shiftfalls out of theChunkedSlotsrefactor (commitb43c413) 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 at1 << shift × sizeof::<Option<JvmObject>>(). Smaller chunks survive harsher fragmentation.inline_array_datais the inline-vs-arena threshold inArrayHeap. 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_depthandpending_op_queuesize 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 manystartService/startActivitycalls 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] storageline (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 siteBoth 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 = 10Lower SCORE means a better tradeoff. The loop:
- Baseline —
./scripts/sim.sh --app perfbench --board <board>. Note theSCORE ...line. - 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. - Re-run the same command. Compare the new SCORE.
- 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.tomlso 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 markslot_chunk_shift = 4 # 16-slot chunks; halves the worst-case contiguous requestThis 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 timeinline_array_data = 16 # short array operations stay in the slot structTrades 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 = 16pending_op_queue = 16Doubles 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.
Related switches outside [jvm]
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.
| Switch | Where | What it does |
|---|---|---|
jvm_loop_ram_kb | MCU 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_kb | board.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-numbers | Cargo 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-diag | Cargo feature | The 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.rsandemit_jvm_state_configvalidate against the declared range andpanic!with a clear citation. The accepted bounds are designed so that even the extremes are safe — the upper bound ongc_alloc_threshold(8192) does not OOM any board the project has tested, and the lower bound onslot_chunk_shift(3 = 8-slot chunks) does not measurably slow index math. const_asserts incrates/jvm/src/tunables.rsare a second line of defence against a corrupted generated file. They fire at type-check time with a clear message.- Direct
cargo buildskips the script bridge. If you’re not using./scripts/sim.shor./scripts/flash.sh, thePICODROID_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-changeddirectives incrates/jvm/build.rsmean 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_thresholdwhile also loweringslot_chunk_shiftoverweights 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/unbindServicecall that would have been dropped throwsIllegalStateExceptionand the log sayspending-op queue full, op dropped. A full Activity stack is not: the push is dropped, the log saysactivity 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
[background_pool]— adjacent thread-pool tuning in the sameboard.tomlschema.perfbench— the speed + memory composite-score benchmark used in the tuning workflow.crates/jvm/build.rs— env-var reader for the three JVM-side knobs.crates/build_support/board_cfg.rs—emit_jvm_state_configfor the two platform-side knobs and theprereserve_*keys.crates/build_support/jvm_defaults.rs— every default and range, in one place.scripts/board-lib.sh—apply_jvm_envshell-side bridge fromboard.tomlto environment.- Porting guide — full
board.tomlschema, MCU contract. - Advanced configuration — files outside
board.toml.