[Java] 用 CLI 展示 EnumSet 增减元素时的内部变化(下)

28 阅读10分钟

背景

在 上一篇文章 里我们提到 EnumSet.noneOf 方法所返回的对象的精确类型取决于枚举常量个数 nn:

  • n≤64n \le 64 时,返回 java.util.RegularEnumSet 的实例
    • 它有 private long elements 字段
  • n>64n \gt 64 时,返回 java.util.JumboEnumSet 的实例

上一篇只观察了前者,这一篇我们来观察后者。先查看 java.util.JumboEnumSet 中的字段 ⬇️

image.png

也就是说,常量个数超过 64 之后,位图从一个 long 变成了 long 数组。约定:ordinal 为 i 的常量,位于 elements[i >>> 6] 的第 i & 63 位。这里 >>> 6 就是右移 6 位(相当于用整数除法除以 64),& 63 就是对 64 取余。

顺带能看出第二个差异:RegularEnumSet 没有 size 字段(size() 方法直接返回 Long.bitCount(elements)),而 JumboEnumSet 显式维护了一个 int size 字段。这一点在 size 从算出来变成记下来 这一小节会提到。

正文

我借助 trae 生成了可以观察 java.util.JumboEnumSet 中 elements 字段变化的代码。为了行文连贯,完整的代码会在 完整代码 这一小节提供。

演示用的枚举类型 Tag 里有 130 个常量,需要 3 个 long 才能表示(130=2×64+2130 = 2\times 64 + 2),因此 elements 是长度为 3 的 long 数组,它的最后一个 long 元素只会用到 2 位。

/** 130 constants: ordinals 0..129 span three 64-bit words (word 2 uses only 2 bits). */
enum Tag {
    C0,  C1,  C2,  C3,  C4,  C5,  C6,  C7,  C8,  C9,
    C10, C11, C12, C13, C14, C15, C16, C17, C18, C19,
    C20, C21, C22, C23, C24, C25, C26, C27, C28, C29,
    C30, C31, C32, C33, C34, C35, C36, C37, C38, C39,
    C40, C41, C42, C43, C44, C45, C46, C47, C48, C49,
    C50, C51, C52, C53, C54, C55, C56, C57, C58, C59,
    C60, C61, C62, C63, C64, C65, C66, C67, C68, C69,
    C70, C71, C72, C73, C74, C75, C76, C77, C78, C79,
    C80, C81, C82, C83, C84, C85, C86, C87, C88, C89,
    C90, C91, C92, C93, C94, C95, C96, C97, C98, C99,
    C100, C101, C102, C103, C104, C105, C106, C107, C108, C109,
    C110, C111, C112, C113, C114, C115, C116, C117, C118, C119,
    C120, C121, C122, C123, C124, C125, C126, C127, C128, C129
}

如何运行

请将 完整代码 保存为 JumboEnumSetCliDemo.java。使用如下命令可以编译该文件(代码在 JDK 17 上编译通过)。

javac JumboEnumSetCliDemo.java

编译之后,会得到以下 class 文件 ⬇️

  • JumboEnumSetCliDemo.class
  • JumboEnumSetCliDemo$Action.class
  • JumboEnumSetCliDemo$Tag.class

使用如下命令,可以运行 JumboEnumSetCliDemo 中的 main 方法。通过反射读取 JumboEnumSet 的私有 elements 字段,会受到 JDK 9+ 模块系统限制,需要使用 --add-opens 选项 ⬇️

java --add-opens=java.base/java.util=ALL-UNNAMED JumboEnumSetCliDemo

运行效果示例

初始效果

程序启动后会执行 EnumSet.noneOf(Tag.class),此时 elements 已经是一个长度为 3 的全零数组 ⬇️

image.png

可以看到 class 一行显示的是 JumboEnumSet,这和前文的分析一致。

添加元素

我们可以通过指定 name 或者 ordinal,向 EnumSet 中添加对应的元素。最终执行的是 java.util.JumboEnumSet 中的 add(E e) 方法 ⬇️

image.png

先添加 C0(ordinal 0),它和 elements[0] 的 bit 0 对应 ⬇️

image.png

再添加 C63(ordinal 63),它和 elements[0] 的 bit 63(即该 long 元素的最高位)对应 ⬇️

image.png

