This is a question about Java/JavaFX concurrency. Specifically, how can the UI request intermediate data from a thread running in an infinite loop.
I have a simulation program in Java and JavaFX. The simulation model executes its calculations in an infinite loop running in the background. The simulation updates its state in discrete steps, but there is no particular "task" to be completed. The point of the program is to generate semi-quantitative data to display in the UI as a continuously running animation. The model calculations run until the user exits the program.
As part of a project to speed up the simulation, I would like to include the ability to retrieve "sanity check" calculations on demand from the JavaFX UI.
The simulation breaks down its execution into single steps. Depending on simulation parameters, it can execute a few iterations per second up to several thousand iterations per second.
It must be possible for the user to pause and restart the simulation at will (to examine the animation or calculated values in more detail for example).
The SSCCE listed below was created with Java 17 and JavaFX 17.0.0.1.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class Coord extends Application {
private static final String BTN_START_TEXT = "Start";
private static final String BTN_PAUSE_TEXT = "Pause";
private static final String BTN_RESUME_TEXT = "Resume";
// Update statistics about every half second.
private static final long STAT_UPDATE_NANOS = 500000000;
final AtomicLong flagOrStepCount = new AtomicLong(-1L);
Instant lastStatUpdate = Instant.now();
SimModel sm;
SimRunner sr;
public Coord() {
sm = new SimModel();
sr = new SimRunner(sm);
}
@Override
public void start(Stage stage) {
stage.setTitle("Coordination SSCCE");
Label stepLbl = new Label("0");
Label statLbl = new Label("0.0");
Label modelLbl = new Label("No data yet.");
Button startBtn = new Button(BTN_START_TEXT);
startBtn.setOnAction(e -> {
switch (startBtn.getText()) {
case BTN_START_TEXT:
case BTN_RESUME_TEXT:
startBtn.setText(BTN_PAUSE_TEXT);
sr.resumeThread();
break;
case BTN_PAUSE_TEXT:
startBtn.setText(BTN_RESUME_TEXT);
sr.pauseThread();
break;
default:
break;
}
});
Button exitBtn = new Button("Exit");
exitBtn.setOnAction(evt -> Platform.exit());
VBox root = new VBox(20, stepLbl, statLbl, modelLbl, startBtn, exitBtn);
root.setAlignment(Pos.CENTER);
root.setPadding(new Insets(20));
Scene scene = new Scene(root, 325, 200);
sr.stepsTaken()
.addListener((ObservableValue<? extends Number> observable,
Number oldValue, Number newValue) -> {
if (flagOrStepCount.getAndSet(newValue.longValue()) == -1) {
Platform.runLater(() -> {
long steps = flagOrStepCount.getAndSet(-1);
stepLbl.setText(Long.toString(steps));
if (sr.getHaveNewStats()) {
statLbl.setText(Double.toString(sm.getStatistic()));
sr.setHaveNewStats(false);
}
if (Duration.between(lastStatUpdate,
Instant.now()).getNano() > STAT_UPDATE_NANOS) {
// Next line added as bug fix.
lastStatUpdate = Instant.now();
sr.setWantNewStats(true);
}
if (sr.haveNewCoords) {
modelLbl.setText(String.valueOf(sm.getArrayCopy()));
sr.setHaveNewCoords(false);
}
// We always want any new array data on the next frame.
sr.setWantNewCoords(true);
});
}
});
sr.pauseThread();
stage.setScene(scene);
stage.show();
sr.start();
}
public static void main(String[] args) {
launch();
}
class SimModel {
private static final int ARRAY_SIZE = 100;
double statistic;
char[] charArray;
char[] arrayCopy;
public SimModel() {
statistic = 0.0;
charArray = new char[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
charArray[i] = (i % 2 == 0) ? 'X' : '.';
}
}
public void singleStep() {
charArray = null;
charArray = new char[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++) {
charArray[i] = (i % 2 == 0) ? 'X' : '.';
}
}
private void computeStatistic() {
double sum = 0.0;
for (char c : charArray) {
sum += ((c == '.') ? 1 : 0);
}
statistic = Math.sqrt(sum);
}
public double getStatistic() {
return statistic;
}
public char[] getArrayCopy() {
return arrayCopy;
}
private char[] makeArrayCopy() {
arrayCopy = new char[ARRAY_SIZE];
System.arraycopy(charArray, 0, arrayCopy, 0, ARRAY_SIZE);
return arrayCopy;
}
}
class SimRunner extends Thread {
private volatile boolean running = true;
private volatile boolean wantNewStats = false;
private volatile boolean haveNewStats = false;
private volatile boolean wantNewCoords = false;
private volatile boolean haveNewCoords = false;
private final LongProperty stepsTaken;
SimModel sm;
public SimRunner(SimModel simModel) {
stepsTaken = new SimpleLongProperty(this, "stepsTaken", 0L);
sm = simModel;
setDaemon(true);
}
public void setWantNewCoords(boolean wantThem) {
wantNewCoords = wantThem;
}
public boolean getHaveNewCoords() {
return haveNewCoords;
}
public void setHaveNewCoords(boolean hnc) {
haveNewCoords = hnc;
}
public LongProperty stepsTaken() {
return stepsTaken;
}
public void setWantNewStats(boolean wantThem) {
wantNewStats = wantThem;
}
public boolean getHaveNewStats() {
return haveNewStats;
}
public void setHaveNewStats(boolean hns) {
haveNewStats = hns;
}
public void resetStepsTaken() {
stepsTaken.set(0L);
}
public void pauseThread() {
running = false;
}
public void resumeThread() {
resetStepsTaken();
running = true;
}
@Override
public void run() {
while (true) {
while (!running) {
Thread.onSpinWait();
}
sm.singleStep();
if (wantNewStats) {
sm.computeStatistic();
haveNewStats = true;
wantNewStats = false;
}
if (wantNewCoords) {
sm.makeArrayCopy();
haveNewCoords = true;
wantNewCoords = false;
}
stepsTaken.set(stepsTaken.get() + 1);
}
}
}
}
The Coord
class sets up the UI and controls. The SimModel
class does a "simulation" by repeatedly building a string for display and calculating a statistic, which should always the square root of 50 for purposes of the example. The SimRunner
class runs the simulation code in the background, off the JavaFX Application Thread.
In the "real" program, copying and displaying the array data takes very little time. It is insignificant compared to the other simulation calculations, which typically take 90%+ of the execution time. Calls to run the animation are throttled in any event, only executing when JavaFX is ready to display a frame. Calculating the "statistic" data in the real program can become quite expensive. It should only be done when needed. In the example, the calculation is requested about every half second. After the data is copied to a safe place for access by the UI, the simulation model calculations continue on.
If you run the program as listed, you will see the step counter advance quickly. It is only valid to calculate the statistic and copy the array of data after the singleStep()
method completes and before another call starts. The "statistic" should be 7.07106... and should not flicker.
Repeatedly pressing the Start/Pause/Resume button should cause the step counter to stop and restart.
The program, as it stands, behaves like I want. But I am a little leery of my flag flipping, ad hoc solution.
I've looked into running the simulation as a Service
, but it is not clear to me how to get updates without generating extraneous calls to perform the statistical calculations and slowing things down. Just the opposite of what I need to achieve.
Is there a better way to do this?
Update 2021-10-12 Fixed bug in SSCCE where the variable lastStatUpdate
was not updated correctly in the Platform.runLater
block. It was not updated at all.
JavaFx
aTask
orService
should be used over a pure Thread. docs.oracle.com/javafx/2/threads/jfxpub-threads.htm \$\endgroup\$