Design a Good Key for HashMap in Java: Rules and Examples

A good HashMap key overrides equals() and hashCode() together, uses fields that never change and spreads its hash codes well. Records, a mutable key that gets lost, collisions, Comparable keys, EnumMap and Lombok, with tested Java 25 code.

Java 7 logo

A good key for a HashMap has equals() and hashCode() methods that agree and never change while the key is in the map. In modern Java, the best custom key is a record, because its fields cannot change and the compiler writes equals() and hashCode() for us from those fields.

We design our own key class when a map is looked up by more than one value, such as a first and last name, or by a domain object such as a bank account.

The following example uses a record as a key and shows how a mutable key gets lost, with the result of each line as a comment.

// 1. A record is a good key
record Person(String firstName, String lastName) {}

Map<Person, Integer> ages = new HashMap<>();
ages.put(new Person("Lokesh", "Gupta"), 37);
Integer age = ages.get(new Person("Lokesh", "Gupta"));  // age = 37

// 2. A mutable key gets lost (hashCode() of MutablePerson uses the name field)
MutablePerson key = new MutablePerson("John");
Map<MutablePerson, Integer> map = new HashMap<>();
map.put(key, 40);
Integer before = map.get(key);                          // 40
key.setName("Alex");                                    // hashCode() changes
Integer after = map.get(key);                           // null
Integer withNewKey = map.get(new MutablePerson("John"));  // null
int size = map.size();                                  // 1 (the entry is unreachable)

Notice that after we change a field of the mutable key, get() returns null, although the entry is still in the map.

A class makes a good HashMap key when it follows three rules.

  • The methods equals() and hashCode() are overridden together, so equal keys always return the same hash code.
  • The fields used by equals() and hashCode() never change after the key goes into the map. Immutable classes (classes whose objects cannot change), such as records, String, Integer and LocalDate, follow this rule by design.
  • The method hashCode() returns different numbers for different keys as often as possible, so few keys end up in the same slot of the map (the same bucket).

In the following sections, we look at the equals()/hashCode() rules and use a bucket diagram to see why a mutable key gets lost. Then we compare records and String with a hand-written key class, and finish with hash collisions, Comparable keys, EnumMap, IdentityHashMap and Lombok-generated keys.

1. The Key Must Follow the Contract Between hashCode() and equals()

The most basic need is that we must be able to get the value back from the map without failure. Internally, a HashMap keeps an array of buckets, where each bucket is one slot of the array and holds the entries whose hash codes point to that index. Every put() and get() uses the key in two steps.

  1. The method hashCode() returns an int, and the map turns that number into a bucket index.
  2. Inside that bucket, the method equals() compares the given key with the stored keys, and the map picks the matching entry.

For key design, we need only these two methods, not every detail of how HashMap works internally.

  • The method equals() decides whether two keys are the same key, so we override it to compare the fields that identify the key.
  • The method hashCode() returns an integer that picks the bucket. Two different keys may return the same number, and then the map uses equals() to tell them apart.

The Javadoc of the Object class defines the hashCode() and equals() contract, which is a set of rules that every class must follow.

SituationRule for hashCode()
a.equals(b) is truea.hashCode() == b.hashCode() must be true
a.equals(b) is falsethe hash codes may be equal or different. Equal hash codes for different keys are called a collision, and a collision is allowed.
hashCode() called twice on an unchanged objectreturns the same value both times
equals()must be reflexive (a.equals(a) is true), symmetric (a.equals(b) gives the same answer as b.equals(a)), transitive (if a equals b and b equals c, then a equals c), and must return false for null

Developers break the first rule most often. When a class overrides only equals(), it keeps the default hashCode() from Object, which gives each object its own number, so two equal objects almost always end up in different buckets.

// EqualsOnlyPerson overrides equals() on the name field, but not hashCode()
Map<EqualsOnlyPerson, Integer> map = new HashMap<>();
map.put(new EqualsOnlyPerson("Lokesh"), 37);

boolean equal = new EqualsOnlyPerson("Lokesh").equals(new EqualsOnlyPerson("Lokesh"));  // true
Integer age = map.get(new EqualsOnlyPerson("Lokesh"));                                 // null
map.put(new EqualsOnlyPerson("Lokesh"), 38);
int size = map.size();                                                                 // 2 (duplicate key)