接着添加 C64(ordinal 64)。注意:它和 elements[1] 的 bit 0 对应 ⬇️

image.png

再添加 C69(ordinal 69),它和 elements[1] 的 bit 5 对应(69−64=569 - 64 = 5)⬇️

image.png

0x21 就是二进制 0b100001,正好对应 bit 5 和 bit 0。

如果重复添加一个已经存在的元素,add 返回 false,数组不会有任何变化 ⬇️

image.png

最后再试试涉及 elements[2] 的操作。添加 C128(ordinal 128)。128 >>> 6 = 2,它和 elements[2] 的 bit 0 对应 ⬇️

image.png

elements[2] 从 0x0000_0000_0000_0000 变成 0x0000_0000_0000_0001。因为 130 = 2 × 64 + 2,只有 ordinal 128 和 129 会映射到第三个 long,所以这个元素最多只会用到低 2 位。

移除元素

我们可以通过指定 name 或者 ordinal,从 EnumSet 中移除对应的元素。最终执行的是 java.util.JumboEnumSet 中的 remove(Object) 方法 ⬇️

在刚才操作的基础上,移除 C64,效果如下 ⬇️

image.png

0x21 变成 0x20:bit 0 被清掉,bit 5 保留。

清空 EnumSet

我们可以执行 clear 命令来清空 EnumSet。

image.png

最终执行的是 java.util.JumboEnumSet 中的 clear() 方法。和 RegularEnumSet 里的 elements = 0 不同,这里要把数组里每个 long 都清零,同时把 size 变为 0 ⬇️

image.png

退出

通过执行 quit 命令可以退出该程序。另外,exit 是 quit 命令的别名。

源码里值得一提的细节

移位距离不需要对 64 取模

add 方法的逻辑如下 ⬇️

image.png

注意到 elements[eWordNum] |= (1L << eOrdinal); 里写的是 1L << eOrdinal,而不是 1L << (eOrdinal & 63)。当 ordinal 是 64 时,按字面理解 1L << 64 似乎会把 1 移动到 64 位的范围之外,但它实际落在 elements[1] 的 bit 0,也就是 1L << 0 的结果。

原因在于 Java 语言对 long 的移位指令只会用到右操作数的低 6 位,所以 1L << 64 等价于 1L << 0、1L << 69 等价于 1L << 5。

在 The Java® Language Specification 的 15.19. Shift Operators 中可以找到如下描述

image.png

If the promoted type of the left-hand operand is long, then only the six lowest-order bits of the right-hand operand are used as the shift distance. It is as if the right-hand operand were subjected to a bitwise logical AND operator & (§15.22.1) with the mask value 0x3f (0b111111). The shift distance actually used is therefore always in the range 0 to 63, inclusive.

size 从算出来变成记下来

RegularEnumSet 的 size() 方法只有一句 Long.bitCount(elements),因为统计一个 long 里有多少个 1 只花费常数时间。到了 JumboEnumSet 里,elements 是数组,统计位数就要遍历所有 long 元素,所以它改为在 add/remove 成功时直接对 size 字段加一减一 ⬇️

image.png

完整代码

完整的代码如下(在 JDK 17 上编译通过)⬇️

import java.lang.reflect.Field;
import java.util.EnumSet;
import java.util.Scanner;

/**
 * CLI counterpart of EnumSetCliDemo, but for an enum with 130 constants.
 * EnumSet then switches from RegularEnumSet (a single long)
 * to JumboEnumSet, whose private "elements" field is a long[]: constant
 * with ordinal i lives in elements[i >>> 6], at bit i & 63 of that entry.
 *
 * Run with (the "elements" field is private, hence --add-opens):
 * javac JumboEnumSetCliDemo.java
 * java --add-opens=java.base/java.util=ALL-UNNAMED JumboEnumSetCliDemo
 */
public class JumboEnumSetCliDemo {

