
Baeldung Pro comes with both absolutely No-Ads as well as finally with Dark Mode, for a clean learning experience:
Once the early-adopter seats are all used, the price will go up and stay at $33/year.
Last updated: December 14, 2024
In this tutorial, we’ll explore HashSet, one of the most popular Set implementations and an integral part of the Java Collections Framework.
HashSet is one of the fundamental data structures in the Java Collections API.
Let’s recall the most important aspects of this implementation:
Note that this internal HashMap gets initialized when an instance of the HashSet is created:
public HashSet() {
map = new HashMap<>();
}
If we want to go deeper into how the HashMap works, we can read the article focused on it here.
In this section, we’re going to review the most commonly used methods and have a look at some simple examples.
The add() method can be used for adding elements to a set. The method contract states that an element will be added only when it isn’t already present in a set. If an element was added, the method returns true, otherwise – false.
We can add an element to a HashSet like:
@Test
public void whenAddingElement_shouldAddElement() {
Set<String> hashset = new HashSet<>();
assertTrue(hashset.add("String Added"));
}
From an implementation perspective, the add method is extremely important. Implementation details illustrate how the HashSet works internally and leverages the HashMap’s put method:
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
The map variable is a reference to the internal, backing HashMap:
private transient HashMap<E, Object> map;
It’d be a good idea to get familiar with the hashcode first to get a detailed understanding of how the elements are organized in hash-based data structures.
Summarizing:
The purpose of the contains method is to check if an element is present in a given HashSet. It returns true if the element is found, otherwise false.
We can check for an element in the HashSet:
@Test
public void whenCheckingForElement_shouldSearchForElement() {
Set<String> hashsetContains = new HashSet<>();
hashsetContains.add("String Added");
assertTrue(hashsetContains.contains("String Added"));
}
Whenever an object is passed to this method, the hash value gets calculated. Then, the corresponding bucket location gets resolved and traversed.
The method removes the specified element from the set if it’s present. This method returns true if a set contained the specified element.
Let’s see a working example:
@Test
public void whenRemovingElement_shouldRemoveElement() {
Set<String> removeFromHashSet = new HashSet<>();
removeFromHashSet.add("String Added");
assertTrue(removeFromHashSet.remove("String Added"));
}
We use this method when we intend to remove all the items from a set. The underlying implementation simply clears all elements from the underlying HashMap.
Let’s see that in action:
@Test
public void whenClearingHashSet_shouldClearHashSet() {
Set<String> clearHashSet = new HashSet<>();
clearHashSet.add("String Added");
clearHashSet.clear();
assertTrue(clearHashSet.isEmpty());
}
This is one of the fundamental methods in the API. It’s used heavily as it helps in identifying the number of elements present in the HashSet. The underlying implementation simply delegates the calculation to the HashMap’s size() method.
Let’s see that in action:
@Test
public void whenCheckingTheSizeOfHashSet_shouldReturnThesize() {
Set<String> hashSetSize = new HashSet<>();
hashSetSize.add("String Added");
assertEquals(1, hashSetSize.size());
}
We can use this method to figure if a given instance of a HashSet is empty or not. This method returns true if the set contains no elements:
@Test
public void whenCheckingForEmptyHashSet_shouldCheckForEmpty() {
Set<String> emptyHashSet = new HashSet<>();
assertTrue(emptyHashSet.isEmpty());
}
The method returns an iterator over the elements in the Set. The elements are visited in no particular order and iterators are fail-fast.
We can observe the random iteration order here:
@Test
public void whenIteratingHashSet_shouldIterateHashSet() {
Set<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> 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<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> 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<String> hashset = new HashSet<>();
hashset.add("First");
hashset.add("Second");
hashset.add("Third");
Iterator<String> 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 can’t 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.
We can convert a HashSet to a TreeSet using the class constructor TreeSet(Collection<? extends E> c). This creates a new TreeSet object containing the elements in the collection passed to it, sorted according to the elements’ natural ordering.
Let’s demonstrate with an example how to convert a HashSet comprised of elements, each of which is an object (instance of a class), to a TreeSet. We use an example class, Employee. This class should implement the Comparable interface to convert a HashSet with its elements derived from the class to a TreeSet. Accordingly, we override the compareTo(Object) method in Comparable to compare two Employee objects based on their IDs:
public class Employee implements Comparable {
private int employeeId;
private String employeeName;
Employee(int employeeId, String employeeName) {
this.employeeId = employeeId;
this.employeeName = employeeName;
}
int getEmployeeId() {
return employeeId;
}
public String getEmployeeName() {
return employeeName;
}
@Override
public String toString() {
return employeeId + " " + employeeName;
}
@Override
public int compareTo(Employee o) {
if (this.employeeId == o.employeeId) {
return 0;
} else if (this.employeeId < o.employeeId) {
return 1;
} else {
return -1;
}
}
}
Moreover, having defined an object class, let’s create a HashSet from it. Let’s use a JUnit 5 integration test that includes an assertDoesNotThrow() assertion to verify the conversion doesn’t throw an exception:
@Test
public void givenComparableObject_whenConvertingToTreeSet_thenNoExceptionThrown() {
HashSet hashSet = new HashSet();
hashSet.add(new Employee(3, "John"));
hashSet.add(new Employee(5, "Mike"));
hashSet.add(new Employee(2, "Bob"));
hashSet.add(new Employee(1, "Tom"));
hashSet.add(new Employee(4, "Johnny"));
assertDoesNotThrow(()->{
TreeSet treeSet=new TreeSet(hashSet);
});
}
The JUnit integration test should pass when we use the Employee class that implements the Comparable interface.
However, when we use an Employee class definition that doesn’t implement the Comparable interface to create a HashSet instance, converting it to TreeSet throws an exception. Again, we can use a JUnit 5 test to verify it:
@Test
public void givenNonComparableObject_whenConvertingToTreeSet_thenExceptionThrown() {
HashSet hashSet = new HashSet();
hashSet.add(new Employee(3, "John"));
hashSet.add(new Employee(5, "Mike"));
hashSet.add(new Employee(2, "Bob"));
hashSet.add(new Employee(1, "Tom"));
hashSet.add(new Employee(4, "Johnny"));
assertThrows(ClassCastException.class,() -> {
TreeSet treeSet = new TreeSet(hashSet);
});
}
This time, the conversion throws the ClassCastException.class exception type, and the assertThrows() test passes.
When we put an object into a HashSet, it uses the object’s hashcode value to determine if an element is not already in the set.
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.
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<String> hashset = new HashSet<>();
Set<String> hashset = new HashSet<>(20);
Set<String> 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:
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.
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 and how they can help us as a developer to use a HashSet to its potential.