Build1 publisher3 min readPublished
A smaller GC trigger floor cut ParparVM's resident memory from 98 MB to 38 MB
Codename One went looking for stack allocation to explain Go's edge and ended up tuning when its collector runs. Three attempts to size the heap allowance from the live set failed because the sweep counters miss live objects.
The Engineer · Build desk

What happened
- Codename One expected stack allocation to explain Go's edge over its Java runtime and reported that allocation was not the whole explanation, pointing instead at when Go collects and how it shares the work.
- Lowering the collector's minimum allocation threshold took the test process from 98 MB of resident memory to 38 MB, with throughput and p99 latency staying inside the noise between runs.
- Three attempts to size the allowance from the live set failed because an ordinary sweep misses live objects on partial pages, never sweeps pages a mutator still owns, and excludes the large-object heap path.
- On a loop allocating 20 million short-lived objects against a 4,096-node live set, four markers cut the long stalls, and Go finished its worst pause in the same test in about 20 ms.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Adaptive heap sizing stays blocked in ParparVM until the sweep accounting changes, because the only number the policy can read is a lower bound of unknown depth.
- decision Anyone building ParparVM for a memory-constrained target has to set the floor themselves, since the shipped default is the setting that produced the 98 MB run.
- capability An allocating thread can now mark a batch of objects instead of blocking. The stall moves out of the pause and into allocation latency on the thread that hit the limit.
- cost On a loop where almost nothing survives, the memory saving costs nothing. On an application with a large live set, the extra collections a lower floor brings mean re-marking the same live objects.
The accounting fails in an interesting way. An ordinary sweep in ParparVM samples retired pages, so it does not count live objects sitting on partial pages, and pages a mutator thread still owns are not swept at all [11]. Large objects travel a separate heap path [12]. Even the major sweep's policy counters leave part of that population out [13]. "The number available to the policy therefore cannot safely stand in for the whole live set," the Codename One post says [14].
Three versions of a live-set-driven threshold ran into that [10]. One of them kept an old live-set estimate after the objects were gone, and the high threshold it computed suppressed the sweeps needed to return pages to the operating system [15].
What shipped is a build define. `CN1_BIBOP_GC_MIN_TRIGGER_BYTES` falls back to `CN1_BIBOP_GC_TRIGGER_BYTES` when nothing sets it, so a runtime build picks its own floor at compile time [16]. The default stays 24 MB, the same allowance the 98 MB run had [17][5][6].
ParparVM translates Java to C ahead of time, and the closed-world build sees the whole application before it runs [3]. The team changes object layout, reference handling and the collector together, and the post argues those constraints sit closer to Go's native runtime than to HotSpot's dynamic execution model [3][4]. It is the runtime behind Codename One, which builds native iOS, Android, desktop and web apps from one Java or Kotlin codebase [26].
The GcPause loop allocates 20 million short-lived objects against a live set of 4,096 nodes [18], about one surviving node per 4,900 allocations [27]. On a heap that empty, collecting more often is cheap, because each collection finds almost nothing to mark. For the 38 MB to mean anything for a service, its live set has to stay small relative to its allocation rate.
Median and p99 on that loop were already comparable to Go. The long stalls were not [19]. Four markers cut those stalls, and Go's worst pause was about 20 ms [20][21]. The post does not state a pause figure for the four-marker runs or the value of the smaller floor it settled on [28].
I would port mutator assistance first. A thread that reaches the limit on how far it may run ahead of the collector marks a batch of objects instead of waiting [22]. The thread registers as active before it releases the worklist lock, so the collector cannot declare the mark phase finished while that batch is still in flight [23].
Both of those remain on an experimental path. An old arm64 corruption report had kept ParparVM on the serial default, and barriers, object validation and root handling have been fixed since [24]. A dedicated workflow now exercises one and four markers on arm64 and four on x64 [25].
What to watch
- Whether the 24 MB default floor moves once Codename One has measured more workloads.
- A sweep accounting change that counts partial pages, mutator-owned current pages and the large-object path, which is what the live-set-driven threshold needs.
- Pause figures from the arm64 and x64 workflow runs with four markers and assistance enabled.