Java - Microbenchmark: A Beginner's Guide

Hello there, future Java wizards! ? Today, we're going to embark on an exciting journey into the world of Java microbenchmarking. Don't worry if you've never written a line of code before - we'll start from the very beginning and work our way up together. So, grab a cup of coffee (or tea, if that's your thing), and let's dive in!

Java - Microbenchmark

What is Microbenchmarking?

Before we get into the nitty-gritty of Java microbenchmarking, let's understand what microbenchmarking actually is.

Imagine you're a chef trying to perfect a recipe. You wouldn't just taste the final dish to see if it's good, right? You'd taste each ingredient, test different cooking times, and try various techniques. That's exactly what microbenchmarking is in programming - it's a way to measure the performance of small, isolated parts of your code.

Why is Java Benchmarking Important?

Now, you might be wondering, "Why should I care about benchmarking?" Well, let me tell you a little story.

Back when I was a junior developer, I once wrote a program that worked perfectly... on my computer. But when we deployed it to the company servers, it was slower than a turtle carrying a heavy backpack! That's when I learned the importance of benchmarking. It helps us:

  1. Identify performance bottlenecks
  2. Compare different implementations
  3. Ensure our code runs efficiently on different systems

Java Benchmarking Techniques

Let's look at some common Java benchmarking techniques:

1. Manual Timing

The simplest way to benchmark is manual timing. Here's a basic example:

public class SimpleTimingExample {
    public static void main(String[] args) {
        long startTime = System.nanoTime();

        // Your code here
        for (int i = 0; i < 1000000; i++) {
            Math.sqrt(i);
        }

        long endTime = System.nanoTime();
        long duration = (endTime - startTime);
        System.out.println("Execution time: " + duration + " nanoseconds");
    }
}

In this example, we're using System.nanoTime() to measure how long it takes to calculate the square root of numbers from 0 to 999,999.

2. Using JMH (Java Microbenchmark Harness)

While manual timing is simple, it's not always accurate. That's where JMH comes in. JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks.

To use JMH, you'll need to add it to your project. If you're using Maven, add these dependencies to your pom.xml:

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

Now, let's write a simple JMH benchmark:

import org.openjdk.jmh.annotations.*;
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;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
@Warmup(iterations = 3)
@Measurement(iterations = 3)
public class JMHExample {

    @Benchmark
    public void benchmarkMathSqrt() {
        Math.sqrt(143);
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(JMHExample.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

This benchmark measures the average time it takes to calculate the square root of 143. Let's break down the annotations:

  • @BenchmarkMode: Specifies what to measure (average time in this case)
  • @OutputTimeUnit: Specifies the unit for the results
  • @State: Defines the scope in which "state" objects will be shared
  • @Fork: How many times to fork a single benchmark
  • @Warmup and @Measurement: Define how many warmup and measurement iterations to do

Java Collections Algorithms

While we're on the topic of benchmarking, let's take a quick detour to talk about Java Collections Algorithms. These are incredibly useful tools that can significantly impact your program's performance.

Here's a table of some common algorithms:

Algorithm Description Use Case
Collections.sort() Sorts a list When you need to order elements
Collections.binarySearch() Searches a sorted list Finding an element in a large, sorted list
Collections.reverse() Reverses a list When you need to invert the order of elements
Collections.shuffle() Randomly permutes a list Randomizing the order of elements
Collections.fill() Replaces all elements with specified element Initializing a list with a specific value

Let's benchmark the performance of sorting a list using Collections.sort():

import org.openjdk.jmh.annotations.*;
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;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
@Warmup(iterations = 3)
@Measurement(iterations = 3)
public class SortingBenchmark {

    @Param({"100", "1000", "10000"})
    private int listSize;

    private List<Integer> list;

    @Setup
    public void setup() {
        list = new ArrayList<>(listSize);
        Random rand = new Random();
        for (int i = 0; i < listSize; i++) {
            list.add(rand.nextInt());
        }
    }

    @Benchmark
    public void benchmarkCollectionsSort() {
        Collections.sort(list);
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(SortingBenchmark.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

This benchmark measures how long it takes to sort lists of different sizes (100, 1000, and 10000 elements). Running this will give you a good idea of how the sorting time increases with the size of the list.

Conclusion

And there you have it, folks! We've just scratched the surface of Java microbenchmarking. Remember, benchmarking is not just about writing fast code - it's about understanding your code's performance characteristics and making informed decisions.

As you continue your Java journey, keep benchmarking in your toolbox. It's like a trusty compass that'll help you navigate the sometimes turbulent seas of software performance.

Happy coding, and may your benchmarks always be insightful! ??‍??‍?

Credits: Image by storyset