Serguey Asael Shinder: Double.equals says NaN equals NaN and 0.0 does not equal -0.0, the opposite of ==
Both of these are true at the same time, and neither is a bug:
Double.NaN == Double.NaN // false
Double.valueOf(Double.NaN).equals(Double.valueOf(Double.NaN)) // true
0.0 == -0.0 // true
Double.valueOf(0.0).equals(Double.valueOf(-0.0)) // false
The javadoc explains why in one sentence, and it is worth reading slowly:
"Instead,
equalsuses representation equivalence, defining NaN arguments to be equal to each other, restoring reflexivity, and defining +0.0 to not be equal to -0.0. For comparisons,compareTodefines a total order where -0.0 is less than +0.0 and where a NaN is equal to itself and considered greater than positive infinity."
The reason it cannot be otherwise is stated just above:
"a NaN is neither less than, nor greater than, nor equal to any value, including itself. This means the trichotomy of comparison does not hold. To provide the appropriate semantics for
equalsandcompareTomethods, those methods cannot simply be wrappers around==or ordered comparison operations."
A Set whose elements are never equal to themselves would be broken in a way no amount of care at the call site could fix. So Double picks a different relation. Three expressions implement that same relation, and the javadoc lists them together:
Double.doubleToLongBits(a) == Double.doubleToLongBits(b)
Double.valueOf(a).equals(Double.valueOf(b))
Double.compare(a, b) == 0

Where it bites
Everything in the collections framework goes through equals and hashCode, so everything in the collections framework uses the second relation, not ==:
Set<Double> seen = new HashSet<>();
seen.add(Double.NaN);
seen.contains(Double.NaN); // true — you can find a NaN in a set
seen.add(0.0);
seen.add(-0.0);
seen.size(); // 3 — the two zeros are separate elements
The first line surprises people who know NaN != NaN and conclude that a NaN goes into a set and is lost forever. It does not. The second is the one that actually causes incidents: -0.0 shows up from ordinary arithmetic — -1.0 * 0.0, 0.0 / -3.0, a rounding step that lands on zero from below — and a Map<Double, X> keyed on it will not find the entry filed under 0.0.
distinct() in a stream, List.contains, indexOf, Objects.equals, the generated equals of a record with a double component: all the same relation.
What to do
Do not key a collection on a floating-point value. This is the real fix and it is almost always available. Keys exist to be looked up by an exactly reconstructed value, and a double that has been through arithmetic is not exactly reconstructible. Use BigDecimal at a fixed scale, or a long of minor units, or a string produced by a canonical formatter.
When you do need to compare, say which relation you mean. a == b for numerical equality — and accept that it is false for NaN and true across the two zeros. Double.compare(a, b) == 0 for representation equivalence. Writing the one you mean is also documentation for the next reader, who otherwise cannot tell whether the behaviour at the zeros was intended.
Normalise -0.0 away at the boundary if a value is going to be stored or compared: x + 0.0 turns -0.0 into 0.0 and leaves every other value alone. Put it where the value enters the system, not at each comparison, or you will miss one.
Do not assume the three relations coincide. The javadoc is precise about when they do:
"for binary floating-point values, the three relations only differ if at least one argument is zero or NaN."
Which is exactly the two arguments your tests are least likely to contain.
Reference: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Double.html
⚠️ There is no JDK on the machine this was written on. Every claim above is taken from the javadoc linked here, not from a run.