Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions pkgs/jni/lib/src/jclass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ class JClass extends JObject {
JClass.forName(String name)
: super.fromReference(JGlobalReference(Jni.findClass(name)));

/// Constructs a [JClass] associated with the class or interface with
/// the given string name, using the global LRU cache.
///
/// This constructor uses borrowed references to minimize the number of
/// JGlobalReferences created. Multiple instances of the same class will
/// share a single underlying GlobalRef via reference counting.
JClass.forNameCached(String name)
: super.fromReference(_createBorrowedReference(name));

static JReference _createBorrowedReference(String name) {
final entry = Jni.getCachedClassEntry(name);
return BorrowedReference.create(
parent: entry.globalRef,
entryPtr: entry.asPointer,
);
}

JConstructorId constructorId(String signature) {
return JConstructorId._(this, signature);
}
Expand Down
166 changes: 165 additions & 1 deletion pkgs/jni/lib/src/jni.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ abstract final class Jni {
static final DynamicLibrary _dylib = _loadDartJniLibrary(dir: _dylibDir);
static final JniBindings _bindings = JniBindings(_dylib);

static final _classCache = _JClassCache();

/// Store dylibDir if any was used.
static String _dylibDir = join('build', 'jni_libs');

Expand Down Expand Up @@ -194,6 +196,72 @@ abstract final class Jni {
.checkedClassRef;
}

/// Finds the class from its [name], using an internal LRU cache.
///
/// This is preferred for repeated lookups of the same class, as it avoids
/// repeated JNI calls.
///
/// **Returns a new JGlobalReference** that the caller owns and must delete
/// when done (via `DeleteGlobalRef` or by wrapping in `JGlobalReference`).
/// Each call returns a distinct global reference, even for cache hits.
///
/// The cache maintains its own global references internally. Deleting the
/// returned reference does not invalidate the cache.
///
/// **Note**: This API is deprecated for direct use. Prefer
/// [JClass.forNameCached] which uses borrowed references to minimize
/// GlobalRef count.
static JClassPtr getCachedClass(String name) {
final entry = _classCache.get(name);
return Jni.env.NewGlobalRef(entry.globalRef.pointer);
}

/// Internal method to get cache entry for `BorrowedReference` creation.
@internal
static JClassCacheEntry getCachedClassEntry(String name) {
_ensureBorrowedReferenceCallbacks();
return _classCache.get(name);
}

static bool _callbacksSetup = false;

static final _entryRegistry = <int, JClassCacheEntry>{};

static void _ensureBorrowedReferenceCallbacks() {
if (_callbacksSetup) return;
_callbacksSetup = true;

BorrowedReference.onBorrowCallback = (Pointer<Void> entryId) {
final id = entryId.address;
final entry = _entryRegistry[id]!;
entry.borrowCount++;
};

BorrowedReference.onReleaseCallback = (Pointer<Void> entryId) {
final id = entryId.address;
final entry = _entryRegistry[id]!;
entry.borrowCount--;
if (entry.isEvicted && entry.borrowCount == 0) {
entry.globalRef.release();
_entryRegistry.remove(id);
}
};
}

/// Sets the capacity of the internal LRU class cache.
///
/// If the new [size] is smaller than the current number of cached classes,
/// the least recently used classes will be **immediately evicted** and
/// their global references released (via `DeleteGlobalRef`).
///
/// This does **not** affect references returned by prior [getCachedClass]
/// calls. Those are owned by callers and remain valid until deleted.
///
/// The cache is isolate-local, so this only affects the current isolate.
static void setClassCacheSize(int size) {
_classCache.capacity = size;
}

/// Throws an exception.
// TODO(#561): Throw an actual `JThrowable`.
@internal
Expand Down Expand Up @@ -427,8 +495,104 @@ extension AdditionalEnvMethods on GlobalJniEnv {

@internal
extension StringMethodsForJni on String {
/// Returns a Utf-8 encoded `Pointer<Char>` with contents same as this string.
Comment thread
sagar-h007 marked this conversation as resolved.
Pointer<Char> toNativeChars(Allocator allocator) {
return toNativeUtf8(allocator: allocator).cast<Char>();
}
}

/// Internal cache entry for JNI class references.
///
/// Tracks a single JNI global reference along with metadata for reference
/// counting and eviction.
@internal
class JClassCacheEntry {
final String name;
final JGlobalReference globalRef;
int borrowCount = 0;
bool isEvicted = false;

JClassCacheEntry(this.name, this.globalRef) {
// Register this entry for pointer-based callbacks
Jni._entryRegistry[identityHashCode(this)] = this;
}

@internal
Pointer<Void> get asPointer => Pointer.fromAddress(identityHashCode(this));
}

/// Internal LRU cache for JNI class references.
///
/// **GlobalRef Ownership Model:**
///
/// This cache is the **sole owner** of the JNI global references it stores.
/// Each cache entry contains exactly one [JGlobalReference] and tracks how
/// many wrappers are currently borrowing it via reference counting.
///
/// **Critical Lifecycle Rules:**
///
/// 1. **Cache owns GlobalRefs**: Each [JClassCacheEntry] contains a
/// [JGlobalReference] owned by the cache. The cache is responsible for
/// deleting it when the entry is evicted AND no wrappers are using it.
///
/// 2. **Callers borrow references**: [get] returns a cache entry that
/// wrappers use to create `BorrowedReference` instances. Each borrow
/// increments `borrowCount`, and each release decrements it.
///
/// 3. **Reference counting enables deterministic cleanup**: When an entry is
/// evicted, it's marked as `isEvicted = true`. The underlying GlobalRef is
/// deleted immediately if `borrowCount == 0`, or when the last borrower
/// releases (deterministic, not GC-dependent).
///
/// 4. **Minimal GlobalRef usage**: Only N GlobalRefs exist (where N = number of
/// distinct classes in use), regardless of wrapper count. This is the
/// primary benefit over duplication-based caching.
///
/// **Isolate-local**: Each isolate has its own cache instance. References
/// are not shared across isolates.
class _JClassCache {
int _capacity = 256;
final _map = <String, JClassCacheEntry>{};

JClassCacheEntry get(String name) {
var entry = _map[name];

if (entry != null) {
// Cache hit: Move entry to end for LRU tracking.
_map.remove(name);
_map[name] = entry;
return entry;
}

// Cache miss: Load class and create new entry.
final clsPtr = Jni.findClass(name);
entry = JClassCacheEntry(name, JGlobalReference(clsPtr));

// Evict LRU entry if at capacity.
if (_map.length >= _capacity) {
final keyToEvict = _map.keys.first;
_evict(keyToEvict);
}

_map[name] = entry;
return entry;
}

void _evict(String name) {
final entry = _map.remove(name)!;
entry.isEvicted = true;

// Deterministic cleanup: If no active borrowers, delete immediately.
if (entry.borrowCount == 0) {
entry.globalRef.release();
}
}

set capacity(int size) {
_capacity = size;
// Evict LRU entries immediately if new capacity is smaller.
while (_map.length > _capacity) {
final keyToEvict = _map.keys.first;
_evict(keyToEvict);
}
}
}
52 changes: 52 additions & 0 deletions pkgs/jni/lib/src/jreference.dart
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,55 @@ final class _JNullReference extends JReference {
@override
bool get isNull => true;
}

/// A borrowed reference to a cached pointer.
///
/// Allows multiple wrappers to share one [JGlobalReference].
///
/// Lifecycle:
/// Creation: increments cache borrow count
/// Release: decrements count; deletes GlobalRef if evicted and count hits 0
/// Does not delete the JNI reference in its finalizer
@pragma('vm:deeply-immutable')
final class BorrowedReference extends JReference {
final JReference _parent;
final Pointer<Bool> _isLocallyReleased;
final Pointer<Void> _entryPtr; // Points to _JClassCacheEntry

@internal
BorrowedReference.create({
required JReference parent,
required Pointer<Void> entryPtr,
}) : _parent = parent,
_entryPtr = entryPtr,
_isLocallyReleased = calloc<Bool>()..value = false,
super._(parent._finalizable) {
onBorrowCallback(entryPtr);
}

@override
bool get isReleased => _isLocallyReleased.value || _parent.isReleased;

@override
bool get isNull => _parent.isNull;

@override
void _setAsReleased() {
if (_isLocallyReleased.value) {
return;
}
_isLocallyReleased.value = true;
onReleaseCallback(_entryPtr);
}

@override
void _deleteReference() {
calloc.free(_isLocallyReleased);
}

// These are set by _setupBorrowedReferenceCallbacks in jni.dart
@internal
static late void Function(Pointer<Void>) onBorrowCallback;
@internal
static late void Function(Pointer<Void>) onReleaseCallback;
}
29 changes: 29 additions & 0 deletions pkgs/jni/lib/src/third_party/jni_bindings_generated.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ class JniBindings {
late final _JniFindClass = _JniFindClassPtr.asFunction<
JniClassLookupResult Function(ffi.Pointer<ffi.Char>)>();

JniClassLookupResult GetCachedClass(
Comment thread
sagar-h007 marked this conversation as resolved.
Outdated
ffi.Pointer<ffi.Char> name,
) {
return _GetCachedClass(
name,
);
}

late final _GetCachedClassPtr = _lookup<
ffi.NativeFunction<
JniClassLookupResult Function(
ffi.Pointer<ffi.Char>)>>('GetCachedClass');
late final _GetCachedClass = _GetCachedClassPtr.asFunction<
JniClassLookupResult Function(ffi.Pointer<ffi.Char>)>();

void SetClassCacheSize(
int size,
) {
return _SetClassCacheSize(
size,
);
}

late final _SetClassCacheSizePtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Int32)>>(
'SetClassCacheSize');
late final _SetClassCacheSize =
_SetClassCacheSizePtr.asFunction<void Function(int)>();

JniExceptionDetails GetExceptionDetails(
JThrowablePtr exception,
) {
Expand Down
Loading
Loading