JavaでUUID.compareToの挙動が思ってたのと違った

UUIDはv6,v7,v8とかで時間ベースのソートが出来るようになった。べんりー

なのでUUIDの上下関係が大事になったんだけどJavaのUUID.compareToはそこまで考慮されていなさそうだった。

たとえば2つのUUIDを用意してみる。

  • 00000000-0000-7dda-8cc3-13f289aa2814
    • 1970/01/01とかでつくったUUID
  • e677d1fe-e676-7224-be39-f88d1cd436d8
    • 9999/12/31とかでつくったUUID
    • こっちのほうが大きくなってほしい

まあ、見た目通り文字列で比較すればe677d1fe-e676-7224-be39-f88d1cd436d8のほうが大きい。 ちなみに postgres で比較するとそのとおりの挙動になる

neko=>  select ('00000000-0000-7dda-8cc3-13f289aa2814')::uuid < ('e677d1fe-e676-7224-be39-f88d1cd436d8')::uuid;
 ?column?
----------
 t
(1 row)

でも、Java(21)でUUIDオブジェクトとして比較すると逆になる

# jshell                                                                                                     2023ms  20240222154921秒
|  Welcome to JShell -- Version 21.0.2
|  For an introduction type: /help intro

jshell> UUID max = UUID.fromString("e677d1fe-e676-7224-be39-f88d1cd436d8");
max ==> e677d1fe-e676-7224-be39-f88d1cd436d8

jshell> UUID min = UUID.fromString("00000000-0000-7dda-8cc3-13f289aa2814");
min ==> 00000000-0000-7dda-8cc3-13f289aa2814

jshell> max.compareTo(min)
$3 ==> -1

ちなみに文字列にして比較すると(当たり前だけど)見た目通りの挙動になる。

jshell> max.toString().compareTo(min.toString())
$4 ==> 53

なぜかと言うとOpenJDKの実装では前半と後半にわけて(符号付きの)longとして保持している。

    public UUID(long mostSigBits, long leastSigBits) {
        this.mostSigBits = mostSigBits;
        this.leastSigBits = leastSigBits;
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L140-L143

で、compareToも実装されていていて、最初に前半のlongで比較している

    @Override
    public int compareTo(UUID val) {
        // The ordering is intentionally set up so that the UUIDs
        // can simply be numerically compared as two numbers
        int mostSigBits = Long.compare(this.mostSigBits, val.mostSigBits);
        return mostSigBits != 0 ? mostSigBits : Long.compare(this.leastSigBits, val.leastSigBits);
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L557-L562

で、このmostSigBitsの値が一定の大きさを超えたUUIDではマイナスになる(まぁ、それはそう)。 例えば上記のものは内部的には下記のような感じ

max = {UUID@3077} "e677d1fe-e676-7224-be39-f88d1cd436d8"
 mostSigBits = -1839771030039137756
 leastSigBits = -4739483847872989480

ちなみに変換ロジックはこれ

    public static UUID fromString(String name) {
        if (name.length() == 36) {
            char ch1 = name.charAt(8);
            char ch2 = name.charAt(13);
            char ch3 = name.charAt(18);
            char ch4 = name.charAt(23);
            if (ch1 == '-' && ch2 == '-' && ch3 == '-' && ch4 == '-') {
                long msb1 = parse4Nibbles(name, 0);
                long msb2 = parse4Nibbles(name, 4);
                long msb3 = parse4Nibbles(name, 9);
                long msb4 = parse4Nibbles(name, 14);
                long lsb1 = parse4Nibbles(name, 19);
                long lsb2 = parse4Nibbles(name, 24);
                long lsb3 = parse4Nibbles(name, 28);
                long lsb4 = parse4Nibbles(name, 32);
                if ((msb1 | msb2 | msb3 | msb4 | lsb1 | lsb2 | lsb3 | lsb4) >= 0) {
                    return new UUID(
                            msb1 << 48 | msb2 << 32 | msb3 << 16 | msb4,
                            lsb1 << 48 | lsb2 << 32 | lsb3 << 16 | lsb4);
                }
            }
        }
        return fromString1(name);
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L242C1-L265C6

まぁ、Java のUUIDは4くらいまでしか対象にしていない気がするのでしょうがないような気がするし、これが仕様なのかバグなのかは人によって捉え方が違うと思う。

ただ、UUIDv7とかのロジックで生成したUUIDをJava上でオブジェクトとして比較すると順番が思ったのと違うことになるというお話でした。 僕はとりあえずtoString()して比較するようにします。