boolean[]与BitSet的性能比较

2023/05/09

1. 概述

在本文中,我们将比较BitSet和boolean[] 在不同场景下的性能。

我们通常非常宽松地使用术语性能,并考虑不同的含义。因此,我们将从了解术语“性能”的各种定义开始。

然后,我们将使用两种不同的性能指标进行基准测试:内存占用和吞吐量。为了对吞吐量进行基准测试,我们将比较位向量上的一些常见操作。

2. 绩效定义

性能是一个非常笼统的术语,指的是范围广泛的“性能”相关概念!

有时我们用这个术语来谈论特定应用程序的启动速度;也就是说,应用程序在能够响应其第一个请求之前所花费的时间。

除了启动速度,我们在谈性能的时候可能会想到内存占用。所以内存占用是这个术语的另一个方面。

可以将“性能”解释为我们的代码运行的“速度”。所以延迟是另一个性能方面。

对于某些应用程序,了解每秒操作数方面的系统容量非常重要。所以吞吐量可以是性能的另一个方面。

有些应用程序只有在响应一些请求并从技术上“预热”之后,才能以最佳性能水平运行。因此,达到峰值性能的时间是另一个方面。

可能的定义列表还在继续!然而,在整篇文章中,我们将只关注两个性能指标:内存占用和吞吐量。

3.内存占用

虽然我们可能期望 布尔值 只消耗一位,但boolean[] 中的 每个 布尔 值都消耗一个字节的内存。这主要是为了避免单词撕裂和可访问性问题。因此,如果我们需要一个位向量, boolean[] 将占用大量内存。

为了使事情更具体,我们可以使用Java 对象布局 (JOL)来检查 boolean[] 的内存布局,比如包含 10,000 个元素:

boolean[] ba = new boolean[10_000];
System.out.println(ClassLayout.parseInstance(ba).toPrintable());

这将打印内存布局:

