Finding a value in an unsorted array or linked list without an additional index can require scanning every element. A hash table uses a hash to locate a bucket and supports constant-time basic lookup when hashes are well distributed; it does not guarantee O(1) lookup for every input.
HashMap is an implementation of the Map interface based on a hash table. This implementation provides all optional mapping operations and permits null values and null keys. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.
This article follows the source through the array-and-list structure, constructors, put and resizing, get, and the iterator checks using modCount. A final section covers changes in later JDKs.
Version note (reviewed 2026-10-13): This preserves the original 2015 source analysis, checked against OpenJDK
jdk7u21-b11. Eager array allocation,Entrychains, and alternative hashing are historical implementation details, not descriptions of every later release—even Java 7 updates differ. These are JDK source excerpts, not standalone classes to copy and run. Android implementations must be checked against the relevant libcore version.
1. HashMap Data Structure
This version uses an array and linked lists: each array slot holds the head of a bucket chain, or null for an empty bucket. The excerpt below shows only the Entry fields; its methods are in the linked source.

1 | static class Entry<K,V> implements Map.Entry<K,V> { |
Entry is a package-private static nested class implementing Map.Entry<K,V>. Its next reference points to the following node. A final key reference does not make the key object immutable: do not change state used by its hashCode() or equals() while it is stored as a key.
2. Constructors
HashMap has four constructors:
HashMap(): Constructs an emptyHashMapwith the default initial capacity (16) and default load factor (0.75).HashMap(int initialCapacity): Constructs an emptyHashMapwith a specified initial capacity and the default load factor (0.75).HashMap(int initialCapacity, float loadFactor): Constructs an emptyHashMapwith a specified initial capacity and load factor.HashMap(Map<? extends K,? extends V> m): Constructs a newHashMapwith the same mappings as the specifiedMap.
In essence, these boil down to two types: one where you specify initial capacity and load factor, and another that generates a new HashMap from an existing map. Let’s look at the first type:
1 | /** |
initialCapacity requests an initial bucket count, not the number of mappings that fit without resizing. This version allocates the array in the constructor. Negative requests are rejected, requests above MAXIMUM_CAPACITY are clamped to 1 << 30, and the capacity is rounded up to a power of two; a request of 0 produces an array of length 1. The defaults are 16 buckets and a load factor of 0.75.
The constructor calculates threshold using capacity * loadFactor and the expression shown above. Do not treat (1 << 30) + 1 as a lifetime threshold limit: resize sets it to Integer.MAX_VALUE when maximum capacity is reached. The bucket limit of 1 << 30 does not guarantee enough heap memory to allocate that array.
As shown below, addEntry requests resizing only for a new key when the pre-insertion size >= threshold and the destination bucket is nonempty. Reaching the threshold alone does not immediately trigger a resize.
The constructor throws IllegalArgumentException if the load factor is nonpositive or NaN, the special floating-point “not a number” value.
3. put Operation
1 | /** |
Since HashMap allows null keys, it first checks if the key is null and performs special handling.
1 | /** |
A null key is always inserted at index 0 of the array. If an entry already exists at that position with a null key, its value is replaced; otherwise, a new entry is added. We’ll examine the addEntry function shortly.
Question: If a null key is always inserted into table[0], why do we still need to traverse the linked list?
For a non-null key, put calls hash(key). This mixes bits from the key’s hashCode(); when alternative hashing is enabled, it also uses a seed and a separate String path. Here is the complete method from this version:
1 | final int hash(Object k) { |
useAltHashing, hashSeed, and sun.misc.Hashing are implementation details, not public APIs for application code. This tag defaults the alternative-hashing threshold to Integer.MAX_VALUE, so an ordinary map should not be assumed to use that path. indexFor then selects a bucket:
1 | /** |
There is a neat trick in that one-line indexFor method. Remember the restriction on the array length? Because length is a positive power of two, length - 1 is a mask of low-order ones. The bitwise AND produces an index from 0 to length - 1. This is not unconditionally equivalent to Java’s h % length: a negative h can produce a negative remainder, whereas this index is nonnegative. For other lengths, the mask would not cover all buckets evenly. The operator alone does not establish a performance advantage on every runtime.
This also answers the previous question. Since indexFor returns values ranging from 0 to (length - 1), it’s possible for non-null keys to also map to table[0]. Thus, we must traverse the linked list to find the exact key.
The bucket chain is searched for a matching hash and a key matching by identity or equals(); equal hashes alone do not imply equal keys. Replacing an existing value returns before modCount++. The count is incremented only on the new-key path here, before addEntry; it tracks structural changes for fail-fast checks, not every value write.
1 | /** |
addEntry takes the hash, key, value, and bucket index; the last comes from indexFor(hash, table.length). It attempts to double capacity only when size >= threshold and the destination bucket is nonempty. Inserting into an empty bucket does not lengthen an existing chain, so this version can defer resizing. After a resize, it recalculates the pending key’s hash and bucket index before calling createEntry.
1 | /** |
resize first checks if the table has already reached its maximum capacity (1 << 30). If so, the threshold is set to Integer.MAX_VALUE to prevent further resizing. Otherwise, the addEntry call path shown here creates an array with double the previous length. Because capacities are powers of two up to MAXIMUM_CAPACITY, this doubling does not exceed that limit. The transfer function then migrates the elements.
1 | /** |
transfer moves the existing nodes. It calls hash(key) again for non-null keys only when rehash is true, meaning that resizing switched alternative hashing on. Otherwise, it uses each node’s cached hash to calculate its new bucket index.
Doubling capacity does not move every node to a different index. With an unchanged hash, the new index is either the old index or that index plus the old capacity. Head insertion during migration can reverse node order within a chain. resize then replaces table and updates the threshold. Applications must not depend on HashMap iteration order, even without resizing.
Choosing capacity from an expected data size can reduce migration, but excessive capacity costs memory and traversal time: iteration scans both the bucket array and its nodes, not just size entries.
Back in addEntry, after potentially resizing, a new entry is created:
1 | /** |
createEntry uses the same parameters as addEntry. Its logic is closely tied to the Entry constructor:
1 | /** |
The constructor takes an Entry object (n) and sets it as the next value for the new entry. Effectively, createEntry takes the existing list at table[bucketIndex], attaches it to the end of the new entry, and then places the new entry at the head of table[bucketIndex].

This concludes the put function. If a new key-value pair is inserted, it returns null.
4. get Operation
1 | /** |
Like put, it first handles null keys:
1 | /** |
For non-null keys, it calls getEntry:
1 | /** |
getEntry calculates the hash and bucket index, searches the chain, and returns a matching node or null. The outer get returns the node’s value. Thus, get(key) == null can mean either a missing key or a key mapped to null; use containsKey(key) when that distinction matters. The two calls do not form an atomic concurrent operation.
5. Fail-Fast Mechanism
1 | /** |
HashMap is not thread-safe. Fail-fast checks detect structural changes during iteration, such as adding or removing mappings through the map. A modification outside the iterator in the same thread can also trigger the check; a second thread is not required. Merely replacing an existing key’s value is not a structural change here.
modCount is an ordinary int, not volatile, and supplies no synchronization. transient affects default serialization, not cross-thread visibility. The iterator saves expectedModCount at construction and compares the counts in nextEntry() and remove(). Here is the complete internal iterator class:
1 | private abstract class HashIterator<E> implements Iterator<E> { |
hasNext() only checks whether a node remains; it does not compare modification counts. The iterator’s own remove() updates expectedModCount after deletion, so that permitted operation does not invalidate the iterator because of its own change.
Note: Fail-fast is a best-effort bug detector. It cannot guarantee detection of unsynchronized concurrent modification, and correctness must not depend on ConcurrentModificationException. Shared reads and writes need suitable synchronization or a concurrent-container design. A value replacement that leaves the count unchanged is not automatically safe across threads.
6. Changes in Later JDKs (2026 Update)
OpenJDK jdk7u80-b15: An ordinary empty-map constructor no longer allocates the bucket array immediately.
tablestarts asEMPTY_TABLE, and the firstputcallsinflateTable. The 7u21 allocation timing does not describe every Java 7 update.OpenJDK jdk8-b132: Entries use
Node, and collision chains can become red-black trees.TREEIFY_THRESHOLD = 8andMIN_TREEIFY_CAPACITY = 64, but the insertion path and capacity also matter: the eighth node does not unconditionally trigger treeification. A small table is resized first. Resizing ordinary chains splits them into low and high groups while preserving relative order within each group, unlike the old head-insertion transfer.Java SE 19 onward:
HashMap.newHashMap(expectedMappings)accepts an expected mapping count and uses the default load factor. This differs from the constructor’s initial bucket-capacity argument. Checked here against the Java SE 25 API documentation.
These changes do not make HashMap ordered or thread-safe. For modern JDK implementations, follow putVal, resize, and treeifyBin in the relevant release. For Android, check platform API availability and libcore as well: the JDK selected for compilation alone does not establish on-device behavior.
References
- Original reference: Core Java (edition not specified in the original).
- OpenJDK 7u21-b11 HashMap.java
- OpenJDK 7u80-b15 HashMap.java
- OpenJDK 8-b132 HashMap.java
- Java SE 25 HashMap API
About Me && Blog
Below are my personal details and links. I look forward to connecting and sharing knowledge with fellow developers!
- About Me: Includes my WeChat and WeChat group links.
- Blog Navigation: A guide to the content on this blog.
- Curated Android Performance Articles: A collection of must-read performance optimization articles. Self-nominations/recommendations are welcome!
- Android Performance Knowledge Planet: The paid community stopped operating in 2026 and no longer accepts new members; this link preserves its history.
“If you want to go fast, go alone. If you want to go far, go together.”
