Hello
While doing performance testing of https://melodymatrix.rocks/ , an issue was discovered on macOS/Metal/JavaFX 27. Disclaimer, reproducer and report created by Claude.ai. Component: javafx / graphics (Prism Metal pipeline) Type: Bug Affected version: JavaFX 27 (27.0.0+101) OS / hardware: macOS 27.0.1 (26A434), Apple M4 Pro (Metal 4), HiDPI display (scale 2) JDK: OpenJDK 27 (Zulu27.28+103-CA, build 27+35) SUMMARY With the Metal pipeline (the default on macOS), a node that has cache = true keeps its cached texture on the Java heap after the node has been removed from the scene and is no longer referenced. The memory is never released, so the heap grows by the size of the cached image (scaled for HiDPI) for every such node that has been shown. ScrollPaneSkin.initialize() calls setCache(true) on the viewport, so every ScrollPane is affected without the application ever enabling caching itself. In our application (views and tabs that are opened and closed by the user) the heap after a full GC grew by about 250 MB per open/close cycle of all views, without a limit. The same test case with -Dprism.order=es2 does not leak. STEPS TO REPRODUCE 1. Save the test case below as CacheTextureLeak.java. 2. Run it with the Metal pipeline (default) and with ES2, in both modes: FX=<path to the JavaFX 27 SDK lib folder, or the javafx-base/graphics/controls jars> java --module-path $FX --add-modules javafx.controls CacheTextureLeak.java cached java --module-path $FX --add-modules javafx.controls CacheTextureLeak.java scrollpane java -Dprism.order=es2 --module-path $FX --add-modules javafx.controls CacheTextureLeak.java cached java -Dprism.order=es2 --module-path $FX --add-modules javafx.controls CacheTextureLeak.java scrollpane The test adds a node to a 1600x900 scene, lets it render for one second, removes it again, and repeats this 45 times. After every 15 nodes it prints the used heap after System.gc(). EXPECTED RESULT The heap stays roughly flat: removed nodes and their cached textures can be collected. ACTUAL RESULT With Metal the heap grows linearly with the number of nodes that have been shown, and is never released: Pipeline Mode 0 nodes 15 nodes 30 nodes 45 nodes --------------- ------------------------------------------- ------- -------- -------- -------- Metal (default) cached (800x800 Circle, setCache(true)) 29 MB 269 MB 509 MB 749 MB Metal (default) scrollpane (plain ScrollPane) 29 MB 392 MB 752 MB 1112 MB ES2 cached 5 MB 5 MB 5 MB 6 MB ES2 scrollpane 5 MB 8 MB 8 MB 8 MB That is about 16 MB per cached circle and 24 MB per ScrollPane, which matches the size of the cached area at HiDPI scale 2 as 32-bit pixels (e.g. 3200 x 1800 x 4 bytes = 23 MB for the 1600x900 viewport). In a heap dump of the real application, the retained memory consists of large int[] arrays (5-23 MB each) that are referenced from com.sun.prism.mtl.MTLRTTexture.pixels, reachable via the CacheFilter images; no application objects are in the GC root chains. Further observations (from a larger reproducer with more modes): - A large Canvas (1600x900) without caching does NOT leak (the heap stays flat after an initial increase). - A DropShadow effect without caching does NOT leak. - Calling setCache(false) on the node just before removing it does NOT release the memory. - Calling setCache(false) on the ScrollPane viewport BEFORE its first render prevents the leak. TEST CASE import javafx.application.Application; import javafx.application.Platform; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.ScrollPane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.stage.Stage; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; /** * Adds a node to the scene, lets it render for 1 second, removes it, and repeats. * Prints the heap after a full GC every 15 nodes. * * Usage: java CacheTextureLeak.java [cached|scrollpane] */ public class CacheTextureLeak extends Application { private static StackPane root; public static void main(String[] args) { String mode = args.length > 0 ? args[0] : "cached"; Supplier<Node> factory = mode.equals("scrollpane") // ScrollPaneSkin calls setCache(true) on its viewport ? () -> new ScrollPane(new StackPane(new Circle(700, Color.ORANGE))) : () -> { Circle circle = new Circle(400, Color.ORANGE); circle.setCache(true); return circle; }; Thread driver = new Thread(() -> drive(mode, factory)); driver.setDaemon(true); driver.start(); launch(args); } @Override public void start(Stage stage) { root = new StackPane(); stage.setScene(new Scene(root, 1600, 900)); stage.show(); } private static void drive(String mode, Supplier<Node> factory) { try { while (root == null) Thread.sleep(100); Thread.sleep(1000); System.out.println("JavaFX " + System.getProperty("javafx.runtime.version") + ", prism.order=" + System.getProperty("prism.order", "(default)") + ", mode=" + mode); printHeap(0); for (int round = 1; round <= 3; round++) { for (int i = 0; i < 15; i++) { Node node = onFx(() -> { Node n = factory.get(); root.getChildren().add(n); return n; }); Thread.sleep(1000); onFx(() -> root.getChildren().remove(node)); Thread.sleep(100); } printHeap(round); } } catch (Exception e) { e.printStackTrace(); } finally { Platform.exit(); } } private static void printHeap(int round) throws InterruptedException { System.gc(); Thread.sleep(500); System.gc(); Runtime rt = Runtime.getRuntime(); System.out.printf("after %2d nodes: heap used %4d MB%n", round * 15, (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024); } private static <T> T onFx(java.util.concurrent.Callable<T> block) throws Exception { CompletableFuture<T> future = new CompletableFuture<>(); Platform.runLater(() -> { try { future.complete(block.call()); } catch (Throwable t) { future.completeExceptionally(t); } }); return future.get(30, TimeUnit.SECONDS); } } WORKAROUND Either use the ES2 pipeline (-Dprism.order=es2), or make sure no node is ever rendered with cache = true. We use a post-layout pulse listener on every scene (it runs after layout, before rendering) that switches the cache off for all nodes, which catches the viewport of every ScrollPane before its first render: scene.addPostLayoutPulseListener(() -> disableCache(scene.getRoot())); static void disableCache(Node node) { if (node.isCache()) node.setCache(false); if (node instanceof Parent parent) { for (Node child : parent.getChildrenUnmodifiable()) disableCache(child); } } With this workaround the heap of our application stays flat (111 -> 142 -> 142 -> 143 MB over three open/close cycles of all views, instead of 143 -> 440 -> 693 -> 957 MB). Best regards Frank Delporte Want to have coding-fun? Check my blog https://webtechie.be/ and book "Java Programming for Raspberry Pi" on https://webtechie.be/books/