    /** 130 constants: ordinals 0..129 span three 64-bit words (word 2 uses only 2 bits). */
    enum Tag {
        C0,  C1,  C2,  C3,  C4,  C5,  C6,  C7,  C8,  C9,
        C10, C11, C12, C13, C14, C15, C16, C17, C18, C19,
        C20, C21, C22, C23, C24, C25, C26, C27, C28, C29,
        C30, C31, C32, C33, C34, C35, C36, C37, C38, C39,
        C40, C41, C42, C43, C44, C45, C46, C47, C48, C49,
        C50, C51, C52, C53, C54, C55, C56, C57, C58, C59,
        C60, C61, C62, C63, C64, C65, C66, C67, C68, C69,
        C70, C71, C72, C73, C74, C75, C76, C77, C78, C79,
        C80, C81, C82, C83, C84, C85, C86, C87, C88, C89,
        C90, C91, C92, C93, C94, C95, C96, C97, C98, C99,
        C100, C101, C102, C103, C104, C105, C106, C107, C108, C109,
        C110, C111, C112, C113, C114, C115, C116, C117, C118, C119,
        C120, C121, C122, C123, C124, C125, C126, C127, C128, C129
    }

    private enum Action {
        ADD("add"),
        REMOVE("remove"),
        CLEAR("clear"),
        SHOW("show", "state"),
        HELP("help"),
        QUIT("quit", "exit"),
        INVALID();

        private final String[] words;

        Action(String... words) {
            this.words = words;
        }

        boolean matches(String text) {
            for (String word : words) {
                if (word.equals(text)) {
                    return true;
                }
            }
            return false;
        }
    }

    private final Tag[] tags = Tag.values();
    private final EnumSet<Tag> set = EnumSet.noneOf(Tag.class);

    public static void main(String[] args) {
        new JumboEnumSetCliDemo().run();
    }

    private void run() {
        printHelp();
        System.out.println();
        System.out.println("  start: EnumSet.noneOf(Tag.class)");
        printState();
        System.out.println();

        try (Scanner in = new Scanner(System.in)) {
            COMMAND_LOOP:
            while (true) {
                System.out.print("> ");
                System.out.flush();
                if (!in.hasNextLine()) {
                    break;
                }
                String line = in.nextLine().trim();
                if (line.isEmpty()) {
                    continue;
                }

                String[] parts = line.split("\\s+", 2);
                String cmd = parts[0].toLowerCase();
                String arg = parts.length > 1 ? parts[1].trim() : "";

                switch (parseAction(cmd)) {
                    case ADD -> add(arg);
                    case REMOVE -> remove(arg);
                    case CLEAR -> clear();
                    case SHOW -> {
                        System.out.println();
                        printState();
                        System.out.println();
                    }
                    case HELP -> printHelp();
                    case QUIT -> {
                        break COMMAND_LOOP;
                    }
                    case INVALID -> System.out.println("  unknown command: \"" + cmd
                            + "\" (type help for the command list)");
                }
            }
        }
    }

    private void add(String arg) {
        Tag t = parseConstant(arg);
        if (t == null) {
            printUnknownConstant(arg);
            return;
        }
        reportChange(t, true, set.add(t));
    }

    private void remove(String arg) {
        Tag t = parseConstant(arg);
        if (t == null) {
            printUnknownConstant(arg);
            return;
        }
        reportChange(t, false, set.remove(t));
    }

    private void printUnknownConstant(String arg) {
        System.out.println("  unknown constant: \"" + arg
                + "\" (use a name or an ordinal 0-" + (tags.length - 1) + ")");
    }

    /** Prints which word/bit changed (or why it did not), then the new state. */
    private void reportChange(Tag t, boolean add, boolean changed) {
        int ordinal = t.ordinal();
        int word = ordinal >>> 6;      // ordinal / 64
        int bit = ordinal & 63;        // ordinal % 64
        System.out.println();
        System.out.println("  " + (add ? "add" : "remove") + "(Tag." + t.name() + ")");
        if (changed) {
            String op = add
                    ? "|= 1L << " + bit
                    : "&= ~(1L << " + bit + ")";
            System.out.println("    elements[" + word + "] " + op
                    + "  (ordinal " + ordinal + " -> elements[" + word + "], bit " + bit
                    + (add ? " set by OR)" : " cleared by AND NOT)"));
        } else {
            System.out.println("    no-op: elements[" + word + "], bit " + bit
                    + (add ? " was already set" : " was not set"));
        }
        printState();
        System.out.println();
    }

    private void clear() {
        boolean wasEmpty = set.isEmpty();
        set.clear();
        System.out.println();
        System.out.println("  clear()"
                + (wasEmpty ? "   no-op: every elements[i] was already 0" : ""));
        printState();
        System.out.println();
    }