The get() call looks in the wrong bucket and returns null, and the second put() adds a duplicate key instead of replacing the value. Whenever we override equals(), we override hashCode() with the same fields.

2. What if the Key’s hashCode() Changes After put()?

The hash code picks the bucket at the moment of put(), and HashMap stores the entry in that bucket. The map never moves the entry when the key object changes later, so when a field used by hashCode() changes, the next get() computes a new hash, looks in a different bucket and finds nothing.

The MutablePerson key from the intro shows the effect. With the name John, hashCode() returns 2314539, and after setName(“Alex”) it returns 2043454. In a map with the default 16 buckets, those two hashes point to buckets 8 and 1.

HashMap table with 16 buckets: put() stores the entry in bucket 8 while the key name is John; after setName("Alex"), get(key) computes hash 2043454 and looks in empty bucket 1; get(new MutablePerson("John")) reaches bucket 8 but equals() compares John with Alex and fails
The entry stays in bucket 8, but no key can reach the entry any more. The changed key looks in bucket 1. A fresh “John” key reaches bucket 8, but fails the equals() check.

The entry is still in the map, but remove() cannot find it either, so the entry stays there for as long as the map exists.

Integer value = map.get(key);                     // null
boolean found = map.containsKey(key);             // false
Integer removed = map.remove(key);                // null (nothing removed)
int size = map.size();                            // 1
Collection<Integer> values = map.values();        // [40]

When a long-lived map, such as a cache, keeps collecting lost entries, it holds memory it can never free. The result is a memory leak, and no exception warns us about it.

The JVM does not watch the key for changes, and it does not recompute the hash for the map. Every call to hashCode() computes the value from the current fields (String caches its hash, which is safe because a String never changes), whereas HashMap keeps the hash it computed at put() time.

3. We Should Make the HashMap Key Immutable

An immutable class is a class whose objects cannot change after we create them. An immutable key returns the same hash code every time, so the problem from section 2 cannot happen. An immutable class must still follow the hashCode() and equals() contract, though.

3.1. Records as HashMap Keys

A record is the shortest way to write an immutable key, and records are standard since Java 16. For a record, the compiler generates a constructor, accessor (getter) methods, equals() and hashCode(), and both equals() and hashCode() use every component (field) of the record.

record Person(String firstName, String lastName) {}

Map<Person, Integer> ages = new HashMap<>();
ages.put(new Person("Lokesh", "Gupta"), 37);
Integer age = ages.get(new Person("Lokesh", "Gupta"));   // 37

A record key has no setters, and there is no hand-written equals() to get wrong. Java does not specify the exact hash formula of a record, so our code must never depend on the number itself. On JDK 25, new Person(“Lokesh”, “Gupta”).hashCode() returns 2079745021, whereas Objects.hash(“Lokesh”, “Gupta”) returns 2079745982 for the same names.

3.2. String, Integer and Other Immutable Classes

String, Integer, Long and the other wrapper classes are immutable, and each of them overrides equals() and hashCode() correctly. String is the most popular HashMap key, because a String cannot change and it caches its hash code after the first call. The JDK has other good immutable keys as well.

  • Long, UUID and BigDecimal for numeric or generated IDs. We need to watch out with BigDecimal, because BigDecimal.equals() also compares the scale (the number of decimal places), so new BigDecimal(“1.0”) and new BigDecimal(“1.00”) are different keys.
  • LocalDate and the other java.time classes.
  • Enum constants (see section 6).

3.3. Records With Mutable Components

A record is only shallowly immutable, which means the record cannot switch to another object, but that object itself can still change. A List component is the usual problem, because adding a name to members changes the hash code of team, so get() returns null.

record Team(String name, List<String> members) {}

List<String> members = new ArrayList<>(List.of("Lokesh"));
Team team = new Team("blue", members);
scores.put(team, 10);
members.add("John");                            // changes team.hashCode()
Integer score = scores.get(team);               // null

The fix is a compact constructor (a record constructor written without a parameter list). In SafeTeam, the compact constructor copies the list with List.copyOf(), and because the copy cannot change, the key becomes fully immutable.

