diff --git a/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java b/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java index 6d8cf0962b..4acb4aa66f 100644 --- a/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java +++ b/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java @@ -69,6 +69,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTextDialogInput { private static final Logger logger = Logger.getLogger(OGLESContext.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); @@ -86,7 +87,20 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex final String implementation = BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION; if (System.getProperty(implementation) == null) { - System.setProperty(implementation, PrimitiveAllocator.class.getName()); + if (isClassPresent(SAFER_BUFFER_ALLOCATOR_CLASS)) { + System.setProperty(implementation, SAFER_BUFFER_ALLOCATOR_CLASS); + } else { + System.setProperty(implementation, PrimitiveAllocator.class.getName()); + } + } + } + + private static boolean isClassPresent(String className) { + try { + Class.forName(className, false, OGLESContext.class.getClassLoader()); + return true; + } catch (Throwable ignored) { + return false; } } diff --git a/jme3-examples/build.gradle b/jme3-examples/build.gradle index e3a5048fc1..1f1d0f286c 100644 --- a/jme3-examples/build.gradle +++ b/jme3-examples/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation project(':jme3-jogg') // implementation project(':jme3-lwjgl') implementation project(':jme3-lwjgl3') + implementation project(':jme3-saferallocator') implementation project(':jme3-networking') implementation project(':jme3-niftygui') implementation project(':jme3-plugins') 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 535fd295b1..6545d393a5 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 @@ -58,6 +58,8 @@ import com.jme3.util.BufferAllocatorFactory; import com.jme3.util.LWJGLBufferAllocator; import com.jme3.util.LWJGLBufferAllocator.ConcurrentLWJGLBufferAllocator; +import com.jme3.util.LWJGLSaferAllocMemoryAllocator; + import static com.jme3.util.LWJGLBufferAllocator.PROPERTY_CONCURRENT_BUFFER_ALLOCATOR; import java.nio.IntBuffer; import java.util.*; @@ -79,6 +81,7 @@ import static org.lwjgl.opengl.GL.createCapabilities; import static org.lwjgl.opengl.GL11.glGetInteger; import org.lwjgl.opengl.GLCapabilities; +import org.lwjgl.system.Configuration; import org.lwjgl.system.MemoryStack; import org.lwjgl.system.Platform; @@ -90,10 +93,16 @@ public abstract class LwjglContext implements JmeContext { private static final Logger logger = Logger.getLogger(LwjglContext.class.getName()); static { - final String implementation = BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION; + final String configuredImplementation = System.getProperty(implementation); - if (System.getProperty(implementation) == null) { + if (LWJGLSaferAllocMemoryAllocator.isAvailable()) { + Configuration.MEMORY_ALLOCATOR.set(new LWJGLSaferAllocMemoryAllocator()); + if (configuredImplementation == null) { + System.setProperty(implementation, + 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()); } else { diff --git a/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLSaferAllocMemoryAllocator.java b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLSaferAllocMemoryAllocator.java new file mode 100644 index 0000000000..66bbd469c0 --- /dev/null +++ b/jme3-lwjgl3/src/main/java/com/jme3/util/LWJGLSaferAllocMemoryAllocator.java @@ -0,0 +1,272 @@ +package com.jme3.util; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.logging.Logger; + +import org.lwjgl.system.MemoryUtil; + +public final class LWJGLSaferAllocMemoryAllocator implements MemoryUtil.MemoryAllocator { + private static final Logger logger = Logger.getLogger(LWJGLSaferAllocMemoryAllocator.class.getName()); + + public static final String SAFER_BUFFER_ALLOCATOR_CLASS = "com.jme3.util.SaferBufferAllocator"; + + private static final Bindings BINDINGS = Bindings.create(); + + private final long fnMalloc; + private final long fnCalloc; + private final long fnRealloc; + private final long fnFree; + private final long fnAlignedAlloc; + private final long fnAlignedFree; + + public static boolean isAvailable() { + return BINDINGS != null; + } + + public LWJGLSaferAllocMemoryAllocator() { + if (BINDINGS == null) { + throw new IllegalStateException(SAFER_BUFFER_ALLOCATOR_CLASS + " is not available on classpath."); + } + logger.info(getClass().getSimpleName() + " enabled!"); + this.fnMalloc = BINDINGS.getMallocFunctionPointer(); + this.fnCalloc = BINDINGS.getCallocFunctionPointer(); + this.fnRealloc = BINDINGS.getReallocFunctionPointer(); + this.fnFree = BINDINGS.getFreeFunctionPointer(); + this.fnAlignedAlloc = BINDINGS.getAlignedAllocFunctionPointer(); + this.fnAlignedFree = BINDINGS.getAlignedFreeFunctionPointer(); + } + + @Override + public long getMalloc() { + return fnMalloc; + } + + @Override + public long getCalloc() { + return fnCalloc; + } + + @Override + public long getRealloc() { + return fnRealloc; + } + + @Override + public long getFree() { + return fnFree; + } + + @Override + public long getAlignedAlloc() { + return fnAlignedAlloc; + } + + @Override + public long getAlignedFree() { + return fnAlignedFree; + } + + @Override + public long malloc(long size) { + return BINDINGS.malloc(size); + } + + @Override + public long calloc(long num, long size) { + return BINDINGS.calloc(num, size); + } + + @Override + public long realloc(long ptr, long size) { + return BINDINGS.realloc(ptr, size); + } + + @Override + public void free(long ptr) { + BINDINGS.free(ptr); + } + + @Override + public long aligned_alloc(long alignment, long size) { + return BINDINGS.alignedAlloc(alignment, size); + } + + @Override + public void aligned_free(long ptr) { + BINDINGS.alignedFree(ptr); + } + + private static final class Bindings { + private final MethodHandle mallocFnPtr; + private final MethodHandle callocFnPtr; + private final MethodHandle reallocFnPtr; + private final MethodHandle freeFnPtr; + private final MethodHandle alignedAllocFnPtr; + private final MethodHandle alignedFreeFnPtr; + private final MethodHandle malloc; + private final MethodHandle calloc; + private final MethodHandle realloc; + private final MethodHandle free; + private final MethodHandle alignedAlloc; + private final MethodHandle alignedFree; + + private Bindings( + MethodHandle mallocFnPtr, + MethodHandle callocFnPtr, + MethodHandle reallocFnPtr, + MethodHandle freeFnPtr, + MethodHandle alignedAllocFnPtr, + MethodHandle alignedFreeFnPtr, + MethodHandle malloc, + MethodHandle calloc, + MethodHandle realloc, + MethodHandle free, + MethodHandle alignedAlloc, + MethodHandle alignedFree) { + this.mallocFnPtr = mallocFnPtr; + this.callocFnPtr = callocFnPtr; + this.reallocFnPtr = reallocFnPtr; + this.freeFnPtr = freeFnPtr; + this.alignedAllocFnPtr = alignedAllocFnPtr; + this.alignedFreeFnPtr = alignedFreeFnPtr; + this.malloc = malloc; + this.calloc = calloc; + this.realloc = realloc; + this.free = free; + this.alignedAlloc = alignedAlloc; + this.alignedFree = alignedFree; + } + + static Bindings create() { + try { + Class clazz = Class.forName(SAFER_BUFFER_ALLOCATOR_CLASS, true, + LWJGLSaferAllocMemoryAllocator.class.getClassLoader()); + MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + return new Bindings( + lookup.findStatic(clazz, "getMallocFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "getCallocFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "getReallocFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "getFreeFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "getAlignedAllocFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "getAlignedFreeFunctionPointer", MethodType.methodType(long.class)), + lookup.findStatic(clazz, "malloc", MethodType.methodType(long.class, long.class)), + lookup.findStatic(clazz, "calloc", MethodType.methodType(long.class, long.class, long.class)), + lookup.findStatic(clazz, "realloc", MethodType.methodType(long.class, long.class, long.class)), + lookup.findStatic(clazz, "free", MethodType.methodType(void.class, long.class)), + lookup.findStatic(clazz, "alignedAlloc", + MethodType.methodType(long.class, long.class, long.class)), + lookup.findStatic(clazz, "alignedFree", MethodType.methodType(void.class, long.class))); + } catch (ReflectiveOperationException | LinkageError e) { + return null; + } + } + + long getMallocFunctionPointer() { + try { + return (long) mallocFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long getCallocFunctionPointer() { + try { + return (long) callocFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long getReallocFunctionPointer() { + try { + return (long) reallocFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long getFreeFunctionPointer() { + try { + return (long) freeFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long getAlignedAllocFunctionPointer() { + try { + return (long) alignedAllocFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long getAlignedFreeFunctionPointer() { + try { + return (long) alignedFreeFnPtr.invokeExact(); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long malloc(long size) { + try { + return (long) malloc.invokeExact(size); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long calloc(long num, long size) { + try { + return (long) calloc.invokeExact(num, size); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long realloc(long ptr, long size) { + try { + return (long) realloc.invokeExact(ptr, size); + } catch (Throwable t) { + throw unchecked(t); + } + } + + void free(long ptr) { + try { + free.invokeExact(ptr); + } catch (Throwable t) { + throw unchecked(t); + } + } + + long alignedAlloc(long alignment, long size) { + try { + return (long) alignedAlloc.invokeExact(alignment, size); + } catch (Throwable t) { + throw unchecked(t); + } + } + + void alignedFree(long ptr) { + try { + alignedFree.invokeExact(ptr); + } catch (Throwable t) { + throw unchecked(t); + } + } + + private RuntimeException unchecked(Throwable t) { + if (t instanceof RuntimeException) { + return (RuntimeException) t; + } + if (t instanceof Error) { + throw (Error) t; + } + return new RuntimeException(t); + } + } +} diff --git a/jme3-saferallocator/build.gradle b/jme3-saferallocator/build.gradle new file mode 100644 index 0000000000..aafe17cb1b --- /dev/null +++ b/jme3-saferallocator/build.gradle @@ -0,0 +1,17 @@ +dependencies { + api project(':jme3-core') + + implementation 'org.ngengine:saferalloc:0.0.7' + implementation 'org.ngengine:saferalloc-natives-linux-x86_64:0.0.7' + implementation 'org.ngengine:saferalloc-natives-linux-aarch64:0.0.7' + implementation 'org.ngengine:saferalloc-natives-windows-x86_64:0.0.7' + implementation 'org.ngengine:saferalloc-natives-macos-x86_64:0.0.7' + implementation 'org.ngengine:saferalloc-natives-macos-aarch64:0.0.7' + implementation 'org.ngengine:saferalloc-natives-android:0.0.7' +} +javadoc { + // Disable doclint for JDK8+. + if (JavaVersion.current().isJava8Compatible()){ + options.addStringOption('Xdoclint:none', '-quiet') + } +} diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java new file mode 100644 index 0000000000..6517bd361a --- /dev/null +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferAllocMemoryGuard.java @@ -0,0 +1,365 @@ +package com.jme3.util; + +import java.util.Locale; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import java.util.logging.Level; +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()); + private static final String propertyPrefix = "saferalloc."; + + // saferalloc.softBudget.minBytes + // Minimum adaptive soft budget in bytes. + private static final long minSoftBudget = readLongProperty( + propertyPrefix + "softBudget.minBytes", + 128L * 1024L * 1024L, + 1L, + Long.MAX_VALUE + ); + // saferalloc.softBudget.initialBytes + // Initial soft budget in bytes. Clamped to at least softBudget.minBytes. + private static final long initialSoftBudget = Math.max( + minSoftBudget, + readLongProperty( + propertyPrefix + "softBudget.initialBytes", + 512L * 1024L * 1024L, + 1L, + Long.MAX_VALUE + ) + ); + + // saferalloc.gc.intervalMillis + // Minimum delay between explicit GC requests. + private static final int gcIntervalMillis = readIntProperty( + propertyPrefix + "gc.intervalMillis", + 1000, + 0, + Integer.MAX_VALUE + ); + // saferalloc.gc.maintenanceIntervalMillis + // If > 0, request maintenance GC when this much time passes without explicit GC. + private static final int maintenanceGcIntervalMillis = readIntProperty( + propertyPrefix + "gc.maintenanceIntervalMillis", + 60_000, + 0, + Integer.MAX_VALUE + ); + // saferalloc.gc.maintenanceMinUsageRatio + // Minimum current usage ratio (currentBytes / softBudget) needed before maintenance GC is considered. + private static final float maintenanceGcMinUsageRatio = readFloatProperty( + propertyPrefix + "gc.maintenanceMinUsageRatio", + 0.25f, + 0f, + 1f + ); + + // saferalloc.adapt.intervalMillis + // Minimum delay between adaptive budget updates. + private static final int adaptIntervalMillis = readIntProperty( + propertyPrefix + "adapt.intervalMillis", + 2000, + 1, + Integer.MAX_VALUE + ); + // saferalloc.adapt.growWhenOverRatio + // Mark high pressure when projected usage reaches/exceeds this ratio of soft budget. + private static final float growWhenOverRatio = readFloatProperty( + propertyPrefix + "adapt.growWhenOverRatio", + 0.90f, + 0f, + Float.MAX_VALUE + ); + // saferalloc.adapt.shrinkWhenUnderRatio + // Mark low pressure when current usage is at/below this ratio of soft budget. + private static final float shrinkWhenUnderRatio = readFloatProperty( + propertyPrefix + "adapt.shrinkWhenUnderRatio", + 0.35f, + 0f, + 1f + ); + // saferalloc.adapt.growTriggerCount + // Consecutive high-pressure observations required before growing soft budget. + private static final int growTriggerCount = readIntProperty( + propertyPrefix + "adapt.growTriggerCount", + 3, + 1, + Integer.MAX_VALUE + ); + // saferalloc.adapt.shrinkTriggerCount + // Consecutive low-pressure observations required before shrinking soft budget. + private static final int shrinkTriggerCount = readIntProperty( + propertyPrefix + "adapt.shrinkTriggerCount", + 8, + 1, + Integer.MAX_VALUE + ); + // saferalloc.adapt.growCurrentFactor + // Growth factor applied to current soft budget when growing. + private static final float growCurrentFactor = readFloatProperty( + propertyPrefix + "adapt.growCurrentFactor", + 1.25f, + 1f, + Float.MAX_VALUE + ); + // saferalloc.adapt.growDemandFactor + // Growth factor applied to projected usage when growing. + private static final float growDemandFactor = readFloatProperty( + propertyPrefix + "adapt.growDemandFactor", + 1.10f, + 1f, + Float.MAX_VALUE + ); + // saferalloc.adapt.shrinkFactor + // Multiplicative factor applied to soft budget when shrinking. + private static final float shrinkFactor = readFloatProperty( + propertyPrefix + "adapt.shrinkFactor", + 0.90f, + 0f, + 1f + ); + + private static final AtomicLong softBudget = new AtomicLong(initialSoftBudget); + private static final AtomicLong lastGCRun = new AtomicLong(0L); + private static final AtomicLong lastAdaptUpdate = new AtomicLong(0L); + 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 Runnable gcInvoker = System::gc; + + public static void beforeAlloc(long size){ + if (size < 0) return; + long now = nowSupplier.getAsLong(); + long currentBytes = allocatedBytesSupplier.getAsLong(); + long currentSoftBudget = softBudget.get(); + long projectedBytes = safeAdd(currentBytes, size); + + adaptSoftBudget(currentBytes, projectedBytes, currentSoftBudget); + currentSoftBudget = softBudget.get(); + + if(LOGGER.isLoggable(Level.FINER)){ + float softBudgetPercent = projectedBytes / (float)currentSoftBudget * 100f; + LOGGER.log(Level.FINER, "\n"+ + " Requested " + human(size) + "\n" + + " Currently allocated: " + human(currentBytes) + "\n" + + " Soft budget: " + human(currentSoftBudget) + "\n" + + " Soft budget used: " + String.format(Locale.ROOT, "%.2f %%", softBudgetPercent) + ); + } + + if (projectedBytes > currentSoftBudget) { + requestGC(now); + return; + } + + if (maintenanceGcIntervalMillis > 0) { + long minimumBytesForMaintenanceGC = (long) (currentSoftBudget * maintenanceGcMinUsageRatio); + if (currentBytes >= minimumBytesForMaintenanceGC) { + long last = lastGCRun.get(); + if (now - last >= maintenanceGcIntervalMillis) { + requestGC(now); + } + } + } + } + + public static void notifyGC(){ + long now = nowSupplier.getAsLong(); + long currentBytes = allocatedBytesSupplier.getAsLong(); + lastGCRun.set(now); + adaptSoftBudget(currentBytes, currentBytes, softBudget.get()); + } + + private static void adaptSoftBudget(long currentBytes, long projectedBytes, long currentSoftBudget) { + if (projectedBytes >= (long)(currentSoftBudget * growWhenOverRatio)) { + highPressureCount.incrementAndGet(); + lowPressureCount.set(0L); + } else if (currentBytes <= (long)(currentSoftBudget * shrinkWhenUnderRatio)) { + lowPressureCount.incrementAndGet(); + highPressureCount.set(0L); + } else { + highPressureCount.set(0L); + lowPressureCount.set(0L); + } + + long now = nowSupplier.getAsLong(); + long lastUpdate = lastAdaptUpdate.get(); + if (now - lastUpdate < adaptIntervalMillis) { + return; + } + if (!lastAdaptUpdate.compareAndSet(lastUpdate, now)) { + return; + } + + long highs = highPressureCount.get(); + if (highs >= growTriggerCount) { + long grownFromCurrent = (long)(currentSoftBudget * growCurrentFactor); + long grownFromDemand = (long)(projectedBytes * growDemandFactor); + long newBudget = clampBudget(Math.max(grownFromCurrent, grownFromDemand)); + if (newBudget > currentSoftBudget && softBudget.compareAndSet(currentSoftBudget, newBudget)) { + if (LOGGER.isLoggable(Level.FINER)) { + LOGGER.log(Level.FINER, ">>> Adaptive soft budget up: {0} -> {1}", + new Object[]{human(currentSoftBudget), human(newBudget)}); + } + } + highPressureCount.set(0L); + return; + } + + long lows = lowPressureCount.get(); + if (lows >= shrinkTriggerCount) { + long reduced = clampBudget((long)(currentSoftBudget * shrinkFactor)); + if (reduced < currentSoftBudget && softBudget.compareAndSet(currentSoftBudget, reduced)) { + if (LOGGER.isLoggable(Level.FINER)) { + LOGGER.log(Level.FINER, ">>> Adaptive soft budget down: {0} -> {1}", + new Object[]{human(currentSoftBudget), human(reduced)}); + } + } + lowPressureCount.set(0L); + } + } + + private static void requestGC(long now) { + if (now - lastGCRun.get() >= gcIntervalMillis) { + 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(); + + lastGCRun.updateAndGet(v -> { + if (v < now) return now; + return v; + }); + } + } + + // 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; + gcInvoker = gcAction != null ? gcAction : System::gc; + } + + static void resetStateForTests() { + softBudget.set(initialSoftBudget); + lastGCRun.set(0L); + lastAdaptUpdate.set(0L); + highPressureCount.set(0L); + lowPressureCount.set(0L); + } + + static long getSoftBudgetForTests() { + return softBudget.get(); + } + + private static long clampBudget(long candidate) { + if (candidate < minSoftBudget) { + return minSoftBudget; + } + return candidate; + } + + private static long safeAdd(long a, long b) { + if (b > 0 && a > Long.MAX_VALUE - b) { + return Long.MAX_VALUE; + } + return a + b; + } + + 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)); + } + if (bytes >= 1024L * 1024L) { + return String.format(Locale.ROOT, "%.2f MB", bytes / (1024.0 * 1024.0)); + } + if (bytes >= 1024L) { + return String.format(Locale.ROOT, "%.2f KB", bytes / 1024.0); + } + return bytes + " B"; + } + + private static long readLongProperty(String key, long defaultValue, long minValue, long maxValue) { + String raw = System.getProperty(key); + if (raw == null || raw.trim().isEmpty()) { + return defaultValue; + } + try { + long value = Long.parseLong(raw.trim()); + return clamp(value, minValue, maxValue); + } catch (NumberFormatException e) { + LOGGER.log(Level.WARNING, "Invalid value for {0}: {1}. Using default {2}.", + new Object[]{key, raw, defaultValue}); + return defaultValue; + } + } + + private static int readIntProperty(String key, int defaultValue, int minValue, int maxValue) { + String raw = System.getProperty(key); + if (raw == null || raw.trim().isEmpty()) { + return defaultValue; + } + try { + int value = Integer.parseInt(raw.trim()); + return clamp(value, minValue, maxValue); + } catch (NumberFormatException e) { + LOGGER.log(Level.WARNING, "Invalid value for {0}: {1}. Using default {2}.", + new Object[]{key, raw, defaultValue}); + return defaultValue; + } + } + + private static float readFloatProperty(String key, float defaultValue, float minValue, float maxValue) { + String raw = System.getProperty(key); + if (raw == null || raw.trim().isEmpty()) { + return defaultValue; + } + try { + float value = Float.parseFloat(raw.trim()); + return clamp(value, minValue, maxValue); + } catch (NumberFormatException e) { + LOGGER.log(Level.WARNING, "Invalid value for {0}: {1}. Using default {2}.", + new Object[]{key, raw, defaultValue}); + return defaultValue; + } + } + + private static long clamp(long value, long minValue, long maxValue) { + if (value < minValue) { + return minValue; + } + if (value > maxValue) { + return maxValue; + } + return value; + } + + private static int clamp(int value, int minValue, int maxValue) { + if (value < minValue) { + return minValue; + } + if (value > maxValue) { + return maxValue; + } + return value; + } + + private static float clamp(float value, float minValue, float maxValue) { + if (value < minValue) { + return minValue; + } + if (value > maxValue) { + return maxValue; + } + return value; + } +} diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java new file mode 100644 index 0000000000..24aad54c5e --- /dev/null +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java @@ -0,0 +1,219 @@ +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.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; +import java.util.logging.Level; +import org.ngengine.saferalloc.SaferAlloc; +import org.ngengine.saferalloc.SaferAllocFunctionPointers; +import org.ngengine.saferalloc.SaferAllocNative; + +public final class SaferBufferAllocator implements BufferAllocator { + private static final Logger logger = Logger.getLogger(SaferBufferAllocator.class.getName()); + private static final ReferenceQueue refQueue = new ReferenceQueue<>(); + private static final ConcurrentHashMap allocations = new ConcurrentHashMap<>(); + + private static final Thread reaperThread = new Thread(SaferBufferAllocator::reapLoop, + "Safer Deallocator"); + + static { + reaperThread.setDaemon(true); + reaperThread.start(); + SaferAlloc.ensureLoaded(); + } + + public SaferBufferAllocator() { + logger.info(getClass().getSimpleName() + " enabled!"); + } + + private static void reapLoop() { + for (;;) { + try { + AllocationRef ref = (AllocationRef) refQueue.remove(); + SaferAllocMemoryGuard.notifyGC(); + ref.freeFromQueue(); + } catch (InterruptedException e) { + return; + } catch (Throwable t) { + // Keep the reaper alive even if one cleanup fails. + logger.log(Level.SEVERE, "Error in reaper thread", t); + } + } + } + + private static final class AllocationRef extends PhantomReference { + private final long address; + private final AtomicBoolean retired = new AtomicBoolean(false); + + private AllocationRef(ByteBuffer referent, long address) { + super(referent, refQueue); + this.address = address; + } + + /** + * Used when native realloc has already taken care of the old allocation. Removes tracking without + * freeing again. + */ + private void retireWithoutFree() { + if (!retired.compareAndSet(false, true)) { + return; + } + allocations.remove(address, this); + clear(); + } + + /** + * Explicit free or queued phantom cleanup. + */ + private void freeNow() { + if (!retired.compareAndSet(false, true)) { + return; + } + + boolean removed = allocations.remove(address, this); + clear(); + + if (removed) { + SaferAlloc.free(address); + } + } + + private void freeFromQueue() { + freeNow(); + } + } + + private static ByteBuffer register(ByteBuffer buffer) { + if (buffer == null) { + return null; + } + + long address = SaferAlloc.address(buffer); + if (address == 0L) { + throw new IllegalStateException("SaferAlloc returned null address for non-null buffer"); + } + + AllocationRef ref = new AllocationRef(buffer, address); + AllocationRef previous = allocations.put(address, ref); + + if (previous != null) { + // This should normally not happen unless the old allocation was already + // logically retired (for example after realloc) or bookkeeping got out of sync. + // Never free here: the address now belongs to the new allocation. + previous.retireWithoutFree(); + } + + return buffer; + } + + public static long getMallocFunctionPointer() { + return SaferAllocFunctionPointers.malloc(); + } + + public static long getCallocFunctionPointer() { + return SaferAllocFunctionPointers.calloc(); + } + + public static long getReallocFunctionPointer() { + return SaferAllocFunctionPointers.realloc(); + } + + public static long getFreeFunctionPointer() { + return SaferAllocFunctionPointers.free(); + } + + public static long getAlignedAllocFunctionPointer() { + return SaferAllocFunctionPointers.alignedAlloc(); + } + + public static long getAlignedFreeFunctionPointer() { + return SaferAllocFunctionPointers.alignedFree(); + } + + public static long malloc(long size) { + if (size < 0) { + throw new IllegalArgumentException("size < 0"); + } + long pointer = SaferAllocNative.malloc(size); + if (pointer == 0L && size != 0) { + throw new OutOfMemoryError("SaferAlloc malloc failed: " + size); + } + return pointer; + } + + public static long calloc(long num, long size) { + if (num < 0 || size < 0) { + throw new IllegalArgumentException("num/size < 0"); + } + if (num != 0 && size > Long.MAX_VALUE / num) { + throw new OutOfMemoryError("calloc overflow"); + } + + long pointer = SaferAllocNative.calloc(num, size); + if (pointer == 0L && (num * size) != 0) { + throw new OutOfMemoryError("SaferAlloc calloc failed: " + num + "*" + size); + } + return pointer; + } + + public static long realloc(long ptr, long size) { + if (size < 0) { + throw new IllegalArgumentException("size < 0"); + } + long pointer = SaferAllocNative.realloc(ptr, size); + if (pointer == 0L && size != 0) { + throw new OutOfMemoryError("SaferAlloc realloc failed: " + size); + } + return pointer; + } + + public static void free(long ptr) { + if (ptr != 0L) { + SaferAllocNative.free(ptr); + } + } + + public static long alignedAlloc(long alignment, long size) { + if (alignment <= 0) { + throw new IllegalArgumentException("alignment <= 0"); + } + if (size < 0) { + throw new IllegalArgumentException("size < 0"); + } + long pointer = SaferAllocNative.mallocAligned(size, alignment); + if (pointer == 0L && size != 0) { + throw new OutOfMemoryError("SaferAlloc aligned_alloc failed: " + size); + } + return pointer; + } + + public static void alignedFree(long ptr) { + if (ptr != 0L) { + SaferAllocNative.free(ptr); + } + } + + @Override + public ByteBuffer allocate(int size) { + SaferAllocMemoryGuard.beforeAlloc(size); + return register(SaferAlloc.calloc(1, size)); + } + + @Override + public void destroyDirectBuffer(Buffer buffer) { + if (buffer == null) { + return; + } + + long address = SaferAlloc.address(buffer); + AllocationRef ref = allocations.get(address); + + if (ref != null) { + ref.freeNow(); + } + } +} diff --git a/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java b/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java new file mode 100644 index 0000000000..102e8ea362 --- /dev/null +++ b/jme3-saferallocator/src/test/java/com/jme3/util/SaferAllocMemoryGuardTest.java @@ -0,0 +1,104 @@ +package com.jme3.util; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class SaferAllocMemoryGuardTest { + + private static final long MIB = 1024L * 1024L; + + @Before + public void setUp() { + SaferAllocMemoryGuard.resetStateForTests(); + SaferAllocMemoryGuard.setTestHooks(null, null, null); + } + + @After + public void tearDown() { + SaferAllocMemoryGuard.resetStateForTests(); + SaferAllocMemoryGuard.setTestHooks(null, null, null); + } + + @Test + public void shouldAdaptBudgetUpThenDown() { + 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(3_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + now.set(6_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + + long grownBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + Assert.assertTrue("Expected budget to grow under sustained high pressure", grownBudget > initialBudget); + Assert.assertEquals("Should not request GC while still below budget", 0, gcCalls.get()); + + currentBytes.set(0L); + for (int i = 0; i < 8; i++) { + now.addAndGet(3_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + } + + long shrunkBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + Assert.assertTrue("Expected budget to shrink under sustained low pressure", shrunkBudget < grownBudget); + } + + @Test + public void shouldRequestGcOnBurstEvenWithAdaptiveBudget() { + 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(3_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + now.set(6_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + + long grownBudget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + Assert.assertTrue(grownBudget > initialBudget); + + currentBytes.set(grownBudget + 64L * MIB); + now.addAndGet(3_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + + Assert.assertTrue("Expected explicit GC request on over-budget burst", gcCalls.get() >= 2); + } + + @Test + public void shouldRequestMaintenanceGcAfterSilence() { + AtomicLong now = new AtomicLong(0L); + AtomicLong currentBytes = new AtomicLong(); + AtomicInteger gcCalls = new AtomicInteger(0); + + SaferAllocMemoryGuard.setTestHooks(currentBytes::get, now::get, gcCalls::incrementAndGet); + + long budget = SaferAllocMemoryGuard.getSoftBudgetForTests(); + currentBytes.set((long) (budget * 0.50f)); + + SaferAllocMemoryGuard.beforeAlloc(0L); + Assert.assertEquals(0, gcCalls.get()); + + now.set(61_000L); + SaferAllocMemoryGuard.beforeAlloc(0L); + + Assert.assertTrue("Expected maintenance GC after long silence under non-trivial usage", gcCalls.get() >= 2); + } +} diff --git a/settings.gradle b/settings.gradle index d0eeda5c40..fa84f6c8b0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -18,6 +18,7 @@ include 'jme3-desktop' include 'jme3-lwjgl' if (JavaVersion.current().isJava8Compatible()) { include 'jme3-lwjgl3' + include 'jme3-saferallocator' } // Other external dependencies