Hey Frank! Based on your report, I've filed https://bugs.openjdk.org/browse/JDK-8395377
Best regards, Jose On Thu, Oct 15, 2026 at 8:52 AM Frank Delporte <[email protected]> wrote: > 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/ --
