HashMap and ConcurrentHashMap Microbenchmark

Is ConcurrentHashMap always faster than HashMap for concurrent access ?

If the map is accessible by concurrent threads, ConcurrentHashMap without additional synchronization mechanisms is often enough for reading operations. We may have some delay in the returned values by the Map but that is just delay. 
If that is acceptable for you, it is fine. Otherwise, ConcurrentHashMap is probably not what you need.
But about writing operations, ConcurrentHashMap often needs additional synchronization mechanisms to ensure the data consistency.
We rarely put values in a map with map.put(key, value) without additional computations.
It is indeed very common to check the content of the map before putting the element or to retrieve the actual value associated to a key and to enrich it rather than only overwrite it. To ensure data consistency, these statements have to be synchronized.
So external synchronization for a ConcurrentHashMap object is  very often unavoidable.

The following microbenmark compares HashMap and ConcurrentHashMap metrics both for put() and get() operations.

Here are the conclusions :
– writing operations are not less or more fast whatever the way.
– reading operations are (48X) drastically faster with ConcurrentHashMap.

Our scenario :
We have two classes (one using a HashMap, the other using a ConcurrentHashMap) that provides two methods :
addLetterOccurence(String) that updates the occurrence of encountered letters.
The method takes as parameter a String that may contain zero or one alphabetical character.
When a alphabetical character is found, we update the occurrence of the current letter in a Map instance field.
getOccurence(Character) that returns the occurrence of the Character passed as parameter.

Here is the OccurrenceCounterWithHashMap class :

import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
public class OccurrenceCounterWithHashMap {
 
    private Map<Character, Integer> mapCountByLetter = new HashMap<>();
 
    public void addLetterOccurence(String word) {
        Character letter = retrieveCharacter(word);
        if (letter == null) {
            return;
        }
        synchronized (this) {
            Integer occurrence = mapCountByLetter.get(letter);
            if (occurrence == null) {
                occurrence = 0;
            }
            mapCountByLetter.put(letter, ++occurrence);
        }
    }
 
    public int getOccurence(Character letter) {
        Integer occurrence = null;
        synchronized (this) {
            occurrence = mapCountByLetter.get(letter);
        }
        if (occurrence == null) {
            occurrence = 0;
        }
        return occurrence;
    }
 
    private Character retrieveCharacter(String word) {
 
        Matcher matcher = Pattern.compile("([A-Za-z])")
                                 .matcher(word);
        if (matcher.find()) {
            String group = matcher.group(1);
            return group.charAt(0);
        }
        return null;
    }
 
}

Note that HashMap.get() is invoked in a synchronized statement. Why ?

Because HashMap methods are not designed to be accessed concurrently. More specifically, the HashMap javadoc states :
Note that this implementation is not synchronized. If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, it must be synchronized externally.
It means that for example invoking HashMap.get() in a foo() method  by a thread A while HashMap.put() is invoked in a bar() method  by a thread B may create a undesirable and unpredictable behavior if no explicit synchronization is performed.

To get more information about synchronization with HashMap, you can read this article.

Here is the OccurrenceCounterWithHashMap class :

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
public class OccurrenceCounterWithConcurrentHashMap {
 
    private Map<Character, Integer> mapCountByLetter = new ConcurrentHashMap<>();
 
    public void addLetterOccurence(String word) {
        Character letter = retrieveCharacter(word);
        if (letter == null) {
            return;
        }
        synchronized (this) {
            Integer occurrence = mapCountByLetter.get(letter);
            if (occurrence == null) {
                occurrence = 0;
            }
            mapCountByLetter.put(letter, ++occurrence);
        }
    }
 
    public void addLetterOccurenceWithComputeMethod(String word) {
        Character letter = retrieveCharacter(word);
        if (letter == null) {
            return;
        }
        mapCountByLetter.compute(letter, (c, i) -> i == null ? 1 : ++i);
    }
 
    public int getOccurence(Character letter) {
        Integer occurrence = mapCountByLetter.get(letter);
 
        if (occurrence == null) {
            occurrence = 0;
        }
        return occurrence;
    }
 
    private Character retrieveCharacter(String word) {
 
        Matcher matcher = Pattern.compile("([A-Za-z])")
                                 .matcher(word);
        if (matcher.find()) {
            String group = matcher.group(1);
            return group.charAt(0);
        }
        return null;
    }
 
}

