2

I try to high frequently update the cells in a JFX TableView (proof of concept application). I load a TableView via FXML and start an ExecutorService to change the value of a cell.

When I start the application I notice, that the update works for the first 3-4 million elements and then it stucks. If I slow down the updates (see MAGIC#1) it works (10ms is still too fast, but 100ms delay works). So I thought it might be a threading issue.

But then I found out that if I add an empty ChangeListener (see MAGIC#2) to the property it works fine. Even without the need of MAGIC#1.

Am I doing something wrong? Do I have to update the cells in a different way?

Thanks in advance for your help!!

The elements in the TableView:

public class Element {
  public static final AtomicInteger x = new AtomicInteger(0);
  private final StringProperty nameProperty = new SimpleStringProperty("INIT");

  public Element() {
    // MAGIC#2
    // this.nameProperty.addListener((observable, oldValue, newValue) -> {});
  }

  public void tick() {
    this.setName(String.valueOf(x.incrementAndGet()));
  }

  public String getName() ...
  public void setName(String name)...
  public StringProperty nameProperty() ...
}

The controller for FXML:

public class TablePerformanceController implements Initializable {
  private final ObservableList<Element> data = FXCollections.observableArrayList();

  public Runnable changeValues = () -> {
    while (true) {
      if (Thread.currentThread().isInterrupted()) break;
      data.get(0).tick();
      // MAGIC#1
      // try { Thread.sleep(100); } catch (Exception e) {}
    }
  };

  private ExecutorService executor = null;

  @FXML
  public TableView<Element> table;

  @Override
  public void initialize(URL location, ResourceBundle resources) {
    this.table.setEditable(true);

    TableColumn<Element, String> nameCol = new TableColumn<>("Name");
    nameCol.setCellValueFactory(cell -> cell.getValue().nameProperty());
    this.table.getColumns().addAll(nameCol);

    this.data.add(new Element());
    this.table.setItems(this.data);

    this.executor = Executors.newSingleThreadExecutor();
    this.executor.submit(this.changeValues);
  }
}
Ankit Shubham
  • 2,989
  • 2
  • 36
  • 61

1 Answers1

2

You are violating the single threaded rule for JavaFX: updates to the UI must only be made from the FX Application Thread. Your tick() method updates the nameProperty(), and since the table cell is observing the nameProperty(), tick() results in an update to the UI. Since you're calling tick() from a background thread, this update to the UI happens on the background thread. The resulting behavior is essentially undefined.

Additionally, your code ends up with too many requests to update the UI. So even if you fix the threading issues, you need to somehow throttle the requests so that you don't flood the FX Application Thread with too many requests to update, which will make it unresponsive.

The technique to do this is addressed in Throttling javafx gui updates. I'll repeat it here in the context of a table model class:

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class Element {

    // Note that in the example we only actually reference this from a single background thread,
    // in which case we could just make this a regular int. However, for general use this might
    // need to be threadsafe.
    private final AtomicInteger x = new AtomicInteger(0);

    private final StringProperty nameProperty = new SimpleStringProperty("INIT");

    private final AtomicReference<String> name = new AtomicReference<>();


    /** This method is safe to call from any thread. */
    public void tick() {
        if (name.getAndSet(Integer.toString(x.incrementAndGet())) == null) {
            Platform.runLater(() -> nameProperty.set(name.getAndSet(null)));
        }
    }

    public String getName() {
        return nameProperty().get();
    }

    public void setName(String name) {
        nameProperty().set(name);
    }

    public StringProperty nameProperty() {
        return nameProperty;
    }
}

The basic idea here is to use an AtomicReference<String to "shadow" the real property. Atomically update it and check if it's null, and if so schedule an update to the real property on the FX Application Thread. In the update, atomically retrieve the "shadow" value and reset it to null, and set the real property to the retrieved value. This ensures that new requests to update on the FX Application thread are only made as often as the FX Application Thread consumes them, ensuring that the FX Application Thread is not flooded. Of course, if there is a delay between scheduling the update on the FX Application Thread, and the update actually occurring, when the update does happen it will still retrieve the latest value to which the "shadow" value was set.

Here's a standalone test, which is basically equivalent to the controller code you showed:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.stage.Stage;

public class FastTableUpdate extends Application {

    private final ObservableList<Element> data = FXCollections.observableArrayList();

    public final Runnable changeValues = () -> {
      while (true) {
        if (Thread.currentThread().isInterrupted()) break;
        data.get(0).tick();
      }
    };

    private final ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> {
        Thread t = new Thread(runnable);
        t.setDaemon(true);
        return t ;
    });



    @Override
    public void start(Stage primaryStage) {

        TableView<Element> table = new TableView<>();
        table.setEditable(true);

        TableColumn<Element, String> nameCol = new TableColumn<>("Name");
        nameCol.setPrefWidth(200);
        nameCol.setCellValueFactory(cell -> cell.getValue().nameProperty());
        table.getColumns().add(nameCol);

        this.data.add(new Element());
        table.setItems(this.data);

        this.executor.submit(this.changeValues);        

        Scene scene = new Scene(table, 600, 600);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}
Community
  • 1
  • 1
James_D
  • 201,275
  • 16
  • 291
  • 322