record SafeTeam(String name, List<String> members) {
  SafeTeam {
    members = List.copyOf(members);
  }
}

safeMembers.add("John");                        // the key keeps its own copy
Integer score = safeScores.get(safeTeam);           // 10
boolean added = safeTeam.members().add("Alex");     // UnsupportedOperationException

4. HashMap Custom Key Example

Immutability is recommended but not required, so a mutable class can still be a safe key when equals() and hashCode() use only fields that never change. The Account class compares accounts by the account number alone. The account number is final, so it never changes, whereas the holder name can change at runtime without affecting the key.

public class Account {

  private final int accountNumber;
  private String holderName;

  public Account(int accountNumber) {
    this.accountNumber = accountNumber;
  }

  public String getHolderName() {
    return holderName;
  }

  public void setHolderName(String holderName) {
    this.holderName = holderName;
  }

  public int getAccountNumber() {
    return accountNumber;
  }

  // Depends only on the account number
  @Override
  public int hashCode() {
    return Integer.hashCode(accountNumber);
  }

  // Compares only the account numbers
  @Override
  public boolean equals(Object obj) {
    if (this == obj) {
      return true;
    }
    if (obj == null || getClass() != obj.getClass()) {
      return false;
    }
    Account other = (Account) obj;
    return accountNumber == other.accountNumber;
  }
}

Changing the holder name does not break anything, because Account follows the contract. Equal accounts produce the same hash code, and the hash code stays the same for the whole life of the object.

HashMap<Account, String> map = new HashMap<>();

Account a1 = new Account(1);
a1.setHolderName("A_ONE");
Account a2 = new Account(2);
a2.setHolderName("A_TWO");

map.put(a1, a1.getHolderName());
map.put(a2, a2.getHolderName());

// Change the non-key state
a1.setHolderName("Defaulter");
a2.setHolderName("Bankrupt");

String first = map.get(a1);                     // A_ONE
String second = map.get(a2);                    // A_TWO

// A new object with the same account number finds the same entry
Account a3 = new Account(1);
a3.setHolderName("A_THREE");
String third = map.get(a3);                     // A_ONE

Printing the three results shows the holder names stored at put() time.

A_ONE
A_TWO
A_ONE

This design works only while every developer remembers that accountNumber identifies the key. If a later change adds holderName to equals() and hashCode(), the Account key breaks like MutablePerson in section 2. A safer design avoids that risk. We use a record with the identifying fields, such as record AccountId(int number), as the key and keep the Account object as the value.

5. hashCode() Quality and Collisions

When two different keys land in the same bucket, we call it a collision. The contract allows collisions, and they happen all the time, even with String.

int hashAa = "Aa".hashCode();                   // 2112
int hashBB = "BB".hashCode();                   // 2112
map.put("Aa", 1);
map.put("BB", 2);
Integer valueAa = map.get("Aa");                // 1
Integer valueBB = map.get("BB");                // 2

Both keys share one bucket, and equals() tells them apart, so a few collisions cost little. A poor hashCode() costs much more. When hashCode() returns the same value for many keys, or in the worst case a constant, every entry goes into one bucket and each lookup must search through all of those entries.

Top: get() calls hashCode(), picks the bucket from the hash and table size, then compares hashes and calls equals() in the bucket. Bottom left: up to 8 colliding keys such as "Aa" and "BB" form a linked list in bucket 0. Bottom right: more than 8 keys in one bucket form a red-black tree in Java 8 and later; Comparable keys let compareTo() choose the branch, other keys may force a search of both branches
A bucket with more than 8 keys turns into a tree. The tree is fast only when the keys are Comparable.

5.1. Treeified Buckets in Java 8 and Later

Since Java 8 (JEP 180), HashMap turns a crowded bucket from a linked list into a red-black tree (a sorted tree that keeps itself balanced). This happens when the bucket holds more than 8 entries and the table has at least 64 buckets; a smaller table is resized instead. The tree speeds up the worst-case lookup from O(n), which checks every entry, to O(log n), but only when the tree can put the keys in order.

5.2. Comparable Keys Help Treeified Buckets

