Link

Maps

The Map interface is the base interface for collections which allows to store unique key/value pairs, no necessary in any particular order.

Table of contents

  1. Create Maps
  2. Hashtable
  3. HashMap
    1. What’s the difference between the Hashtable and HashMap implementations?
  4. Is HashMap the successor of the Hashtable?
  5. LinkedHashMap
  6. TreeMap
  7. Can we store nulls?
  8. Which Map to Use?
  9. Map keys MUST BE immutable
    1. How can we modify keys that are contained within a map?
  10. Double brace initialization
  11. Mutable and immutable maps

Create Maps

Java 9 added static methods to the Map interface Map.of(). Consider the following example.

package demo;

import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marks = Map.of(
      "Aden", 82,
      "Jade", 92,
      "Peter", 74,
      "Jane", 68
    );
    System.out.printf( "Marks: %s%n", marks );
  }
}

The above example creates a map of students and their tests’ mark, and simply print the map’s elements (also referred to as entries), in no particular order.

Marks: {Aden=82, Jane=68, Peter=74, Jade=92}

Maps take a key and a value, the students’ name and their marks in the above example, and stores them as entries, as shown in the following image,

Map-Entry.png

Maps can be seen as a collection of entries that use the entry’s key to determine where to save the entity. Maps can only contain unique keys. We cannot have two entries with the same key. The Map.of() method will throw an IllegalArgumentException if duplicate keys are provided. Consider the following example.

⚠ The following example will compile but will throw IllegalArgumentException!!
package demo;

import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    /* ⚠️ Throws IllegalArgumentException!! */
    final Map<String, Integer> marks = Map.of(
      "Aden", 82,
      "Aden", 92
    );
    System.out.printf( "Marks: %s%n", marks );
  }
}

The above example will fail as expected.

Exception in thread "main" java.lang.IllegalArgumentException: duplicate key: Aden
	at java.base/java.util.ImmutableCollections$MapN.<init>(ImmutableCollections.java:977)
	at java.base/java.util.Map.of(Map.java:1328)
	at demo.App.main(App.java:8)

Generally, maps do not fail when duplicate keys are added. Instead, the value associated with the duplicate key will simply replace the existing value. This is a unique behaviour of the Map.of() methods.

Some implementations of Map support null keys and/or values. Unfortunately, the Map.of() method does not support nulls, either as key or value and a NullPointerException is thrown when null is passed.

⚠ The following example will compile but will throw NullPointerException!!
package demo;

import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    /* ⚠️ Throws NullPointerException!! */
    final Map<String, Integer> marks = Map.of( "Aden", null );
    System.out.printf( "Marks: %s%n", marks );
  }
}

The above example will fail as expected.

Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:222)
	at java.base/java.util.ImmutableCollections$Map1.<init>(ImmutableCollections.java:884)
	at java.base/java.util.Map.of(Map.java:1308)
	at demo.App.main(App.java:8)

Hashtable

Hashtable is an implementation of Map interface based on hash functions and buckets, as shown in the following diagram.

Hashtable-Buckets-Entries.png

The image shown above is very similar to another image shown in the sets page. Hash based sets, use hash-based maps as their underlying data structure.

A Hashtable can be seen as a list of lists, where entities are placed in the bucket they belong. A hash function is used to determine the bucket the entities belongs to, as shown in the following diagram.

Hashtable-Buckets-Hash-Function.png

The Hashtable will use the entry’s key hashCode() method to determine the bucket to which the entry belongs, then the equals() method to determine whether this already exists within the bucket, as shown in the following diagram.

ⓘ NoteThe entry's value does not take part in finding the entry in the map.

Hashtable-Buckets-HashCode-Equals.png

The relation between these two methods is so strong that the Effective Java book has an item about this, Item 11: Always override hashCode when you override equals.

A Hashtable can be created like any other object, as shown next.

package demo;

import java.util.Hashtable;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new Hashtable<>();
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

