From b91f99cc7730325c93671e933a1f7a14c233658b Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 10:44:21 +0200 Subject: [PATCH 01/13] add memory management to ios and android allocators --- gradle/libs.versions.toml | 7 +- .../util/AndroidNativeBufferAllocator.java | 73 +++++++++++++++++-- .../util/LibJGLIOSNativeBufferAllocator.java | 63 +++++++++++++++- 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 968b70afd4..8844720917 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ checkstyle = "13.3.0" jacoco = "0.8.12" lwjgl3 = "3.4.1" angle = "2026-05-09" -libjglios = "0.4" -saferalloc = "0.0.8" +libjglios = "0.6" +saferalloc = "0.0.9" nifty = "1.4.3" spotbugs = "4.9.8" jmeAndroidNatives = "3.10.0-xt16kb" @@ -73,9 +73,10 @@ saferalloc-natives-windows-aarch64 = { module = "org.ngengine:saferalloc-natives saferalloc-natives-macos-x8664 = { module = "org.ngengine:saferalloc-natives-macos-x86_64", version.ref = "saferalloc" } saferalloc-natives-macos-aarch64 = { module = "org.ngengine:saferalloc-natives-macos-aarch64", version.ref = "saferalloc" } saferalloc-natives-android = { module = "org.ngengine:saferalloc-natives-android", version.ref = "saferalloc" } +saferalloc-natives-ios = { module = "org.ngengine:saferalloc-natives-ios", version.ref = "saferalloc" } [bundles] -saferalloc = ["saferalloc", "saferalloc-natives-linux-x8664", "saferalloc-natives-linux-aarch64", "saferalloc-natives-windows-x8664", "saferalloc-natives-windows-aarch64", "saferalloc-natives-macos-x8664", "saferalloc-natives-macos-aarch64", "saferalloc-natives-android"] +saferalloc = ["saferalloc", "saferalloc-natives-linux-x8664", "saferalloc-natives-linux-aarch64", "saferalloc-natives-windows-x8664", "saferalloc-natives-windows-aarch64", "saferalloc-natives-macos-x8664", "saferalloc-natives-macos-aarch64", "saferalloc-natives-android", "saferalloc-natives-ios"] [plugins] jacoco = { id = "jacoco", version.ref = "jacoco" } diff --git a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java index f91334db7e..6e5f8bba76 100644 --- a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java +++ b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java @@ -31,8 +31,15 @@ */ package com.jme3.util; +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; import java.nio.Buffer; import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; /** * Allocates and destroys direct byte buffers using native code. @@ -40,28 +47,84 @@ * @author pavl_g. */ public final class AndroidNativeBufferAllocator implements BufferAllocator { + private static final Logger LOGGER = Logger.getLogger(AndroidNativeBufferAllocator.class.getName()); + private static final ReferenceQueue REFERENCE_QUEUE = new ReferenceQueue<>(); + private static final Map DEALLOCATORS = new ConcurrentHashMap<>(); + private static final Thread CLEAN_THREAD = new Thread(AndroidNativeBufferAllocator::freeCollectedBuffers); static { System.loadLibrary("bufferallocatorjme"); + CLEAN_THREAD.setDaemon(true); + CLEAN_THREAD.setName("Android Native Buffer Deallocator"); + CLEAN_THREAD.start(); } @Override public void destroyDirectBuffer(Buffer toBeDestroyed) { - releaseDirectByteBuffer(toBeDestroyed); + long address = directBufferAddress(toBeDestroyed); + if (address == 0L) { + LOGGER.log(Level.WARNING, "Not found address of the {0}", toBeDestroyed); + return; + } + Deallocator deallocator = DEALLOCATORS.get(address); + if (deallocator == null) { + LOGGER.log(Level.WARNING, "Not found a deallocator for address {0}", address); + return; + } + deallocator.freeNow(); } @Override public ByteBuffer allocate(int size) { - return createDirectByteBuffer(size); + ByteBuffer buffer = createDirectByteBuffer(size); + if (buffer != null) { + long address = directBufferAddress(buffer); + if (address != 0L) { + DEALLOCATORS.put(address, new Deallocator(buffer, address)); + } + } + return buffer; + } + + private static void freeCollectedBuffers() { + try { + for (;;) { + Deallocator deallocator = (Deallocator) REFERENCE_QUEUE.remove(); + deallocator.freeNow(); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private static final class Deallocator extends PhantomReference { + private final long address; + private final AtomicBoolean freed = new AtomicBoolean(false); + + private Deallocator(ByteBuffer referent, long address) { + super(referent, REFERENCE_QUEUE); + this.address = address; + } + + private void freeNow() { + if (!freed.compareAndSet(false, true)) { + return; + } + DEALLOCATORS.remove(address, this); + clear(); + releaseDirectByteBufferAddress(address); + } } /** - * Releases the memory of a direct buffer using a buffer object reference. + * Releases the memory of a direct buffer using its native address. * - * @param buffer the buffer reference to release its memory. + * @param address the native address to release * @see AndroidNativeBufferAllocator#destroyDirectBuffer(Buffer) */ - private native void releaseDirectByteBuffer(Buffer buffer); + private static native void releaseDirectByteBufferAddress(long address); + + private static native long directBufferAddress(Buffer buffer); /** * Creates a new direct byte buffer explicitly with a specific size. diff --git a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java index a96d041afb..314c37fe4c 100644 --- a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java +++ b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java @@ -4,16 +4,43 @@ */ package com.jme3.util; +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; import java.nio.Buffer; import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; import org.ngengine.libjglios.core.LibJGLIOSBufferAllocator; public final class LibJGLIOSNativeBufferAllocator implements BufferAllocator { + private static final Logger LOGGER = Logger.getLogger(LibJGLIOSNativeBufferAllocator.class.getName()); + private static final ReferenceQueue REFERENCE_QUEUE = new ReferenceQueue<>(); + private static final Map DEALLOCATORS = new ConcurrentHashMap<>(); + private static final Thread CLEAN_THREAD = new Thread(LibJGLIOSNativeBufferAllocator::freeCollectedBuffers); + + static { + CLEAN_THREAD.setDaemon(true); + CLEAN_THREAD.setName("libJGLIOS Native Buffer Deallocator"); + CLEAN_THREAD.start(); + } @Override public void destroyDirectBuffer(Buffer toBeDestroyed) { - LibJGLIOSBufferAllocator.free(toBeDestroyed); + long address = LibJGLIOSBufferAllocator.baseAddress(toBeDestroyed); + if (address == 0L) { + LOGGER.log(Level.WARNING, "Not found address of the {0}", toBeDestroyed); + return; + } + Deallocator deallocator = DEALLOCATORS.get(address); + if (deallocator == null) { + LOGGER.log(Level.WARNING, "Not found a deallocator for address {0}", address); + return; + } + deallocator.freeNow(); } @Override @@ -22,6 +49,40 @@ public ByteBuffer allocate(int size) { if (buffer == null) { throw new OutOfMemoryError("Could not allocate " + size + " bytes through libJGLIOS"); } + long address = LibJGLIOSBufferAllocator.baseAddress(buffer); + if (address != 0L) { + DEALLOCATORS.put(address, new Deallocator(buffer, address)); + } return buffer; } + + private static void freeCollectedBuffers() { + try { + for (;;) { + Deallocator deallocator = (Deallocator) REFERENCE_QUEUE.remove(); + deallocator.freeNow(); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private static final class Deallocator extends PhantomReference { + private final long address; + private final AtomicBoolean freed = new AtomicBoolean(false); + + private Deallocator(ByteBuffer referent, long address) { + super(referent, REFERENCE_QUEUE); + this.address = address; + } + + private void freeNow() { + if (!freed.compareAndSet(false, true)) { + return; + } + DEALLOCATORS.remove(address, this); + clear(); + LibJGLIOSBufferAllocator.freeAddress(address); + } + } } From 81f4e175d03da3e3bfbecf0699a29316596cbf55 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 10:44:33 +0200 Subject: [PATCH 02/13] default ios and android to saferalloc when available --- jme3-android-examples/build.gradle | 1 + .../com/jme3/app/AndroidHarnessFragment.java | 19 ++++++++++++++++--- jme3-ios-examples/build.gradle | 1 + .../com/jme3/system/ios/IGLESContext.java | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/jme3-android-examples/build.gradle b/jme3-android-examples/build.gradle index 8691c919cb..91e8ecddbf 100644 --- a/jme3-android-examples/build.gradle +++ b/jme3-android-examples/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(':jme3-plugins') implementation project(':jme3-plugins-json') implementation project(':jme3-plugins-json-gson') + implementation project(':jme3-saferallocator') implementation project(':jme3-terrain') implementation files(examplesJar.flatMap { it.archiveFile }) } diff --git a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java index 371819034a..68b25b29cd 100644 --- a/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java +++ b/jme3-android/src/main/java/com/jme3/app/AndroidHarnessFragment.java @@ -66,6 +66,7 @@ */ public abstract class AndroidHarnessFragment extends Fragment implements SystemListener { private static final Logger logger = Logger.getLogger(AndroidHarnessFragment.class.getName()); + private static final String SAFER_BUFFER_ALLOCATOR_CLASS = "com.jme3.util.SaferBufferAllocator"; protected GLSurfaceView view; protected LegacyApplication app; @@ -90,9 +91,12 @@ public void onCreate(Bundle savedInstanceState) { logger.fine("onCreate"); super.onCreate(savedInstanceState); - System.setProperty( - BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION, - AndroidNativeBufferAllocator.class.getName()); + if (System.getProperty(BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION) == null) { + String allocator = isClassPresent(SAFER_BUFFER_ALLOCATOR_CLASS) + ? SAFER_BUFFER_ALLOCATOR_CLASS + : AndroidNativeBufferAllocator.class.getName(); + System.setProperty(BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION, allocator); + } try { app = createApplication(); @@ -112,6 +116,15 @@ public void onCreate(Bundle savedInstanceState) { */ protected abstract LegacyApplication createApplication() throws Exception; + private static boolean isClassPresent(String className) { + try { + Class.forName(className, false, AndroidHarnessFragment.class.getClassLoader()); + return true; + } catch (Throwable ignored) { + return false; + } + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { diff --git a/jme3-ios-examples/build.gradle b/jme3-ios-examples/build.gradle index 6d22a1b2d6..d68d66da84 100644 --- a/jme3-ios-examples/build.gradle +++ b/jme3-ios-examples/build.gradle @@ -303,6 +303,7 @@ sourceSets { dependencies { implementation project(':jme3-core') implementation project(':jme3-ios') + implementation project(':jme3-saferallocator') implementation libs.libjglios.core.ios implementation libs.libjglios.gles.ios implementation libs.libjglios.sdl3.ios diff --git a/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java b/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java index d0cbfc1f74..c12af62195 100644 --- a/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java +++ b/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java @@ -68,6 +68,7 @@ public class IGLESContext implements JmeContext { private static final String BLIT_MATERIAL = "Common/MatDefs/Blit/Blit.j3md"; private static final Logger logger = Logger.getLogger(IGLESContext.class.getName()); + private static final String SAFER_BUFFER_ALLOCATOR_CLASS = "com.jme3.util.SaferBufferAllocator"; protected final AtomicBoolean created = new AtomicBoolean(false); protected final AtomicBoolean renderable = new AtomicBoolean(false); protected final AtomicBoolean needClose = new AtomicBoolean(false); @@ -102,7 +103,20 @@ public class IGLESContext implements JmeContext { final String implementation = BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION; if (System.getProperty(implementation) == null) { - System.setProperty(implementation, LibJGLIOSNativeBufferAllocator.class.getName()); + if (isClassPresent(SAFER_BUFFER_ALLOCATOR_CLASS)) { + System.setProperty(implementation, SAFER_BUFFER_ALLOCATOR_CLASS); + } else { + System.setProperty(implementation, LibJGLIOSNativeBufferAllocator.class.getName()); + } + } + } + + private static boolean isClassPresent(String className) { + try { + Class.forName(className, false, IGLESContext.class.getClassLoader()); + return true; + } catch (Throwable ignored) { + return false; } } From fb61c312e236bccc548a80f00fb41088ade1d07a Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 11:17:26 +0200 Subject: [PATCH 03/13] make allocators fully thread safe --- .../util/AndroidNativeBufferAllocator.java | 2 +- .../util/LibJGLIOSNativeBufferAllocator.java | 2 +- .../com/jme3/system/lwjgl/LwjglContext.java | 7 +- .../com/jme3/util/LWJGLBufferAllocator.java | 98 +++++-------------- 4 files changed, 30 insertions(+), 79 deletions(-) diff --git a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java index 6e5f8bba76..dac45ccdbd 100644 --- a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java +++ b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java @@ -66,7 +66,7 @@ public void destroyDirectBuffer(Buffer toBeDestroyed) { LOGGER.log(Level.WARNING, "Not found address of the {0}", toBeDestroyed); return; } - Deallocator deallocator = DEALLOCATORS.get(address); + Deallocator deallocator = DEALLOCATORS.remove(address); if (deallocator == null) { LOGGER.log(Level.WARNING, "Not found a deallocator for address {0}", address); return; diff --git a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java index 314c37fe4c..9d95d248fd 100644 --- a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java +++ b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java @@ -35,7 +35,7 @@ public void destroyDirectBuffer(Buffer toBeDestroyed) { LOGGER.log(Level.WARNING, "Not found address of the {0}", toBeDestroyed); return; } - Deallocator deallocator = DEALLOCATORS.get(address); + Deallocator deallocator = DEALLOCATORS.remove(address); if (deallocator == null) { LOGGER.log(Level.WARNING, "Not found a deallocator for address {0}", address); return; diff --git a/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java b/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java index d80b133901..1bb153e8f3 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java @@ -51,7 +51,6 @@ import com.jme3.util.BufferAllocatorFactory; import com.jme3.util.BufferUtils; import com.jme3.util.LWJGLBufferAllocator; -import com.jme3.util.LWJGLBufferAllocator.ConcurrentLWJGLBufferAllocator; import com.jme3.util.LWJGLSaferAllocMemoryAllocator; import java.nio.IntBuffer; @@ -83,6 +82,8 @@ public abstract class LwjglContext implements JmeContext { protected boolean useAngle = false; private static final Logger logger = Logger.getLogger(LwjglContext.class.getName()); + private static final String CONCURRENT_LWJGL_BUFFER_ALLOCATOR_CLASS = + "com.jme3.util.LWJGLBufferAllocator$ConcurrentLWJGLBufferAllocator"; static { final String implementation = BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION; @@ -95,8 +96,8 @@ public abstract class LwjglContext implements JmeContext { LWJGLSaferAllocMemoryAllocator.SAFER_BUFFER_ALLOCATOR_CLASS); } } else if (configuredImplementation == null) { - if (Boolean.parseBoolean(System.getProperty(PROPERTY_CONCURRENT_BUFFER_ALLOCATOR, "true"))) { - System.setProperty(implementation, ConcurrentLWJGLBufferAllocator.class.getName()); + if (Boolean.parseBoolean(System.getProperty(PROPERTY_CONCURRENT_BUFFER_ALLOCATOR, "false"))) { + System.setProperty(implementation, CONCURRENT_LWJGL_BUFFER_ALLOCATOR_CLASS); } else { System.setProperty(implementation, LWJGLBufferAllocator.class.getName()); } diff --git a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java index 77e6330696..7dc15d841c 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java @@ -7,7 +7,7 @@ import java.nio.*; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.StampedLock; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -38,45 +38,13 @@ public class LWJGLBufferAllocator implements BufferAllocator { private static final Map DEALLOCATORS = new ConcurrentHashMap<>(); /** - * Threadsafe implementation of the {@link LWJGLBufferAllocator}. + * Deprecated compatibility alias for {@link LWJGLBufferAllocator}. * * @author JavaSaBr + * @deprecated {@link LWJGLBufferAllocator} is thread-safe for allocation bookkeeping. */ + @Deprecated public static class ConcurrentLWJGLBufferAllocator extends LWJGLBufferAllocator { - - /** - * The synchronizer. - */ - private final StampedLock stampedLock; - - public ConcurrentLWJGLBufferAllocator() { - this.stampedLock = new StampedLock(); - } - - @Override - public void destroyDirectBuffer(final Buffer buffer) { - final long stamp = stampedLock.writeLock(); - try { - super.destroyDirectBuffer(buffer); - } finally { - stampedLock.unlockWrite(stamp); - } - } - - @Override - public ByteBuffer allocate(final int size) { - final long stamp = stampedLock.writeLock(); - try { - return super.allocate(size); - } finally { - stampedLock.unlockWrite(stamp); - } - } - - @Override - Deallocator createDeallocator(final Long address, final ByteBuffer byteBuffer) { - return new ConcurrentDeallocator(byteBuffer, DUMMY_QUEUE, address, stampedLock); - } } /** @@ -87,7 +55,8 @@ static class Deallocator extends PhantomReference { /** * The address of LWJGL byte buffer. */ - volatile Long address; + final Long address; + final AtomicBoolean freed = new AtomicBoolean(false); Deallocator(final ByteBuffer referent, final ReferenceQueue queue, final Long address) { super(referent, queue); @@ -95,47 +64,29 @@ static class Deallocator extends PhantomReference { } /** - * @param address the address of LWJGL byte buffer. + * Retire this deallocator when the caller will free the memory itself. + * + * @return true if the caller now owns the native free */ - void setAddress(final Long address) { - this.address = address; + boolean retireForExternalFree() { + if (!freed.compareAndSet(false, true)) { + return false; + } + DEALLOCATORS.remove(address, this); + clear(); + return true; } /** * Free memory. */ void free() { - if (address == null) return; - freeMemory(); - DEALLOCATORS.remove(address); - } - - void freeMemory() { - MemoryUtil.nmemFree(address); - } - } - - /** - * The LWJGL byte buffer deallocator. - */ - static class ConcurrentDeallocator extends Deallocator { - - final StampedLock stampedLock; - - ConcurrentDeallocator(final ByteBuffer referent, final ReferenceQueue queue, - final Long address, final StampedLock stampedLock) { - super(referent, queue, address); - this.stampedLock = stampedLock; - } - - @Override - protected void freeMemory() { - final long stamp = stampedLock.writeLock(); - try { - super.freeMemory(); - } finally { - stampedLock.unlockWrite(stamp); + if (!freed.compareAndSet(false, true)) { + return; } + DEALLOCATORS.remove(address, this); + clear(); + MemoryUtil.nmemFree(address); } } @@ -169,7 +120,6 @@ public void destroyDirectBuffer(final Buffer buffer) { return; } - // disable deallocator final Deallocator deallocator = DEALLOCATORS.remove(address); if (deallocator == null) { @@ -177,9 +127,9 @@ public void destroyDirectBuffer(final Buffer buffer) { return; } - deallocator.setAddress(null); - - MemoryUtil.memFree(buffer); + if (deallocator.retireForExternalFree()) { + MemoryUtil.nmemFree(address); + } } /** From 2a6892278e37f5156063d485315e10166094eaa4 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 11:40:26 +0200 Subject: [PATCH 04/13] deprecate ReflectionAllocator --- jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java b/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java index b71ad69910..c01d708b86 100644 --- a/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java +++ b/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java @@ -41,7 +41,9 @@ /** * This class contains the reflection based way to remove DirectByteBuffers in * java, allocation is done via ByteBuffer.allocateDirect + * @deprecated This class relies on internal APIs that are not accessible in Java 9+, and is not thread-safe for allocation bookkeeping. Use {@link SaferBufferAllocator} instead. */ +@Deprecated public final class ReflectionAllocator implements BufferAllocator { private static Method cleanerMethod = null; private static Method cleanMethod = null; From b8d2e14d03eb3d8a132e04bbd4e6e58f5eed288b Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 11:51:21 +0200 Subject: [PATCH 05/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java b/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java index c01d708b86..a1ba2eaedd 100644 --- a/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java +++ b/jme3-core/src/main/java/com/jme3/util/ReflectionAllocator.java @@ -41,7 +41,7 @@ /** * This class contains the reflection based way to remove DirectByteBuffers in * java, allocation is done via ByteBuffer.allocateDirect - * @deprecated This class relies on internal APIs that are not accessible in Java 9+, and is not thread-safe for allocation bookkeeping. Use {@link SaferBufferAllocator} instead. + * @deprecated This class relies on internal APIs that are not accessible in Java 9+, and is not thread-safe for allocation bookkeeping. Use {@code com.jme3.util.SaferBufferAllocator} from the {@code jme3-saferallocator} module instead. */ @Deprecated public final class ReflectionAllocator implements BufferAllocator { From f4dfc142c13166029033e8deb71068cf03e4afb6 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 12:08:13 +0200 Subject: [PATCH 06/13] update jmeAndroidNatives --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8844720917..87c9fac663 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ libjglios = "0.6" saferalloc = "0.0.9" nifty = "1.4.3" spotbugs = "4.9.8" -jmeAndroidNatives = "3.10.0-xt16kb" +jmeAndroidNatives = "3.10.0-xt16kb-alloc" [libraries] From afdb539c7a0a0e97509bf2939e3b25ca15cb88b7 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 12:11:10 +0200 Subject: [PATCH 07/13] Harden native buffer allocators against allocation and cleanup failures --- .../util/AndroidNativeBufferAllocator.java | 22 +++++++++++-------- .../util/LibJGLIOSNativeBufferAllocator.java | 11 ++++++---- .../com/jme3/util/LWJGLBufferAllocator.java | 13 ++++++----- .../com/jme3/util/SaferBufferAllocator.java | 6 ++++- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java index dac45ccdbd..a0ca781327 100644 --- a/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java +++ b/jme3-android/src/main/java/com/jme3/util/AndroidNativeBufferAllocator.java @@ -77,23 +77,27 @@ public void destroyDirectBuffer(Buffer toBeDestroyed) { @Override public ByteBuffer allocate(int size) { ByteBuffer buffer = createDirectByteBuffer(size); - if (buffer != null) { - long address = directBufferAddress(buffer); - if (address != 0L) { - DEALLOCATORS.put(address, new Deallocator(buffer, address)); - } + if (buffer == null) { + throw new OutOfMemoryError("Could not allocate " + size + " bytes through Android native allocator"); + } + long address = directBufferAddress(buffer); + if (address != 0L) { + DEALLOCATORS.put(address, new Deallocator(buffer, address)); } return buffer; } private static void freeCollectedBuffers() { - try { - for (;;) { + for (;;) { + try { Deallocator deallocator = (Deallocator) REFERENCE_QUEUE.remove(); deallocator.freeNow(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + break; + } catch (Throwable throwable) { + LOGGER.log(Level.SEVERE, "Error deallocating direct buffer", throwable); } - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); } } diff --git a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java index 9d95d248fd..823e1d1c79 100644 --- a/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java +++ b/jme3-ios/src/main/java/com/jme3/util/LibJGLIOSNativeBufferAllocator.java @@ -57,13 +57,16 @@ public ByteBuffer allocate(int size) { } private static void freeCollectedBuffers() { - try { - for (;;) { + for (;;) { + try { Deallocator deallocator = (Deallocator) REFERENCE_QUEUE.remove(); deallocator.freeNow(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + break; + } catch (Throwable throwable) { + LOGGER.log(Level.SEVERE, "Error deallocating direct buffer", throwable); } - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); } } diff --git a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java index 7dc15d841c..a686762ebe 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java @@ -100,13 +100,16 @@ void free() { * Free unnecessary LWJGL byte buffers. */ static void freeByteBuffers() { - try { - for (;;) { + for (;;) { + try { final Deallocator deallocator = (Deallocator) DUMMY_QUEUE.remove(); deallocator.free(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (final Throwable throwable) { + LOGGER.log(Level.SEVERE, "Error deallocating direct buffer", throwable); } - } catch (final InterruptedException e) { - e.printStackTrace(); } } @@ -161,7 +164,7 @@ long getAddress(final Buffer buffer) { @Override public ByteBuffer allocate(final int size) { - final Long address = MemoryUtil.nmemCalloc(size, 1); + final Long address = MemoryUtil.nmemCallocChecked(size, 1); final ByteBuffer byteBuffer = MemoryUtil.memByteBuffer(address, size); DEALLOCATORS.put(address, createDeallocator(address, byteBuffer)); return byteBuffer; diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java index 24aad54c5e..ecf17cdd5d 100644 --- a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java @@ -200,7 +200,11 @@ public static void alignedFree(long ptr) { @Override public ByteBuffer allocate(int size) { SaferAllocMemoryGuard.beforeAlloc(size); - return register(SaferAlloc.calloc(1, size)); + ByteBuffer buffer = SaferAlloc.calloc(1, size); + if (buffer == null) { + throw new OutOfMemoryError("Could not allocate " + size + " bytes through SaferAlloc"); + } + return register(buffer); } @Override From 9c0f05bdcb10634a228bb7dd509418460a88c499 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 12:20:39 +0200 Subject: [PATCH 08/13] Update jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/main/java/com/jme3/util/LWJGLBufferAllocator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java index a686762ebe..a5d24b96c9 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java @@ -55,10 +55,10 @@ static class Deallocator extends PhantomReference { /** * The address of LWJGL byte buffer. */ - final Long address; + final long address; final AtomicBoolean freed = new AtomicBoolean(false); - Deallocator(final ByteBuffer referent, final ReferenceQueue queue, final Long address) { + Deallocator(final ByteBuffer referent, final ReferenceQueue queue, final long address) { super(referent, queue); this.address = address; } From 580d4148980591148ffead6c914cf322b86d978b Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 12:20:50 +0200 Subject: [PATCH 09/13] Update jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/main/java/com/jme3/util/LWJGLBufferAllocator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java index a5d24b96c9..dc5fbdc23c 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLBufferAllocator.java @@ -164,7 +164,7 @@ long getAddress(final Buffer buffer) { @Override public ByteBuffer allocate(final int size) { - final Long address = MemoryUtil.nmemCallocChecked(size, 1); + final long address = MemoryUtil.nmemCallocChecked(size, 1); final ByteBuffer byteBuffer = MemoryUtil.memByteBuffer(address, size); DEALLOCATORS.put(address, createDeallocator(address, byteBuffer)); return byteBuffer; From 29559247408b6716d904230703f928ddd9d880aa Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 13:53:48 +0200 Subject: [PATCH 10/13] update to saferalloc 0.0.10 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87c9fac663..b0eca459c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ jacoco = "0.8.12" lwjgl3 = "3.4.1" angle = "2026-05-09" libjglios = "0.6" -saferalloc = "0.0.9" +saferalloc = "0.0.10" nifty = "1.4.3" spotbugs = "4.9.8" jmeAndroidNatives = "3.10.0-xt16kb-alloc" From 8c550dfe40dbdb4a61388b22e85478f0a8c033e8 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 14:27:53 +0200 Subject: [PATCH 11/13] Fix saferalloc native memory pressure accounting --- .../com/jme3/util/SaferAllocMemoryGuard.java | 54 ++++++++++++------- .../com/jme3/util/SaferBufferAllocator.java | 11 +++- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java index 6517bd361a..5a30edc499 100644 --- a/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java @@ -7,7 +7,6 @@ import java.util.logging.Logger; import org.ngengine.saferalloc.SaferAlloc; -import org.ngengine.saferalloc.SaferAllocNative; public class SaferAllocMemoryGuard { private static final Logger LOGGER = Logger.getLogger(SaferAllocMemoryGuard.class.getName()); @@ -129,7 +128,7 @@ public class SaferAllocMemoryGuard { private static final AtomicLong highPressureCount = new AtomicLong(0L); private static final AtomicLong lowPressureCount = new AtomicLong(0L); private static volatile LongSupplier allocatedBytesSupplier = SaferAlloc::currentAllocatedBytes; - private static volatile LongSupplier nowSupplier = System::currentTimeMillis; + private static volatile LongSupplier nowSupplier = System::nanoTime; private static volatile Runnable gcInvoker = System::gc; public static void beforeAlloc(long size){ @@ -161,7 +160,7 @@ public static void beforeAlloc(long size){ long minimumBytesForMaintenanceGC = (long) (currentSoftBudget * maintenanceGcMinUsageRatio); if (currentBytes >= minimumBytesForMaintenanceGC) { long last = lastGCRun.get(); - if (now - last >= maintenanceGcIntervalMillis) { + if (hasElapsed(now, last, millisToNanos(maintenanceGcIntervalMillis))) { requestGC(now); } } @@ -189,7 +188,7 @@ private static void adaptSoftBudget(long currentBytes, long projectedBytes, long long now = nowSupplier.getAsLong(); long lastUpdate = lastAdaptUpdate.get(); - if (now - lastUpdate < adaptIntervalMillis) { + if (!hasElapsed(now, lastUpdate, millisToNanos(adaptIntervalMillis))) { return; } if (!lastAdaptUpdate.compareAndSet(lastUpdate, now)) { @@ -225,27 +224,32 @@ private static void adaptSoftBudget(long currentBytes, long projectedBytes, long } private static void requestGC(long now) { - if (now - lastGCRun.get() >= gcIntervalMillis) { - if (LOGGER.isLoggable(Level.FINER)) { - LOGGER.log(Level.FINER, "!!! Requesting GC..."); + long minimumInterval = millisToNanos(gcIntervalMillis); + for (;;) { + long last = lastGCRun.get(); + if (!hasElapsed(now, last, minimumInterval)) { + return; } + if (!lastGCRun.compareAndSet(last, now)) { + continue; + } + break; + } - // Calling gc() twice is a common heuristic to increase the likelihood of a full - // garbage collection cycle, which is important for timely release of native memory. - gcInvoker.run(); - gcInvoker.run(); - - lastGCRun.updateAndGet(v -> { - if (v < now) return now; - return v; - }); + if (LOGGER.isLoggable(Level.FINER)) { + LOGGER.log(Level.FINER, "!!! Requesting GC..."); } + + // Calling gc() twice is a common heuristic to increase the likelihood of a full + // garbage collection cycle, which is important for timely release of native memory. + gcInvoker.run(); + gcInvoker.run(); } // Test-only hooks to keep unit tests deterministic without real native allocations or GC calls. static void setTestHooks(LongSupplier allocatedBytes, LongSupplier now, Runnable gcAction) { - allocatedBytesSupplier = allocatedBytes != null ? allocatedBytes : SaferAllocNative::currentAllocatedBytes; - nowSupplier = now != null ? now : System::currentTimeMillis; + allocatedBytesSupplier = allocatedBytes != null ? allocatedBytes : SaferAlloc::currentAllocatedBytes; + nowSupplier = now != null ? now : System::nanoTime; gcInvoker = gcAction != null ? gcAction : System::gc; } @@ -275,6 +279,17 @@ private static long safeAdd(long a, long b) { return a + b; } + private static boolean hasElapsed(long now, long last, long intervalNanos) { + return now - last >= intervalNanos; + } + + private static long millisToNanos(long millis) { + if (millis > Long.MAX_VALUE / 1_000_000L) { + return Long.MAX_VALUE; + } + return millis * 1_000_000L; + } + private static String human(long bytes) { if (bytes >= 1024L * 1024L * 1024L) { return String.format(Locale.ROOT, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); @@ -325,6 +340,9 @@ private static float readFloatProperty(String key, float defaultValue, float min } try { float value = Float.parseFloat(raw.trim()); + if (Float.isNaN(value) || Float.isInfinite(value)) { + throw new NumberFormatException("non-finite"); + } return clamp(value, minValue, maxValue); } catch (NumberFormatException e) { LOGGER.log(Level.WARNING, "Invalid value for {0}: {1}. Using default {2}.", diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java index ecf17cdd5d..b1e2a0c226 100644 --- a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java @@ -34,8 +34,8 @@ private static void reapLoop() { for (;;) { try { AllocationRef ref = (AllocationRef) refQueue.remove(); - SaferAllocMemoryGuard.notifyGC(); ref.freeFromQueue(); + SaferAllocMemoryGuard.notifyGC(); } catch (InterruptedException e) { return; } catch (Throwable t) { @@ -138,6 +138,7 @@ public static long malloc(long size) { if (size < 0) { throw new IllegalArgumentException("size < 0"); } + SaferAllocMemoryGuard.beforeAlloc(size); long pointer = SaferAllocNative.malloc(size); if (pointer == 0L && size != 0) { throw new OutOfMemoryError("SaferAlloc malloc failed: " + size); @@ -153,8 +154,10 @@ public static long calloc(long num, long size) { throw new OutOfMemoryError("calloc overflow"); } + long requestedBytes = num * size; + SaferAllocMemoryGuard.beforeAlloc(requestedBytes); long pointer = SaferAllocNative.calloc(num, size); - if (pointer == 0L && (num * size) != 0) { + if (pointer == 0L && requestedBytes != 0) { throw new OutOfMemoryError("SaferAlloc calloc failed: " + num + "*" + size); } return pointer; @@ -164,6 +167,9 @@ public static long realloc(long ptr, long size) { if (size < 0) { throw new IllegalArgumentException("size < 0"); } + // saferalloc does not currently expose the old allocation size, so use the + // requested size as a conservative pressure estimate. + SaferAllocMemoryGuard.beforeAlloc(size); long pointer = SaferAllocNative.realloc(ptr, size); if (pointer == 0L && size != 0) { throw new OutOfMemoryError("SaferAlloc realloc failed: " + size); @@ -184,6 +190,7 @@ public static long alignedAlloc(long alignment, long size) { if (size < 0) { throw new IllegalArgumentException("size < 0"); } + SaferAllocMemoryGuard.beforeAlloc(size); long pointer = SaferAllocNative.mallocAligned(size, alignment); if (pointer == 0L && size != 0) { throw new OutOfMemoryError("SaferAlloc aligned_alloc failed: " + size); From 8c647f5cc242d656325d76e521e399ac3aac8347 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 14:28:03 +0200 Subject: [PATCH 12/13] Add saferalloc memory pressure guard tests --- .../jme3/util/SaferAllocMemoryGuardTest.java | 142 +++++++++++++++++- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java b/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java index b9448174e7..9a78202362 100644 --- a/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java +++ b/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java @@ -1,5 +1,11 @@ package com.jme3.util; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -8,11 +14,13 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class SaferAllocMemoryGuardTest { private static final long MIB = 1024L * 1024L; + private static final long SECOND = 1_000_000_000L; @BeforeEach public void setUp() { @@ -38,9 +46,9 @@ public void shouldAdaptBudgetUpThenDown() { currentBytes.set((long) (initialBudget * 0.95f)); SaferAllocMemoryGuard.beforeAlloc(0L); - now.set(3_000L); + now.set(3L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); - now.set(6_000L); + now.set(6L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); long grownBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); @@ -49,7 +57,7 @@ public void shouldAdaptBudgetUpThenDown() { currentBytes.set(0L); for (int i = 0; i < 8; i++) { - now.addAndGet(3_000L); + now.addAndGet(3L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); } @@ -69,16 +77,16 @@ public void shouldRequestGcOnBurstEvenWithAdaptiveBudget() { currentBytes.set((long) (initialBudget * 0.95f)); SaferAllocMemoryGuard.beforeAlloc(0L); - now.set(3_000L); + now.set(3L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); - now.set(6_000L); + now.set(6L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); long grownBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); assertTrue(grownBudget > initialBudget); currentBytes.set(grownBudget + 64L * MIB); - now.addAndGet(3_000L); + now.addAndGet(3L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); assertTrue(gcCalls.get() >= 2, "Expected explicit GC request on over-budget burst"); @@ -98,9 +106,129 @@ public void shouldRequestMaintenanceGcAfterSilence() { SaferAllocMemoryGuard.beforeAlloc(0L); assertEquals(0, gcCalls.get()); - now.set(61_000L); + now.set(61L * SECOND); SaferAllocMemoryGuard.beforeAlloc(0L); assertTrue(gcCalls.get() >= 2, "Expected maintenance GC after long silence under non-trivial usage"); } + + @Test + public void nativeWrapperAllocationsShouldParticipateInMemoryPressure() { + AtomicLong now = new AtomicLong(2L * SECOND); + AtomicLong currentBytes = new AtomicLong(SaferAllocMemoryGuard.getSoftBudgetForTests()); + AtomicInteger gcCalls = new AtomicInteger(0); + + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + + long pointer = SaferBufferAllocator.malloc(1L); + SaferBufferAllocator.free(pointer); + assertEquals(2, gcCalls.get(), "malloc should request GC through the guard"); + + resetHooks(currentBytes, now, gcCalls); + pointer = SaferBufferAllocator.calloc(1L, 1L); + SaferBufferAllocator.free(pointer); + assertEquals(2, gcCalls.get(), "calloc should request GC through the guard"); + + resetHooks(currentBytes, now, gcCalls); + pointer = SaferBufferAllocator.alignedAlloc(8L, 1L); + SaferBufferAllocator.alignedFree(pointer); + assertEquals(2, gcCalls.get(), "alignedAlloc should request GC through the guard"); + + pointer = SaferBufferAllocator.malloc(1L); + resetHooks(currentBytes, now, gcCalls); + pointer = SaferBufferAllocator.realloc(pointer, 2L); + SaferBufferAllocator.free(pointer); + assertEquals(2, gcCalls.get(), "realloc should request GC through the guard"); + } + + @Test + public void callocOverflowShouldNotWrapPressureAccounting() { + assertThrows(OutOfMemoryError.class, () -> SaferBufferAllocator.calloc(Long.MAX_VALUE, 2L)); + } + + @Test + public void concurrentGcRequestsShouldBeThrottledAtomically() throws InterruptedException { + AtomicLong now = new AtomicLong(2L * SECOND); + AtomicLong currentBytes = new AtomicLong(SaferAllocMemoryGuard.getSoftBudgetForTests() + 1L); + AtomicInteger gcCalls = new AtomicInteger(0); + int workers = 16; + CountDownLatch ready = new CountDownLatch(workers); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(workers); + List failures = new ArrayList<>(); + + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + + for (int i = 0; i < workers; i++) { + executor.execute(() -> { + try { + ready.countDown(); + start.await(); + SaferAllocMemoryGuard.beforeAlloc(1L); + } catch (Throwable throwable) { + synchronized (failures) { + failures.add(throwable); + } + } + }); + } + + assertTrue(ready.await(5L, TimeUnit.SECONDS)); + start.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(failures.isEmpty(), "Worker failures: " + failures); + assertEquals(2, gcCalls.get(), "Only one thread should win the GC request interval"); + } + + @Test + public void adaptiveShrinkShouldObservePostFreeAllocatedBytes() { + AtomicLong now = new AtomicLong(0L); + AtomicLong currentBytes = new AtomicLong(); + AtomicInteger gcCalls = new AtomicInteger(0); + + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + + long initialBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + currentBytes.set((long) (initialBudget * 0.95f)); + SaferAllocMemoryGuard.beforeAlloc(0L); + now.set(3L * SECOND); + SaferAllocMemoryGuard.beforeAlloc(0L); + now.set(6L * SECOND); + SaferAllocMemoryGuard.beforeAlloc(0L); + long grownBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + assertTrue(grownBudget > initialBudget); + + currentBytes.set(0L); + for (int i = 0; i < 8; i++) { + now.addAndGet(3L * SECOND); + SaferAllocMemoryGuard.notifyGC(); + } + + assertTrue(SaferAllocMemoryGuard.getSoftBudgetForTests() < grownBudget, + "Post-free notifications should allow the adaptive budget to shrink"); + } + + @Test + public void monotonicTimeHookShouldDriveElapsedIntervalsDeterministically() { + AtomicLong now = new AtomicLong(999_000_000L); + AtomicLong currentBytes = new AtomicLong(SaferAllocMemoryGuard.getSoftBudgetForTests() + 1L); + AtomicInteger gcCalls = new AtomicInteger(0); + + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + + SaferAllocMemoryGuard.beforeAlloc(1L); + assertEquals(0, gcCalls.get()); + + now.set(1_000_000_000L); + SaferAllocMemoryGuard.beforeAlloc(1L); + assertEquals(2, gcCalls.get()); + } + + private static void resetHooks(AtomicLong currentBytes, AtomicLong now, AtomicInteger gcCalls) { + SaferAllocMemoryGuard.resetStateForTests(); + now.set(2L * SECOND); + gcCalls.set(0); + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + } } From 4e88282eb87570e3d2be82b25c3e0d305664d248 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 29 May 2026 14:45:57 +0200 Subject: [PATCH 13/13] make screenshot tests use safer allocator --- jme3-screenshot-tests/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/jme3-screenshot-tests/build.gradle b/jme3-screenshot-tests/build.gradle index 2d892ae089..dade388fd3 100644 --- a/jme3-screenshot-tests/build.gradle +++ b/jme3-screenshot-tests/build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(':jme3-terrain') implementation project(':jme3-lwjgl3') implementation project(':jme3-plugins') + implementation project(':jme3-saferallocator') implementation 'com.aventstack:extentreports:5.1.2' implementation platform('org.junit:junit-bom:5.9.1')