Java HashMap: Complete Guide with Practical Examples

Master Java's HashMap to store key-value pairs efficiently. Learn how hashing works internally, avoid common pitfalls with custom keys, and optimize performance.

Introduction to HashMap

While lists ( ArrayList ) are perfect for storing sequences of items, many real-world problems require you to look up data based on a unique identifier. Think of a dictionary where you look up a word (the key) to find its definition (the value), or a phone book where you look up a name to find a number.

HashMap is a part of the Java Collections Framework that implements the Map interface. It stores data in key-value pairs . It is designed to provide extremely fast lookups—on average, you can retrieve a value if you know its key in constant time, O(1), regardless of whether the map holds ten entries or ten million entries.

When to Use HashMap

Use a HashMap when you need to associate specific keys with values and perform frequent lookups, insertions, or deletions. It is ideal for caching data, counting frequencies, creating dictionaries, or mapping IDs to objects. Do not use it if you need to maintain the insertion order of elements (use LinkedHashMap ) or if you need the keys to be automatically sorted (use TreeMap ).

Prerequisites

Before working through this tutorial, you should understand:

What You Will Learn

  • How to create, populate, and access a HashMap
  • The difference between HashMap , Hashtable , and LinkedHashMap
  • How HashMap works internally (buckets, hashing, collisions)
  • The critical contract between equals() and hashCode()
  • How to safely iterate over a map and modify it
  • Common mistakes that cause memory leaks and data loss

How HashMap Works Internally

To use HashMap effectively—and to pass technical interviews—you need a basic understanding of its internals. A HashMap does not store entries in a single continuous block. Instead, it uses an array of buckets .

  1. Hashing: When you call put(key, value) , Java calls the hashCode() method on your key. This generates an integer.
  2. Bucket Index: Java performs a mathematical operation on this hash code to calculate an index within the internal array (e.g., index 0 to 15 for an array of size 16).
  3. Storage: The key-value pair is stored in a "bucket" at that index.
  4. Collisions: If two different keys produce the same index (a hash collision), they are stored in the same bucket as a linked list (or a balanced tree if the list gets too long, as of Java 8).
  5. Lookup: When you call get(key) , Java recalculates the index, jumps directly to that bucket, and uses equals() to find the exact key in the list.

This is why lookups are O(1) on average: instead of searching the whole map, Java calculates exactly where the key should be and goes straight there.

HashMap Syntax

Java Syntax
import java.util.HashMap;
import java.util.Map;

// Always program to the Map interface
Map<KeyType, ValueType> mapName = new HashMap<>();

// With an initial capacity (optimization)
Map<String, Integer> map = new HashMap<>(100);

Two Type Parameters

Unlike ArrayList<T> which takes one type, HashMap takes two: the type for the Key and the type for the Value . For example, Map<String, Integer> means the keys are Strings and the values are Integers.

Simple Example: Storing User Ages

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

public class HashMapBasics {
    public static void main(String[] args) {
        Map<String, Integer> ages = new HashMap<>();

        // 1. Adding key-value pairs
        ages.put("Alice", 28);
        ages.put("Bob", 35);
        ages.put("Charlie", 22);

        // 2. Retrieving a value by key
        System.out.println("Bob's age: " + ages.get("Bob"));

        // 3. Updating a value (put replaces if key exists)
        ages.put("Alice", 29);
        System.out.println("Alice's updated age: " + ages.get("Alice"));

        // 4. Checking if a key or value exists
        System.out.println("Contains David? " + ages.containsKey("David"));
        System.out.println("Contains age 22? " + ages.containsValue(22));

        // 5. Safe retrieval with default value
        System.out.println("David's age: " + ages.getOrDefault("David", -1));

        // 6. Size of the map
        System.out.println("Total entries: " + ages.size());
    }
}
Output
Bob's age: 35 Alice's updated age: 29 Contains David? false Contains age 22? true David's age: -1 Total entries: 3

Important Methods Explained

  • put(key, value) : Calculates the hash, finds the bucket, and inserts the pair. If the key already exists, it overwrites the old value and returns the old value.
  • get(key) : Calculates the hash, jumps to the bucket, uses equals() to find the key, and returns the value. Returns null if the key doesn't exist.
  • getOrDefault(key, defaultValue) : Identical to get() , but returns your specified default instead of null if the key is missing. This is heavily used in modern Java to prevent NullPointerException .
  • putIfAbsent(key, value) : Only inserts the pair if the key does not already exist. Great for initializing caches safely.

Iterating Over a HashMap

Because a Map is not a Collection , you cannot iterate over it directly with a standard for-each loop. You must iterate over its entry set , its key set , or its values .

Java
Map<String, Double> prices = new HashMap<>();
prices.put("Apple", 1.50);
prices.put("Banana", 0.75);

