Java Collections: The Map Interface

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:

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 a Set of Entry<K, V> which are key/value pairs representing the elements of the map
  • keySet(): Returns a Set of keys of the map
  • values(): Returns a Set 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.

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

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 with null keys and values. It is a direct implementation of Map and therefore ensure neither order of the elements nor thread-safety.
  • EnumMap: An implementation which takes enum constants as the keys of the map. Therefore, the number of elements in the Map are bound by the number of constants of the enum. Plus, the implementation is optimized for handling the generally rather small number of elements such a Map will contain.
  • TreeMap: As an implementation of the SortedMap and NavigableMap 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 a Comparator we can give to the TreeMap constructor.
  • ConcurrentHashMap: This last implementation is most likely the same as HashMap, expect that it ensures thread-safety for updating operations, as guaranteed by the ConcurrentMap 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.