Inside a tree bucket, HashMap sorts keys by hash first. For keys with equal hashes, the map uses compareTo() if the key class implements Comparable with itself as the type argument. Without Comparable, the tree has no order for those keys, so the map may have to search both branches.

The HashMapKeyCollisions class in the example code measures the effect with three record types. GoodKey uses the generated hashCode(), whereas BadComparableKey and BadKey both return the constant 42, and only BadComparableKey implements Comparable. Each run puts 20,000 keys into a map and gets them back.

Put + get 20000 keys (best of 5 runs):
GoodKey           1 ms
BadComparableKey  30 ms
BadKey            8828 ms

The times are from JDK 25 and change between machines, but the ratio stays the same. A well-spread hashCode() matters most, and Comparable only limits the damage when many keys collide. For hand-written classes, Objects.hash(field1, field2) or the classic 31 result + field* formula gives a good spread, and a record gets such a formula from the compiler.

6. Enum Keys and Identity Keys

Two special cases have their own map classes, which are faster or more correct than a plain HashMap for their use case.

The first case is enum keys. EnumMap stores the values in an array indexed by the position of each enum constant, so it never computes a hash. It also iterates in the order the constants are declared in the enum.

Map<DayOfWeek, String> plan = new EnumMap<>(DayOfWeek.class);
plan.put(DayOfWeek.FRIDAY, "gym");
plan.put(DayOfWeek.MONDAY, "swim");
System.out.println(plan);                       // {MONDAY=swim, FRIDAY=gym}

The second case is identity keys. IdentityHashMap compares keys with == (which is true only for the same object in memory) instead of equals(), and it uses System.identityHashCode() instead of hashCode(). It is meant for rare cases, such as tracking which objects were already visited while copying a graph of objects, not for general lookups. In the example, k1 and k2 hold the same text but are two objects, so HashMap keeps one entry, while IdentityHashMap keeps two.

String k1 = new String("apple");
String k2 = new String("apple");

hashMap.put(k1, 5);
hashMap.put(k2, 3);                             // hashMap = {apple=3}
identityMap.put(k1, 5);
identityMap.put(k2, 3);                         // identityMap.size() = 2
Integer value = identityMap.get("apple");        // null (a different object)

7. Generating equals() and hashCode() With Lombok

Lombok is a library that writes common methods for us from annotations, and projects that use it often generate equals() and hashCode() this way. By default, @EqualsAndHashCode uses every non-static, non-transient field. @Data includes @EqualsAndHashCode, so it behaves the same, and on a class with setters the result is the mutable key from section 2. Lombok offers two safer options.

  • @Value makes all fields private final, generates no setters, and generates equals() and hashCode(), so the result is an immutable key, much like a record.
  • @EqualsAndHashCode(onlyExplicitlyIncluded = true) limits both methods to the fields we mark with @EqualsAndHashCode.Include, so the result works like the Account class in section 4.

The Lombok version of Account uses the second option.

@Getter
@Setter
@RequiredArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Account {

  @EqualsAndHashCode.Include
  private final int accountNumber;

  private String holderName;
}

On Java 16 and later, a record gives the same result for key classes without an annotation processor (the extra compile-time tool that Lombok uses).

8. HashMap Key FAQs

Most of these questions also come up among the Java interview questions, right after “How does HashMap work?”.

8.1. Can We Use a Mutable Object as a HashMap Key?

Yes, if equals() and hashCode() use only fields that never change, as in the Account example. When a field used by those methods changes while the key is in the map, lookups such as get() and remove() stop finding the entry. So when a key must change, we remove the entry first and put it back after changing the key.

8.2. Is a Java Record a Good HashMap Key?

Yes. A record is the recommended key class in Java 16 and later, because its components are final and the generated equals() and hashCode() follow the contract. A component that can itself change, such as an ArrayList, needs a defensive copy (a private copy made in the constructor, see section 3.3).

8.3. What Happens if Two Keys Have the Same hashCode()?

Both entries go into the same bucket, and equals() separates them, as section 5 shows with “Aa” and “BB”. Equal hash codes never overwrite an entry; only a key that is equals() to an existing key replaces the value.

