历史背景
HashMap 最早在 JDK 1.2(1998 年发布的 Java 2)引入,是 java.util 集合框架(Collections Framework)随 1.2 一起推出的一部分,与 ArrayList、HashSet、Collection/Map 接口等同批加入;
在此之前,JDK 1.0/1.1 只有 Hashtable——线程安全但不允许 null key/value。HashMap 是作为 Hashtable 的非同步、允许 null 的替代品引入的,因此在没有并发访问需求的场景下性能更好;本文后续讨论的实现细节(JDK 1.7 数组 + 链表、JDK 1.8 引入红黑树)都是在这个基础上的持续演进。二者更详细的差异对比见 HashMap 与 Hashtable 的区别。
JDK 1.7
默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。 因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。
put 方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
put 方法执行流程:
- 判断当前数组是否需要初始化。
- 如果 key 为空,则 put 一个空值进去。
- 根据 key 计算出 hashcode。
- 根据计算出的 hashcode 定位出所在桶。
- 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
- 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
当调用 addEntry 写入 Entry 时需要判断是否需要扩容。如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位;而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在这个位置形成链表。
get 方法(JDK 1.7)
再来看看 get 函数:
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
get 方法执行流程:
- 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
- 判断该位置是否为链表。
- 不是链表就根据 key、key 的 hashcode 是否相等来返回值。
- 为链表则需要遍历直到 key 及 hashcode 相等时才返回值。
- 啥都没取到就直接返回
null。
1.7 的问题
当 Hash 冲突严重时,在桶上形成的链表会变得越来越长,这样查询时的效率就会越来越低,时间复杂度为 O(N)。
JDK 1.8
核心成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
和 1.7 大体上都差不多,还是有几个重要的区别:
TREEIFY_THRESHOLD用于判断是否需要将链表转换为红黑树的阈值。HashEntry修改为Node。Node的核心组成其实也是和 1.7 中的HashEntry一样,存放的都是 key、value、hashcode、next 等数据。
putVal 方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) // 1: check whether table is empty
n = (tab = resize()).length; // initialize table
if ((p = tab[i = (n - 1) & hash]) == null) // 2: bucket is empty after hashing
tab[i] = newNode(hash, key, value, null); // create a new node
else {
// bucket already has a value (hash collision)
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // 3: keys are equal
e = p;
else if (p instanceof TreeNode) // 4: bucket is a red-black tree
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// bucket is a linked list
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 6: convert to red-black tree?
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) // 7: found the key in the list
break;
p = e;
}
}
if (e != null) { // 8: existing mapping for key, need to overwrite
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 9: resize?
resize();
afterNodeInsertion(evict);
return null;
}
看似要比 1.7 的复杂,我们一步步拆解:
- 判断当前 table 是否为空,空的就需要初始化(resize 中会判断是否进行初始化或者扩容)。
- 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶(newNode())。
- 如果当前桶有值(Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
- 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
- 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
- 如果在遍历过程中找到 key 相同时直接退出遍历。
- 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
- 最后判断是否需要进行扩容。
get 方法(JDK 1.8)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get 方法看起来就要简单许多了:
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回
null。 - 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。
总结
无论是 1.7 还是 1.8,JDK 都没有对 HashMap 做任何的同步操作,所以并发场景下会出问题,甚至 1.7 中出现死循环导致系统不可用(1.8 已经修复死循环问题)。
红黑树优化(JDK 1.8 新增 TreeNode)
JDK 1.8 以前 HashMap 的实现是数组 + 链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。
当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
针对这种情况,JDK 1.8 的 HashMap 引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。红黑树本身的性质、与 AVL 树的对比见 Red-Black Tree, 红黑树。
Node 与 TreeNode
JDK 1.8 中 HashMap 除了链表节点:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // hash value, i.e. the bucket position
final K key; // key
V value; // value
Node<K,V> next; // pointer to the next node
//...
}
还有另外一种节点:TreeNode,它是 1.8 新增的,属于数据结构中的红黑树。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
可以看到就是个红黑树节点,有父亲、左右孩子、前一个元素的节点,还有个颜色值。
另外由于它继承自 LinkedHashMap.Entry,而 LinkedHashMap.Entry 继承自 HashMap.Node,因此还有额外的 6 个属性:
// inherited from LinkedHashMap.Entry
Entry<K,V> before, after;
// inherited from HashMap.Node
final int hash;
final K key;
V value;
Node<K,V> next;
树化相关参数
HashMap 中关于红黑树的三个关键参数:
TREEIFY_THRESHOLDUNTREEIFY_THRESHOLDMIN_TREEIFY_CAPACITY
值及作用如下:
// treeify threshold for a bucket
// when the element count in a bucket exceeds this value, replace list nodes with tree nodes
// this value must be 8, otherwise frequent conversions would hurt performance
static final int TREEIFY_THRESHOLD = 8;
// threshold to revert a tree back to a linked list
// when resizing, if the element count in a bucket falls below this value, the tree is split back into a linked list
// this value should be smaller than the one above, at least 6, to avoid frequent conversions
static final int UNTREEIFY_THRESHOLD = 6;
// minimum table capacity for treeification
// buckets can only be treeified once the table capacity exceeds this value
// otherwise the table resizes instead of treeifying when a bucket has too many elements
// to avoid conflicts between resizing and treeifying decisions, this must be at least 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
treeifyBin():桶的树形化
在 Java 8 中,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8),就使用红黑树来替换链表,从而提高速度,这个替换的方法叫 treeifyBin(),即树形化。
// replace all linked-list nodes in a bucket with tree nodes
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// if the table is empty, or its capacity is below the treeify threshold (default 64), resize instead
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// the element count exceeds the treeify threshold, so treeify
// e is the linked-list node at the given bucket, starting from the first one
TreeNode<K,V> hd = null, tl = null; // head and tail of the red-black tree
do {
// create a new tree node with the same content as the linked-list node e
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null) // set the tree head node
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// point the bucket's first element to the new red-black tree head node;
// this bucket now holds a tree instead of a linked list
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
上述操作做了这些事:
- 根据哈希表中元素个数确定是扩容还是树形化。
- 如果是树形化,遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系。
- 然后让桶第一个元素指向新建的红黑树头结点,替换桶的链表内容为树形内容。
但是这些操作并没有设置红黑树的颜色值,现在得到的只能算是个二叉树,最后需要调用树形节点的 hd.treeify(tab) 方法进行塑造红黑树:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) { // first iteration: set the root node, colored black
x.parent = null;
x.red = false;
root = x;
}
else { // later iterations: x points to some node in the tree
K k = x.key;
int h = x.hash;
Class<?> kc = null;
// walk from the root, comparing every node against x to find its position (similar to bubble sort)
for (TreeNode<K,V> p = root;;) {
int dir, ph; // dir: comparison direction
K pk = p.key;
if ((ph = p.hash) > h) // if the compared node's hash is greater than x's, dir = -1
dir = -1;
else if (ph < h) // if smaller, dir = 1
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// hash values are equal but not directly comparable, so break the tie
dir = tieBreakOrder(k, pk);
// make the compared node the parent of x;
// if its hash is greater than x's, x becomes the left child, otherwise the right child
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
可以看到,将二叉树变为红黑树时,需要保证有序。这里有个双重循环,拿树中的所有节点和当前节点的哈希值进行对比(如果哈希值相等,就对比键,这里不用完全有序),然后根据比较结果确定在树中的位置。
putTreeVal():红黑树中添加元素
上面介绍了如何把一个桶中的链表结构变成红黑树结构。在添加时,如果一个桶中已经是红黑树结构,就要调用红黑树的添加元素方法 putTreeVal()。
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
TreeNode<K,V> root = (parent != null) ? root() : this;
// on every insertion, walk from the root and compare hash values
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
// hash and key both match the one being added, so return the current node
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// hashes are equal but keys are not directly comparable, so search left/right subtrees
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
// found the node to add in the subtree rooted at ch, return it directly
return q;
}
// hashes are equal but keys can't be compared, break the tie with a special rule
dir = tieBreakOrder(k, pk);
}
// the previous comparisons determined the ordering between the current node and the new node;
// insert into the left subtree if smaller, right subtree if larger
TreeNode<K,V> xp = p;
// only insert once the current node has no left or right child, otherwise continue the loop
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// necessary balance adjustment after inserting into a red-black tree
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
// used when a and b have the same hash but can't be compared, comparing their reference addresses directly
// the source comment also notes the tree doesn't need to be fully ordered, just balanced using a consistent rule
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
通过上面的代码可以知道,HashMap 往红黑树中添加一个新节点 n 时,有以下操作:
- 从根节点开始遍历当前红黑树中的元素 p,对比 n 和 p 的哈希值。
- 如果哈希值相等并且键也相等,就判断为已经有这个元素(这里不清楚为什么不对比值)。
- 如果哈希值相等但无法直接比较,就通过其他信息(比如引用地址)给个大概比较结果,红黑树的比较并不是很准确,注释里也说了,只是保证个相对平衡即可。
- 最后得到哈希值比较结果后,如果当前节点 p 还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环。
- 插入元素后还需要进行红黑树例行的平衡调整,还要确保根节点的领先地位。
getTreeNode():红黑树中查找元素
HashMap 的查找方法是 get(),它通过计算指定 key 的哈希值后,调用内部方法 getNode():
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) & hash)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。getTreeNode() 方法通过调用树形节点的 find() 方法进行查找:
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
// walk from the root, searching by hash and key
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率很高。
这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回(也没有判断值);不相等就从子树中递归查找。
split():树形结构修剪
HashMap 中,resize() 方法的作用就是初始化或者扩容哈希表。当扩容时,如果当前桶中元素结构是红黑树,并且元素个数小于链表还原阈值 UNTREEIFY_THRESHOLD(默认为 6),就会把桶中的树形结构缩小或者直接还原(切分)为链表结构,调用的就是 split():
// parameters:
// tab: the hash table holding the bucket head nodes
// index: the bucket position to start splitting from
// bit: the bit (based on hash) used for splitting
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// if the relevant bit of the current node's hash is 0
if ((e.hash & bit) == 0) {
// put the current node into the lo list
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
// loTail now tracks e
loTail = e;
// track the lo list size
++lc;
}
else { // otherwise, put the current node into the hi list
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
// track the hi list size
++hc;
}
}
if (loHead != null) {
// if the lo list has 6 or fewer nodes, flatten it back into a plain linked list
// and point this bucket to the resulting list
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
// otherwise keep it as a (now smaller) red-black tree
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
// same logic for the elements at position index + bit
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。
**1. 分类**
指定位置、指定范围,让指定位置中的元素 `(hash & bit) == 0` 的,放到 lo 树中,不相等的放到 hi 树中。
**2. 根据元素个数决定处理情况**
符合要求的元素(即 lo 树),在元素个数小于 6 时还原成链表,最后让哈希表中修剪的桶 `tab[index]` 指向 lo 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶。
不符合要求的元素(即 hi 树)也是一样的操作,只不过最后它是放在了修剪范围外 `tab[index + bit]`。
### 总结
JDK 1.8 以后哈希表的添加、删除、查找、扩容方法都增加了一种节点为 `TreeNode` 的情况:
- 添加时,当桶中链表个数超过 8 时会转换成红黑树。
- 删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构。
- 查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差。
本文根据源码分析了 HashMap 在 JDK 1.8 里新增的 `TreeNode` 的一些关键方法,可以看到,1.8 以后的 HashMap 结合了哈希表和红黑树的优点,不仅快速,而且在极端情况也能保证性能。
> 参考:<https://blog.csdn.net/wushiwude/article/details/75331926>
## 现代 Java 中的变化
HashMap 的核心实现(数组 + 链表 + 红黑树,JDK 1.8 那套算法)从 JDK 8 到现在(包括 JDK 21~26)没有本质变化,但周边有一些新增内容。
### newHashMap(int)(JDK 19)
```java
// sized for the expected number of entries, avoids mid-flight resizing
HashMap<String, String> map = HashMap.newHashMap(100);
按“期望存放的元素个数”而不是“初始容量”来创建,内部会换算成合适的桶数组大小,避免自己算 capacity / loadFactor 算错导致中途扩容。LinkedHashMap、HashSet、WeakHashMap 也都加了对应的 newXxx(int) 静态方法。
扩容开销与预估容量建议
核心算法从 JDK 8 到现在没有本质变化,扩容依然是有性能损耗的操作,预估容量这条建议依然成立:
- JDK 1.8 对扩容做了一处优化:不需要像 1.7 那样对每个 key 重新计算 hash,只需判断
hash & oldCap这一位是 0 还是 1,就能确定节点在新数组中是留在原索引还是移动到原索引 + oldCap,还能保持同一桶内节点的相对顺序。但这只是省去了重新哈希的计算量,扩容本身仍然是 O(n) 操作——要遍历所有桶、所有节点重新分配位置,如果桶已经树化还要走一遍split()拆分红黑树;旧数组也变成待回收的垃圾,带来额外的 GC 压力。 - 正因如此,
HashMap.newHashMap(int expectedSize)才有存在的意义:很多人手动计算capacity = expectedSize / loadFactor时容易算错或忘记算,导致"以为预估了容量"实际还是触发了一次扩容。用newHashMap()直接按期望元素个数换算容量,能把这条老建议落实得更可靠。
Sequenced Collections(JEP 431,JDK 21)与 HashMap 无关
JDK 21 给集合框架加了 SequencedCollection / SequencedSet / SequencedMap 体系,LinkedHashMap 因此拿到了 putFirst()/putLast()/reversed()/firstEntry() 等方法。但普通 HashMap 不保证顺序,所以它没有实现 SequencedMap——Javadoc 里专门写了一句:
This class makes no guarantees as to the order of the map.
Map.of() / Map.ofEntries()(JEP 269,JDK 9)
// creates an immutable map, not backed by HashMap
Map<String, Integer> map = Map.of("a", 1, "b", 2);
创建不可变 map 的工厂方法,底层是专门的不可变实现类,不是 HashMap,但常被拿来跟双括号初始化写法一起讨论(参见 HashMap Initialization)。
延伸阅读
HashMap 的初始化写法(双括号初始化、Java 7 集合字面量提案等)已拆分到单独文章:HashMap Initialization 初始化写法。
维护记录
| 时间 | 修改内容 | 原因 |
|---|---|---|
| 2026-07-02 | 整理标题层级与代码块格式;转载源码注释翻译为英文;补充 lastmod、标签 | 原文标题层级混乱、代码缺少围栏、注释含中文,不符合仓库规范 |
| 2026-07-03 | 将 HashMap 初始化写法一节拆分至独立文章 hashmap-initialization.md,母文添加延伸阅读链接;新增「现代 Java 中的变化」一节(newHashMap(int)、Sequenced Collections 与 HashMap 的关系、Map.of());新增「历史背景」一节,说明 HashMap 于 JDK 1.2 引入,并内链至 hashmap-vs-hashtable.md | 原文档超过 800 行且包含两个独立主题;补充 JDK 9~26 中与 HashMap 相关的变化说明及引入版本背景;关联站内已有的 HashMap/Hashtable 对比文章 |
| 2026-07-13 | 「红黑树优化」一节内链至 Red-Black Tree, 红黑树;「现代 Java 中的变化」新增「扩容开销与预估容量建议」小节 | 站内已有专门讲红黑树原理的文章,此前未互链;补充说明现代 JDK 中扩容仍有性能损耗、预估容量建议依然成立 |