Introduction
The Java Collections Framework is a fundamental and essential framework that any strong Java developer should know like the back of their hand.
A Collection in Java is defined as a group or collection of individual objects that act as a single object.
There are many collection classes in Java and all of them extend the java.util.Collection
and java.util.Map
interfaces. These classes mostly offer different ways to formulate a collection of objects within a single object.
Java Collections is a framework that provides numerous operations over a collection - searching, sorting, insertion, manipulation, deletion etc.
This is the third part of a series of Java Collections articles:
- The List Interface
- The Set Interface
- The Map Interface (you are here)
- The Queue and Deque Interfaces
Lists and Sets Limitations
First of all, let's discuss the limitations of List
and Set
. They provide many features to add, remove, and check for the presence of items, as well as iteration mechanisms. But when it comes to retrieve specific items, they are not very handy.
The Set
interface doesn't provide any mean to retrieve a specific object, as it's unordered. And the List
interface merely provides the possibility to retrieve items by their index.
Unfortunately, indices are not always very self-speaking and thus have little meaning.
Maps
That's where the java.util.Map
interface shows up. A Map
associates items to keys, allowing us to retrieve items by those keys. Such associations carry much more sense than associating an index to an item.
Map
is a generic interface with two types, one for the keys and one for the values. Therefore, if we wanted to declare a Map
storing words count in a text, we would write:
Map<String, Integer> wordsCount;
Such a Map
uses a String
as its key and an Integer
as its value.
Adding Elements
Let's now dive into the Map
operations, starting with the addition of elements. There are a few ways to add elements to a Map
, the most common one being the put()
method:
Map<String, Integer> wordsCount = new HashMap<>();
wordsCount.put("the", 153);
Note: In addition to associating a value to a key, the put()
method also returns the previously associated value, if any, and null
otherwise.
But, what if we only want to add an element only if nothing is associated to its key? Then we have a few possibilities, the first being to test for the key presence with the containsKey()
method:
if (!wordsCount.containsKey("the")) {
wordsCount.put("the", 150);
}
Thanks to the containsKey()
method, we can check if an element is already associated to the key the
and only add a value if not.
However, that's a bit verbose, especially considering there are two other options. First of all, let's see the most ancient one, the putIfAbsent()
method:
wordsCount.putIfAbsent("the", 150);
This method call achieves the same result as the previous one, but using only one line.
Now, let's see the second option. Since Java 8, another method, similar to putIfAbsent()
, exists - computeIfAbsent()
.
It works roughly the same way as the former, but takes a Lambda Function instead of a direct value, giving us the possibility to instantiate the value only if nothing is attached to the key yet.
The function argument is the key, in case the value instantiation depends on it. So, to achieve the same result as with the precedent methods, we would have to do:
wordsCount.computeIfAbsent("the", key -> 3 + 150);
It will provide the same result as before, only it will not compute the value 153 if another value is already associated to the key the
.
Note: This method is particularly useful when the value is heavy to instantiate or if the method is called often and we want to avoid creating too many objects.
Retrieving Elements
Until now, we learned how to put elements into a Map
, but how about retrieving them?
To achieve that, we use the get()
method:
wordsCount.get("the");
That code will return the words count of the word the
.
If no value matches the given key, then get()
returns null
. We can avoid that, though, by using the getOrDefault()
method:
wordsCount.getOrDefault("duck", 0);
Note: Here, if nothing is associated to the key, we'll get 0
back instead of null
.
Now, that's for retrieving one element at a time using its key. Let's see how to retrieve all the elements. The Map
interface offers three methods to achieve this:
entrySet()
: Returns aSet
ofEntry<K, V>
which are key/value pairs representing the elements of the mapkeySet()
: Returns aSet
of keys of the mapvalues()
: Returns aSet
of values of the map
Removing Elements
Now that we know how to put and retrieve elements from a map, let's see how to remove some!
First, let's see how to remove an element by its key. To this end, we'll use the remove()
method, which takes a key as its parameter:
wordsCount.remove("the");
The method will remove the element and return the associated value if any, otherwise it does nothing and returns null
.
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
The remove()
method has an overloaded version taking also a value. Its goal is to remove an entry only if it has the same key and value as those specified in the parameters:
wordsCount.remove("the", 153);
This call will remove the entry associated to the word the
only if the corresponding value is 153
, otherwise it doesn't do anything.
This method doesn't return an Object
, but rather returns a boolean
telling if an element has been removed or not.
Iterating over Elements
We can't talk about a Java collection without explaining how to iterate over it. We'll see two ways to iterate over the elements of a Map
.
The first one is the for-each
loop, that we can use on the entrySet()
method:
for (Entry<String, Integer> wordCount: wordsCount.entrySet()) {
System.out.println(wordCount.getKey() + " appears " + wordCount.getValue() + " times");
}
Before Java 8, this was the standard way of iterating through a Map
. Fortunately for us, a less verbose way has been introduced in Java 8: the forEach()
method which takes a BiConsumer<K, V>
:
wordsCount.forEach((word, count) -> System.out.println(word + " appears " + count + " times"));
Since some may not be familiar with the functional interface, BiConsumer
- it accepts two arguments and doesn't return any value. In our case, we pass a word
and its count
, which are then printed out via a Lambda Expression.
This code is very concise and easier to read than the previous one.
Checking for an Element Presence
Although we already had an overview of how to check for an element presence in a Map
, let's talk about the possible ways of achieving that.
First of all, there is the containsKey()
method, which we already used and that returns a boolean
value telling us if an element matches the given key or not. But, there is also the containsValue()
method which checks for the presence of a certain value.
Let's imagine a Map
representing players scores for a game and the first to hit 150 wins, then we could use the containsValue()
method to tell if a player wins the game or not:
Map<String, Integer> playersScores = new HashMap<>();
playersScores.put("James", 0);
playersScores.put("John", 0);
while (!playersScores.containsValue(150)) {
// Game taking place
}
System.out.println("We have a winner!");
Retrieving Size and Checking for Emptiness
Now, as for List
and Set
, there are operations for counting the number of elements.
Those operations are size()
, which returns the number of elements of the Map
, and isEmpty()
, which returns a boolean
telling if the Map
does or doesn't contain any element:
Map<String, Integer> map = new HashMap<>();
map.put("One", 1);
map.put("Two", 2);
System.out.println(map.size());
System.out.println(map.isEmpty());
The output is:
2
false
SortedMap
We've now covered the main operations we can realize on Map
via the HashMap
implementation. But there are other map interfaces that inherit from it that offer new features and make the contracts more strict.
The first one we'll learn about is the SortedMap
interface, which ensures that entries of the map will maintain a certain order based on its keys.
In addition, this interface offers features taking advantage of the maintained ordering, such as the firstKey()
and lastKey()
methods.
Let's reuse our first example, but using a SortedMap
this time:
SortedMap<String, Integer> wordsCount = new TreeMap<>();
wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);
System.out.println(wordsCount.firstKey());
System.out.println(wordsCount.lastKey());
Because the default ordering is the natural one, this will produce the following output:
ball
the
If you'd like to customize the order criteria, you can define a custom Comparator
in the TreeMap
constructor.
By defining a Comparator
, we can compare keys (not full map entries) and sort them based on them, instead of values:
SortedMap<String, Integer> wordsCount =
new TreeMap<String, Integer>(new Comparator<String>() {
@Override
public int compare(String e1, String e2) {
return e2.compareTo(e1);
}
});
wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);
System.out.println(wordsCount.firstKey());
System.out.println(wordsCount.lastKey());
As the order is reversed, the output is now:
the
ball
NavigableMap
The NavigableMap
interface is an extension of the SortedMap
interface and it adds methods allowing to navigate the map more easily by finding the entries lower or higher than a certain key.
For example, the lowerEntry()
method returns the entry with the greatest key that is strictly less than the given key:
Taking the map from the previous example:
SortedMap<String, Integer> wordsCount = new TreeMap<>();
wordsCount.put("the", 150);
wordsCount.put("ball", 2);
wordsCount.put("duck", 4);
System.out.println(wordsCount.lowerEntry("duck"));
The output would be:
ball
ConcurrentMap
Finally, the last Map
extension we'll cover is the ConcurrentMap
, which make the contract of the Map
interface more strict by ensuring it to be thread-safe, that is usable in a multi-threading context without fearing the content of the map to be inconsistent.
This is achieved by making the updating operations, like put()
and remove()
, synchronized.
Implementations
Now, let's take a look at the implementations of the different Map
interfaces. We'll not cover all of them, just the main ones:
HashMap
: This is the implementation we used the most since the beginning, and is the most straightforward as it offers simple key/value mapping, even withnull
keys and values. It is a direct implementation ofMap
and therefore ensure neither order of the elements nor thread-safety.EnumMap
: An implementation which takesenum
constants as the keys of the map. Therefore, the number of elements in theMap
are bound by the number of constants of theenum
. Plus, the implementation is optimized for handling the generally rather small number of elements such aMap
will contain.TreeMap
: As an implementation of theSortedMap
andNavigableMap
interfaces,TreeMap
ensures that elements added to it will observe a certain order (based on the key). This order will either be the natural order of the keys, or the one enforced by aComparator
we can give to theTreeMap
constructor.ConcurrentHashMap
: This last implementation is most likely the same asHashMap
, expect that it ensures thread-safety for updating operations, as guaranteed by theConcurrentMap
interface.
Conclusion
The Java Collections framework is a fundamental framework that every Java developer should know how to use.
In this article, we've talked about the Map
interface. We covered the main operations through a HashMap
as well as a few interesting extensions such as SortedMap
or ConcurrentMap
.