Skip to content

Add JavaVirtualMachine.current, a lock-free read that never creates a JVM - #26

Open
piercifani wants to merge 1 commit into
swiftlang:mainfrom
piercifani:jnicore/current-vm
Open

piercifani wants to merge 1 commit into
swiftlang:mainfrom
piercifani:jnicore/current-vm

Conversation

@piercifani

Copy link
Copy Markdown

Problem

JavaVirtualMachine.shared() is the only way to get the shared instance, and it has two properties that make it a poor fit for hot paths:

  1. It takes a lock on every call. Higher-level libraries look up the JVM on every JNI call. SwiftJava does it in JavaObjectHolder.forget() and _JNIMethodIDCache; Skip's bridging does it on every bridged call. On an Android emulator, routing a production Skip Fuse app's bridging benchmark through shared() made it 1.09–1.33× slower on every case that crosses JNI (strings, structs, arrays). That was a debug build, one run per configuration.
  2. It finds or creates a JVM. So "is a JVM available?" can't be asked without side effects. A host process (macOS, or a Swift test) that only wants to know whether it's running inside a JVM ends up launching an embedded one, or trapping when no Java is installed.

Change

  • public static var current: JavaVirtualMachine?: the published shared instance, read with an acquire load. Returns nil if nothing was set or adopted, and never creates a JVM.
  • Every write (setShared, setSharedJVM, forgetShared, shared(replace:), and adoption inside shared()) still goes through the existing lock and publishes the instance with a release store. Pointer-sized atomics are two static inline helpers in CSwiftJavaJNI, so this works on every platform without requiring Synchronization (macOS 15+).
  • shared() returns current without locking when an instance exists. Its behavior doesn't change: an existing instance was already returned regardless of the options passed.
  • The thread-exit detach uses current, so a thread exiting never creates a JVM.

Replacing or forgetting the shared instance releases the old one. As with destroyJVM(), callers must not do that while other threads are still using it. The doc comment says so.

Numbers

Measurement Before After
macOS, release build: read cost (currentIsCheaperThanLockedRead) locked read 16.6 ns current 3.3 ns
Android emulator (API 36), a bridging benchmark of a production Skip Fuse app (debug build, median ns/op) the shared()-per-call version: 1.09–1.33× slower than Skip's previous JNI layer (its own wrapper, not built on jni-core) 0.80–1.00× of that previous layer

Example cases from that benchmark:

Case before (shared() per call) after (current)
[String] × 200 getter 268,840 171,478
struct getter 1,852 1,436

"After" was measured together with a follow-up change (application class loader applied on thread attach). That change runs once per attached thread and doesn't touch the per-call path.

Tests

JavaVirtualMachineCurrentTests:

  • current is identical to shared().
  • 8 threads × 100k concurrent reads always see the same instance.
  • A printed (not asserted) cost comparison.

Results:

Platform Result
macOS (HotSpot) 22 tests in 5 suites pass
Linux (swift:6.4.2-noble, OpenJDK 17) 22 tests in 5 suites pass
Android API 36 emulator pass, but only in a combined run with follow-up changes. On Android the JVM tests run only inside an APK with SWIFT_JAVA_JNI_TEST_JVM=1.

🤖 Generated with Claude Code

… JVM

shared() takes a lock on every call and finds or creates a JVM when none is
set. Libraries that look up the JVM on every JNI call pay for that lock, and
'is a JVM available?' checks end up launching one.

current reads the published shared instance through an acquire load and
returns nil when nothing has been set or adopted. shared() uses it as a fast
path, and the thread-exit detach uses it so a thread exiting never creates a
JVM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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