Jerónimo López
Jerónimo López
5 min read

In my last post I talked about the problems of using an incorrect hash function when you put an object with a composite key in a Java HashMap, but I was stuck with the question: Which data structure is better to index those objects?

Continuing with the same example I will talk about products and stores, and I will use their identifiers to form the map key. The proposed data structures are:

  • A single map with a key containing its indexes: HashMap<Tuple<Integer, Integer>, MyObject>, which I will call TupleMap.
  • A nested map: HashMap<Integer, HashMap<Integer, MyObject>>, which I will call DoubleMap.

To solve all doubts and draw conclusions I will measure:

  • Memory consumed by indexing a collection of objects
  • The time needed to randomly store that collection of objects
  • The time needed to recall, also randomly, all the elements of the collection

. . .

TL;DR: this post is more boring, so I’ll save you the job of reading the whole post:

  • DoubleMap is more memory efficient and consumes 30% less than TupleMap
  • In large collections, DoubleMap is 30% faster indexing, while in small collections it is considerably faster.
  • In large collections, DoubleMap and TupleMap have similar fetching performance, while in small collections DoubleMap is significantly faster.

. . .

All the source code needed to reproduce the tests is in this GitHub repository: https://github.com/jerolba/hashcode-map.

In this case, I will apply the hash function that generates the least number of collisions and minimizes memory consumption, so I will not have to worry about this in my benchmarks and I will be in an optimal (and optimistic) case in the TupleMap version of not having to deal with collisions (less memory and CPU consumption).

Memory consumption

If we use an object as the primary key, how much memory will those instances of the primary key consume? If we use nested HashMaps, will it penalize the overhead of those objects?

If we randomly fill a map with 10,000 products and 500 stores, we get the following chart of memory consumption, taking into account only the classes involved in the maps:

Map memory consumption comparison

On average, the map with a key object (Tuple) consumes 50% more memory, using 299 MB compared to the 193 MB of DoubleMap.

Looking at the histogram of the objects in memory we see that the instances of the Tuple class are using 114 MB and collisions are not taking place because instances of the TreeMap type do not appear:

Class instances size
java.util.HashMap$Node 5,000,000 160,000,000
com.jerolba.bikey.Tuple 5,000,000 120,000,000
java.util.HashMap$Node[] 1 33,554,448
java.util.HashMap 1 48
com.jerolba.bikey.TupleMap 1 16

while in the DoubleMap version the extra HashMap instances are consuming only half a megabyte, and the biggest difference lies in the space used in the node arrays:

Class instances size
java.util.HashMap$Node 5,010,000 160,320,000
java.util.HashMap$Node[] 10,001 41,185,552
java.util.HashMap 10,001 480,048
com.jerolba.bikey.DoubleMap 1 16

Therefore, if you need to create new instances of the object that represents the primary key when using this type of maps, I would choose a data structure such as HashMap<A, HashMap<B, MyObject>>.

Indexing performance

How long does it take to create a large collection in each case? Does the number of elements of each type have much influence?

In order not to bore you with the details of the benchmark I summarize it in a single chart where it shows the time (milliseconds) needed to insert a random collection of products and stores according to different total numbers of products and stores:

Indexing performance

On average, keeping all the information on a single map has a penalty of at least 40% of the time.

Even if I don’t show you graphs (you have the data at the end of the post), the increase in the number of data in either of the two variables (products or stores) increases the execution time linearly.

Search performance

How long does it take to access to the value associated to a composite key? Will it penalize having to consult in two maps? In the TupleMap version, will it take longer if I have to instantiate a new Tuple object for each query?

As before, I will summarize in a single chart the benchmark of consulting in a large collection all its values randomly, according to different total numbers of products and stores:

Search performance

Although DoubleMap’s execution time is always slightly below that of TupleMap, we can consider that they have a very similar performance, and therefore the access time should not condition the choice of one data structure over another.

Surprisingly, having to create a Tuple instance for each access does not penalize performance, and even slightly improves it in large collections (JVM optimizations are inscrutable).

Small collections

The problems I face usually have large collections, but in the benchmark results we can see that in small collections the implementation of DoubleMap performs much better.

Indexing

To visualize it better I show two charts: when the collection has 1,000 products and when it has 2,000:

Creation time 1,000 products

Creation time 1,000 products

TupleMap version times are between 2 and 6 times worse. Without having analyzed the internal behavior of the JVM/CPU/Memory, I intuit that the smaller size in data influences the location of the information and will give less problem with the cache lines.

