Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Introduction

In this tutorial, we’ll look at several ways to increment a numerical value associated with a key in a Map. The Maps interface, part of the Collections framework in Java, represents a collection of key-value pairs. Some common Map implementations include the HashMap, TreeMap, and LinkedHashMap.

2. Problem Statement

Let’s see an example where we have a sentence as a String input and store the frequencies of each character that appears in the sentence in a Map. Here’s an example of the problem and the output:

sample sentence:
"the quick brown fox jumps over the lazy dog"

character frequency:

t: 2 times
h: 2 times
e: 3 times
q: 1 time
u: 2 times
...... and so on

The solution will involve storing the character frequencies in a Map where the key is a character, and the value is the total number of times the character appears in the given String. As there can be repeated characters in a String, we’ll need to update the value associated with a key, or rather increment the current value of a key many times.

3. Solutions

3.1. Using containsKey()

The simplest solution to our problem is to walk through the String, and at each step, we check the character’s existence in the map using the containsKey() method. If the key exists, we increment the value by 1 or put a new entry in the map with the key as the character and the value as 1:

public Map<Character, Integer> charFrequencyUsingContainsKey(String sentence) {
    Map<Character, Integer> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        int count = 0;
        if (charMap.containsKey(sentence.charAt(c))) {
            count = charMap.get(sentence.charAt(c));
        }
        charMap.put(sentence.charAt(c), count + 1);
    }
    return charMap;
}

3.2. Using getOrDefault()

Java 8 introduced the getOrDefault() method in Map as a simple way to retrieve the value associated with a key in a Map or a default preset value if the key doesn’t exist:

V getOrDefault(Object key, V defaultValue);

We’ll use this method to fetch the value associated with the current character of the String (our key) and increment the value by 1. This is a simpler and less verbose alternative to containsKey():

public Map<Character, Integer> charFrequencyUsingGetOrDefault(String sentence) {
    Map<Character, Integer> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        charMap.put(sentence.charAt(c),
          charMap.getOrDefault(sentence.charAt(c), 0) + 1);
    }
    return charMap;
}

3.3. Using merge()

The merge() method is provided by Java 8 as a way to override the values associated with a specific key with an updated value in a Map. The method takes in a key, a value, and a remapping function, which is used to compute the new updated value that will replace the existing value in the Map:

default V merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction)

The remapping function is a BiFunction, which means that it follows the functional programming paradigm of Java 8. We send our desired function inline as an argument to the merge() method along with the parameters, and it performs the desired function.

The method expects us to define a default value, which will be merged with the result of the remapping function. Hence, we can write our remapping function as a summation of the default value(which is 1) and the current value that exists for the key:

(a, b) -> a + b

We can also rewrite the same using method reference:

public Map<Character, Integer> charFrequencyUsingMerge(String sentence) {
    Map<Character, Integer> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        charMap.merge(sentence.charAt(c), 1, Integer::sum);
    }
    return charMap;
}

3.4. Using compute()

The compute() method differs from the merge() method in that the compute() method has no effect on missing keys and does not throw an exception. The method does not take any value, and the remapping function is supposed to decide the new value:

V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction)

We’ll need to handle the case when the key is missing and the value is null and set the value to the default 1.

public Map<Character, Integer> charFrequencyUsingCompute(String sentence) {
    Map<Character, Integer> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        charMap.compute(sentence.charAt(c), (key, value) -> (value == null) ? 1 : value + 1);
    }
    return charMap;
}

3.5. Using incrementAndGet() of AtomicInteger

We can use the AtomicInteger type to store the frequencies of the characters instead of our regular Integer wrapper class. This benefits us by introducing atomicity to the code, and we can use the incrementAndGet() method of the AtomicInteger class. This method performs an atomic increment operation on the value, equivalent to a ++i operation.

Additionally, we should make sure that the key exists in place using the putIfAbsent() method of Map:

public Map<Character, AtomicInteger> charFrequencyWithGetAndIncrement(String sentence) {
    Map<Character, AtomicInteger> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        charMap.putIfAbsent(sentence.charAt(c), new AtomicInteger(0));
        charMap.get(sentence.charAt(c)).incrementAndGet();
    }
    return charMap;
}

Notice that the return type of this method uses AtomicInteger.

Furthermore, we can rewrite the same code in a slightly more compact way by using computeIfAbsent() by passing a remapping function to it:

