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