8.4. Do TreeMap Keys Need hashCode()?

No. A TreeMap never calls hashCode() or equals() to find a key, because it uses compareTo() or the Comparator given to the constructor. So the TreeMap keys must be Comparable, and compareTo() should return 0 exactly when equals() returns true, so the same keys behave the same in both maps.

9. Conclusion

A good HashMap key overrides equals() and hashCode() together and uses only fields that never change in both methods, and its hash codes are well spread. A record meets all three rules with one line of code, and String, Integer, UUID and enum constants are safe keys without extra code. When a key class must stay mutable, only its fixed identifying fields belong in equals() and hashCode(). For enum keys we use EnumMap, and Comparable keys keep heavily colliding buckets fast.

10. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Hi Lokesh,
    could you plz explain me below doubts ?
    if HashMap key is a String object/user defined class obj then how the hashcode generates ?
    HashMap hm= new HashMap();
    hm.put(21, 121);
    normally hashcode calulates : key %capacity = 21%4= 1 // suppose my Initial capacity is 4 and it will tore @ index position 1
    same way want to know how it calculates for a String and userdefined class as a key ..

  2. is equals methods compare each key on the same bucket and if keys are equal then replace the values to the recent keys value?
    please explain how equal method works in hashmap?

    • In Hash map equals method works on keys. Generally Hash code is may same or may not same for two different keys. if hash code is same and and the index is same but keys are different In this case hash map create a linked list and stores the values at the next node of present key value pair

  3. Hi Lokesh,

    Can you can explain hashmap get(), with example , i tried so many time times but i have some little bit confusion,

  4. above hashcode method :

    final int prime = 31;
    int result = 1;
    result = prime * result + accountNumber;
    return result;

    is same as :
    return 31 * accountNumber;

    Any reason why you wrote those 4 lines?

    • No. You are right that it could be one line code also. But if you have multiple fields in hashCode() calculation then It will be easy to change them method as below :

      result = prime * result + accountNumber;
      result = prime * result + field1;
      result = prime * result + field2.hashCode();
      result = prime * result + field3;
      
  5. To avoid any unforced error as reassigning value to accountNumber, It’s better if we make the field “accountNumber” final.

  6. To avoid any unforced error as reassigning accountNumber a new value,It would be better if you make “accountNumber” final

  7. i think it is important that neither equals nor hashcode should change when changing the state of object. In your example, if I have hashcode on the basis of account number and equals on the basis of account number and holder name, then neither property can be changed if you a reliable key is required for map.

  8. Nice post.
    In #33, you are setting for value for a1 but I guess you might be interested in setting value for a3.

    Assume that, I have included holdername in equals method and I put the a3 in the same map. Then the above code will return “A_THREE” because after matching the hashcode, it will use the equals to identify the correct key. Please correct me if I am not

    • Hi.. I included holdername in equals method and then put a3 in the same map . I got null as output for a3 as key.
      If i include holdername in hash code as well then i get null for all the three keys. Can you please mention as to how will I get “A_THREE “as value for a3 as key.

          • Hi Lokesh, Can u help me in understanding the output following program. Why the output is different in the below two cases?

            import java.util.HashMap;
            
            public class HashMapValue 
            {
            	public static void main(String[] args) 
            	{
            		Test objTest = new Test("Hello");
            		HashMap<Integer, Test> hm = new HashMap<Integer, Test>();
            		hm.put(1, objTest);
            		objTest.setName("World");
            		Test secondObject = hm.get(1);
            		System.out.println(secondObject.getName());
            
            		HashMap<Integer, StringBuffer> hm2 = new HashMap<Integer, StringBuffer>();
            		StringBuffer sb = new StringBuffer("Hello");
            		hm2.put(1, sb);
            		sb = new StringBuffer("World");
            		System.out.println(hm2.get(1));
            	}
            }
            
            class Test {
            	String name;
            
            	Test(String name) {
            		this.name = name;
            	}
            
            	public String getName() {
            		return name;
            	}
            
            	public void setName(String name) {
            		this.name = name;
            	}
            }
          • Just to help others reading this: Output of above program is –
            World
            Hello

            In your first part of program, you created an instance of Test and set it’s value to “Hello”. After inserting this instance in map, you used the same reference to change the value to “World”. So value got changed.
            In second part of program, you created instance of StringBuffer and set value to “Hello”. BUT, now you created another instance of StringBuffer and assigned the reference to variable sb. Please note that after changing the reference, sb does not point to first StringBuffer instance, rather it points to second instance. So any operation you does using sb now, does not affect the instance inside map. So when you fetch it from map, it’s unchanged.

            If you really want to change the value, then do not assign the reference of new StringBuffer to sb, rather simply use sb.append() method.

            //sb = new StringBuffer(&quot;World&quot;);
            sb.append(&quot;World&quot;);

            Now output will be:

            World
            HelloWorld

  9. Hey well explained, but I am not clear about immutability. Since you told at the start how to create immutable class. I dont see it you above example. Am I missing anything here ?

  10. Is the last println line correct in TestMutableKey??
    we have not added a3 obj into map, so how can we retrieve it?

    • Hi Ajay,
      As far I I understand, both a1 and a3 would produce same hascode and also equals method wouldl return true since both account numbers are same. That are the only two things HashMap will check before returning a value object from the map.

  11. What if I would like both values in the account class to be immutable? Like the account number and the holder name would have to be equal for the objects to be equal? How does this change the hashcode and equals methods?

  12. 1.) when a hashcode value is calculated , this value happens to be some memory address on the heap. What is the guarantee OR how hashMap ensures that the hashcode that will be calculated will be a free mem area(the same mem area is not being used by some other program)

    2.) Although internally objects will be stored in a transient Entry[]. Is it that this array which is a datastructure with contiguous mem locations will already claim its space on the heap, once declared and then the bucket allocation happens from within this transient array.

    Kindly help on the above 2 questions

    • 1) NO, hashcode is not same as memory address. Rather it’s kind of representation of memory address. For default hashCode() method, JVM derives the value from the value of the reference to the object. So, first object is created in memory and then hashcode is calculated.

      2) Yes you are right.

  13. Can i do the same hascode implementation with String, as u did with integer value(Acc no)? If so how?what should be the code inside hashcode?

  14. Most important thing to know about HashMap is it’s data structures and algorithms used to write this class. As name of class(HashMap) is indicating that its works on hashing mechanism. This class basically uses two data structures, one is Array and other is Linked-List. HashMap internally create Array of Entry type objects. Entry is an inner class used by HashMap to stores Key and Value type’s objects. To place an Entry object in array, we need an index at which that object can store in array. This index is generated by hash code of key object provide by user. Hash code of key object can get by hashCode() method of key object. After knowing index, Entry object can place in array. These array indexes are known as bucket. Now if you want to retrieve any object from HashMap, you need to provide key for that. This is key object gives hash code, this hash code generates an index and now at this index you have your object.

    If you want to read more about HashMap [Click Here]

    • In this case, Account a3 = new Account(1); creates an object whose hashcode will be different from a1. We have not put a3 in map, so it’s not available to matched with a1.
      If we override hashcode and equals then a3 is matched with a1 due to same account number, and a1 is returned.

  15. Hi, I have not implemented hashcode() and equals() methods and the output is:
    A_ONE
    A_TWO
    null

    I have changed the accountNumber field to String, still get the same output
    A_ONE
    A_TWO
    null

    String being immutable, should the output be different?

  16. Nice article .I really enjoyed all your articles.I have one request, could you please explain ThreadLocal .I tried understanding but not able to implement it practically. It will be very helpful.

  17. This is only acceptable if you are okay with the mutable parts of the class being unimportant for seeing if two instances of the class are “equal”. Many people might be thrown off by the idea of them changing something and seeing that it is still considered equal to something that it was equal to before. This is not neccessarily a bad thing, but you should probably state in the documentation that certain parts aren’t factored into its equality.

      • Hi Lokesh,
        if in this case as we are not overriding the hashcode and equal method,and we are changing object state i.e.(mutable key) then how its is fetching the values A_ONE
        A_TWO ?

          • here account a1 always returns same hashcode even after setting the name.
            Is that mean its not necessary that hashcode will change incase we are changing state of mutable class (Provided we are not override the method)

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.