public Map<Character, AtomicInteger> charFrequencyWithGetAndIncrementComputeIfAbsent(String sentence) {
    Map<Character, AtomicInteger> charMap = new HashMap<>();
    for (int c = 0; c < sentence.length(); c++) {
        charMap.computeIfAbsent(sentence.charAt(c), k-> new AtomicInteger(0)).incrementAndGet();
    }
    return charMap;
}

3.6. Using Guava

We can use the Guava library to solve the problem as well. To use Guava, we should first add the Maven library as a dependency:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency>

Guava provides an AtomicLongMap class, which has an inbuilt getAndIncrement() method which helps us in our use case:

public Map<Character, Long> charFrequencyUsingAtomicMap(String sentence) {
    AtomicLongMap<Character> map = AtomicLongMap.create();
    for (int c = 0; c < sentence.length(); c++) {
        map.getAndIncrement(sentence.charAt(c));
    }
    return map.asMap();
}

4. Benchmarking the Approaches

JMH is a tool at our disposal to benchmark the different approaches mentioned above. To get started, we include the relevant dependencies:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
</dependency>

The latest versions of the JMH Core and JMH Annotation Processor can be found in Maven Central.

We wrap each approach within a Benchmark annotated method along and pass some additional parameters:

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(value = 1, warmups = 1)
public void benchContainsKeyMap() {
    IncrementMapValueWays im = new IncrementMapValueWays();
    im.charFrequencyUsingContainsKey(getString());
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(value = 1, warmups = 1)
public void benchMarkComputeMethod() {
    IncrementMapValueWays im = new IncrementMapValueWays();
    im.charFrequencyUsingCompute(getString());
}
// and so on

After running the benchmark tests, we see that the compute() and merge() methods scored better than the other approaches:

Benchmark                                      Mode  Cnt       Score       Error  Units
BenchmarkMapMethodsJMH.benchContainsKeyMap     avgt    5   50697.511 ± 25054.056  ns/op
BenchmarkMapMethodsJMH.benchMarkComputeMethod  avgt    5   45124.359 ±   377.541  ns/op
BenchmarkMapMethodsJMH.benchMarkGuavaMap       avgt    5  121372.968 ±   853.782  ns/op
BenchmarkMapMethodsJMH.benchMarkMergeMethod    avgt    5   46185.990 ±  5446.775  ns/op

We should also note that these results vary slightly and might be unnoticeable when the overall spread of the keys is not as big. As we are considering only English characters and some special characters in our example, the range of keys is capped at a few hundred. Performance would be a big concern for other scenarios where the number of keys might be large.

5. Multithreading Considerations

The approaches we discussed above do not have any multi-threading considerations. If multiple threads were reading the input String and updating a shared Map, we would most definitely be susceptible to concurrent modifications in the increment operation. This would lead to inconsistent counts in our final result.

One of the ways we can solve this is by using a ConcurrentHashMap, a thread-safe alternative to our regular HashMap. A ConcurrentHashMap provides support for concurrent operations and does not need external synchronization.

Map<Character, Integer> charMap = new ConcurrentHashMap<>();

In our solution, we should also consider that the character count increment operation that we are doing should be atomic. To ensure this, we should use atomic methods, such as compute() and merge(). 

Let’s write a test case to validate our assertion. We’ll create two threads with a shared instance of a concurrent hashmap. Each thread takes a portion of the String input and performs the same operation:

@Test
public void givenString_whenUsingConcurrentMapCompute_thenReturnFreqMap() throws InterruptedException {
    Map<Character, Integer> charMap = new ConcurrentHashMap<>();
    Thread thread1 = new Thread(() -> {
        IncrementMapValueWays ic = new IncrementMapValueWays();
        ic.charFrequencyWithConcurrentMap("the quick brown", charMap);
    });

    Thread thread2 = new Thread(() -> {
        IncrementMapValueWays ic = new IncrementMapValueWays();
        ic.charFrequencyWithConcurrentMap(" fox jumps over the lazy dog", charMap);
    });

    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();

    Map<Character, Integer> expectedMap = getExpectedMap();
    Assert.assertEquals(expectedMap, charMap);
}

6. Conclusion

In this article, we looked at different ways to increment the value of a Map entry. We benchmarked the execution speeds of the approaches and looked at how we could write a thread-safe solution as well.

As always, the code snippets used in this article are available over on GitHub.
Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
1 Comment
Oldest
Newest
Inline Feedbacks
View all comments
Comments are closed on this article!