Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions pkgs/jni/lib/src/jclass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ 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.
JClass.forNameCached(String name)
: super.fromReference(JGlobalReference(Jni.getCachedClass(name)));
Comment thread
sagar-h007 marked this conversation as resolved.
Outdated

JConstructorId constructorId(String signature) {
return JConstructorId._(this, signature);
}
Expand Down
104 changes: 103 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,35 @@ 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.
static JClassPtr getCachedClass(String name) {
return _classCache.get(name);
}

/// 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 +458,79 @@ 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 LRU cache for JNI class references.
///
/// **GlobalRef Ownership Model:**
///
/// This cache is the **sole owner** of the JNI global references it stores.
/// All stored [JClassPtr] values are global references that the cache is
/// responsible for deleting when they are evicted.
///
/// **Critical Lifecycle Rules:**
///
/// 1. **Cache owns stored refs**: Each entry in [_map] is a JGlobalReference
/// that belongs to the cache. The cache must call [DeleteGlobalRef] on
/// these entries when they are evicted.
///
/// 2. **Callers receive duplicates**: [get] always returns a newly created
/// global reference via [NewGlobalRef]. The caller owns this duplicate
/// and must delete it when done. This prevents GC of caller-side
/// [JObject]/[JClass] wrappers from invalidating cache-held references.
///
/// 3. **Eviction deletes owned refs**: When capacity is reduced or entries
/// are evicted during LRU replacement, the cache calls [DeleteGlobalRef]
/// on the evicted reference that it owns.
///
/// **Isolate-local**: Each isolate has its own cache instance. References
/// are not shared across isolates.
///
/// **Thread-safety**: Access is inherently single-threaded per isolate.
class _JClassCache {
int _capacity = 256;
final _map = <String, JClassPtr>{};

JClassPtr get(String name) {
final existing = _map[name];
if (existing != null) {
// Cache hit: Move entry to end for LRU tracking.
_map.remove(name);
_map[name] = existing;
// CRITICAL: Return a duplicate GlobalRef. The cache still owns
// `existing`. The caller owns the returned duplicate and must delete it.
return Jni.env.NewGlobalRef(existing);
}

// Cache miss: Jni.findClass returns a new GlobalRef.
final cls = Jni.findClass(name);

// Evict LRU entry if at capacity.
if (_map.length >= _capacity) {
final keyToRemove = _map.keys.first;
final valueToRemove = _map.remove(keyToRemove)!;
// CRITICAL: Delete the GlobalRef the cache owned.
Jni.env.DeleteGlobalRef(valueToRemove);
}

// Store the GlobalRef. The cache now owns `cls`.
_map[name] = cls;
// CRITICAL: Return a duplicate GlobalRef. The cache still owns `cls`.
// The caller owns the returned duplicate and must delete it.
return Jni.env.NewGlobalRef(cls);
}

set capacity(int size) {
_capacity = size;
// Evict LRU entries immediately if new capacity is smaller.
while (_map.length > _capacity) {
final keyToRemove = _map.keys.first;
final valueToRemove = _map.remove(keyToRemove)!;
// CRITICAL: Delete the GlobalRef the cache owned.
Jni.env.DeleteGlobalRef(valueToRemove);
}
}
}
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
205 changes: 205 additions & 0 deletions pkgs/jni/test/class_cache_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

@Tags(['load_test'])
library;

import 'dart:ffi';
import 'dart:io';

import 'package:jni/jni.dart';
import 'package:test/test.dart';

import 'test_util/test_util.dart';

void main() {
if (!Platform.isAndroid) {
spawnJvm();
}
run(testRunner: test);
}

