It's been always said to use atomic whenever possible instead of other synchronization mechanisms. It's claimed atomic outperforms the other ones. But is that really true?
Let's take as an example two possible implementations of Counter that can be found in my previous posts.
We have two slightly different implementations. The first one uses synchronized.
public class Counter {
private int cnt = 0;
public synchronized int getCnt() {
return cnt;
}
public synchronized void inc() {
++cnt;
}
}
And the other one using AtomicInteger.
public class Counter {
private final AtomicInteger cnt = new AtomicInteger();
public int getCnt() {
return cnt.get();
}
public void inc() {
cnt.incrementAndGet();
}
}
That's a really good starting point. Now, we need to have measures to compare them. For that purpose, we will use Java Microbenchmark Harness (JMH). JMH will allow us to write benchmarks to compare the average execution time of methods.
Benchmarks
JMH is a Java harness for building, running, and analyzing benchmarks written in Java and other languages targeting Java Virtual Machine (JVM). For more details, please refer to the link below.
GitHub - openjdk/jmh: https://openjdk.org/projects/code-tools/jmh
In general, both Counters have the same two methods:
getCnt() to obtain the current value and
inc() to increment that value.
The first one just returns the internal state of Counter, but the other changes its state. Definitely, we would like to learn how those methods behave when multiple threads access them at the same time. For that reason, we will be increasing the number of threads calling those methods.
Here is a code snippet of the benchmark for AtomicCounter. Benchmark for SynchronizedCounter is the same - just AtomicCounter is replaced with SynchronizedCounter.
@Fork(1)
@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 20)
@Warmup(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
public class AtomicCounterBenchmark {
private final Counter counter = new Counter();
@Benchmark
@Threads(1)
public void getCnt_singleThread(Blackhole blackhole){
blackhole.consume(counter.getCnt());
}
@Benchmark
@Threads(2)
public void getCnt_twoThreads(Blackhole blackhole){
blackhole.consume(counter.getCnt());
}
@Benchmark
@Threads(4)
public void getCnt_fourThreads(Blackhole blackhole){
blackhole.consume(counter.getCnt());
}
@Benchmark
@Threads(8)
public void getCnt_eightThreads(Blackhole blackhole){
blackhole.consume(counter.getCnt());
}
@Benchmark
@Threads(1)
public void inc_singleThread(Blackhole blackhole){
counter.inc();
}
@Benchmark
@Threads(2)
public void inc_twoThreads(Blackhole blackhole){
counter.inc();
}
@Benchmark
@Threads(4)
public void inc_fourThreads(Blackhole blackhole){
counter.inc();
}
@Benchmark
@Threads(8)
public void inc_eightThreads(Blackhole blackhole){
counter.inc();
}
}
The following benchmark will measure both mentioned methods. It will compute the average execution time of each method. This average time will be measured when running 1, 2, 4, and 8 threads. There will be 10 warmup iterations and 20 measurement iterations. Results will be expressed in nanoseconds. Let's run it!
Execution
Benchmark was run on MacBook Pro 2019 equipped with Intel Core i9, 2.3 GHz, 8 cores, and 64 GB RAM (2667 MHz DDR4). Amazon Corretto 11.0.17 Java Virtual Machine was used to run tests.
The above table contains the outcome of running benchmarks. Let's plot a chart for each method and see how it scales with an increasing number of threads.
Reading value: getCnt()
In the above chart, the blue line represents atomic and the red line represents synchronized. In the case of atomic, with an increasing number of threads concurrently accessing the method, the average execution time remains constant. However, in the case of synchronized, time spent in the method grows linearly.
Updating value: inc()
Similarly to the previous chart, the blue line represents atomic and the red line represents synchronized. In the case of synchronized, with the increasing number of threads concurrently accessing the method, time spent in the method grows linearly. On the other hand - in the case of atomic - average execution time resembles logarithmic growth.
Conclusion
Choosing the right synchronization mechanism is really not an easy task and always has to be done wisely. We always have to consider all the pros and cons of possible approaches before a final call is made. In this particular case, choosing AtomicInteger seems like the way to go. But one question still remains, why is it performing so well?