The above example creates a map and adds (puts) four entries to the map. We can provide hits to the Hashtable constructor about its initial capacity and the load factor. The load factor is the relation between number of buckets and the size of the map. This is a trade-off between memory used and performance. In most cases the default load factor value works well, but there are cases where this needs to be tuned.

ⓘ NotePremature optimization is the root of all evil.

It is always recommended to provide an initial capacity when this is known as it minimises the number of times the Hashtable has to resize its internal data structures, as shown in the following example.

package demo;

import java.util.Hashtable;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new Hashtable<>( 4 );
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

Both examples will print the same output.

Marks: {Jane=68, Peter=74, Jade=92, Aden=82}

The Hashtable’s put() method returns the previous value, if one exists and null if no value exists.

ⓘ NoteSome types of maps may contain null as the entry's value. Therefore, a null does not necessarily indicate that an entry did not exist for the given key.

When adding (putting) an entry, which key already exists in the map, the put() method will replace the existing entry with the new one and returns the previous entry’s value, as shown in the following example.

package demo;

import java.util.Hashtable;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new Hashtable<>( 4 );
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    final Integer previousMark = marksByName.put( "Aden", 84 );
    System.out.printf( "Previous mark: %s%n", previousMark );
  }
}

The entry for the given key is replaced by the new entry and the previous entry’s values is returned, if one exists otherwise null, as also indicated in the following output.

Previous mark: 82

In some of above examples, the output is always returned in particular order. This may give the wrong impression that the Hashtable always returns the elements in a given order. The order in which the entries are returned is not guaranteed and may vary between different versions of the JVM and JRE. There are other map implementations, such as LinkedHashMap and TreeMap, that always return the elements in a specific order.

Hashtable is one of the oldest Map implementation. Java provides better implementations of Map, such as HashMap when concurrency is not an issue or ConcurrentHashMap when we need to work with multiple threads.

HashMap

HashMap is another implementation of the Map interface that works in a similar fashion as the Hashtable described before. Consider the following example.

package demo;

import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new HashMap<>( 4 );
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    /* Update an existing entry */
    marksByName.put( "Aden", 84 );

    System.out.printf( "Marks: %s%n", a );
  }
}

The order in which the items are returned is not guaranteed.

Marks: {John=91, Aden=72, Peter=74, Jane=68, Jade=92}

What’s the difference between the Hashtable and HashMap implementations?

These two Map implementations are very similar.

  1. Hashtable (available since Java 1.0) came before HashMap (added in Java 1.2)

  2. Hashtable methods are synchronized, which means that only one thread can access each method at any point in time keeping the data within the map consistent. The Hashtable, despite being thread-safe, is still susceptible to the check-then-act problem when multiple methods are used as one operation. The HashMap is not synchronized and thus cannot safely be used by multiple threads without additional safeguards.

  3. HashMap supports nulls for both the key and the value. Hashtable does not support nulls either as key or the value.

    Hashtable does not support null to mitigate the check-then-act problem. The put() method of the Hashtable will only return null if the map does not contain an entry for the given key. This does not apply to the HashMap as it can contain null values.

  4. Hashtable is slower when compared to HashMap as synchronisation comes at a performance cost.

Is HashMap the successor of the Hashtable?

NO

HashMap does not provide any concurrent safety and was never intended to. Concurrency adds complexity and slows things down. The HashMap is ideal for situations where concurrency is not a requirement.

The ConcurrentHashMap supersedes the Hashtable as it provides a highly performant concurrent map.

LinkedHashMap

LinkedHashMap is a HashMap that also preserve the order in which entries are returned. Consider the following example.

package demo;

import java.util.LinkedHashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> a = new LinkedHashMap<>();
    a.put( "Aden", 82 );
    a.put( "Jade", 92 );
    a.put( "Peter", 74 );
    a.put( "Jane", 68 );

    System.out.printf( "Marks: %s%n", a );
  }
}

The entries will always be return in the same order these were added, as shown next.