Note that we have an additional addLetter method for the ConcurrentHashMap case : addLetterOccurenceWithComputeMethod().
Functionally, it performs the same thing as addLetterOccurence(). It only does that in a different way : it uses the Map.compute() default method added in Java 8.

The JMH benchmark class :

import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
 
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
 
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(10)
public class MapBenchmark {
 
    private int charNumericValue;
    private OccurrenceCounterWithHashMap occurrenceCounterWithHashMap = new OccurrenceCounterWithHashMap();
    private OccurrenceCounterWithConcurrentHashMap occurrenceCounterConcurrentHashMap = new OccurrenceCounterWithConcurrentHashMap();
 
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(MapBenchmark.class.getSimpleName())
                                          .warmupIterations(5)
                                          .measurementIterations(5)
                                          .forks(1)
                                          .build();
 
        new Runner(opt).run();
    }
 
    @State(Scope.Benchmark)
    public static class HashMapStatementsState {
 
        public OccurrenceCounterWithHashMap occurrenceCounterSynchStatements = new OccurrenceCounterWithHashMap();
 
        @Setup(Level.Iteration)
        public void doSetup() {
            IntStream.range(0, 50)
                     .forEach(i -> {
                         final String string = String.valueOf((char) i);
                         occurrenceCounterSynchStatements.addLetterOccurence(string);
                     });
 
        }
 
    }
 
    @State(Scope.Benchmark)
    public static class ConcurrentHashMapState {
 
        public OccurrenceCounterWithConcurrentHashMap occurrenceCounterConcurrentHashMap = new OccurrenceCounterWithConcurrentHashMap();
 
        @Setup(Level.Iteration)
        public void doSetup() {
            IntStream.range(0, 50)
                     .forEach(i -> {
                         final String string = String.valueOf((char) i);
                         occurrenceCounterConcurrentHashMap.addLetterOccurence(string);
                     });
 
        }
 
    }
 
    @Benchmark
    public void _1_synchStatementsAddLetterOccurence() {
        occurrenceCounterWithHashMap.addLetterOccurence("123456" + getNextLetter_50_distinct());
    }
 
    @Benchmark
    public void _2_concurrentHashMapAddLetterOccurence() {
        occurrenceCounterConcurrentHashMap.addLetterOccurence("123456" + getNextLetter_50_distinct());
    }
 
    @Benchmark
    public void _3_concurrentHashMapAddLetterOccurenceWithComputeMethod() {
        occurrenceCounterConcurrentHashMap.addLetterOccurenceWithComputeMethod("123456" + getNextLetter_50_distinct());
    }
 
    @Benchmark
    public void _4_synchStatementsGetOccurence(HashMapStatementsState synchronizedStatementsState) {
        synchronizedStatementsState.occurrenceCounterSynchStatements.getOccurence(getNextLetter_10_distinct());
    }
 
    @Benchmark
    public void _5_concurrentHashMapGetOccurence(ConcurrentHashMapState concurrentHashMapState) {
        concurrentHashMapState.occurrenceCounterConcurrentHashMap.getOccurence(getNextLetter_10_distinct());
    }
 
    private synchronized char getNextLetter_50_distinct() {
        if (charNumericValue >= 49) {
            charNumericValue = 0;
        }
        return (char) ++charNumericValue;
    }
 
    private synchronized char getNextLetter_10_distinct() {
        if (charNumericValue >= 9) {
            charNumericValue = 0;
        }
        return (char) ++charNumericValue;
    }
 
}

The benchmark results (less is faster):

# Run complete. Total time: 00:00:52
 
Benchmark                                                       Mode  Cnt     Score    Error  Units
MapBenchmark._1_synchStatementsAddLetterOccurence               avgt    5  2398,325 ±  9,521  ns/op
MapBenchmark._2_concurrentHashMapAddLetterOccurence             avgt    5  2401,879 ± 10,965  ns/op
MapBenchmark._3_concurrentHashMapAddLetterOccurenceWithCompute  avgt    5  2391,523 ±  7,598  ns/op
MapBenchmark._4_synchStatementsGetOccurence                     avgt    5  1974,261 ± 20,800  ns/op
MapBenchmark._5_concurrentHashMapGetOccurence                   avgt    5    41,796 ±  1,194  ns/op
Ce contenu a été publié dans benchmark, java, map, microbenchmark, performance en JAVA, avec comme mot(s)-clé(s) . Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *