Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions pkgs/jni/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
- Changed the behavior of `JObject.releasedBy`. It now does not throw a
`DoubleReleaseError` if the object was manually released before the end of
arena.
- Added an isolate-local bounded LRU cache (default 256 entries) for `JClass`global
references to reduce repeated FindClass calls and GlobalRef allocations.Introduced
`Jni.getCachedClass`, `JClass.forNameCached`, and `Jni.setClassCacheSize` to access
and configure the cache, with immediate eviction when the capacity is reduced.
- Added `JThrowable` class which inherits from `JObject` and implements
`Exception`.
- **Breaking Change**: `JniException` has been deleted. Java exceptions are now
Expand Down
11 changes: 11 additions & 0 deletions pkgs/jni/lib/src/jclass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ class JClass extends JObject {
JClass.forName(String name)
: super.fromReference(JGlobalReference(Jni.findClass(name)));

/// Returns a cached [JClass] for the class or interface with the given name.
///
/// Uses an internal LRU cache to minimize JNI calls and GlobalRef usage.
/// Returns the cached instance owned by the cache.
///
/// Do NOT hold a long term reference to the returned [JClass]. The cache
/// manages their lifecycle and may evict it at any time.
factory JClass.forNameCached(String name) {
return Jni.getCachedClass(name);
}

JConstructorId constructorId(String signature) {
return JConstructorId._(this, signature);
}
Expand Down
94 changes: 94 additions & 0 deletions 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 @@ -198,6 +200,34 @@ abstract final class Jni {
.checkedClassRef;
}

/// Returns a cached [JClass] for the given class name.
///
/// This is preferred for repeated lookups of the same class, as it avoids
/// repeated JNI calls and minimizes GlobalRef usage.
///
/// **Returns the cached JClass instance** owned by the cache. Callers must
/// NOT release this instance. The cache manages its lifecycle.
///
/// The cache is isolate-local and has bounded capacity.
@internal
static JClass 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.
@internal
static void throwException(JThrowablePtr exception) {
Expand Down Expand Up @@ -434,3 +464,67 @@ extension StringMethodsForJni on String {
return toNativeUtf8(allocator: allocator).cast<Char>();
}
}

/// Internal cache entry for JNI class references.
class _JClassCacheEntry {
final String name;
final JGlobalReference globalRef;
final JClass jclass;

_JClassCacheEntry(this.name, this.globalRef, this.jclass);
}

/// Internal LRU cache for JNI class references.
///
/// The cache owns [JGlobalReference] instances and provides [JClass] wrappers
/// that share these references. The cache is responsible for releasing
/// GlobalRefs on eviction.
///
/// Cache capacity directly bounds the number of underlying JGlobalReferences.
///
/// Isolate-local: Each isolate has its own cache instance.
class _JClassCache {
int _capacity = 256;
final _map = <String, _JClassCacheEntry>{};

JClass 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.jclass;
}

// Cache miss: Create GlobalRef and JClass wrapper.
final globalRef = JGlobalReference(Jni.findClass(name));
final jclass = JClass.fromReference(globalRef);
entry = _JClassCacheEntry(name, globalRef, jclass);

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

_map[name] = entry;
return entry.jclass;
}

void _evict(String name) {
final entry = _map.remove(name)!;
// Release the GlobalRef; JClass wrapper becomes invalid but that's OK
// since cached instances; callers must not release
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);
}
}
}
167 changes: 167 additions & 0 deletions pkgs/jni/test/class_cache_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// 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: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, isNotNull);

final stringClass2 = Jni.getCachedClass('java/lang/String');
expect(stringClass2, isNotNull);
// Same cached instance
expect(identical(stringClass, stringClass2), isTrue);

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

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 evicts c1
final c3 = Jni.getCachedClass('java/util/HashMap');

// c2 and c3 still cached
expect(c2, isNotNull);
expect(c3, isNotNull);

// Reload c1 (was evicted, creates new cached instance)
final c1Reload = Jni.getCachedClass('java/lang/String');
// c1Reload is a different Dart instance since eviction recreated it
expect(identical(c1, c1Reload), isFalse);
});

testRunner('Stress test cache', () {
Jni.setClassCacheSize(100);
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, isNotNull);
}
});

testRunner('Cache hit returns same instance', () {
final c1 = Jni.getCachedClass('java/lang/Object');
final c2 = Jni.getCachedClass('java/lang/Object');
// Both are the same cached instance
expect(identical(c1, c2), isTrue);
// Same underlying JNI object
expect(Jni.env.IsSameObject(c1.reference.pointer, c2.reference.pointer),
isTrue);
});