Marks: {Aden=82, Jade=92, Peter=74, Jane=68}

LinkedHashMap uses a doubly linked list to preserve the order in which the entries are added to the map.

TreeMap

TreeMap is another Map implementation that uses a tree data structure. The TreeMap is based on the red–black self-balancing binary search tree implementation.

Red Black Tree (Reference)

Similar to other map implementations, only the entry’s key take part in indexing and finding entries. In the above image, the keys are integers and the value can be any object.

The tree marks its nodes red or black, hence the name, and rebalances itself following an addition or deletion of elements, guaranteeing searches in O(log n) time. This makes mutation more complex as the tree needs to be rebalanced every time elements are added or removed.

ⓘ NoteDifferent to what many believe, the TreeMap does not outperform the HashMap when searching elements. In most case the `HashMap` finds elements faster than the TreeMap.

Consider the following example.

package demo;

import java.util.Map;
import java.util.TreeMap;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new TreeMap<>();
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

In the above example, the TreeMap stores the given entries in alphabetical order, using the entry’s key. We can control how entries are handled by the TreeMap by providing a Comparator instance, as shown next.

package demo;

import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> marksByName = new TreeMap<>( Comparator.reverseOrder() );
    marksByName.put( "Aden", 82 );
    marksByName.put( "Jade", 92 );
    marksByName.put( "Peter", 74 );
    marksByName.put( "Jane", 68 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

Different from the previous example, the map will return the elements in reverse order, as shown next.

Marks: {Peter=74, Jane=68, Jade=92, Aden=82}

The order in which the entries are sorted is governed by the provided Comparator or by their natural ordering (the entry’s key implements Comparable).

Note that adding entries to a TreeMap which key does not support natural ordering (the key does not implement Comparable) and without providing a Comparator will throw a ClassCastException at runtime.

⚠ The following example will compile but will throw ClassCastException!!
package demo;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Map;
import java.util.TreeMap;

public class App {
  public static void main( final String[] args ) {
    final Map<Student, Integer> marksByName = new TreeMap<>();

    /* ⚠️ Throws ClassCastException!! */
    marksByName.put( new Student( "Aden" ), 82 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

@Data
@AllArgsConstructor
class Student {
  private String name;
}

The Student class does not implement the Comparable interface, thus this type of object does not provide natural ordering. A Comparator needs to be provided to the TreeMap to be able to work with the Student class, as shown in the following example.

package demo;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Comparator;
import java.util.Map;
import java.util.TreeMap;

public class App {
  public static void main( final String[] args ) {
    final Comparator<Student> comparator = Comparator.comparing( Student::getName );

    final Map<Student, Integer> marksByName = new TreeMap<>( comparator );

    /* ⚠️ Throws ClassCastException!! */
    marksByName.put( new Student( "Aden" ), 82 );

    System.out.printf( "Marks: %s%n", marksByName );
  }
}

@Data
@AllArgsConstructor
class Student {
  private String name;
}

We can work with any key, as long as we provide a Comparator when the key being used do not implement Comparable. The above will print.

Marks: {Student(name=Aden)=82}

The TreeMap always store the elements sorted.

Can we store nulls?

Some map implementations accept nulls as both key and value while others do not, as shown in the following table.

MapAllows null keysAllows null values
HashtableNONO
HashMapYESYES
LinkedHashMapYESYES
TreeMapNOYES

Following are some basic example that tries to work with null keys and values for each of the above implementations

  1. Hashtable (does not accept nulls)

    Null Key

    ⚠ The following example will compile but will throw NullPointerException!!
    package demo;
    
    import java.util.Hashtable;
    import java.util.Map;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new Hashtable<>();
    
        /* ⚠️ Throws NullPointerException!! */
        map.put( "k", null );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    Null Value

    ⚠ The following example will compile but will throw NullPointerException!!
    package demo;
    
    import java.util.Hashtable;
    import java.util.Map;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new Hashtable<>();
    
        /* ⚠️ Throws NullPointerException!! */
        map.put( null, "v" );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    Both examples will fail with a NullPointerException.

  2. HashMap (accepts null keys and values)

    package demo;
    
    import java.util.HashMap;
    import java.util.Map;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new HashMap<>();
        map.put( null, "v" );
        map.put( "k", null );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    HashMap accepts null keys and values. At most, there can be only one null key in a map, but there can be as many null values.

  3. LinkedHashMap (accepts null keys and values)

    package demo;
    
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new LinkedHashMap<>();
        map.put( null, "v" );
        map.put( "k", null );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    LinkedHashMap accepts null keys and values. At most, there can be only one null key in a map, but there can be as many null values.

  4. TreeMap (does not accept null keys but accepts null values)

    Null Key

    ⚠ The following example will compile but will throw NullPointerException!!
    package demo;
    
    import java.util.Map;
    import java.util.TreeMap;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new TreeMap<>();
        /* ⚠️ Throws NullPointerException!! */
        map.put( null, "v" );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    TreeMap does not work with null keys and a NullPointerException will be thrown if we attempt to add nulls.

    Exception in thread "main" java.lang.NullPointerException
        at java.base/java.util.TreeMap.compare(TreeMap.java:1291)
        at java.base/java.util.TreeMap.put(TreeMap.java:536)
        at demo.App.main(App.java:10)
    

    Null Value

    package demo;
    
    import java.util.Map;
    import java.util.TreeMap;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, String> map = new TreeMap<>();
        map.put( "k", null );
        System.out.printf( "Map %s%n", map );
      }
    }
    

    TreeMap can contain null values and the above will print.

    Map {k=null}
    

Which Map to Use?

HashMap is my first choice as it is very fast and can handle nulls. With that said, HashMap consumes more space when compared to TreeMap. LinkedHashMap is a variant of HashMap, where the entries’ order is preserved, at some extra space cost. The following table shows which map I prefer and a one sentence describing the motivation behind this decision.

MapMotivation
HashMapMy default go-to map implementation
LinkedHashMapWhen I need to preserve the insertion order of the entries
TreeMapWhen ordering is important and no need to deal with null keys
HashtableNever. I use ConcurrentHashMap instead when need to deal with concurrency

Each map implementation is compared in more details next.

  1. Performance

    HashMap performs faster than TreeMap. This comes to a surprise especially when searching element.

  2. Ordering

    HashMap provides no ordering guarantees. LinkedHashMap preserves the order in which the entries are added while Treemap always contains the entries in an ordered manner (based on the entry’s key natural ordering or the provided Comparator).

    When an ordered map (a map of type SortedMap) is required, it is recommended to create a HashMap and populate it with the entries first. Then create an TreeMap from the HashMap, as shown next.

    package demo;
    
    import java.util.HashMap;
    import java.util.Map;
    import java.util.SortedMap;
    import java.util.TreeMap;
    
    public class App {
      public static void main( final String[] args ) {
        final Map<String, Integer> temporary = new HashMap<>();
        temporary.put( "Jade", 82 );
        temporary.put( "Aden", 84 );
    
        final SortedMap<String, Integer> ordered = new TreeMap<>( temporary );
        System.out.printf( "Ordered:  %s%n", ordered );
      }
    }
    

    This example takes advantage from the bulk population of the TreeMap and does not suffer the cost associated with the rebalancing with every entry addition.

  3. null support

    TreeMap does not support null keys, but support null values. HashMap and LinkedHashMap support null keys and values.

    ⓘ NoteThere can be at most one null key in a map.
  4. Comparison

    The HashMap and LinkedHashMap use the entry’s key hashCode() method to determine which bucket to use and the entry’s key equals() method to compare between entries within the same bucket.

    The TreeMap relies on the entry’s key compareTo() method for same purpose.

    The relation between the collections and the elements which they contain is discussed in more depth in the relation to objects section.

  5. Concurrency

    Only the Hashtable provide thread-safety. The HashMap, LinkedHashMap and the TreeMap are not thread-safe and provide no thread-safety.

    As mentioned before, prefer the ConcurrentHashMap over the Hashtable when dealing with concurrent situations.

Map keys MUST BE immutable

Modifying the entry’s key after adding them to the map may break the map. Consider the following example.

package demo;

import java.awt.*;
import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Point point = new Point( 1, 1 );

    final Map<Point, String> points = new HashMap<>( 3 );
    points.put( point, "Lower left corner" );

    System.out.println( "-- Before modifying the key ----" );
    System.out.printf( "The map contains %d points%n", points.size() );
    System.out.printf( "Is point %s in map? %s%n", point, points.containsKey( point ) );

    /* Modify the key */
    point.y = 10;

    System.out.println( "-- After modifying the key -----" );
    System.out.printf( "The map contains %d points%n", points.size() );
    System.out.printf( "Is point %s in map? %s%n", point, points.containsKey( point ) );
    System.out.printf( "The map contains: %s%n", points );
  }
}

