From 98726595f3ef8a00c8b5bf4e928ae7c2b2eaba15 Mon Sep 17 00:00:00 2001 From: curforever <77817413+curforever@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:44:09 +0000 Subject: [PATCH] Release discarded library class loaders retained by cached options Generated-by: OpenAI Codex --- CHANGES.md | 2 + build.xml | 3 + src/META-INF/proguard/jna.pro | 6 ++ src/com/sun/jna/Native.java | 52 ++++++++++++- test/com/sun/jna/NativeTest.java | 124 +++++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/META-INF/proguard/jna.pro diff --git a/CHANGES.md b/CHANGES.md index c7c5c2551..1adb579bc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ Features Bug Fixes --------- + +* Allow discarded library class loaders to be collected on JVMs by associating cached options with their classes. Android retains the existing cache for compatibility with runtimes without `ClassValue`. See [#1742](https://github.com/java-native-access/jna/issues/1742). * [#1740](https://github.com/java-native-access/jna/issues/1740): Fix `c.s.j.p.unix.solaris.LibKstat.KstatCtl` mapping `kc_chain` as an inline `Kstat` instead of a pointer, which made every call taking a `KstatCtl` write past the end of the structure `kstat_open()` allocated, and map the `Kstat` function pointers `ks_update` and `ks_snapshot` as `Pointer` instead of `int` - [@dbwiddis](https://github.com/dbwiddis). Breaking Changes diff --git a/build.xml b/build.xml index 00bf10900..ab0d67dca 100644 --- a/build.xml +++ b/build.xml @@ -272,6 +272,7 @@ + @@ -450,6 +451,7 @@ osname=macosx;processor=aarch64 + @@ -587,6 +589,7 @@ osname=macosx;processor=aarch64 + JNA bindings diff --git a/src/META-INF/proguard/jna.pro b/src/META-INF/proguard/jna.pro new file mode 100644 index 000000000..5ef5b64a5 --- /dev/null +++ b/src/META-INF/proguard/jna.pro @@ -0,0 +1,6 @@ +# Copyright (c) 2026 curforever. All Rights Reserved. +# Dual-licensed under LGPL 2.1 or later and Apache License 2.0. +# See LGPL2.1 and AL2.0 distributed with JNA. +# Android selects the legacy options cache and never loads the JVM-only backend. +# Older Android boot class paths do not declare this optional Java API. +-dontwarn java.lang.ClassValue diff --git a/src/com/sun/jna/Native.java b/src/com/sun/jna/Native.java index ab45f84fc..7cbfc3f16 100644 --- a/src/com/sun/jna/Native.java +++ b/src/com/sun/jna/Native.java @@ -62,6 +62,7 @@ import java.util.Map; import java.util.StringTokenizer; import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; @@ -147,7 +148,56 @@ public final class Native implements Version { // Used by tests, do not remove static String jnidispatchPath = null; - private static final Map, Map> typeOptions = Collections.synchronizedMap(new WeakHashMap, Map>()); + private static final TypeOptions typeOptions = TypeOptionsCache.create(Platform.isAndroid()); + + private interface TypeOptions { + Map get(Class type); + void put(Class type, Map options); + } + + private static class TypeOptionsCache { + static TypeOptions create(boolean android) { + // Older Android runtimes do not provide java.lang.ClassValue. + return android ? new WeakMapTypeOptions() : new ClassValueTypeOptions(); + } + } + + private static class WeakMapTypeOptions implements TypeOptions { + private final Map, Map> options = + Collections.synchronizedMap(new WeakHashMap, Map>()); + + @Override + public Map get(Class type) { + return options.get(type); + } + + @Override + public void put(Class type, Map value) { + options.put(type, value); + } + } + + private static class ClassValueTypeOptions implements TypeOptions { + // Values may refer to the class or its loader (e.g. a custom TypeMapper). + // ClassValue keeps that cycle local to the class, rather than in a static map. + private final ClassValue>> options = + new ClassValue>>() { + @Override + protected AtomicReference> computeValue(Class type) { + return new AtomicReference<>(); + } + }; + + @Override + public Map get(Class type) { + return options.get(type).get(); + } + + @Override + public void put(Class type, Map value) { + options.get(type).set(value); + } + } private static final Map, Reference> libraries = Collections.synchronizedMap(new WeakHashMap, Reference>()); private static final String _OPTION_ENCLOSING_LIBRARY = "enclosing-library"; private static final UncaughtExceptionHandler DEFAULT_HANDLER = diff --git a/test/com/sun/jna/NativeTest.java b/test/com/sun/jna/NativeTest.java index 0dd7bf8d6..90a8eb36a 100644 --- a/test/com/sun/jna/NativeTest.java +++ b/test/com/sun/jna/NativeTest.java @@ -23,7 +23,11 @@ */ package com.sun.jna; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.util.Arrays; @@ -38,6 +42,126 @@ //@SuppressWarnings("unused") public class NativeTest extends TestCase { + + public interface CollectibleLibrary extends Library { + int abs(int value); + + class Mapper extends DefaultTypeMapper { + } + + @Structure.FieldOrder({"value"}) + class Value extends Structure { + public int value; + } + } + + private static class CollectibleClassLoader extends ClassLoader { + CollectibleClassLoader() { + super(NativeTest.class.getClassLoader()); + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (!name.startsWith(CollectibleLibrary.class.getName())) { + return super.loadClass(name, resolve); + } + Class type = findLoadedClass(name); + if (type == null) { + String resource = "/" + name.replace('.', '/') + ".class"; + try (InputStream stream = NativeTest.class.getResourceAsStream(resource)) { + if (stream == null) { + throw new ClassNotFoundException(name); + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = stream.read(buffer)) != -1) { + bytes.write(buffer, 0, length); + } + byte[] content = bytes.toByteArray(); + type = defineClass(name, content, 0, content.length); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) { + resolveClass(type); + } + return type; + } + } + + @SuppressWarnings("unchecked") + private WeakReference loadCollectibleLibrary(boolean withMapper, + boolean withLoader) throws Exception { + ClassLoader loader = new CollectibleClassLoader(); + Class type = (Class) + loader.loadClass(CollectibleLibrary.class.getName()); + Map options = new HashMap<>(); + if (withMapper) { + Object mapper = loader.loadClass(CollectibleLibrary.Mapper.class.getName()) + .getDeclaredConstructor().newInstance(); + assertSame(loader, mapper.getClass().getClassLoader()); + options.put(Library.OPTION_TYPE_MAPPER, mapper); + } + if (withLoader) { + options.put(Library.OPTION_CLASSLOADER, loader); + } + Library library = Native.load(Platform.C_LIBRARY_NAME, type, options); + assertEquals(7, type.getMethod("abs", int.class).invoke(library, -7)); + Class nested = loader.loadClass(CollectibleLibrary.Value.class.getName()); + assertSame(loader, type.getClassLoader()); + assertSame(loader, nested.getClassLoader()); + assertSame(Native.getLibraryOptions(type), Native.getLibraryOptions(nested)); + assertSame(type, Native.findEnclosingLibraryClass(nested)); + assertSame(options.get(Library.OPTION_TYPE_MAPPER), Native.getTypeMapper(nested)); + return new WeakReference<>(loader); + } + + private void assertClassLoaderCollected(WeakReference loader) throws Exception { + for (int i = 0; i < 100 && loader.get() != null; i++) { + System.gc(); + Thread.sleep(50); + } + assertNull("Discarded library class loader is retained by cached options", loader.get()); + } + + public void testDiscardedLibraryClassLoader() throws Exception { + assertClassLoaderCollected(loadCollectibleLibrary(false, false)); + } + + public void testDiscardedLibraryClassLoaderWithTypeMapper() throws Exception { + assertClassLoaderCollected(loadCollectibleLibrary(true, false)); + } + + public void testDiscardedLibraryClassLoaderWithLoaderOption() throws Exception { + assertClassLoaderCollected(loadCollectibleLibrary(false, true)); + } + + @SuppressWarnings("unchecked") + public void testLibraryOptionsRetainedWhileClassIsLive() throws Exception { + ClassLoader loader = new CollectibleClassLoader(); + Class type = (Class) + loader.loadClass(CollectibleLibrary.class.getName()); + Object mapper = loader.loadClass(CollectibleLibrary.Mapper.class.getName()) + .getDeclaredConstructor().newInstance(); + Map options = new HashMap<>(); + options.put(Library.OPTION_TYPE_MAPPER, mapper); + Library library = Native.load(Platform.C_LIBRARY_NAME, type, options); + Class nested = loader.loadClass(CollectibleLibrary.Value.class.getName()); + Map cached = Native.getLibraryOptions(type); + assertSame(cached, Native.getLibraryOptions(nested)); + for (int i = 0; i < 3; i++) { + System.gc(); + assertSame(cached, Native.getLibraryOptions(type)); + assertSame(cached, Native.getLibraryOptions(nested)); + assertSame(mapper, Native.getTypeMapper(nested)); + assertSame(type, Native.findEnclosingLibraryClass(nested)); + assertEquals(7, type.getMethod("abs", int.class).invoke(library, -7)); + } + } + private static final String UNICODE = "[\u0444]"; public void testLoadLibraryMethods() throws Exception {