Concurrency issues in JavaFX GUI Updater utility c

2019-02-11 05:26发布

I'm building a GUI in JavaFX for a rather large Java project. This project has many different worker threads doing some heavy computations in the background and I'm trying to visualize the progress of these worker threads in a GUI. By progress I mean not only a bare percentage but also other variables not contained within the Task class such as (for example):

  • The current file
  • The current Error count
  • The amount of bytes read so far
  • ...

As these progress variables change very rapidly and because I have to do the GUI updates from the JavaFX thread (Platform.runLater()), the JavaFX event queue gets overloaded very quickly. I'm trying to fix this by building a utility class capable of asynchronously updating GUI Properties from outside the JavaFX threads. Rapid consecutive updates should be skipped in order to only display the latest value and thus avoid swarming the JavaFX event queue with Runnables.

I therefore built the following class GUIUpdater to bind Properties (Usually a GUI element such as a Label) to ObservableValues (like a SimpleStringProperty). This class has two InnerClasses:

  • PropertyUpdateris responsible for binding a single Property to a single ObservableValue and updating it.
  • The Updater provides a re-usable Runnable object for Platform.runLater().

The utility class:

package main;

import java.util.concurrent.ConcurrentLinkedQueue;

import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;

/**
 * Class for enabling fast updates of GUI components from outside the JavaFX thread.
 *  Updating GUI components (such as labels) should be done from the JavaFX thread by using Platform.runLater for example.
 *  This makes it hard to update the GUI with a fast changing variable as it is very easy to fill up the JavaFX event queue faster than it can be emptied (i.e. faster than it can be drawn).
 *  This class binds ObservableValues to (GUI) Properties and ensures that quick consecutive updates are ignored, only updating to the latest value.
 */
public class GUIUpdater {
    private ConcurrentLinkedQueue<PropertyUpdater<?>>   dirtyPropertyUpdaters   =   new ConcurrentLinkedQueue<>();
    private Updater                                     updater                 =   new Updater();
    private boolean                                     isUpdating              =   false;

    /**
     * Binds an ObservableValue to a Property.
     *  Updates to the ObservableValue can be made from outside the JavaFX thread and the latest update will be reflected in the Property.
     * @param property      (GUI) Property to be updated/
     * @param observable    ObservableValue to update the GUI property to.
     */
    public <T> void bind(Property<T> property, ObservableValue<T> observable) {
        PropertyUpdater<T>  propertyUpdater = new PropertyUpdater<>(property, observable);
        observable.addListener(propertyUpdater);
    }

    /**
     * Unbinds the given ObservableValue from the given Property.
     *  Updates to the ObservableValue will no longer be reflected in the Property.
     * @param property      (GUI) Property to unbind the ObservableValue from.
     * @param observable    ObservableValue to unbind from the given Property.
     */
    public <T> void unbind(Property<T> property, ObservableValue<T> observable) {
        PropertyUpdater<T>  tmpPropertyUpdater = new PropertyUpdater<>(property, observable);
        observable.removeListener(tmpPropertyUpdater);
    }

    /**
     * Schedules an update to the GUI by using a call to Platform.runLater().
     *  The updated property is added to the dirtyProperties list, marking it for the next update round.
     *  Will only submit the event to the event queue if the event isn't in the event queue yet.
     * @param updater
     */
    private void scheduleUpdate(PropertyUpdater<?> updater) {
        this.dirtyPropertyUpdaters.add(updater);

        // Make sure the isUpdating var isn't changed concurrently by the Updater thread (on the JavaFX event queue)
        synchronized (this) {
            if (!this.isUpdating) {
                this.isUpdating = true;
                Platform.runLater(this.updater);
            }
        }
    }

    /**
     * Class used for binding a single ObservableValue to a Property and updating it.
     *
     * @param <T>
     */
    private class PropertyUpdater<T> implements ChangeListener<T> {
        private boolean             isDirty     =   false;
        private Property<T>         property    =   null;
        private ObservableValue<T>  observable  =   null;

        public PropertyUpdater(Property<T> property, ObservableValue<T> observable) {
            this.property = property;
            this.observable = observable;
        }

        @Override
        /**
         * Called whenever the ObservableValue has changed. Marks this Updater as dirty.
         */
        public synchronized void changed(ObservableValue<? extends T> observable, T oldValue, T newValue) {
            if (!this.isDirty) {
                this.isDirty = true;
                GUIUpdater.this.scheduleUpdate(this);
            }
        }

        /**
         * Updates the Property to the ObservableValue and marks it as clean again.
         *  Should only be called from the JavaFX thread.
         */
        public synchronized void update() {
            T value = this.observable.getValue();
            this.property.setValue(value);
            this.isDirty = false;
        }