The Point class is mutable and thus not suitable to be used as a key in any Map. Modifying the point’s state, as shown above example, will break the map. In the above example, the map is not able to locate the same key object after it is modified.

-- Before modifying the key ----
The map contains 1 points
Is point java.awt.Point[x=1,y=1] in map? true
-- After modifying the key -----
The map contains 1 points
Is point java.awt.Point[x=1,y=10] in map? false
The map contains: {java.awt.Point[x=1,y=10]=Lower left corner}

The strangest thing when debugging such problems is that the map seems to contain this key, as printed in the last line from the above output. The issue here happened because the element now belongs to a different bucket and that’s why the map is not able to find it.

How can we modify keys that are contained within a map?

ⓘ NoteAvoid working with mutable keys.

If a key within a map is mutable and needs to be updated, then it should first be removed from the map, updated and then added back to the map, as shown in the following example.

⚠ Proceed with caution!!
package demo;

import java.awt.Point;
import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Point point = new Point( 1, 1 );

    final Map<Point, String> points = new HashMap<>( 3 );
    points.put( point, "Lower left corner" );

    /* Remove, update and put back */
    final String description = points.remove( point );
    point.y = 10;
    points.put( point, description );

    System.out.printf( "The map contains %d points%n", points.size() );
    System.out.printf( "Is point %s in map? %s%n", point, points.containsKey( point ) );
  }
}

