> This provides and uses a new implementation of `ExpressionHelper`, called > `ListenerManager` with improved semantics. > > # Behavior > > |Listener...|ExpressionHelper|ListenerManager| > |---|---|---| > |Invocation Order|In order they were registered, invalidation listeners > always before change listeners|(unchanged)| > |Removal during Notification|All listeners present when notification started > are notified, but excluded for any nested changes|Listeners are removed > immediately regardless of nesting| > |Addition during Notification|Only listeners present when notification > started are notified, but included for any nested changes|New listeners are > never called during the current notification regardless of nesting| > > ## Nested notifications: > > | |ExpressionHelper|ListenerManager| > |---|---|---| > |Type|Depth first (call stack increases for each nested level)|(same)| > |# of Calls|Listeners * Depth (using incorrect old values)|Collapses nested > changes, skipping non-changes| > |Vetoing Possible?|No|Yes| > |Old Value correctness|Only for listeners called before listeners making > nested changes|Always| > > # Performance > > |Listener|ExpressionHelper|ListenerManager| > |---|---|---| > |Addition|Array based, append in empty slot, resize as needed|(same)| > |Removal|Array based, shift array, resize as needed|(same)| > |Addition during notification|Array is copied, removing collected > WeakListeneres in the process|Tracked (append at end)| > |Removal during notification|As above|Entry is `null`ed| > |Notification completion with changes|-|Null entries (and collected > WeakListeners) are removed| > |Notifying Invalidation Listeners|1 ns each|(same)| > |Notifying Change Listeners|1 ns each (*)|2-3 ns each| > > (*) a simple for loop is close to optimal, but unfortunately does not provide > correct old values > > # Memory Use > > Does not include alignment, and assumes a 32-bit VM or one that is using > compressed oops. > > |Listener|ExpressionHelper|ListenerManager|OldValueCaching ListenerManager| > |---|---|---|---| > |No Listeners|none|none|none| > |Single InvalidationListener|16 bytes overhead|none|none| > |Single ChangeListener|20 bytes overhead|none|16 bytes overhead| > |Multiple listeners|57 + 4 per listener (excluding unused slots)|57 + 4 per > listener (excluding unused slots)|61 + 4 per listener (excluding unused > slots)| > > # About nested changes > > Nested changes are simply changes that are made to a property that is > currently in the process of notifying its listeners. This all occurs on the > same thread, and a nested change is nothing more than the same property being > modified, triggering its listeners again deeper in the call stack with > another notification, while higher up the call stack a notification is still > being handled: > > (top of stack) > fireValueChangedEvent (property A) <-- nested notification > setValue (property A) <-- listener modifies property A > changed (Listener 1) <-- a listener called by original > notification > fireValueChangedEvent (property A) <-- original notification > > ## How do nested changes look? > > Let's say we have three listeners, where the middle listener changes values > to uppercase. When changing a property with the initial value "A" to a > lowercase "b" the listeners would see the following events: > > ### ExpressionHelper > |Nesting Level|Time|Listener 1|Listener 2|Listener 3|Comment| > |---|---|---|---|---|---| > | 0 |T1 |A -> b| | | | > | 0 |T2 | |A -> b| |Value is changed to B| > | 1 |T3 |b -> B| | | A nested loop started deeper on the call stack | > | 1 |T4 | |b -> B| | | > | 1 |T5 | | |b -> B| | > | 0 |T6 | | |A -> B|Top level loop is resumed| > > Note how the values received by the 3rd listener are non-sensical. It > receives two changes both of which changes to B from old values that are out > of order. > > ### ListenerManager (new) > This how ListenerManager sends out these events: > |Nesting Level|Time|Listener 1|Listener 2|Listener 3|Comment| > |---|---|---|---|---|---| > | 0 |T1 |A -> b| | | | > | 0 |T2 | |A -> b| |Value is changed to B| > | 1 |T3 |b -> B| | | A nested loop started deeper on the call stack | > | 1 |T4 | |b -> B| | The nested loop is terminated early at this point | > | 0 |T5 | | |A -> B|Top level loop is resumed| > > Note how the 3rd listener now receives an event that reflects what actually > happened from its perspective. Also note that less events overall needed to > be send out. > > # About Invocation Order > > A lot of code depends on the fact that an earlier registered listener of the > same type is called **before** a later registered later of the same type. For > listeners of different types it is a bit less clear. What is clear however > is that invalidation and change listeners are defined by separate interfaces. > Mixing their invocations (to conserve registration order) would not make > sense. Historically, invalidation listeners are called before change > listeners. No doubt, code will be (unknowingly) relying on this in today's > JavaFX applications so changing this is not recommended. Perhaps there is > reason to say that invalidation listeners should be called first as they're > defined by the super interface of `ObservableValue` which introduces change > listeners. > > # About Listener Add/Remove performance > > Many discussions have happened in the past to improve the performance of > removing listeners, ranging from using maps to ordered data structures with > better remove performance. Often these solutions would subtly change the > notification order, or increase the overhead per listener significantly. > > But these discussions never really looked at the other consequences of having > tens of thousands of listeners. Even if listeners could be removed in > something approaching O(1) time (additions are already O(1) and so are not > the issue), what about the performance of notifying that many listeners? > That will still be O(n), and so even if JavaFX could handle addition and > removal of that many listeners comfortably, actually using a property with > that many listeners is still impossible as it would block the FX thread for > far too long when sending out that many notifications. > > Therefore, I'm of the opinion that "fixing" this "problem" is pointless. > Instead, having that many listeners should be considered a design flaw in the > application. A solution that registers only a single listener that updates a > shared model may be much more appropriate. > > # About Old Value Correctness > ...and why it is important. > > A change listener provides a callback that gives the old and the new value. > One may reasonably expect that these values are never the same, and one may > reasonably expect that the given old value is the same as the previous > invocation's new value (if there was a previous invocation). > > In JavaFX, many change listeners are used for important tasks, ranging from > reverting changes in order to veto something, to registering and > unregistering listeners on properties. Many of those change listeners do not > care about the old value, but there are a significant number that use it and > rely on it being correct. A common example is the registering of a listener > on the "new" value, and removing the same listener from the "old" value in > order to maintain a link to some other property that changes location: > > (obs, old, current) -> { > if (old != null) { > old.removeListener(x); > } > if (current != null) { > current.addListener(x); > } > } > > The above code looks bug free, and it would be if the provided old values are > always correct. Unfortunately, this does not have to be the case. When a > nested change is made (which can be made by a user registered listener on the > same property), `ExpressionHelper` makes no effort to ensure that for all > registered listener the received old and new values make sense. This leads > to listeners being notified twice with the same "new" value for example, but > with a different old value. Imagine the above listener receives the > following change events: > > scene1 becomes scene3 > scene2 becomes scene3 > > The above code would remove its listeners from `scene1` and `scene2`, and > register two listeners on `scene3`. This leads to the listener being called > twice when something changes. When later the scene changes to `scene4`, it > receives: > > scene3 becomes scene4 > > Because it registered its listener twice on `scene3`, and only removes one of > them, it now has listeners on both `scene3` and `scene4`. > > Clearly it is incredibly important that changes make sense, or even simple > code that looks innocuous becomes problematic. > > # The PR > > The `ListenerManager` differs from `ExpressionHelper` in the following ways: > - Provides correct old/new values to `ChangeListener`s under all circumstances > - Unnecessary change events are never sent > - Single invalidation or change listeners are inlined directly into the > observable class (in other words, properties with only a single listener > don't take up any extra space at all) > - Performance is slightly worse when calling **change** listeners (but > remember that `ExpressionHelper` is not following the contract). > - Removed listeners are never called after being removed (even if they were > part of the initial list when the notification triggered) > - Added listeners are only called when a new non-nested (top level) > notification starts > - Locking and maintaining the listener list works a bit differently -- the > main takeaway is that the list indices remain the same when modified during > nested modifications, which allows using the same list no matter how deep the > nesting > - No reference is stored to the ObservableValue and no copy is kept of the > current value > - Memory use when there is more than 1 listener should be similar, and better > when not > - Although complicated, the code is I think much better separated, and more > focused on accomplishing a single task: > - `ListenerManager` manages the listener storage in property classes, and > the transformation between the listener variants (it either uses listeners > directly, or uses a `ListenerList` when there are multiple listeners). > - `ListenerListBase` handles the locking and compacting of its listener > lists. > - `ListenerList` which extends `ListenerListBase` is only concerned with > the recursion algorithm for notification. > - `ArrayManager` handles resizing and reallocating of arrays. > - There are variants of `ListenerList` and `ListenerManager` which can > cache their old values when its not possible to supply these (this has a > cost, and is what `ExpressionHelper` does by default). > > The notification mechanism deals with nested notifications by tracking how > many listeners were notified already before the nested notification occurs. > For example, if 5 listeners were notified, and then listener 5 makes a nested > change, then in that nested change only the first 5 listeners are notified > again (if they still exist). The nested loop is then terminated early, at > which point the top level loop resumes: it continues where it left of and > notifies listener 6 and so on. This ensures that all notifications are always > correct, and that listeners that "veto" changes can effectively block later > listeners from seeing those at all. > > For example, if the first listener always uppercases any received values, > then any succeeding listeners will always see only uppercase values. The > first listener receives two notifications (`X -> a` and `a -> A`), and the > second receives only `X -> A`. Contrast this with the old > `ExpressionHelper`, which sends odd notifications to the second listener (`a > -> A` and `X -> A`, in that order). > > Unfortunately, due to a somewhat weird design choice in the PropertyBase > classes, the strategy of not having to cache the "current value" (or old > value) can't be used (it can only be used for Bindings for now). So there is > another variant of this helper, called `OldValueCachingListenerHelper`, with > some slight differences: > > - Has an extra field for storing the old value when there are any > `ChangeListener`s active > - Can't store a `ChangeListener` inline; a minimal wrapper is needed to track > the old value (ExpressionHelper does the same)
John Hendrikx has updated the pull request incrementally with one additional commit since the last revision: Small bug fix in OldValueCachingListenerList - Added a test case to detect and avoid this problem ------------- Changes: - all: https://git.openjdk.org/jfx/pull/1081/files - new: https://git.openjdk.org/jfx/pull/1081/files/997bdcb2..42729644 Webrevs: - full: https://webrevs.openjdk.org/?repo=jfx&pr=1081&range=04 - incr: https://webrevs.openjdk.org/?repo=jfx&pr=1081&range=03-04 Stats: 84 lines in 2 files changed: 81 ins; 1 del; 2 mod Patch: https://git.openjdk.org/jfx/pull/1081.diff Fetch: git fetch https://git.openjdk.org/jfx.git pull/1081/head:pull/1081 PR: https://git.openjdk.org/jfx/pull/1081