void run({required TestRunnerCallback testRunner}) {
testRunner('Basic cache functionality', () {
final stringClass = Jni.getCachedClass('java/lang/String');
expect(stringClass, isNot(nullptr));

final stringClass2 = Jni.getCachedClass('java/lang/String');
expect(stringClass2, isNot(nullptr));
expect(stringClass.address, isNot(equals(stringClass2.address)));

// Check JNI equality (should be same object)
expect(Jni.env.IsSameObject(stringClass, stringClass2), isTrue);
Jni.env.DeleteGlobalRef(stringClass);
Jni.env.DeleteGlobalRef(stringClass2);
});

testRunner('Cache capacity configuration', () {
// Set small capacity
Jni.setClassCacheSize(2);

final c1 = Jni.getCachedClass('java/lang/String');
final c2 = Jni.getCachedClass('java/util/ArrayList');
// c3 would evict c1 if strictly LRU logic was only on insert,
// but lookup of c1 moves it to head? No, we haven't looked it up again yet.
// Insert c1. Head=c1. Size=1.
// Insert c2. Head=c2->c1. Size=2.
// Insert c3. Evict Tail(c1). Head=c3->c2. Size=2.
final c3 = Jni.getCachedClass('java/util/HashMap');

// Verify c1 still works (it's a new global ref)
expect(c1, isNot(nullptr));

// Verify we can reload c1
final c1Reload = Jni.getCachedClass('java/lang/String');
expect(Jni.env.IsSameObject(c1, c1Reload), isTrue);

Jni.env.DeleteGlobalRef(c1);
Jni.env.DeleteGlobalRef(c2);
Jni.env.DeleteGlobalRef(c3);
Jni.env.DeleteGlobalRef(c1Reload);
});

testRunner('Stress test cache', () {
Jni.setClassCacheSize(100);
// Load many classes
final classes = [
'java/lang/Byte',
'java/lang/Short',
'java/lang/Integer',
'java/lang/Long',
'java/lang/Float',
'java/lang/Double',
'java/lang/Boolean',
'java/lang/Character',
'java/lang/Object',
'java/lang/Class',
'java/lang/Number',
'java/util/List',
'java/util/Map',
'java/util/Set',
'java/util/Collection',
'java/util/Iterator',
'java/util/Random',
'java/io/File',
'java/io/InputStream',
'java/io/OutputStream',
];

for (var i = 0; i < 1000; i++) {
final name = classes[i % classes.length];
final cls = Jni.getCachedClass(name);
expect(cls, isNot(nullptr));
Jni.env.DeleteGlobalRef(cls);
}
});

testRunner('Cache hit returns new global ref', () {
// Tests the ownership model: cache owns refs, callers get duplicates.
final c1 = Jni.getCachedClass('java/lang/Object');
final c2 = Jni.getCachedClass('java/lang/Object');
// Pointers are different (different GlobalRefs)
expect(c1, isNot(equals(c2)));
// Objects are same
expect(Jni.env.IsSameObject(c1, c2), isTrue);

// Cleanup
Jni.env.DeleteGlobalRef(c1);
Jni.env.DeleteGlobalRef(c2);

// Cache should still hold a ref internally, so getting it again should work
final c3 = Jni.getCachedClass('java/lang/Object');
expect(c3, isNot(nullptr));
Jni.env.DeleteGlobalRef(c3);
});

testRunner('Cache-held refs survive caller deletion', () {
// Tests that deleting caller-owned refs doesn't invalidate cache.
// This validates the ownership model: cache owns its refs separately.

final ref1 = Jni.getCachedClass('java/lang/System');
expect(ref1, isNot(nullptr));

// Delete the ref we got (caller-owned duplicate)
Jni.env.DeleteGlobalRef(ref1);

// Cache should still have its own ref, so we can get it again
final ref2 = Jni.getCachedClass('java/lang/System');
expect(ref2, isNot(nullptr));
expect(Jni.env.IsSameObject(ref1, ref2), isTrue);

Jni.env.DeleteGlobalRef(ref2);
});

testRunner('Eviction deletes cache-owned refs', () {
// Tests that cache properly deletes owned GlobalRefs on eviction.
Jni.setClassCacheSize(2);

final ref1 = Jni.getCachedClass('java/lang/String');
final ref2 = Jni.getCachedClass('java/lang/Integer');

// Insert third class, evicting String (LRU)
final ref3 = Jni.getCachedClass('java/lang/Double');

// All caller refs should still be valid
expect(Jni.env.IsSameObject(ref1, ref1), isTrue);
expect(Jni.env.IsSameObject(ref2, ref2), isTrue);
expect(Jni.env.IsSameObject(ref3, ref3), isTrue);

// String should have been evicted from cache, but we can reload it
final ref1Reload = Jni.getCachedClass('java/lang/String');
expect(Jni.env.IsSameObject(ref1, ref1Reload), isTrue);

// Clean up all caller-owned refs
Jni.env.DeleteGlobalRef(ref1);
Jni.env.DeleteGlobalRef(ref2);
Jni.env.DeleteGlobalRef(ref3);
Jni.env.DeleteGlobalRef(ref1Reload);
});

testRunner('Capacity reduction evicts and deletes refs', () {
// Tests that reducing capacity immediately evicts and deletes owned refs.
Jni.setClassCacheSize(10);

// Fill cache with 10 classes
final refs = <dynamic>[];
for (var i = 0; i < 10; i++) {
final className = 'java/lang/String'; // Use same class for simplicity
refs.add(Jni.getCachedClass(className));
}

// Reduce capacity - should evict 7 entries
Jni.setClassCacheSize(3);

// All caller-owned refs should still be valid
for (final ref in refs) {
expect(Jni.env.IsSameObject(ref, ref), isTrue);
}

// Clean up caller-owned refs
for (final ref in refs) {
Jni.env.DeleteGlobalRef(ref);
}
});

testRunner('JClass.forNameCached uses cache', () {
// Tests that JClass.forNameCached integrates correctly with the cache.
// This validates that the ownership model works through the wrapper API.

final jclass1 = JClass.forNameCached('java/lang/Object');
final jclass2 = JClass.forNameCached('java/lang/Object');

// Different Dart objects
expect(identical(jclass1, jclass2), isFalse);

// Same underlying JNI class
final ref1 = jclass1.reference;
final ref2 = jclass2.reference;
expect(Jni.env.IsSameObject(ref1.pointer, ref2.pointer), isTrue);

// JGlobalReference finalizers will clean up when GC'd
jclass1.release();
jclass2.release();
});
}
2 changes: 1 addition & 1 deletion pkgs/jnigen/lib/src/bindings/dart_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ class _ClassGenerator extends Visitor<ClassDecl, void> {
final modifier = node.isTopLevel ? '' : ' static ';
final classRef = node.isTopLevel ? '_${node.finalName}Class' : '_class';
s.write('''
${modifier}final $classRef = $_jni.JClass.forName(r'$internalName');
${modifier}$_jni.JClass get $classRef => $_jni.JClass.forNameCached(r'$internalName');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've changed the code gen, so you'll need to regen all the bindings.


''');
return classRef;
Expand Down