The order in which these three operations happen is quite important as if we update the entry’s key before removing it, the remove may not remove the element and then end up with two instances of the same object in the same map.

Mutable objects are not good candidates as map keys.

Double brace initialization

Consider the following example.

package demo;

import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, String> a = new HashMap<>() { {
      put( "a", "A" );
      put( "b", "B" );
      put( "c", "C" );
    } };
    System.out.printf( "Map %s%n", a );
  }
}

The above example makes use of double brace initialization. An inner anonymous class is created and the init block is used to add (put) the entries to the map. The above example is similar to the following.

package demo;

import java.util.HashMap;

public class MyStringMap extends HashMap<String, String> {

  /* Initialisation block */
  {
    put( "a", "A" );
    put( "b", "B" );
    put( "c", "C" );
  }
}

I’ve never used this pattern and prefer other constructs instead, such as Map.of(), Guava Maps.newHashMap() method. I’ve added this example here as you may encounter this in code.

Mutable and immutable maps

Immutable (also referred to as unmodifiable) maps cannot be modified, while mutable (also referred to as modifiable) maps can be modified. Consider the following example.

⚠ The following example will compile but will throw UnsupportedOperationException!!
package demo;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, String> modifiable = new HashMap<>( 3 );
    modifiable.put( "a", "A" );
    modifiable.put( "b", "B" );
    modifiable.put( "c", "C" );

    final Map<String, String> unmodifiable = Collections.unmodifiableMap( modifiable );

    /* ⚠️ Throws UnsupportedOperationException!! */
    unmodifiable.put( "d", "D" );
  }
}

