Search code examples
javakotlinjavafxgarbage-collectioncontrolsfx

Mysterious class field "far" preventing garbage collection


I have a project that contains a class similar to this:

data class Project(val someMemoryHeavyMember: String) {
    companion object {
        fun readFile(file: File): Project {
            TODO("This deserializes the project from disk")
        }
    }
}

This class contains some memory-heavy members, and I have realized that it seems to leak memory. I therefore started to investigate what referenced it and thus prevented garbage collection. To understand my findings, some more context is necessary. Usually, instances of Project are loaded asynchronously using a JavaFX Task<Project>:

fun loadProject(file: File): Task<Project> = object : Task<Project>() {
    init {
        updateTitle("Loading ${file.name}...")
    }

    override fun call(): Project {
        updateMessage("Reading project file...")
        return Project.readFile(file)
    }
}.attachModalProgressUi().scheduleAsBackgroundTask()


fun <T> Task<T>.attachModalProgressUi() = apply {
    // Creates a org.controlsfx.control.TaskProgressView and submits this task to it.
    // Most importantly, it also does the following:
    this.appendOnCancelled { removeAndHideIfApplicable() }
        .appendOnFailed { removeAndHideIfApplicable() }
        .appendOnSucceeded { removeAndHideIfApplicable() }
}

private fun Task<*>.removeAndHideIfApplicable() {
    taskView.tasks.remove(this)
    if (taskView.tasks.isEmpty()) stage.hide()
}

So, in summary, the asynchronous Task gets created, scheduled and is submitted to a ControlsFX TaskProgressView to show the loading progress in the UI. Once the task finishes, it gets removed from the TaskProgressView again and the progress UI is hidden. This works flawlessly.

Desired behavior: Loading a new project should remove all references to the previously loaded project, causing it to get garbage collected.

Actual behavior: All code-references are removed, however, VisualVM still shows the following references leading to GC-roots:

Screenshot of paths to GC roots found by VisualVM

This screenshot shows, that:

  • The JavaFX Task stores its result in a field called outcome (this is desired behavior)
  • The JavaFX Task cannot be garbage collected because some text view still references it.
  • This text view in turn is referenced through a ListView in a field called far that in turn is referenced through the ControlsFX TaskProgressView again in a field called far.

However, when browsing the sources for TaskProgressView and ListView, neither of them or one of their superclasses contains a field called far.

What I found so far: Frankly, not a lot. Google has not shown any significant results. ChatGPT said the following:

In OpenJDK, the field "far" in a heap dump does not correspond to a standard class or public API. It may be related to internal optimizations or memory management features used by the JVM.

My questions therefore are:

  • Where is the field far coming from?
  • Is this really preventing garbage collection, or am I looking at the wrong place?
  • If it is preventing garbage collection and ChatGPT is right, how can I prevent this from causing a memory leak?

Edit:

Here's ModalProgressView.taskView and all of its usages:

import javafx.beans.property.SimpleDoubleProperty
import javafx.collections.ListChangeListener
import javafx.concurrent.Task
import javafx.scene.Scene
import javafx.stage.Stage
import org.controlsfx.control.TaskProgressView

fun <T : Task<*>> T.attachModalProgressUi() = apply {
    runOnUiThread {
        ModalProgressView.submit(this)
    }
}

object ModalProgressView {
    private val stage = Stage()

    private const val TASK_HEIGHT = 65.0

    private val targetHeight = SimpleDoubleProperty(TASK_HEIGHT)

    private val taskView = TaskProgressView<Task<*>>()

    init {
        stage.scene = Scene(taskView)

        taskView.tasks.addListener(
            ListChangeListener {
                targetHeight.value = TASK_HEIGHT * it.list.size.coerceAtLeast(1)
            }
        )
    }

    fun submit(task: Task<*>) {
        if (task.isCancelled || task.isDone) return
        task.appendOnCancelled { task.removeAndHideIfApplicable() }
            .appendOnFailed { task.removeAndHideIfApplicable() }
            .appendOnSucceeded { task.removeAndHideIfApplicable() }

        taskView.tasks.add(task)
        stage.show()
    }

    private fun Task<*>.removeAndHideIfApplicable() {
        taskView.tasks.remove(this)
        if (taskView.tasks.isEmpty()) stage.hide()
    }
}

Since TaskProgressView is from ControlsFX, its source code is available on GitHub.


Solution

    1. Where is the field far coming from?

    It comes from javafx.scene.Parent and it is used for bounds calculations. It is not a memory leak. It points to a child of the parent node.

    1. Is this really preventing garbage collection, or am I looking at the wrong place?

    No it isn't. The problem is that the Parent is reachable. It looks like one reachability path is via the static taskView field of your ModalProgressView class. But there could be other paths too. (I don't know if VisualVM will show you all of the paths.)

    1. If it is preventing garbage collection and ChatGPT is right, how can I prevent this from causing a memory leak?

    ChatGPT doesn't understand what is going on here.

    The far field is not the cause. To fix the problem, you need to assign null to taskView at the appropriate time. For example, you could change

    if (taskView.tasks.isEmpty()) stage.hide()
    

    to assign null to taskView when the task list becomes empty. But, we can't advise on the best solution because we don't really understand the significance of these classes in the overall architecture of your application.

    For example, the real problem could be that the ModalProgressView is accumulating more and more children ... because the children are not being fully removed when the tasks complete.

    Or this could be a wild goose chase. Maybe the memory leak is not going to cause problems because it doesn't increase over time. We don't know. We don't have the context. (And judging from the wording of your question, even you are not sure there is real memory leak here.)