// 1. Iterate over Key-Value pairs (Most Common)
for (Map.Entry<String, Double> entry : prices.entrySet()) {
    System.out.println("Key: " + entry.getKey() + ", Value: $" + entry.getValue());
}

// 2. Iterate over Keys only
for (String item : prices.keySet()) {
    System.out.println("Item: " + item);
}

// 3. Iterate over Values only (Java 8+ forEach)
prices.values().forEach(price -> System.out.println("Price: $" + price));

Modifying a Map During Iteration

Just like with ArrayList , using map.remove(key) inside a standard for-each loop over the map will throw a ConcurrentModificationException . You must use an Iterator or safely use Java 8+ methods like map.entrySet().removeIf(...) .

Real-World Example: Word Frequency Counter

A classic use case for HashMap is counting the frequency of items. Let's build a program that counts how many times each word appears in a sentence.

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

public class WordCounter {
    public static Map<String, Integer> countWords(String text) {
        Map<String, Integer> frequencyMap = new HashMap<>();
        
        if (text == null || text.trim().isEmpty()) {
            return frequencyMap;
        }

        String[] words = text.toLowerCase().split("\\W+"); // Split by non-word characters

        for (String word : words) {
            // getOrDefault fetches current count, or 0 if word is new. Then we add 1.
            int count = frequencyMap.getOrDefault(word, 0);
            frequencyMap.put(word, count + 1);
        }
        
        return frequencyMap;
    }

    public static void main(String[] args) {
        String sentence = "Java is great, and Java is powerful. I love Java!";
        Map<String, Integer> counts = countWords(sentence);

        // Printing results sorted by value (descending) using Java Streams
        counts.entrySet().stream()
            .sorted(Map.Entry.comparingByValue().reversed())
            .forEach(entry -> System.out.println(entry.getKey() + ": " + entry.getValue()));
    }
}
Output
java: 3 is: 2 great: 1 and: 1 powerful: 1 i: 1 love: 1

This example perfectly showcases why HashMap is ideal: looking up whether a word already exists in the map takes O(1) time, making the overall algorithm O(n) where n is the number of words.

The Golden Rule: Custom Objects as Keys

Using String or Integer as keys is easy because Java has already written perfect hashCode() and equals() methods for them. However, if you use a custom class (like Employee or User ) as a key, you must override both methods .

The Contract

  1. If two objects are equal according to equals() , they must return the same integer from hashCode() .
  2. If you put an object in the map, then modify a field used in hashCode() , you will "lose" the object in the map because its hash code changes.
Java — Correct Custom Key Implementation
import java.util.Objects;

public class EmployeeId {
    private final String department;
    private final int id;

    public EmployeeId(String department, int id) {
        this.department = department;
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        EmployeeId that = (EmployeeId) o;
        return id == that.id && Objects.equals(department, that.department);
    }

    @Override
    public int hashCode() {
        return Objects.hash(department, id);
    }
}

Crucial: Use 'final' Fields for Keys

Notice the final keyword on department and id in the class above. If a field used in hashCode() can be changed after the object is created, changing it will change the hash code. The map will look in the wrong bucket, and get() will return null even though the object is in the map. This is a notoriously difficult bug to track down.

Common Mistakes to Avoid

Mistake 1: Using get() Without Null Checks

Java — Incorrect
Map<String, String> capitals = new HashMap<>();
capitals.put("France", "Paris");

// Throws NullPointerException if "Germany" is missing!
int length = capitals.get("Germany").length(); 
Java — Correct
// Safe approach 1: getOrDefault
String capital = capitals.getOrDefault("Germany", "Unknown");
int length = capital.length();

// Safe approach 2: containsKey check
if (capitals.containsKey("Germany")) {
    length = capitals.get("Germany").length();
}

Mistake 2: Mutable Keys

Never use a List , ArrayList , or a mutable custom class as a key. If the contents of the key change after insertion, the hash code changes, and the key becomes permanently lost in the map, causing a memory leak.

Mistake 3: Relying on HashMap Ordering

Do not assume HashMap will print or iterate in the order you inserted items. The order is dependent on the hash codes of the keys and the internal array size.

Best Practices

  1. Program to the Map interface: Always declare your variable as Map<K, V> , not HashMap<K, V> . This allows you to swap to LinkedHashMap or TreeMap later without changing the rest of your code.
  2. Set Initial Capacity: If you know your map will hold 10,000 items, initialize it with new HashMap<>(10_000) . This prevents expensive internal resizes and rehashing.
  3. Prefer getOrDefault , putIfAbsent , and computeIfAbsent : These methods make map interactions safer and more concise by handling null checks internally.
  4. Avoid HashMap in Multi-threaded Environments: If multiple threads will modify the map concurrently, use ConcurrentHashMap . Do not use Collections.synchronizedMap() or Hashtable unless absolutely necessary, as ConcurrentHashMap is vastly superior in performance.