    /** Returns Action.INVALID if the word matches no command (or alias). */
    private Action parseAction(String cmd) {
        for (Action action : Action.values()) {
            if (action.matches(cmd)) {
                return action;
            }
        }
        return Action.INVALID;
    }

    /** Accepts a constant name (case-insensitive) or its ordinal. */
    private Tag parseConstant(String arg) {
        if (arg.isEmpty()) {
            return null;
        }
        for (Tag t : tags) {
            if (t.name().equalsIgnoreCase(arg)) {
                return t;
            }
        }
        try {
            int ordinal = Integer.parseInt(arg);
            if (ordinal >= 0 && ordinal < tags.length) {
                return tags[ordinal];
            }
        } catch (NumberFormatException ignored) {
            // not a number: fall through to null
        }
        return null;
    }

    /** Runtime class, size, membership, then one line per entry of elements. */
    private void printState() {
        long[] e = elements();
        System.out.printf("    %-13s  %s%n", "class", set.getClass().getSimpleName());
        System.out.printf("    %-13s  %d%n", "size", set.size());
        System.out.printf("    %-13s  %s%n", "set", set);
        System.out.printf("    %-13s  long[%d]%n", "elements", e.length);
        for (int w = 0; w < e.length; w++) {
            int lo = w * 64;
            int hi = Math.min(lo + 63, tags.length - 1);
            System.out.printf("      elements[%d]  %s   (ordinals %d..%d)%n", w, toHex(e[w]), lo, hi);
        }
    }

    /** JumboEnumSet's "elements" is a long[]; read it via reflection. */
    private long[] elements() {
        try {
            Field f = set.getClass().getDeclaredField("elements");
            f.setAccessible(true);
            return (long[]) f.get(set);
        } catch (ReflectiveOperationException ex) {
            throw new IllegalStateException(ex);
        }
    }

    /** Full 64 bits as 16 hex digits, grouped every 4: 0x0000_0000_0000_0001. */
    private String toHex(long v) {
        String raw = String.format("%016X", v);
        StringBuilder builder = new StringBuilder("0x");
        for (int i = 0; i < raw.length(); i++) {
            if (i > 0 && i % 4 == 0) {
                builder.append('_');
            }
            builder.append(raw.charAt(i));
        }
        return builder.toString();
    }

    private void printHelp() {
        System.out.println("EnumSet<Tag> CLI demo (130 constants -> JumboEnumSet, long[3] elements)");
        System.out.println("commands:");
        System.out.println("  add <name|ordinal>     set.add(...)");
        System.out.println("  remove <name|ordinal>  set.remove(...)");
        System.out.println("  clear                  set.clear()");
        System.out.println("  show                   print the current state");
        System.out.println("  help                   show this help");
        System.out.println("  quit                   exit");
    }
}

类图

如果忽略泛型,java.util.JumboEnumSet 的简要类图如下 ⬇️

image.png

如何为 java.util.JumboEnumSet 绘制类图?

我使用了 [Java] 如何自动生成简单的 PlantUML 类图 一文中提供的代码。关键的命令如下

javac ClassDiagramGenerator.java
java ClassDiagramGenerator 'java.util.JumboEnumSet'

第二个命令的输出如下

@startuml

interface java.lang.Cloneable
interface java.lang.Iterable
interface java.io.Serializable
interface java.util.Collection
interface java.util.Set
abstract java.util.AbstractCollection
abstract java.util.EnumSet
abstract java.util.AbstractSet
class java.util.JumboEnumSet
java.lang.Iterable <|-- java.util.Collection
java.util.Collection <|.. java.util.AbstractCollection
java.util.Collection <|-- java.util.Set
java.util.AbstractCollection <|-- java.util.AbstractSet
java.util.Set <|.. java.util.AbstractSet
java.util.AbstractSet <|-- java.util.EnumSet
java.lang.Cloneable <|.. java.util.EnumSet
java.io.Serializable <|.. java.util.EnumSet
java.util.EnumSet <|-- java.util.JumboEnumSet

@enduml

借助 IntelliJ IDEA (Community Edition) 中的 PlantUML 插件,就能将上述 puml 转化为类图。

参考资料