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/



--

Reply via email to