Performance Considerations

  • Time Complexity: Average case for get and put is O(1). Worst case (if all keys hash to the same bucket) is O(n), though Java 8's tree bins reduce this worst case to O(log n).
  • Load Factor: HashMap resizes when the number of entries exceeds capacity * loadFactor . The default load factor is 0.75 (75% full). This balances memory usage against lookup speed. Increasing the load factor saves memory but slows down lookups due to more collisions.
  • Memory Overhead: HashMap has significant memory overhead compared to an array due to the bucket array, linked list nodes (or tree nodes), and the Entry objects. For primitive keys/values, consider specialized libraries like FastUtil or Trove to avoid boxing overhead.

Exercises

Exercise 1: Merge Two Maps

Write a method that takes two Map<String, Integer> objects and returns a new map containing all key-value pairs from both. If a key exists in both maps, the value from the second map should overwrite the value from the first map. (Hint: Look at the putAll() method).

Exercise 2: Character Frequency

Write a program that takes a String and uses a HashMap<Character, Integer> to count how many times each character (ignoring spaces) appears in the string. Print the results.

Solutions

Solution to Exercise 1: Merge Two Maps

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

public class MapMerger {
    public static Map<String, Integer> mergeMaps(Map<String, Integer> map1, Map<String, Integer> map2) {
        Map<String, Integer> merged = new HashMap<>(map1);
        merged.putAll(map2); // Overwrites duplicates with map2's values
        return merged;
    }

    public static void main(String[] args) {
        Map<String, Integer> m1 = new HashMap<>();
        m1.put("A", 1);
        m1.put("B", 2);

        Map<String, Integer> m2 = new HashMap<>();
        m2.put("B", 99); // Will overwrite m1's "B"
        m2.put("C", 3);

        System.out.println(mergeMaps(m1, m2));
    }
}
Output
{A=1, B=99, C=3}

Solution to Exercise 2: Character Frequency

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

public class CharFrequency {
    public static void main(String[] args) {
        String text = "hello world";
        Map<Character, Integer> counts = new HashMap<>();

        for (char c : text.toCharArray()) {
            if (c == ' ') continue; // Ignore spaces
            
            // Using computeIfAbsent and merge for a modern approach
            counts.merge(c, 1, Integer::sum);
        }

        System.out.println(counts);
    }
}
Output
{r=1, d=1, e=1, w=1, h=1, l=3, o=2}

Explanation: The merge(key, defaultValue, remappingFunction) method is a powerful Java 8 addition. If the key is missing, it inserts the default value (1). If the key exists, it applies the remapping function ( Integer::sum , which adds the old value and the new value together).

Summary

  • HashMap stores data in key-value pairs and provides O(1) average time complexity for lookups and insertions.
  • It uses an array of buckets based on the key's hashCode() . Collisions are handled via linked lists or tree bins.
  • Always declare variables as Map<K, V> , not HashMap<K, V> .
  • If you use custom objects as keys, you must override equals() and hashCode() , and the fields used should be final .
  • Use getOrDefault and putIfAbsent to write safer, cleaner code.
  • HashMap does not maintain order. Use LinkedHashMap for insertion order or TreeMap for sorted order.
  • It is not thread-safe; use ConcurrentHashMap for concurrent applications.

Frequently Asked Questions

HashMap allows one null key and multiple null values, and is not synchronized, making it fast in single-threaded contexts. Hashtable is a legacy class that does not allow null keys or values, and is synchronized (thread-safe), making it much slower. In modern Java, if you need thread-safety, use ConcurrentHashMap instead of Hashtable .

No, HashMap makes no guarantees about the order of the map over time. The order depends entirely on the hash codes of the keys and the internal resizing of the map. If you need to preserve the order in which elements were inserted, you should use LinkedHashMap . If you need keys sorted in natural order (e.g., alphabetical), use TreeMap .

When HashMap looks up a key, it first calculates the hash code to find the bucket, then uses equals() to find the exact key in that bucket. If you don't override them, Java uses the default implementation from the Object class, which compares memory addresses. This means two distinct objects with identical data will be treated as entirely different keys.

This is called a hash collision. Both key-value pairs will be stored in the same bucket. Before Java 8, they were stored as a linked list, meaning lookup in that specific bucket degraded to O(n). In Java 8 and later, if a single bucket gets too many entries (usually more than 8), the linked list is transformed into a balanced red-black tree. This improves the worst-case lookup time in that bucket from O(n) to O(log n).

No. By definition, a Map maps unique keys to values. If you call put() with a key that already exists in the map, it will not create a duplicate entry. Instead, it will overwrite the existing value associated with that key, and the put() method will return the old value that was replaced.