[Z object internals:
 OFFSET  SIZE      TYPE DESCRIPTION               VALUE
      0     4           (object header)           01 00 00 00 (1)
      4     4           (object header)           00 00 00 00 (0)
      8     4           (object header)           05 00 00 f8 (-134217723)
     12     4           (object header)           10 27 00 00 (10000)
     16 10000   boolean [Z.                       N/A
Instance size: 10016 bytes

如上所示,此 boolean[] 消耗大约 10 KB 的内存。

另一方面,BitSet 使用原始数据类型(特别是long)和按位操作的组合来实现每个标志足迹一位。因此 ,与具有相同大小的boolean[] 相比,具有 10,000 位 的BitSet 消耗的内存要少得多:

BitSet bitSet = new BitSet(10_000);
System.out.println(GraphLayout.parseInstance(bitSet).toPrintable());

同样,这将打印 BitSet的内存布局:

java.util.BitSet@5679c6c6d object externals:
          ADDRESS       SIZE TYPE             PATH      
        76beb8190         24 java.util.BitSet           
        76beb81a8       1272 [J               .words   

正如预期的那样,具有相同位数的 BitSet 消耗大约 1 KB,这远小于 boolean[]。

我们还可以比较不同位数的内存占用:

Path path = Paths.get("footprint.csv");
try (BufferedWriter stream = Files.newBufferedWriter(path, StandardOpenOption.CREATE)) {
    stream.write("bits,bool,bitsetn");

    for (int i = 0; i <= 10_000_000; i += 500) {
        System.out.println("Number of bits => " + i);

        boolean[] ba = new boolean[i];
        BitSet bitSet = new BitSet(i);

        long baSize = ClassLayout.parseInstance(ba).instanceSize();
        long bitSetSize = GraphLayout.parseInstance(bitSet).totalSize();

        stream.write((i + "," + baSize + "," + bitSetSize + "n"));

        if (i % 10_000 == 0) {
            stream.flush();
        }
    }
}

上面的代码将计算具有不同长度的两种类型的位向量的对象大小。然后它将大小比较写入 CSV 文件并将其刷新。

现在,如果我们绘制此 CSV 文件,我们将看到 内存占用的绝对差异随着位数的增加而增加:

足迹比较

这里的关键是, BitSet 在内存占用方面优于 boolean[] ,除了最少的位数。

4.吞吐量

为了比较 BitSet 和 boolean[] 的吞吐量,我们将基于三种不同但日常的位向量操作进行三个基准测试:

  • 获取特定位的值
  • 设置或清除特定位的值
  • 计算设置位的数量

这是我们将用于比较不同长度的位向量的吞吐量的常见设置:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
public class VectorOfBitsBenchmark {

    private boolean[] array;
    private BitSet bitSet;

    @Param({"100", "1000", "5000", "50000", "100000", "1000000", "2000000", "3000000",
      "5000000", "7000000", "10000000", "20000000", "30000000", "50000000", "70000000", "1000000000"})
    public int size;

    @Setup(Level.Trial)
    public void setUp() {
        array = new boolean[size];
        for (int i = 0; i < array.length; i++) {
            array[i] = ThreadLocalRandom.current().nextBoolean();
        }

        bitSet = new BitSet(size);
        for (int i = 0; i < size; i++) {
            bitSet.set(i, ThreadLocalRandom.current().nextBoolean());
        }
    }

    // omitted benchmarks
}

如上所示,我们正在创建 长度在 100-1,000,000,000 范围内的boolean[] s 和 BitSet s。此外,在设置过程中设置了一些位之后,我们将对 boolean[] 和 BitSet执行不同的操作。

4.1. 得到一点

乍一看, boolean[] 中直接内存访问似乎比BitSet中每次get执行两次按位操作 (左移加一个与 操作)更有效。 另一方面, BitSet的内存紧凑性可能允许它们在高速缓存行中容纳更多值。

让我们看看谁赢了!以下是 JMH每次将使用不同的 大小 状态值运行的基准测试:

@Benchmark
public boolean getBoolArray() {
    return array[ThreadLocalRandom.current().nextInt(size)];
}

@Benchmark
public boolean getBitSet() {
    return bitSet.get(ThreadLocalRandom.current().nextInt(size));
}

4.2. 获得一点:吞吐量

我们将使用以下命令运行基准测试:

$ java -jar jmh-1.0-SNAPSHOT.jar -f2 -t4 -prof perfnorm -rff get.csv getBitSet getBoolArray

这将使用四个线程和两个分支运行与 get 相关的基准测试,使用 Linux 上的 perf 工具分析它们的执行统计信息,并将结果输出到bench-get.csv文件中。“ -prof perfnorm” 将使用 Linux 上的 perf工具对基准进行分析,并根据操作数对性能计数器进行标准化。

由于命令结果非常冗长,我们将只在此处绘制它们。在此之前,让我们看看每个基准测试结果的基本结构:

"Benchmark","Mode","Threads","Samples","Score","Score Error (99.9%)","Unit","Param: size"
"getBitSet","thrpt",4,40,184790139.562014,2667066.521846,"ops/s",100
"getBitSet:L1-dcache-load-misses","thrpt",4,2,0.002467,NaN,"#/op",100
"getBitSet:L1-dcache-loads","thrpt",4,2,19.050243,NaN,"#/op",100
"getBitSet:L1-dcache-stores","thrpt",4,2,6.042285,NaN,"#/op",100
"getBitSet:L1-icache-load-misses","thrpt",4,2,0.002206,NaN,"#/op",100
"getBitSet:branch-misses","thrpt",4,2,0.000451,NaN,"#/op",100
"getBitSet:branches","thrpt",4,2,12.985709,NaN,"#/op",100
"getBitSet:dTLB-load-misses","thrpt",4,2,0.000194,NaN,"#/op",100
"getBitSet:dTLB-loads","thrpt",4,2,19.132320,NaN,"#/op",100
"getBitSet:dTLB-store-misses","thrpt",4,2,0.000034,NaN,"#/op",100
"getBitSet:dTLB-stores","thrpt",4,2,6.035930,NaN,"#/op",100
"getBitSet:iTLB-load-misses","thrpt",4,2,0.000246,NaN,"#/op",100
"getBitSet:iTLB-loads","thrpt",4,2,0.000417,NaN,"#/op",100
"getBitSet:instructions","thrpt",4,2,90.781944,NaN,"#/op",100

如上所示,结果是一个以逗号分隔的字段列表,每个字段代表一个指标。例如, “thrpt” 表示吞吐量, “L1-dcache-load-misses” 是一级数据缓存的缓存未命中数, “L1-icache-load-misses” 是该级别的缓存未命中数1 个指令缓存, “instructions” 表示每个基准测试的 CPU 指令数。另外,最后一个字段代表位数,第一个字段代表基准方法名称。

这是具有 4 核 Intel(R) Xeon(R) CPU 2.20GHz 的典型 Digitial Ocean droplet 的吞吐量基准测试结果:

吞吐量获取

如上所示,boolean [] 在较小的尺寸上具有更好的吞吐量。当位数增加时, BitSet 在吞吐量方面优于 boolean[] 。更具体地说,在 100,000 位之后,BitSet 表现出优越的性能。

4.3. 获得一点:每个操作的指令

正如我们所料,boolean[] 上的 get 操作 每次操作的指令更少:

使用说明-获取

4.4. 了解一点:数据缓存未命中

现在,让我们看看数据缓存未命中是如何寻找这些位向量的:

数据缓存未命中 GET

如上所示,boolean[]的数据缓存未命中 数随着位数的增加而增加。

所以缓存未命中比在这里执行更多指令的代价要高得多。因此, 在这种情况下, BitSet API 在大多数情况下都优于boolean[] 。

4.5. 设定一点

为了比较集合操作的吞吐量,我们将使用这些基准:

@Benchmark
public void setBoolArray() {
    int index = ThreadLocalRandom.current().nextInt(size);
    array[index] = true;
}

@Benchmark
public void setBitSet() {
    int index = ThreadLocalRandom.current().nextInt(size);
    bitSet.set(index);
}

基本上,我们选择一个随机位索引并将其设置为 true。同样,我们可以使用以下命令运行这些基准测试:

$ java -jar jmh-1.0-SNAPSHOT.jar -f2 -t4 -prof perfnorm -rff set.csv setBitSet setBoolArray

让我们看看这些操作在吞吐量方面的基准测试结果如何:

吞吐量集

这次 boolean[] 在大多数情况下都优于 BitSet ,除了非常大的尺寸。由于我们可以 在高速缓存行中拥有更多的BitSet 位,因此高速缓存未命中和错误共享的影响在BitSet 实例中可能更加显着 。

这是数据缓存未命中比较:

数据缓存未命中集

如上所示, boolean[] 的数据缓存未命中对于低到中等数量的位来说非常低。同样,当位数增加时, boolean[] 会遇到更多缓存未命中。

类似地, boolean[] 的每次操作的指令数 比 BitSet少:

指令集

4.6. 基数

此类位向量中的其他常见操作之一是计算设置位的数量。这次我们将运行这些基准测试:

@Benchmark
public int cardinalityBoolArray() {
    int sum = 0;
    for (boolean b : array) {
        if (b) sum++;
    }

    return sum;
}

@Benchmark
public int cardinalityBitSet() {
    return bitSet.cardinality();
}

我们可以再次使用以下命令运行这些基准测试:

$ java -jar jmh-1.0-SNAPSHOT.jar -f2 -t4 -prof perfnorm -rff cardinal.csv cardinalityBitSet cardinalityBoolArray

以下是这些基准测试的吞吐量:

吞吐量基数

在基数吞吐量方面, BitSet API 几乎所有时间都优于boolean[] ,因为它的迭代次数少得多。更具体地说, BitSet 只需迭代其内部 的 long[] ,与相应的boolean[]相比,它的元素数量要少得多 。

此外,由于这条线和位向量中设置位的随机分布:

if (b) {
    sum++;
}

分支预测错误的成本也可能是决定性的:

分支预测未命中

如上所示,随着位数的增加, boolean[] 的错误预测数量显着增加。

5.总结

在本文中,我们比较了 BitSet 和 boolean[] 在三种常见操作方面的吞吐量:获取位、设置位和计算基数。除了吞吐量之外,我们还看到 与具有相同大小的boolean[]相比, BitSet 使用的内存要少得多。

回顾一下,在单比特读取密集型场景中, boolean[]在更小的尺寸下 优于 BitSet 。但是,当位数增加时, BitSet 具有更高的吞吐量。

此外,在单比特写入繁重的场景中, boolean[] 几乎所有时间都表现出卓越的吞吐量,除了非常多的比特。另外,在批量读取场景中, BitSet API 完全支配了 boolean[] 方法。

我们使用 JMH-perf 集成来捕获低级 CPU 指标,例如 L1 数据缓存未命中或未命中分支预测。从 Linux 2.6.31 开始,perf是标准的 Linux 分析器,能够公开有用的性能监控计数器或PMC。 也可以单独使用此工具。要查看此独立用法的一些示例,强烈建议阅读Branden Greg的博客。

像往常一样,所有示例都可以在 GitHub 上找到。此外,所有执行基准测试的 CSV 结果也可在 GitHub 上访问。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章