testRunner('Eviction and reload', () {
Jni.setClassCacheSize(2);

const name1 = 'java/lang/String';
const name2 = 'java/lang/Integer';
const name3 = 'java/lang/Double';

final ref1 = Jni.getCachedClass(name1);
final ref2 = Jni.getCachedClass(name2);

// Insert third class, evicting String (LRU)
final ref3 = Jni.getCachedClass(name3);

// ref2 and ref3 still cached and valid
expect(ref2, isNotNull);
expect(ref3, isNotNull);

// String evicted; reload creates new cached instance
final ref1Reload = Jni.getCachedClass(name1);
// Not identical since eviction recreated it
expect(identical(ref1, ref1Reload), isFalse);
});

testRunner('Capacity reduction evicts entries', () {
Jni.setClassCacheSize(10);

final refs = <JClass>[];
final uniqueClasses = [
'java/lang/String',
'java/lang/Integer',
'java/lang/Double',
'java/lang/Boolean',
'java/lang/Long',
'java/lang/Float',
'java/lang/Byte',
'java/lang/Short',
'java/lang/Character',
'java/util/ArrayList',
];

for (final className in uniqueClasses) {
refs.add(Jni.getCachedClass(className));
}

// Reduce capacity - cache evicts 7 entries
Jni.setClassCacheSize(3);

// All cached instances should still be valid
for (final ref in refs) {
expect(ref, isNotNull);
}
});

testRunner('JClass.forNameCached uses cache and returns same instance', () {
final jclass1 = JClass.forNameCached('java/lang/Object');
final jclass2 = JClass.forNameCached('java/lang/Object');

// Same Dart instance from cache
expect(identical(jclass1, jclass2), isTrue);

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

// Do NOT release cached instances - cache owns them
});
}
3 changes: 3 additions & 0 deletions pkgs/jnigen/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
`bool`.
- Kotlin suspend functions with no result (a return type of `Unit`) now return
`Future<void>` in Dart instead of `Future<JObject>`.
- Generated bindings now use `JClass.forNameCached` instead of `JClass.forName`
for class lookups, reducing redundant `FindClass` JNI calls via the new
isolate-local LRU cache in `package:jni`.
- Improve error message for unsupported Java class file versions in summary generation.

## 0.15.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ import 'PDDocumentInformation.dart' as pddocumentinformation$_;
/// The \#close() method must be called once the document is no longer needed.
///@author Ben Litchfield
extension type PDDocument._(jni$_.JObject _$this) implements jni$_.JObject {
static final _class =
jni$_.JClass.forName(r'org/apache/pdfbox/pdmodel/PDDocument');
static jni$_.JClass get _class =>
jni$_.JClass.forNameCached(r'org/apache/pdfbox/pdmodel/PDDocument');

/// The type which includes information such as the signature of this class.
static const jni$_.JType<PDDocument> type = $PDDocument$Type$();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ import 'package:jni/jni.dart' as jni$_;
///@author Gerardo Ortiz
extension type PDDocumentInformation._(jni$_.JObject _$this)
implements jni$_.JObject {
static final _class =
jni$_.JClass.forName(r'org/apache/pdfbox/pdmodel/PDDocumentInformation');
static jni$_.JClass get _class => jni$_.JClass.forNameCached(
r'org/apache/pdfbox/pdmodel/PDDocumentInformation');

/// The type which includes information such as the signature of this class.
static const jni$_.JType<PDDocumentInformation> type =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ import '../pdmodel/PDDocument.dart' as pddocument$_;
///@author Ben Litchfield
extension type PDFTextStripper._(jni$_.JObject _$this)
implements jni$_.JObject {
static final _class =
jni$_.JClass.forName(r'org/apache/pdfbox/text/PDFTextStripper');
static jni$_.JClass get _class =>
jni$_.JClass.forNameCached(r'org/apache/pdfbox/text/PDFTextStripper');

/// The type which includes information such as the signature of this class.
static const jni$_.JType<PDFTextStripper> type = $PDFTextStripper$Type$();
Expand Down
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 @@ -331,7 +331,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');

''');
return classRef;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ import 'JsonParser.dart' as jsonparser$_;
/// changed for JsonFactory.
extension type JsonFactory$Feature._(jni$_.JObject _$this)
implements jni$_.JObject {
static final _class =
jni$_.JClass.forName(r'com/fasterxml/jackson/core/JsonFactory$Feature');
static jni$_.JClass get _class => jni$_.JClass.forNameCached(
r'com/fasterxml/jackson/core/JsonFactory$Feature');

/// The type which includes information such as the signature of this class.
static const jni$_.JType<JsonFactory$Feature> type =
Expand Down Expand Up @@ -335,8 +335,8 @@ final class $JsonFactory$Feature$Type$
/// instances.
///@author Tatu Saloranta
extension type JsonFactory._(jni$_.JObject _$this) implements jni$_.JObject {
static final _class =
jni$_.JClass.forName(r'com/fasterxml/jackson/core/JsonFactory');
static jni$_.JClass get _class =>
jni$_.JClass.forNameCached(r'com/fasterxml/jackson/core/JsonFactory');

/// The type which includes information such as the signature of this class.
static const jni$_.JType<JsonFactory> type = $JsonFactory$Type$();
Expand Down
Loading
Loading