Search

I also analyze it by looking at two charts for a different number of products:

Access time 1,000 products

Access time 1,000 products

The times of the TupleMap version are between 50% and 150% worse. I also don’t dare to say what it’s due to, but I still believe that it is related to differences in the cache usage pattern.

Conclusions

In spite of the obtained results, in this case I consider that it is difficult to draw clear conclusions by performing microbenchmarking. The behavior of the data structures can vary between the production code and the benchmark code.

In production code, between access and access to the map, your application can do many things that influence the availability of information in the caches, generating an access pattern completely different to the one in benchmark.

Personally I keep the idea of consuming less memory by not instantiating the Tuple class and using directly nested HashMaps. To avoid ugly code you can create an abstraction which encapsulates the behavior of the double map.

Working with two levels of HashMaps does not seem to be a performance problem, and is even faster, especially in relatively small collections.

Using DoubleMap we forgot about the problem of having to choose a hash function that minimizes collisions, because the key would be distributed between the two levels of HashMaps.

Benchmarks results

The source code to execute the benchmarks are in the GitHub repository, but in order that you can see the raw data of the charts, I copy you the results. This way you can also do your analysis and draw your conclusions.

Indexing

Nº Productos Nº Tiendas Total TupleMap
(ms)
DoubleMap
(ms)
1,000 100 100,000 33.1 5.61
2,500 100 250,000 103.38 24.78
5,000 100 500,000 177.71 116.43
7,500 100 750,000 272.02 160.95
10,000 100 1,000,000 314.94 241.86
1,000 250 250,000 129.18 19.82
2,500 250 625,000 292.97 114.85
5,000 250 1,250,000 644.29 421.45
7,500 250 1,875,000 1,061.19 631.18
10,000 250 2,500,000 1,432.11 1,102.94
1,000 500 500,000 326.49 55.98
2,500 500 1,250,000 805.16 368.4
5,000 500 2,500,000 1,503.63 994.39
7,500 500 3,750,000 2,601.49 1,687.45
10,000 500 5,000,000 3,158.44 2,601.42
1,000 750 750,000 450.98 82.68
2,500 750 1,875,000 1,427.11 569.73
5,000 750 3,750,000 2,531.31 1,347.68
7,500 750 5,625,000 3,730.37 2,436.08
10,000 750 7,500,000 5,108.52 3,753.73
1,000 1,000 1,000,000 790.76 272.73
2,500 1,000 2,500,000 1,833.54 905.38
5,000 1,000 5,000,000 3,487.37 2,360.07
7,500 1,000 7,500,000 5,550.26 3,886.36
10,000 1,000 10,000,000 7,763.96 5,728.61

Search

Nº Productos Nº Tiendas Total TupleMap
(ms)
Tuple new
(ms)
DoubleMap
(ms)
1,000 100 100,000 12.08 12.55 3.81
2,500 100 250,000 37.11 38.21 14.35
5,000 100 500,000 77.40 77.39 46.84
7,500 100 750,000 125.96 126.35 68.53
10,000 100 1,000,000 148.55 141.47 163.37
1,000 250 250,000 48.96 50.41 18.30
2,500 250 625,000 140.25 144.70 70.04
5,000 250 1,250,000 332.23 344.52 242.27
7,500 250 1,875,000 533.58 498.65 428.10
10,000 250 2,500,000 689.37 656.38 640.08
1,000 500 500,000 112.44 115.91 67.37
2,500 500 1,250,000 426.38 436.60 236.71
5,000 500 2,500,000 838.11 827.97 721.81
7,500 500 3,750,000 1,092.23 1,032.17 1,146.23
10,000 500 5,000,000 1,659.28 1,671.45 1,445.54
1,000 750 750,000 220.95 228.46 104.46
2,500 750 1,875,000 690.21 694.86 493.78
5,000 750 3,750,000 1,224.58 1,185.30 1,052.61
7,500 750 5,625,000 1,950.89 2,206.49 1,735.02
10,000 750 7,500,000 2,750.52 2,567.10 2,750.77
1,000 1,000 1,000,000 342.89 351.78 216.07
2,500 1,000 2,500,000 973.66 1,019.16 677.97
5,000 1,000 5,000,000 1,838.45 1,968.09 1,618.03
7,500 1,000 7,500,000 2,789.49 2,598.46 2,448.23
10,000 1,000 10,000,000 4,468.78 4,318.66 3,970.30