        @Override
        /**
         * Two PropertyUpdaters are equals if their Property and ObservableValue map to the same object (address).
         */
        public boolean equals(Object otherObj) {
            PropertyUpdater<?>  otherUpdater = (PropertyUpdater<?>) otherObj;
            if (otherObj == null) {
                return false;
            } else {
                // Only compare addresses (comparing with equals also compares contents):
                return (this.property == otherUpdater.property) && (this.observable == otherUpdater.observable);
            }
        }
    }

    /**
     * Simple class containing the Runnable for the call to Platform.runLater.
     *  Hence, the run() method should only be called from the JavaFX thread.
     *
     */
    private class Updater implements Runnable {

        @Override
        public void run() {
            // Loop through the individual PropertyUpdaters, updating them one by one:
            while(!GUIUpdater.this.dirtyPropertyUpdaters.isEmpty()) {
                PropertyUpdater<?>  curUpdater = GUIUpdater.this.dirtyPropertyUpdaters.poll();
                curUpdater.update();
            }

            // Make sure we're not clearing the mark when scheduleUpdate() is still setting it:
            synchronized (GUIUpdater.this) {
                GUIUpdater.this.isUpdating = false;
            }
        }

    }
}

And this is a simple class for testing the GUIUpdater utility class:

package main;

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.FlowPane;
import javafx.stage.Stage;

public class JavaFXTest extends Application {
    private GUIUpdater  guiUpdater  =   new GUIUpdater();
    private Label       lblState    =   new Label();
    private ProgressBar prgProgress =   new ProgressBar();

    public static void main(String args[]) {
        JavaFXTest.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        // Init window:
        FlowPane    flowPane = new FlowPane();
        primaryStage.setScene(new Scene(flowPane));
        primaryStage.setTitle("JavaFXTest");

        // Add a Label and a progressBar:
        flowPane.getChildren().add(this.lblState);
        flowPane.getChildren().add(this.prgProgress);

        // Add button:
        Button  btnStart = new Button("Start");
        btnStart.setOnAction(new EventHandler<ActionEvent>() {

            @Override
            public void handle(ActionEvent event) {
                // Create task:
                TestTask    testTask = new TestTask();

                // Bind:
                JavaFXTest.this.guiUpdater.bind(JavaFXTest.this.lblState.textProperty(), testTask.myStateProperty());
                JavaFXTest.this.prgProgress.progressProperty().bind(testTask.progressProperty());   // No need to use GUIUpdater here, Task class provides the same functionality for progress.

                // Start task:
                Thread  tmpThread = new Thread(testTask);
                tmpThread.start();
            }
        });
        flowPane.getChildren().add(btnStart);

        // Show:
        primaryStage.show();
    }

    /**
     * A simple task containing a for loop to simulate a fast running and fast updating process.
     * @author DePhille
     *
     */
    private class TestTask extends Task<Void> {
        private SimpleStringProperty    myState =   new SimpleStringProperty();

        @Override
        protected Void call() throws Exception {

            // Count:
            try {
                int maxValue = 1000000;

                System.out.println("Starting...");
                for(int i = 0; i < maxValue; i++) {
                    this.updateProgress(i, maxValue - 1);
                    this.myState.set("Hello " + i);
                }
                System.out.println("Done!");    
            } catch(Exception e) {
                e.printStackTrace();
            }

            // Unbind:
            JavaFXTest.this.guiUpdater.unbind(JavaFXTest.this.lblState.textProperty(), this.myStateProperty());
            return null;
        }

        public SimpleStringProperty myStateProperty() {
            return this.myState;
        }

    }

}

The problem with the code is that sometimes the Label is not updated to the latest value (in this case 999999). It seems to mostly happen right after the Application is started, so starting the app, clicking the "Start" button, closing it and repeating this process should replicate the problem after a few tries. As far as I can see I've added synchronized blocks where needed which is why I don't understand where the problem comes from.

Even though I'm primarily looking for the solution to the described problem all suggestions are very much appreciated (even those not related to the problem)! I added comments in the code as well, so I hope that together with the information above this provides enough detail about the problem and the code.

Thanks in advance!

2条回答
我命由我不由天
2楼-- · 2019-02-11 05:42

I believe this functionality can be achieved through the Task's messageProperty:

public void handle(ActionEvent event) {
    ...
    JavaFXTest.this.lblState.textProperty().bind(testTask.messageProperty());
    ...
}

...

protected Void call() throws Exception {
    ...
    this.updateProgress(i, maxValue - 1);
    this.updateMessage("Hello " + i);
    ...
}
查看更多
Emotional °昔
3楼-- · 2019-02-11 05:48

I was able to fix the issue myself. After a few days of adding System.out in various places it turned out the problem was due to concurrency issues with the isUpdating variable. The problem occured when the JavaFX thread was inbetween the while loop and the synchronized block in Updater.run. I solved the problem by making both the Updater.run and GUIUpdater.scheduleUpdate methods synchronized on the same object.

I also made the GUIUpdater into a static-only object as having multiple instances will place Runnables in the JavaFX event queue regardless of the other GUIUpdater instances, clogging up the event queue. All in all, this is the resulting GUIUpdater class:

package be.pbeckers.javafxguiupdater;

import java.util.concurrent.ConcurrentLinkedQueue;

import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;


/**
 * Class for enabling fast updates of GUI components from outside the JavaFX thread.
 *  Updating GUI components (such as labels) should be done from the JavaFX thread by using Platform.runLater for example.
 *  This makes it hard to update the GUI with a fast changing variable as it is very easy to fill up the JavaFX event queue faster than it can be emptied (i.e. faster than it can be drawn).
 *  This class binds ObservableValues to (GUI) Properties and ensures that quick consecutive updates are ignored, only updating to the latest value.
 */
public abstract class GUIUpdater {
    private static  ConcurrentLinkedQueue<PropertyUpdater<?>>   dirtyPropertyUpdaters   =   new ConcurrentLinkedQueue<>();
    private static  Updater                                     updater                 =   new Updater();
    private static  boolean                                     isUpdating              =   false;

    /**
     * Binds an ObservableValue to a Property.
     *  Updates to the ObservableValue can be made from outside the JavaFX thread and the latest update will be reflected in the Property.
     * @param property      (GUI) Property to be updated/
     * @param observable    ObservableValue to update the GUI property to.
     */
    public static <T> void bind(Property<T> property, ObservableValue<T> observable) {
        PropertyUpdater<T>  propertyUpdater = new PropertyUpdater<>(property, observable);
        observable.addListener(propertyUpdater);
    }

    /**
     * Unbinds the given ObservableValue from the given Property.
     *  Updates to the ObservableValue will no longer be reflected in the Property.
     * @param property      (GUI) Property to unbind the ObservableValue from.
     * @param observable    ObservableValue to unbind from the given Property.
     */
    public static <T> void unbind(Property<T> property, ObservableValue<T> observable) {
        PropertyUpdater<T>  tmpPropertyUpdater = new PropertyUpdater<>(property, observable);
        observable.removeListener(tmpPropertyUpdater);
    }

    /**
     * Schedules an update to the GUI by using a call to Platform.runLater().
     *  The updated property is added to the dirtyProperties list, marking it for the next update round.
     *  Will only submit the event to the event queue if the event isn't in the event queue yet.
     * @param updater
     */
    private static synchronized void scheduleUpdate(PropertyUpdater<?> updater) {
        GUIUpdater.dirtyPropertyUpdaters.add(updater);

        if (!GUIUpdater.isUpdating) {
            GUIUpdater.isUpdating = true;
            Platform.runLater(GUIUpdater.updater);
        }
    }

    /**
     * Class used for binding a single ObservableValue to a Property and updating it.
     *
     * @param <T>
     */
    private static class PropertyUpdater<T> implements ChangeListener<T> {
        private boolean             isDirty     =   false;
        private Property<T>         property    =   null;
        private ObservableValue<T>  observable  =   null;

        public PropertyUpdater(Property<T> property, ObservableValue<T> observable) {
            this.property = property;
            this.observable = observable;
        }

        @Override
        /**
         * Called whenever the ObservableValue has changed. Marks this Updater as dirty.
         */
        public synchronized void changed(ObservableValue<? extends T> observable, T oldValue, T newValue) {
            if (!this.isDirty) {
                this.isDirty = true;
                GUIUpdater.scheduleUpdate(this);
            }
        }

        /**
         * Updates the Property to the ObservableValue and marks it as clean again.
         *  Should only be called from the JavaFX thread.
         */
        public synchronized void update() {
            T value = this.observable.getValue();
            this.property.setValue(value);
            this.isDirty = false;
        }

        @Override
        /**
         * Two PropertyUpdaters are equals if their Property and ObservableValue map to the same object (address).
         */
        public boolean equals(Object otherObj) {
            PropertyUpdater<?>  otherUpdater = (PropertyUpdater<?>) otherObj;
            if (otherObj == null) {
                return false;
            } else {
                // Only compare addresses (comparing with equals also compares contents):
                return (this.property == otherUpdater.property) && (this.observable == otherUpdater.observable);
            }
        }
    }

    /**
     * Simple class containing the Runnable for the call to Platform.runLater.
     *  Hence, the run() method should only be called from the JavaFX thread.
     *
     */
    private static class Updater implements Runnable {

        @Override
        public void run() {
            synchronized (GUIUpdater.class) {

                // Loop through the individual PropertyUpdaters, updating them one by one:
                while(!GUIUpdater.dirtyPropertyUpdaters.isEmpty()) {
                    PropertyUpdater<?>  curUpdater = GUIUpdater.dirtyPropertyUpdaters.poll();
                    curUpdater.update();
                }

                // Mark as updated:
                GUIUpdater.isUpdating = false;              
            }
        }

    }
}

And this is a slightly updated version of the tester class (I'm not going into detail on this one as it's totally unimportant):

package be.pbeckers.javafxguiupdater.test;

import be.pbeckers.javafxguiupdater.GUIUpdater;
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.FlowPane;
import javafx.stage.Stage;

public class JavaFXTest extends Application {
    private Label       lblCurFile      =   new Label();
    private Label       lblErrors       =   new Label();
    private Label       lblBytesParsed  =   new Label();
    private ProgressBar prgProgress     =   new ProgressBar();

    public static void main(String args[]) {
        JavaFXTest.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        // Init window:
        FlowPane    flowPane = new FlowPane();
        primaryStage.setScene(new Scene(flowPane));
        primaryStage.setTitle("JavaFXTest");

        // Add a few Labels and a progressBar:
        flowPane.getChildren().add(this.lblCurFile);
        flowPane.getChildren().add(this.lblErrors);
        flowPane.getChildren().add(this.lblBytesParsed);
        flowPane.getChildren().add(this.prgProgress);

        // Add button:
        Button  btnStart = new Button("Start");
        btnStart.setOnAction(new EventHandler<ActionEvent>() {

            @Override
            public void handle(ActionEvent event) {
                // Create task:
                TestTask    testTask = new TestTask();

                // Bind:
                GUIUpdater.bind(JavaFXTest.this.lblCurFile.textProperty(), testTask.curFileProperty());
                GUIUpdater.bind(JavaFXTest.this.lblErrors.textProperty(), testTask.errorsProperty());
                GUIUpdater.bind(JavaFXTest.this.lblBytesParsed.textProperty(), testTask.bytesParsedProperty());
                JavaFXTest.this.prgProgress.progressProperty().bind(testTask.progressProperty());   // No need to use GUIUpdater here, Task class provides the same functionality for progress.

                // Start task:
                Thread  tmpThread = new Thread(testTask);
                tmpThread.start();
            }
        });
        flowPane.getChildren().add(btnStart);

        // Show:
        primaryStage.show();
    }

    /**
     * A simple task containing a for loop to simulate a fast running and fast updating process.
     * @author DePhille
     *
     */
    private class TestTask extends Task<Void> {
        private SimpleStringProperty    curFile     =   new SimpleStringProperty();
        private SimpleStringProperty    errors      =   new SimpleStringProperty();
        private SimpleStringProperty    bytesParsed =   new SimpleStringProperty();

        @Override
        protected Void call() throws Exception {

            // Count:
            try {
                int maxValue = 1000000;
                long startTime = System.currentTimeMillis();

                System.out.println("Starting...");
                for(int i = 0; i < maxValue; i++) {
                    this.updateProgress(i, maxValue - 1);

                    // Simulate some progress variables:
                    this.curFile.set("File_" + i + ".txt");
                    if ((i % 1000) == 0) {
                        //this.errors.set("" + (i / 1000) + " Errors");
                    }
                    //this.bytesParsed.set("" + (i / 1024) + " KBytes");
                }
                long stopTime = System.currentTimeMillis();
                System.out.println("Done in " + (stopTime - startTime) + " msec!");
            } catch(Exception e) {
                e.printStackTrace();
            }

            // Unbind:
            GUIUpdater.unbind(JavaFXTest.this.lblCurFile.textProperty(), this.curFileProperty());
            GUIUpdater.unbind(JavaFXTest.this.lblErrors.textProperty(), this.errorsProperty());
            GUIUpdater.unbind(JavaFXTest.this.lblBytesParsed.textProperty(), this.bytesParsedProperty());
            return null;
        }

        public SimpleStringProperty curFileProperty() {
            return this.curFile;
        }

        public SimpleStringProperty errorsProperty() {
            return this.errors;
        }

        public SimpleStringProperty bytesParsedProperty() {
            return this.bytesParsed;
        }

    }

}
查看更多
登录 后发表回答