Skip to content

fix(sharedobjects): cap the JNI global refs shared objects hold - #40

Merged
lukmccall merged 1 commit into
mainfrom
perf/shared-object-global-ref-budget
Oct 28, 2026
Merged

lukmccall merged 1 commit into
mainfrom
perf/shared-object-global-ref-budget

Conversation

@lukmccall

@lukmccall lukmccall commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Why

Every shared object pinned its Kotlin instance with a JNI global ref of its own. A benchmark that runs new SharedPoint(1.5, 2.5) in a loop aborted on Android with JNI ERROR (app bug): global reference table overflow (max=51200): 50,362 of the refs held SharedPointV2. The limit covers the whole process, and Hermes finalizes facades late, so JavaScript can hold or leave behind far more shared objects than there is room for.

What

New objects/InstanceRef replaces the kolibri::GlobalRef in ObjectState:

  • Within the budget: while fewer than config::kSharedObjectGlobalRefs (1024, 2% of Android's limit) are held, a shared object gets a global ref of its own, exactly as before. A released or collected object gives its ref back, so the next object created gets one again.
  • Past the budget: the instance goes into a slot of a Java Object[] chunk. Each chunk holds config::kInstanceSlotsPerChunk (1024) instances and costs one global ref. A call reads the instance out as a local ref through ScopedInstance and deletes it after the call.
    • An InstanceRef points straight at its own chunk, so reading the instance looks nothing up. There is no table of chunks and no cap on how many objects can sit in slots.
    • Each chunk keeps a stack of its own free slots. The chunks with both taken and free slots are on a doubly linked list, and a new slot is taken from one of them first, then from the spare, and only then from a new chunk.
    • Once none of a chunk's slots is taken, nothing points at the chunk, so its Java array and global ref are freed. One empty chunk is kept as a spare, so objects coming and going around a chunk boundary don't make and free an array each time.
    • Releasing a slot sets aside any pending exception and rethrows it afterwards, because writing an array element isn't allowed while an exception is pending.
  • Modules: they always use InstanceRef::global and never count against the budget.
  • Holding 40,000 live objects now takes about 1,063 global refs instead of 40,000.

The path for objects within the budget is unchanged: invokeFunction, getProperty and setProperty borrow the global ref and tail-call the invoker just as before. Reading a slot lives in out-of-line *InSlot functions. The compiled setter differs from main by one predicted null check and one register save and restore.

ObjectState::instance() is replaced by holdsInstance() and instanceRef(). The callers outside the hot path (event emitter, listener table, JniEncoder, JavaScriptObject::nativeInstance) take a local ref through newLocal / ScopedInstance instead of using the raw global.

The new settings sit in Config.h next to the other tuning constants, with a static_assert on the chunk size.

Benchmarks

Host JMH (HotSpot, host Hermes), SharedObjectBenchmark, 3 forks × 5 iterations, main and this branch alternated twice; ns/op:

case main this PR
method 126.7 / 118.6 119.5 / 118.9
method-bound 120.7 / 119.6 133.8 ± 8 / 119.7
property-read 133.9 / 132.7 135.4 / 130.5
property-write 138.3 / 138.4 146.2 ± 5 / 141.1
pass-arg 144.3 / 147.2 150.9 / 149.1
construct ¹ 1370 ± 91 1369 ± 92 / 1393 ± 105
method-slotted 117.0 / 120.9 134.4 / 139.3
property-read-slotted 129.1 / 127.8 155.3 / 156.3

¹ Measured after rebasing onto main with #38, which made creating an object cost about 250 ns more on both sides; the other rows were measured before the rebase.

  • Within the budget: the same as main. property-write and pass-arg came out 2–4 ns higher in these runs, more than the extra null check explains, so it is probably code-layout noise.
  • Past the budget: a call costs +17 to +27 ns for the extra GetObjectArrayElement and DeleteLocalRef.
  • New benchmark cases:
    • construct runs new P(1.5, 2.5) in a loop, like the benchmark that crashed.
    • The *-slotted cases hold 2,048 objects first, so the measured receiver sits in a slot.

None of this has been measured on a device or on ART.

Test plan

  • New SharedObjectTest case: JavaScript holds 40,000 Spots.
    • The globals stay at or under 1024; an object past the budget sits in a slot.
    • Every spot answers as itself through a method call and when passed as an argument; property reads and writes work.
    • An explicit release() frees its slot for reuse.
    • Once Hermes finalizes the dropped facades, every slot is freed, and the next new object holds a global ref again.
    • With every chunk full, releasing an object from one of them and creating a new one reuses the freed slot rather than making a chunk.
    • After everything is collected, only the spare chunk is left, and the next 1,024 objects past the budget go into it.
    • Each of these checks fails on a deliberately broken build: one that never frees chunks, one that never uses the spare, and one that never puts a full chunk back on the list.
    • New test-support hooks: ExpoTestSupport.__instanceRefCounts() (globals, slots, chunks) and __holdsGlobalRef(obj).
  • ./gradlew :test-app:test: 226 tests, 0 failures
  • ./gradlew :api:test: 156 tests, 0 failures
  • With the budget set to 0, so every shared object sits in a slot, both suites passed (checked before rebasing)
  • ./gradlew ':react:buildCMakeDebug[arm64-v8a][expo-modules-v2]'
  • Re-run the SharedPoint benchmark on a device: it should no longer abort

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

libexpo-modules-v2.so size (arm64-v8a, stripped)

+3,888 B (+1.12%)

Base This PR Change
File size 347,576 B (339.4 KiB) 351,464 B (343.2 KiB) +3,888 B (+1.12%)
Exported symbols 20 20 0
Sections (7 changed)
Section Base This PR Change
.text 248,540 251,420 +2,880 (+1.16%)
.eh_frame 34,168 34,724 +556 (+1.63%)
.rodata 19,283 19,171 -112 (-0.58%)
.gcc_except_table 16,444 16,920 +476 (+2.89%)
.eh_frame_hdr 5,076 5,148 +72 (+1.42%)
.dynstr 5,106 5,106 0
.data.rel.ro 4,328 4,328 0
.dynsym 3,480 3,480 0
(headers and padding) 2,318 2,333 +15 (+0.65%)
.rela.dyn 2,328 2,329 +1 (+0.04%)
.rela.plt 2,088 2,088 0
.plt 1,424 1,424 0
.got.plt 720 720 0
.dynamic 464 464 0
.data 328 328 0
.gnu.version 290 290 0
.got 280 280 0
.shstrtab 275 275 0
.comment 204 204 0
.note.android.ident 152 152 0
.gnu.hash 148 148 0
.gnu.version_r 64 64 0
.note.gnu.build-id 36 36 0
.fini_array 16 16 0
.init_array 16 16 0

Kotlin code size (:react, dexed by D8, not shrunk)

No change

Base This PR Change
Dex size 284,264 B (277.6 KiB) 284,264 B (277.6 KiB) 0
Class files 724,773 B (707.8 KiB) 724,773 B (707.8 KiB) 0
Classes 196 196 0
Methods 1,781 1,781 0
Dex sections (0 changed)
Section Base This PR Change
string_data 117,858 117,858 0
code 70,722 70,722 0
annotations 23,380 23,380 0
method_ids 14,248 14,248 0
class_data 9,975 9,975 0
string_ids 9,852 9,852 0
proto_ids 6,960 6,960 0
class_defs 6,848 6,848 0
debug_info 6,078 6,078 0
annotations_directories 5,656 5,656 0
annotation_sets 4,688 4,688 0
type_lists 3,036 3,036 0
field_ids 3,008 3,008 0
type_ids 1,532 1,532 0
map_list 208 208 0
header 112 112 0
encoded_arrays 103 103 0
Class files by package (0 changed)
Package Base This PR Change Classes
io.github.expo.modules.v2.converters 213,544 213,544 0 41
io.github.expo.modules.v2.types 123,662 123,662 0 43
io.github.expo.modules.v2.async 58,113 58,113 0 16
io.github.expo.modules.v2.modules 40,479 40,479 0 11
io.github.expo.modules.v2.jsi 37,357 37,357 0 9
io.github.expo.modules.v2.records 36,048 36,048 0 10
io.github.expo.modules.v2.args 34,500 34,500 0 8
io.github.expo.modules.v2 31,576 31,576 0 19
io.github.expo.modules.v2.binary 25,758 25,758 0 4
io.github.expo.modules.v2.sharedobjects 22,116 22,116 0 4
io.github.expo.modules.v2.events 21,878 21,878 0 4
io.github.expo.modules.v2.react 18,981 18,981 0 6
io.github.expo.modules.v2.cache 16,655 16,655 0 6
io.github.expo.modules.v2.records.writers 16,026 16,026 0 3
io.github.expo.modules.v2.records.readers 15,795 15,795 0 3
io.github.expo.modules.v2.logging 4,635 4,635 0 4
io.github.expo.modules.v2.loader 2,535 2,535 0 1
io.github.expo.modules.v2.errors 2,101 2,101 0 1
io.github.expo.modules.v2.jni 1,563 1,563 0 1
io.github.expo.modules.v2.core 1,451 1,451 0 2

Base: main at 83ca7fa. This PR: bfe5bec, merged into the base.

@lukmccall
lukmccall force-pushed the perf/shared-object-global-ref-budget branch from 7de329b to 4788f8e Compare October 28, 2026 12:29
@lukmccall
lukmccall force-pushed the perf/shared-object-global-ref-budget branch from c409b91 to bfe5bec Compare October 28, 2026 13:27
@lukmccall
lukmccall merged commit c78a625 into main Oct 28, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant