Eine Anleitung zu HashSet in Java

1. Übersicht

In diesem Artikel werden wir uns mit HashSet befassen. Es ist eine der beliebtesten Set- Implementierungen und ein wesentlicher Bestandteil des Java Collections Framework.

2. Einführung in HashSet

HashSet ist eine der grundlegenden Datenstrukturen in der Java Collections-API .

Erinnern wir uns an die wichtigsten Aspekte dieser Implementierung:

  • Es speichert eindeutige Elemente und erlaubt Nullen
  • Es wird von einer HashMap unterstützt
  • Die Einfügereihenfolge wird nicht beibehalten
  • Es ist nicht threadsicher

Beachten Sie, dass diese interne HashMap beim Erstellen einer Instanz des HashSet initialisiert wird:

public HashSet() { map = new HashMap(); }

Wenn Sie mehr über die Funktionsweise der HashMap erfahren möchten , können Sie den Artikel lesen, der sich darauf konzentriert.

3. Die API

In diesem Abschnitt werden wir die am häufigsten verwendeten Methoden überprüfen und einige einfache Beispiele betrachten.

3.1. hinzufügen()

Die add () -Methode kann zum Hinzufügen von Elementen zu einer Menge verwendet werden. Der Methodenvertrag besagt, dass ein Element nur hinzugefügt wird, wenn es nicht bereits in einer Menge vorhanden ist. Wenn ein Element hinzugefügt wurde, gibt die Methode true zurück, andernfalls false.

Wir können einem HashSet ein Element hinzufügen, wie:

@Test public void whenAddingElement_shouldAddElement() { Set hashset = new HashSet(); assertTrue(hashset.add("String Added")); }

Aus Sicht der Implementierung ist die Add- Methode äußerst wichtig. Implementierungsdetails veranschaulichen die interne Funktionsweise von HashSet und nutzen die put- Methode von HashMap :

public boolean add(E e) { return map.put(e, PRESENT) == null; }

Die Map- Variable ist eine Referenz auf die interne, unterstützende HashMap:

private transient HashMap map;

Es wäre eine gute Idee, sich zuerst mit dem Hashcode vertraut zu machen , um ein detailliertes Verständnis darüber zu erhalten, wie die Elemente in Hash-basierten Datenstrukturen organisiert sind.

Zusammenfassend:

  • Eine HashMap ist ein Array von Buckets mit einer Standardkapazität von 16 Elementen. Jeder Bucket entspricht einem anderen Hashcode-Wert
  • Wenn verschiedene Objekte denselben Hashcode-Wert haben, werden sie in einem einzigen Bucket gespeichert
  • Wenn der Lastfaktor erreicht ist, wird ein neues Array erstellt, das doppelt so groß ist wie das vorherige, und alle Elemente werden erneut aufbereitet und auf neue entsprechende Buckets verteilt
  • Um einen Wert abzurufen, hashen wir einen Schlüssel, ändern ihn und gehen dann zu einem entsprechenden Bucket und durchsuchen die potenzielle verknüpfte Liste, falls es mehr als ein Objekt gibt

3.2. enthält ()

Der Zweck der enthält Methode ist zu überprüfen , ob ein Element in einem gegebenen ist HashSet . Es gibt true zurück , wenn das Element gefunden wird, andernfalls false.

Wir können im HashSet nach einem Element suchen :

@Test public void whenCheckingForElement_shouldSearchForElement() { Set hashsetContains = new HashSet(); hashsetContains.add("String Added"); assertTrue(hashsetContains.contains("String Added")); }

Immer wenn ein Objekt an diese Methode übergeben wird, wird der Hashwert berechnet. Dann wird der entsprechende Bucket-Standort aufgelöst und durchlaufen.

3.3. entfernen()

Die Methode entfernt das angegebene Element aus der Menge, falls es vorhanden ist. Diese Methode gibt true zurück , wenn eine Menge das angegebene Element enthält.

Sehen wir uns ein funktionierendes Beispiel an:

@Test public void whenRemovingElement_shouldRemoveElement() { Set removeFromHashSet = new HashSet(); removeFromHashSet.add("String Added"); assertTrue(removeFromHashSet.remove("String Added")); }

3.4. klar()

Wir verwenden diese Methode, wenn wir alle Elemente aus einem Satz entfernen möchten. Die zugrunde liegende Implementierung löscht einfach alle Elemente aus der zugrunde liegenden HashMap.

Lassen Sie uns das in Aktion sehen:

@Test public void whenClearingHashSet_shouldClearHashSet() { Set clearHashSet = new HashSet(); clearHashSet.add("String Added"); clearHashSet.clear(); assertTrue(clearHashSet.isEmpty()); }

3.5. Größe()

Dies ist eine der grundlegenden Methoden in der API. Es wird häufig verwendet, um die Anzahl der im HashSet vorhandenen Elemente zu ermitteln . Die zugrunde liegende Implementierung delegiert die Berechnung einfach an die size () -Methode von HashMap .

Lassen Sie uns das in Aktion sehen:

@Test public void whenCheckingTheSizeOfHashSet_shouldReturnThesize() { Set hashSetSize = new HashSet(); hashSetSize.add("String Added"); assertEquals(1, hashSetSize.size()); }

3.6. ist leer()

Mit dieser Methode können wir herausfinden, ob eine bestimmte Instanz eines HashSet leer ist oder nicht. Diese Methode gibt true zurück , wenn die Menge keine Elemente enthält:

@Test public void whenCheckingForEmptyHashSet_shouldCheckForEmpty() { Set emptyHashSet = new HashSet(); assertTrue(emptyHashSet.isEmpty()); }

3.7. iterator ()

Die Methode gibt einen Iterator über die Elemente im Set zurück . Die Elemente werden in keiner bestimmten Reihenfolge besucht und Iteratoren sind ausfallsicher .

Wir können die zufällige Iterationsreihenfolge hier beobachten:

@Test public void whenIteratingHashSet_shouldIterateHashSet() { Set hashset = new HashSet(); hashset.add("First"); hashset.add("Second"); hashset.add("Third"); Iterator itr = hashset.iterator(); while(itr.hasNext()){ System.out.println(itr.next()); } }

If the set is modified at any time after the iterator is created in any way except through the iterator's own remove method, the Iterator throws a ConcurrentModificationException.

Let's see that in action:

@Test(expected = ConcurrentModificationException.class) public void whenModifyingHashSetWhileIterating_shouldThrowException() { Set hashset = new HashSet(); hashset.add("First"); hashset.add("Second"); hashset.add("Third"); Iterator itr = hashset.iterator(); while (itr.hasNext()) { itr.next(); hashset.remove("Second"); } } 

Alternatively, had we used the iterator's remove method, then we wouldn't have encountered the exception:

@Test public void whenRemovingElementUsingIterator_shouldRemoveElement() { Set hashset = new HashSet(); hashset.add("First"); hashset.add("Second"); hashset.add("Third"); Iterator itr = hashset.iterator(); while (itr.hasNext()) { String element = itr.next(); if (element.equals("Second")) itr.remove(); } assertEquals(2, hashset.size()); }

The fail-fast behavior of an iterator cannot be guaranteed as it's impossible to make any hard guarantees in the presence of unsynchronized concurrent modification.

Fail-fast iterators throw ConcurrentModificationException on a best-effort basis. Therefore, it'd be wrong to write a program that depended on this exception for its correctness.

4. How HashSet Maintains Uniqueness?

When we put an object into a HashSet, it uses the object's hashcode value to determine if an element is not in the set already.

Each hash code value corresponds to a certain bucket location which can contain various elements, for which the calculated hash value is the same. But two objects with the same hashCode might not be equal.

So, objects within the same bucket will be compared using the equals() method.

5. Performance of HashSet

The performance of a HashSet is affected mainly by two parameters – its Initial Capacity and the Load Factor.

The expected time complexity of adding an element to a set is O(1) which can drop to O(n) in the worst case scenario (only one bucket present) – therefore, it's essential to maintain the right HashSet's capacity.

An important note: since JDK 8, the worst case time complexity is O(log*n).

The load factor describes what is the maximum fill level, above which, a set will need to be resized.

We can also create a HashSet with custom values for initial capacity and load factor:

Set hashset = new HashSet(); Set hashset = new HashSet(20); Set hashset = new HashSet(20, 0.5f); 

In the first case, the default values are used – the initial capacity of 16 and the load factor of 0.75. In the second, we override the default capacity and in the third one, we override both.

A low initial capacity reduces space complexity but increases the frequency of rehashing which is an expensive process.

On the other hand, a high initial capacity increases the cost of iteration and the initial memory consumption.

As a rule of thumb:

  • A high initial capacity is good for a large number of entries coupled with little to no iteration
  • A low initial capacity is good for few entries with a lot of iteration

It's, therefore, very important to strike the correct balance between the two. Usually, the default implementation is optimized and works just fine, should we feel the need to tune these parameters to suit the requirements, we need to do judiciously.

6. Conclusion

In this article, we outlined the utility of a HashSet, its purpose as well as its underlying working. We saw how efficient it is in terms of usability given its constant time performance and ability to avoid duplicates.

We studied some of the important methods from the API, how they can help us as a developer to use a HashSet to its potential.

As always, code snippets can be found over on GitHub.