-
Notifications
You must be signed in to change notification settings - Fork 504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
8290310: ChangeListener events are incorrect or misleading when a nested change occurs #1081
base: master
Are you sure you want to change the base?
8290310: ChangeListener events are incorrect or misleading when a nested change occurs #1081
Conversation
👋 Welcome back jhendrikx! A progress list of the required criteria for merging this PR into |
f14f289
to
fcac65d
Compare
d453853
to
9b7e9c9
Compare
9b7e9c9
to
2f4f88c
Compare
- Have ArrayManager allocate arrays with elements of the correct type - Add removeIf method - Add full coverage test case - Update docs
ecaaa2c
to
a5a43fe
Compare
- Used manage pattern on listener helpers - Renamed helpers to managers
a5a43fe
to
8f59a83
Compare
John and I discussed this off-list. I will write a short review of this change. BehaviorThe solution implements has the following behaviors, which are compared to the current (partially-flawed) ones:
ExamplesSuppose 5 change listeners are registered when an event starts.
Addition during an event
Nested event (value change during an event)
Recursive change (see https://continuously.dev/blog/2015/02/10/val-a-better-observablevalue.html) IntegerProperty p = new SimpleIntegerProperty(0);
// L1
p.addListener((obs, old, val) -> {
if (val.intValue() > 0) {
p.set(val.intValue() - 1);
}
});
// L2
p.addListener((obs, old, val) -> System.out.println(old + " -> " + val));
p.set(2); will trigger
instead of the current behavior that will print
Equality checkChange events require a comparison method for the old and new value. The 2 candidates are reference equality ( Currently, PerformancePerformance both in memory and speed is either equal or slightly worse than the current one. This is because the current behavior is wrong and fixing it entails more complications. In practice, the difference should be small. Registering many listeners on the same observable is not recommended and has caused issues in the past as well. Performance is a WIP and benchmarks will be posted later. |
I have some small corrections I think.
Current behavior in
Just to add here, there are actually two checks involved. When you "set" the value on a property, all properties do a reference equality check (apart from String) to determine whether to fire their listeners. This means that When looking only at change listeners, this behavior makes sense for any type that is either primitive, immutable or mutable without overriding
It doesn't happen too often that properties are used with a type that is mutable with its own
The implementation is able to avoid using a wrapper for single invalidation/change listeners, which improves memory use a bit for the second most common state properties are in (having 1 listener only -- the most common state being having no listeners at all). As for adding/removing many listeners, I've have changed my stance on this and I don't think we should cater to situations that can have 10.000's of listeners -- even if add/remove performance was much improved, that won't make notifying such high amounts of listeners any better. Having such high amounts of listeners on a single property is a sign that something is wrong with the design, and even if it were to perform reasonably (which it won't due to the sheer amount of listener calls), it would be better to investigate how to avoid adding so many listeners, perhaps by adding a single listener and distributing the requested information more directly (via a shared model for example). This implementation will have similar performance when it comes to adding/removing listeners as the current implementation. It grows and shrinks the listener list on demand, with the major difference being that when the list is locked (due to an ongoing notification) it will avoid modifying the lists in such a way that indices of current listeners change (removals are |
Fixed a bug that crept in during a clean-up
I have done some more tests with nested value changes converging and diverging. Overall I've tested over 20 scenarios of nested changes (including addition/removal of listers). Looks good. Great job! I'll finish going over the implementation with the new changes.
I missed that you were talking about a specific test. Do you mind adding somewhere in the top comment a link to #837 for bookkeeping purpose? |
Thanks, and thank you for putting in so much testing effort for this. Anything I should add as test-cases?
Ah, okay, that explains my confusion as well.
I added a link. I almost forgot about that implementation which took a different route. I seem to remember there being issues when emissions weren't started nested, but queued up instead. |
When I do the review of the tests I'll compare with my scenarios and see if there's anything worth adding. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First part of the review.
There are several class (and their methods) that are public
, but are only used in their package and can just have package-access:
OldValueCachingListenerList
ListenerManagerBase
ListenerListBase
ListenerList
ArrayManager
If they are public
because of tests, please add a comment like "public for testing purpose".
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerList.java
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/OldValueCachingListenerManager.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerManager.java
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerManager.java
Show resolved
Hide resolved
* will require a wrapper to track this value, and that an extra field is needed | ||
* within listener list. If possible use {@link ListenerManager}, as it has less | ||
* storage requirements and is faster. | ||
* |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest adding a line that says that this class is used by properties.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That doesn't seem like something you should be adding to the docs I think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Up to you. I thought it would make it easier on anyone learning the implementations if they understood why 2 are needed (one that stores the value and one that relies on the value being stored elsewhere).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can always put a comment outside the docs. I think the docs (in OldValueCachingListenerManager
) are quite clear already though why you'd use one or the other. Note that in an ideal world, we would never need OldValueCachingListenerManager
. I mean, it is currently used for properties the exact thing you'd expect to have a readily available old value. However, a big design flaw in properties makes it impossible to do this:
The design flaw is the use of a protected fireValueChangedEvent
method within the property, that has an implementation and is not final. This is poor design, because:
- You can't change the implementation, as subclasses may call
super.fireValueChangedEvent
- You can't change when you call it, as subclasses may be overriding it to be "aware" of changes
- You can't even provide the default implementation somewhere else, then call
fireValueChangedEvent
as subclasses may be purposely disabling or altering the default implementation
So, theoretically, I can easily provide the old value for properties, but it means I have to do one of the following:
- Execute the default implementation (but now with old value support) regardless of whether
fireValueChangedEvent
was overridden -- would break code that overrides this method to block the default implementation - Change the signature of
fireValueChangedEvent
to accept aT oldValue
-- can't do that, itsprotected
- Modify the default implementation to fetch the old value from somewhere to provide it -- can't do that as subclasses may be calling it at random, and they won't have the mechanism in place to provide the old value via something other than a parameter
You should never do all three of these for protected methods, as it makes the providing class fragile and tightly coupled with subclasses:
- Provide an implementation in a protected method.
- Make it non-final.
- Call it internally when its implementation is essential for correct operation.
Doing only two of these is fine:
- Providing an implementation and making it overridable is fine if the base class doesn't rely on it (i.e., it's just a helper).
- Providing an implementation that is final and using it internally is fine because subclasses can't alter the base class's behavior.
- Calling a non-final protected method with no expected implementation in the base class is also fine.
If we choose to address this at some point, to make properties more performant (with regards to listeners) and use less memory (when using a single change listener) it would mean a backwards incompatible change. The change will mostly affect FX classes (as they rely on being able to both call fireValueChangedEvent
as well as being able to override it) but it may affect 3rd parties as well as they can call and/or override it as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the docs (in
OldValueCachingListenerManager
) are quite clear already though why you'd use one or the other.
Alright, no need to add a note about where it's used.
The design flaw is the use of a protected
fireValueChangedEvent
method within the property...
The "old value for properties" is interesting, but another discussion. Might be worth bringing it up on the list if there's more to it than just this extra implementation.
You should never do all three of these for protected methods...
Yes, well, same for public and package methods. This falls under "design for inheritance or prevent it" - don't rely on an implementation that can be overridden.
I understand that making a method public that shouldn't be should be documented as such, but what's against having public classes in non-exported packages even if just for testing purposes? This is done widely through out FX already. Also these classes are well documented, and there's no reason they could not be used outside their package, even if they currently are not. |
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerList.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerList.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerList.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/com/sun/javafx/binding/ListenerList.java
Outdated
Show resolved
Hide resolved
@mstr2 I'm not entirely sure what you mean here. But let me give a few options. When there's two listeners that can't reach agreement, the sequence of events looks like this:
So as to your question, it seems that you're asking if we could ignore what the listener is doing in step 4.ii -- here the listener is changing the value back that eventually leads to a problem. However, we only notice that this happened in step 4.iii -- the property has already been modified by then. Are you asking if we could change the property back again (using Or perhaps you are asking if we can just ignore 4.iv and not do 4.v ? That's what the code did a few changes ago, but which would leave you in the dark that there is conflicting changes happening (whereas with Or perhaps you meant something else? |
What if we didn't call back the second listener (as you said, it would probably result in an infinite loop), and logged an error instead (that's not without precedence, bindings also log errors)? It seems like this would still allow other listeners to receive notifications, while the "defective" listener would not. |
I'm fine with logging a warning/error instead. If Nir also agrees, I can make the modifications. |
If we can know that the values are going to diverge then logging an error instead of throwing it is fine. However, if they are trying to find a common divisor (as in John's example above), it could take many callbacks for the final value to settle. |
I tend to restrict visibility unless necessary, especially when some classes function as helper classes, but it's fine to leave as is. |
I'm unsure what you are saying here. The common divisor example would not log a warning if I place the warning log in the same location as the SOE. As for the behavior of multiple listeners, we can never really know for certain if they will eventually agree. I mean, even the examples with So, aside from letting this "escalate" into a SOE (where the JVM simply gives up) there is no real way to be sure that the values won't converge at some point. The check I added is making the assumption that listeners are deterministic, but they don't have to be. Then again, we're again approaching super rare exotic edge cases, that |
If you replace the thrown error/exception with logging then the code path will continue. What behavior will we get instead? The one before the modification where "first listener wins"? |
Yeah, if there's disagreement, then the listener earliest in the chain (if it is persistent) will win, and one of the disagreeing listeners (after setting a value) will not have been notified of that change (as it was changed back). Note that there's no inconsistency here; after all notification loops complete, the last received new value will match the current value of the property. This goes for all listeners, regardless of disagreements. In other words:
Realistically, there's not really that much wrong with this. It is just that listeners should not make assumptions that a value passed to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Finished the 2nd part of the implementation review. I didn't delve into the logic of the listener management, but it looks sane :)
I'll review the tests as the final part.
This provides and uses a new implementation of
ExpressionHelper
, calledListenerManager
with improved semantics.See also #837 for a previous attempt which instead of triggering nested emissions immediately (like this PR and
ExpressionHelper
) would wait until the current emission finishes and then start a new (non-nested) emission.Behavior
Nested notifications:
Performance
null
ed (to avoid moving elements in array that is being iterated)(*) 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.
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:
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
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:
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:
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:The above code would remove its listeners from
scene1
andscene2
, and register two listeners onscene3
. This leads to the listener being called twice when something changes. When later the scene changes toscene4
, it receives:Because it registered its listener twice on
scene3
, and only removes one of them, it now has listeners on bothscene3
andscene4
.Clearly it is incredibly important that changes make sense, or even simple code that looks innocuous becomes problematic.
The PR
The
ListenerManager
differs fromExpressionHelper
in the following ways:ChangeListener
s under all circumstancesExpressionHelper
is not following the contract).ListenerManager
manages the listener storage in property classes, and the transformation between the listener variants (it either uses listeners directly, or uses aListenerList
when there are multiple listeners).ListenerListBase
handles the locking and compacting of its listener lists.ListenerList
which extendsListenerListBase
is only concerned with the recursion algorithm for notification.ArrayManager
handles resizing and reallocating of arrays.ListenerList
andListenerManager
which can cache their old values when its not possible to supply these (this has a cost, and is whatExpressionHelper
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
anda -> A
), and the second receives onlyX -> A
. Contrast this with the oldExpressionHelper
, which sends odd notifications to the second listener (a -> A
andX -> 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:ChangeListener
s activeChangeListener
inline; a minimal wrapper is needed to track the old value (ExpressionHelper does the same)Progress
Issue
Reviewers
Reviewing
Using
git
Checkout this PR locally:
$ git fetch https://git.openjdk.org/jfx.git pull/1081/head:pull/1081
$ git checkout pull/1081
Update a local copy of the PR:
$ git checkout pull/1081
$ git pull https://git.openjdk.org/jfx.git pull/1081/head
Using Skara CLI tools
Checkout this PR locally:
$ git pr checkout 1081
View PR using the GUI difftool:
$ git pr show -t 1081
Using diff file
Download this PR as a diff file:
https://git.openjdk.org/jfx/pull/1081.diff
Using Webrev
Link to Webrev Comment