Changing the unmodifiable map will throw an UnsupportedOperationException.

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.Collections$UnmodifiableMap.put(Collections.java:1473)
	at demo.App.main(App.java:17)

Changes to the underlying map will also affect the immutable map

package demo;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, String> modifiable = new HashMap<>( 3 );
    modifiable.put( "a", "A" );
    modifiable.put( "b", "B" );
    modifiable.put( "c", "C" );

    final Map<String, String> unmodifiable = Collections.unmodifiableMap( modifiable );

    /* The immutable map will be modified too */
    modifiable.put( "d", "D" );

    System.out.printf( "Map: %s%n", unmodifiable );
  }
}

The unmodifiable map uses the given map as its underlying data structure. Therefore, any changes to the underlying data structure will affect the unmodifiable map too, as shown next.

Map: {a=A, b=B, c=C, d=D}
ⓘ NoteThis is a common misconception and many fall victim to this.

Consider the following class.

package demo;

import java.util.Collections;
import java.util.Map;

public class Data {

  private final Map<String, Integer> sample;

  public Data( final Map<String, Integer> sample ) {
    this.sample = Collections.unmodifiableMap( sample );
  }

  public Map<String, Integer> getSample() {
    return sample;
  }

  @Override
  public String toString() {
    return String.format( "Data: %s", sample );
  }
}

The Data class contains an unmodifiable map, named sample. We cannot add or remove data to/from the sample map. Consider the following example.

⚠ The following example will compile but will throw UnsupportedOperationException!!
package demo;

import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> source = new HashMap<>( 3 );
    source.put( "a", 7 );
    source.put( "b", 4 );
    source.put( "c", 11 );

    final Data data = new Data( source );

    /* ⚠️ Throws UnsupportedOperationException!! */
    data.getSample().put( "d", 6 );
  }
}

The above example compiles and fails whenever we try to modify the sample map through the enclosing Data class.

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.Collections$UnmodifiableMap.put(Collections.java:1473)
	at demo.App.main(App.java:16)

The above example may give you the wrong impression that the sample map is immutable. Consider the following example.

package demo;

import java.util.HashMap;
import java.util.Map;

public class App {
  public static void main( final String[] args ) {
    final Map<String, Integer> source = new HashMap<>( 3 );
    source.put( "a", 7 );
    source.put( "b", 4 );
    source.put( "c", 11 );

    final Data data = new Data( source );

    /* Modify the source */
    source.put( "d", 6 );

    /* The data is changed too as a side effect */
    System.out.println( data );
  }
}

The above example is modifying the map through the source variable, which happens to be the underlying data structured of the immutable map, sample. We are still able to modify the sample by modifying the underlying map.

Data: {a=7, b=4, c=11, d=6}

Defensive copying is a technique which mitigates the negative effects caused by unintentional (or intentional) modifications of shared objects. Instead of sharing the reference to the original map, we create a new map and use the reference to the newly created copy instead. Thus, any modification made to the source will not affect our map.

To address this problem, we need to change the following line

    this.sample = Collections.unmodifiableMap( sample );

with (if you are working with Java 9 or above)

    this.sample = Map.copyOf( sample );

or (if you are working with Java 8 or you need to handle nulls)

   this.sample = Collections.unmodifiableSet( new HashMap<>( sample ) );

There are at least two ways to solve this problem, both options will achieve the same thing.

package demo;

import java.util.Map;

public class Data {

  private final Map<String, Integer> sample;

  public Data( final Map<String, Integer> sample ) {
/**/this.sample = Map.copyOf( sample );
  }

  public Map<String, Integer> getSample() {
    return sample;
  }

  @Override
  public String toString() {
    return String.format( "Data: %s", sample );
  }
}

Any changes made to the source map, will not affect our map. The above example is truly immutable.