Eine Anleitung zu BitSet in Java

1. Übersicht

In diesem Tutorial werden wir sehen, wie wir BitSets verwenden können, um einen Bitvektor darzustellen.

Zunächst beginnen wir mit der Begründung, warum der Boolesche Wert [] nicht verwendet wird . Nachdem wir uns mit den BitSet- Interna vertraut gemacht haben , werden wir uns die API genauer ansehen.

2. Array von Bits

Um Bits-Arrays zu speichern und zu manipulieren, könnte man argumentieren, dass wir boolean [] als Datenstruktur verwenden sollten. Auf den ersten Blick scheint dies ein vernünftiger Vorschlag zu sein.

Jedes boolesche Element in einem booleschen [] verbraucht jedoch normalerweise ein Byte anstelle von nur einem Bit . Wenn wir also einen engen Speicherbedarf haben oder nur einen reduzierten Speicherbedarf anstreben, ist boolean [] alles andere als ideal.

Um die Sache konkreter zu machen, schauen wir uns an, wie viel Platz ein Boolescher Wert [] mit 1024 Elementen benötigt:

boolean[] bits = new boolean[1024]; System.out.println(ClassLayout.parseInstance(bits).toPrintable());

Im Idealfall erwarten wir von diesem Array einen Speicherbedarf von 1024 Bit. Das Java Object Layout (JOL) zeigt jedoch eine ganz andere Realität:

[Z object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 7b 12 07 00 (01111011 00010010 00000111 00000000) (463483) 12 4 (object header) 00 04 00 00 (00000000 00000100 00000000 00000000) (1024) 16 1024 boolean [Z. N/A Instance size: 1040 bytes

Wenn wir den Overhead des Objekt-Headers ignorieren, verbrauchen die Array-Elemente 1024 Bytes anstelle der erwarteten 1024 Bits. Das sind 700% mehr Speicher als erwartet.

Die Adressierbarkeitsprobleme und das Zerreißen von Wörtern sind die Hauptgründe, warum Boolesche Werte mehr als nur ein einzelnes Bit sind.

Um dieses Problem zu lösen, können wir eine Kombination aus numerischen Datentypen (z. B. long ) und bitweisen Operationen verwenden. Hier kommt das BitSet ins Spiel .

3. Wie BitSet funktioniert

Wie bereits erwähnt, verwendet die BitSet- API eine Kombination aus grundlegenden numerischen Datentypen und bitweisen Operationen , um die Speichernutzung von einem Bit pro Flag zu erreichen .

Nehmen wir der Einfachheit halber an, wir werden acht Flags mit einem Byte darstellen . Zuerst initialisieren wir alle Bits dieses einzelnen Bytes mit Null:

Wenn wir nun das Bit an Position drei auf true setzen wollen , sollten wir zuerst die Zahl 1 um drei nach links verschieben:

Und dann oder ihr Ergebnis mit dem aktuellen Byte Wert :

Der gleiche Vorgang wird ausgeführt, wenn Sie das Bit auf den Index sieben setzen:

Wie oben gezeigt, führen wir eine Linksverschiebung um sieben Bits durch und kombinieren das Ergebnis mit dem vorherigen Bytewert unter Verwendung des Operators oder .

3.1. Einen Bit-Index erhalten

Um zu überprüfen, ob ein bestimmter Bitindex auf true gesetzt ist oder nicht, verwenden wir den Operator und . So überprüfen wir beispielsweise, ob Index drei festgelegt ist:

  1. Durchführen einer Linksverschiebung um drei Bits für den Wert Eins
  2. Anding das Ergebnis mit dem aktuellen Byte Wert
  3. Wenn das Ergebnis größer als Null ist, haben wir eine Übereinstimmung gefunden, und dieser Bitindex ist tatsächlich gesetzt. Andernfalls ist der angeforderte Index klar oder gleich false

Das obige Diagramm zeigt die Schritte zum Abrufen der Operation für Index drei. Wenn wir uns jedoch nach einem eindeutigen Index erkundigen, ist das Ergebnis anders:

Da das Ergebnis und gleich Null ist, ist der Index vier klar.

3.2. Speicher erweitern

Derzeit können wir nur einen Vektor von 8 Bits speichern. Um über diese Einschränkung hinauszugehen, müssen wir nur ein Array von Bytes anstelle eines einzelnen Bytes verwenden , das war's!

Jedes Mal, wenn wir einen bestimmten Index festlegen, abrufen oder löschen müssen, sollten wir zuerst das entsprechende Array-Element finden. Nehmen wir zum Beispiel an, wir setzen den Index 14:

Wie im obigen Diagramm gezeigt, haben wir nach dem Finden des richtigen Array-Elements den entsprechenden Index festgelegt.

Wenn wir hier einen Index über 15 festlegen möchten , erweitert das BitSet zunächst sein internes Array. Erst nach dem Erweitern des Arrays und dem Kopieren der Elemente wird das angeforderte Bit gesetzt. Dies ähnelt in etwa der internen Funktionsweise von ArrayList .

Bisher haben wir der Einfachheit halber den Byte- Datentyp verwendet. Die BitSet- API verwendet jedoch intern ein Array langer Werte .

4. Die BitSet- API

Jetzt, da wir genug über die Theorie wissen, ist es Zeit zu sehen, wie die BitSet- API aussieht.

Vergleichen wir zunächst den Speicherbedarf einer BitSet- Instanz mit 1024 Bit mit dem zuvor gesehenen Booleschen Wert [] :

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

Dadurch wird sowohl die geringe Größe der BitSet- Instanz als auch die Größe ihres internen Arrays gedruckt :

[email protected] object externals: ADDRESS SIZE TYPE PATH VALUE 70f97d208 24 java.util.BitSet (object) 70f97d220 144 [J .words [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Wie oben gezeigt, wird intern ein langes [] mit 16 Elementen (16 * 64 Bit = 1024 Bit) verwendet. Auf jeden Fall verwendet diese Instanz insgesamt 168 Bytes, während der Boolesche Wert [] 1024 Bytes verwendet .

The more bits we have, the more the footprint difference increases. For example, to store 1024 * 1024 bits, the boolean[] consumes 1 MB, and the BitSet instance consumes around 130 KB.

4.1. Constructing BitSets

The simplest way to create a BitSet instance is to use the no-arg constructor:

BitSet bitSet = new BitSet();

This will create a BitSet instance with a long[] of size one. Of course, it can automatically grow this array if needed.

It's also possible to create a BitSet with an initial number of bits:

BitSet bitSet = new BitSet(100_000);

Here, the internal array will have enough elements to hold 100,000 bits. This constructor comes in handy when we already have a reasonable estimate on the number of bits to store. In such use cases, it can prevent or decrease the unnecessary copying of array elements while growing it.

It's even possible to create a BitSet from an existing long[], byte[], LongBuffer, and ByteBuffer. For instance, here we're creating a BitSet instance from a given long[]:

BitSet bitSet = BitSet.valueOf(new long[] { 42, 12 });

There are three more overloaded versions of the valueOf() static factory method to support the other mentioned types.

4.2. Setting Bits

We can set the value of a particular index to true using the set(index) method:

BitSet bitSet = new BitSet(); bitSet.set(10); assertThat(bitSet.get(10)).isTrue();

As usual, the indices are zero-based. It's even possible to set a range of bits to true using the set(fromInclusive, toExclusive) method:

bitSet.set(20, 30); for (int i = 20; i <= 29; i++) { assertThat(bitSet.get(i)).isTrue(); } assertThat(bitSet.get(30)).isFalse();

As is evident from the method signature, the beginning index is inclusive, and the ending one is exclusive.

When we say setting an index, we usually mean setting it to true. Despite this terminology, we can set a particular bit index to false using the set(index, boolean) method:

bitSet.set(10, false); assertThat(bitSet.get(10)).isFalse();

This version also supports setting a range of values:

bitSet.set(20, 30, false); for (int i = 20; i <= 29; i++) { assertThat(bitSet.get(i)).isFalse(); }

4.3. Clearing Bits

Instead of setting a specific bit index to false, we can simply clear it using the clear(index) method:

bitSet.set(42); assertThat(bitSet.get(42)).isTrue(); bitSet.clear(42); assertThat(bitSet.get(42)).isFalse();

Moreover, we can also clear a range of bits with the clear(fromInclusive, toExclusive) overloaded version:

bitSet.set(10, 20); for (int i = 10; i < 20; i++) { assertThat(bitSet.get(i)).isTrue(); } bitSet.clear(10, 20); for (int i = 10; i < 20; i++) { assertThat(bitSet.get(i)).isFalse(); }

Interestingly, if we call this method without passing any arguments, it'll clear all the set bits:

bitSet.set(10, 20); bitSet.clear(); for (int i = 0; i < 100; i++) { assertThat(bitSet.get(i)).isFalse(); }

As shown above, after calling the clear() method, all bits are set to zero.

4.4. Getting Bits

So far, we used the get(index) method quite extensively. When the requested bit index is set, then this method will return true. Otherwise, it'll return false:

bitSet.set(42); assertThat(bitSet.get(42)).isTrue(); assertThat(bitSet.get(43)).isFalse();

Similar to set and clear, we can get a range of bit indices using the get(fromInclusive, toExclusive) method:

bitSet.set(10, 20); BitSet newBitSet = bitSet.get(10, 20); for (int i = 0; i < 10; i++) { assertThat(newBitSet.get(i)).isTrue(); }

As shown above, this method returns another BitSet in the [20, 30) range of the current one. That is, index 20 of the bitSet variable is equivalent to index zero of the newBitSet variable.

4.5. Flipping Bits

To negate the current bit index value, we can use the flip(index) method. That is, it'll turn true values to false and vice versa:

bitSet.set(42); bitSet.flip(42); assertThat(bitSet.get(42)).isFalse(); bitSet.flip(12); assertThat(bitSet.get(12)).isTrue();

Similarly, we can achieve the same thing for a range of values using the flip(fromInclusive, toExclusive) method:

bitSet.flip(30, 40); for (int i = 30; i < 40; i++) { assertThat(bitSet.get(i)).isTrue(); }

4.6. Length

There are three length-like methods for a BitSet. The size() method returns the number of bits the internal array can represent. For instance, since the no-arg constructor allocates a long[] array with one element, then the size() will return 64 for it:

BitSet defaultBitSet = new BitSet(); assertThat(defaultBitSet.size()).isEqualTo(64);

With one 64-bit number, we can only represent 64 bits. Of course, this will change if we pass the number of bits explicitly:

BitSet bitSet = new BitSet(1024); assertThat(bitSet.size()).isEqualTo(1024);

Moreover, the cardinality() method represents the number of set bits in a BitSet:

assertThat(bitSet.cardinality()).isEqualTo(0); bitSet.set(10, 30); assertThat(bitSet.cardinality()).isEqualTo(30 - 10);

At first, this method returns zero as all bits are false. After setting the [10, 30) range to true, then the cardinality() method call returns 20.

Also, the length() method returns the one index after the index of the last set bit:

assertThat(bitSet.length()).isEqualTo(30); bitSet.set(100); assertThat(bitSet.length()).isEqualTo(101);

At first, the last set index is 29, so this method returns 30. When we set the index 100 to true, then the length() method returns 101. It's also worth mentioning that this method will return zero if all bits are clear.

Finally, the isEmpty() method returns false when there is at least one set bit in the BitSet. Otherwise, it'll return true:

assertThat(bitSet.isEmpty()).isFalse(); bitSet.clear(); assertThat(bitSet.isEmpty()).isTrue();

4.7. Combining With Other BitSets

The intersects(BitSet) method takes another BitSet and returns true when two BitSets have something in common. That is, they have at least one set bit in the same index:

BitSet first = new BitSet(); first.set(5, 10); BitSet second = new BitSet(); second.set(7, 15); assertThat(first.intersects(second)).isTrue();

The [7, 9] range is set in both BitSets, so this method returns true.

It's also possible to perform the logical and operation on two BitSets:

first.and(second); assertThat(first.get(7)).isTrue(); assertThat(first.get(8)).isTrue(); assertThat(first.get(9)).isTrue(); assertThat(first.get(10)).isFalse();

This will perform a logical and between the two BitSets and modifies the first variable with the result. Similarly, we can perform a logical xor on two BitSets, too:

first.clear(); first.set(5, 10); first.xor(second); for (int i = 5; i < 7; i++) { assertThat(first.get(i)).isTrue(); } for (int i = 10; i < 15; i++) { assertThat(first.get(i)).isTrue(); }

There are other methods such as the andNot(BitSet) or the or(BitSet),which can perform other logical operations on two BitSets.

4.8. Miscellaneous

As of Java 8, there is a stream() method to stream all set bits of a BitSet. For instance:

BitSet bitSet = new BitSet(); bitSet.set(15, 25); bitSet.stream().forEach(System.out::println);

This will print all set bits to the console. Since this will return an IntStream, we can perform common numerical operations such as summation, average, counting, and so on. For instance, here we're counting the number of set bits:

assertThat(bitSet.stream().count()).isEqualTo(10);

Also, the nextSetBit(fromIndex) method will return the next set bit index starting from the fromIndex:

assertThat(bitSet.nextSetBit(13)).isEqualTo(15);

The fromIndex itself is included in this calculation. When there isn't any true bit left in the BitSet, it'll return -1:

assertThat(bitSet.nextSetBit(25)).isEqualTo(-1);

Similarly, the nextClearBit(fromIndex) returns the next clear index starting from the fromIndex:

assertThat(bitSet.nextClearBit(23)).isEqualTo(25);

On the other hand, the previousClearBit(fromIndex) returns the index of the nearest clear index in the opposite direction:

assertThat(bitSet.previousClearBit(24)).isEqualTo(14);

Same is true for previousSetBit(fromIndex):

assertThat(bitSet.previousSetBit(29)).isEqualTo(24); assertThat(bitSet.previousSetBit(14)).isEqualTo(-1);

Moreover, we can convert a BitSet to a byte[] or a long[] using the toByteArray() or toLongArray() methods, respectively:

byte[] bytes = bitSet.toByteArray(); long[] longs = bitSet.toLongArray();

5. Conclusion

In diesem Tutorial haben wir gesehen, wie wir BitSets verwenden können , um einen Vektor von Bits darzustellen.

Zuerst haben wir uns mit den Gründen vertraut gemacht, warum Boolean [] nicht zur Darstellung eines Bitvektors verwendet wird. Dann haben wir gesehen, wie ein BitSet intern funktioniert und wie seine API aussieht.

Wie üblich sind alle Beispiele auf GitHub verfügbar.