最近在学习并发容器 ConcurrentHashMap,所以就先从 HashMap 开始了解。
普及一下后面需要用到的一些知识:
/**
* The default initial capacity - MUST be a power of two.
* 默认数组容量为 16
*/
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.
* 默认负载因子 0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
* 某个桶的链表结点个数大于等于 8 时,链表转为红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
* 某个桶的红黑树结点个数小于等于 6时,红黑树转为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
* 在链表转红黑树之前,需要满足数组结点个数至少为 64,为了避免进行扩容、树形化选择的冲突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
* 存放结点数组,数组大小必须为 2的幂
*/
transient Node<K,V>[] table;
/**
* The next size value at which to resize (capacity * load factor).
* 如果数组结点个数 size > threshold,数组就需要扩容
*/
int threshold;
/**
* 根据泊松分布得到
* 用于与数组容量相乘计算的数组阈值
*/
final float loadFactor;
Node是最核心的内部类,它封装了 key-value 键值对,所有插入 HashMap 的数据都封装在这个对象里。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key的hash值
final K key;
V value;
Node<K,V> next; // 相同hash值的 Node
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
红黑树结点,当链表长度过长的时候,会将 Node 转换为 TreeNode。这个类大概写了500多行代码比较复杂,这里就不着重分析,简单说下类的成员变量。
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; // 将原单链表变为双向链表的前置指针
boolean red; // 判断结点颜色 红/黑
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
想深入学习 HashMap 红黑树的可以参考:史上最详细的HashMap红黑树解析
大家考虑一个问题:为什么 HashMap 的内部类 TreeNode 不继承它的内部类 Node,却继承自 Node 的子类 LinkedHashMap.Entry?
LinkedHashMap.Entry 新增了两个引用 before 、after,用于维护双向链表。TreeNode 继承自 LinkedHashMap.Entry 同样也具有了组成链表的能力(连接 TreeNode 的插入顺序)。但使用 HashMap 并不需要这种链表能力,这样不就浪费了 2 个引用的空间。
仔细想想,也许是 Doug Lea 给开发者留下了 TreeNode 可以组成链表功能的想法,
开发者也可以继承 HashMap 实现一个 TreeNode 具有 链表功能的集合框架。
这个问题可以参考:LinkedHashMap 源码详细分析(JDK1.8)
接下来我们就从构造方法开始一步一步分析HashMap的扩容、添加元素等操作。
HashMap 的构造方法有4个:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
默认容量:16
默认负载因子:0.75
其他所有字段都是默认值
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
调用了 3 的构造方法,初始容量:initialCapacity
默认负载因子:0.75
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException(
"Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException(
"Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
三个判断规定了initialCapacity
的范围,
threshold
通过调用tableSizeFor(initialCapacity)
来设定(threshold
:标志数组扩容的阈值)
tableSizeFor(initialCapacity)
:返回一个比initialCapacity
大且最接近的2幂次方的整数。(比如给定10,返回 2^4=16)
分析tableSizeFor(int cap)
方法:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ?
1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
注意:HashMap 的容量必须为 2的幂次方(后面会有解释)
int n = cap - 1;
:为了防止 cap 已经是2的幂次方,那么下面操作之后会变成2倍(比如 n=4已经符合要求,但经过下面5步之后 n 就会变为 8)
5步“n右移x再取或”
:保证最高位后面的位数全为1
图解 tableSizeFor(int cap) 方法:
这里将得到的 capacity 直接赋值给了threshold
,并不符合threshold
的定义(capacity*loadFactor),在构造方法中并未初始化 table,table 的初始化被推迟到 put 方法中,put 方法在调用 resize 方法会重新计算threshold
(后面会分析)。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
构造一个和指定Map具有相同 mappings 的 HashMap,默认负载因子:0.75,里面调用了putMapEntries(m, false);
:将指定Map放入HashMap中。
分析putMapEntries(Map extends K, ? extends V> m, boolean evict)
方法:
/**
* 将 m 的所有键值对存入到 HashMap实例中
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// m 中元素个数
int s = m.size();
// 如果 m 中存在元素
if (s > 0) {
// table 还未初始化,先保存一些需要的变量
if (table == null) { // pre-size
// s相当于阈值,括号中会计算得到一个容量 +1是为了向上取整
float ft = ((float)s / loadFactor) + 1.0F;
// 容量小于最大容量那么就截断
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 如果上面运算得到的容量 t 大于暂存容量
// threshold(table还未初始化,所以threshold存的是数组容量),
// 那么就重新计算 threshold 暂存容量
if (t > threshold)
threshold = tableSizeFor(t);
}
// table 被初始化,s 元素个数超过阈值 threshold,那么就需要扩容
else if (s > threshold)
resize();
// 将 m 中的元素迭代插入 table 中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
看了上面代码可能有人疑惑:为什么要根据 s 来计算得到一个容量?
可以这样分析,阈值是比容量小的,分析 s 不当做阈值来计算而是直接当做容量的情况:
假设暂存容量 threshold = 8、s = 7;那么在迭代插入table,第一次调用 putVal 方法中
的 resize 方法会初始化 table(table.length = 8、threshold = 6(8*0.75)),可以发
现 7 大于阈值,那么在插入第7个元素时,会再次调用 resize 方法。这样就会多调用一次
resize 方法,增加了内存消耗。而通过 7 作为阈值算得的容量,则不会出现这种问题。
hash函数:用来计算 key 的 hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从代码可以看出 key 的 hash值计算方法:
key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。
那为什么要这样做?
这与HashMap中table下标的计算有关:
n = table.length;
index = (n-1) & hash;
在前言中我提到过,处理 hash值得到数组下标的方式:用 hash值对数组容量取模,得到数组下标。
这里我需要将将两点:
用于数组扩容的函数,其中也包括初始化数组。
final Node<K,V>[] resize() {
// 保存当前 table
Node<K,V>[] oldTab = table;
// 保存当前容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 保存当前阈值
int oldThr = threshold;
// 设置新的阈值为0
int newCap, newThr = 0;
/**
* 1.在 size>threshold 时被调用
* oldCap>0 表示原table非空
* oldThr(threshold) = oldCap × load_factor
*/
if (oldCap > 0) {
// 若之前table容量超过最大容量,阈值设为最大整型,之后不会扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍、阈值翻倍,使用位运算效率高
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/**
* 2.table 为空被调用,oldCap<=0 且 oldThr>0 表示:
* 用户使用下面构造方法创建了 HashMap:
* HashMap(int initialCapacity, float loadFactor)
* HashMap(int initialCapacity)
* HashMap(Map extends K, ? extends V> m)
* oldTab = null,oldCap = 0,oldThr为用户指定容量或m在putMapEntries
* 算得的容量
*/
else if (oldThr > 0) // initial capacity was placed in threshold
// 将暂存容量设为新的容量
newCap = oldThr;
/**
* 3.table 为空被调用,oldCap<=0 且 oldThr<=0 表示调用了默认构造
* HashMap(),oldCap=0、oldThr=0
*/
else { // zero initial threshold signifies using defaults
// 容量设为默认容量(16)
newCap = DEFAULT_INITIAL_CAPACITY;
// 阈值设为默认阈值计算(16*0.75)
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新阈值为0
if (newThr == 0) {
// 根据新的容量和负载因子来计算阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 设置阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 初始化新的 table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把 oldTab 中的结点 reHash 到 newTab 中去
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 置为null 利于GC回收
oldTab[j] = null;
// 如果为单个结点,直接在 newTab 中直到下标存储
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果为 TreeNode 结点,就进行红黑树的 rehash
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 若是链表,进行链表的 rehash
else { // preserve order
// 用于连接索引位置不变的结点
Node<K,V> loHead = null, loTail = null;
// 用于连接索引位置改变的结点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
/**
* 将同一桶的结点根据 (e.hash & oldCap) 是否为0进行分割
* 为0:继续存在当前索引的桶中
* 不为0:存放在(索引+oldCap)的桶中
* 不理解的下面有图解
*/
do {
next = e.next;
// 头指针指向索引不变的第一个结点,尾指针依次将结点串联起来
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 同上将索引改变的结点串联起来
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将索引不变的头指针赋给新的数组相同索引处
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将索引改变的头指针赋给新的数组(索引+oldCap)处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在扩容中使用了 2次幂的扩展(长度翻倍),所以元素的索引位置要么是在原位置,要么是在原位置再移动2次幂的位置。
下图所示,扩容前 n=2^4=16,扩容后 n=2^5=32,因为计算数组下标要减一所以图中直接使用了n-1的位数来表示。
图(a)表示扩容前 key1和key2 所确定的数组下标,图(b)表示扩容后 key1和key2 所确定的数组下标:
因为n扩容翻倍,所以 n-1 的掩码范围增加 1bit,因此 key2的index就会发生变化:
因此,我们在扩容HashMap的时候,只需要看原来hash值新增的一位bit是0还是1,是0的话索引不变,是1索引变为“原索引+oldCap”,可以看下 n从16扩充32 的示意图:
什么时候扩容:通过HashMap源码可以看到是在put操作时,有两处调用 resize 方法。一处是在刚进去判断 table 是否为空,为空则扩容;另一处是在 size>threshold 来进行扩容。
扩容(resize):其实就是重新计算容量;而这个扩容是计算出所需容器的大小之后重新定义一个新的容器,将原来容器中的元素放入其中。
向 HashMap 中添加元素,同时初始化 table。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 table 为空或者 大小为0,调用 resize 方法初始化 table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 1.当前索引的桶没有结点(并未hash碰撞),直接添加
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
/**
* 2.发生hash碰撞,存在两种情况:
* 1.key值相同,需要判断 onlyIfAbsent 来替换value值
* 2.key值不同:
* 1.存在桶的链表中
* 2.存在红黑树中
*/
else {
Node<K,V> e; K k;
// 第一个结点的hash值与将要添加元素的hash值相同且key也相同
// 那么这个添加元素的key已经存在,用 e 暂存这个结点引用
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 2.2
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 2.1
else {
// 不是 TreeNode 就是链表,遍历链表
for (int binCount = 0; ; ++binCount) {
// 遍历完链表也没有找到相同的hash和key,那么就新建一个Node
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 因为第一个结点已经访问过,所以这里 TREEIFY_THRESHOLD - 1
// 这里判断当前桶下链表的个数是否达到转换为红黑树的条件
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 将链表转为红黑树
treeifyBin(tab, hash);
break;
}
// 在链表遍历中,遇到hash和key相同的结点,e 记录该结点引用
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果 e 记录的结点引用不为空,那么就存在相同hash和key的元素
if (e != null) { // existing mapping for key
// 记录之前的value
V oldValue = e.value;
// 如果 onlyIfAbsent 为false 或 之前的value为空,就将新的value赋值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回之前的value
return oldValue;
}
}
// 数组修改次数+1
++modCount;
// 数组大小+1 并判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
注:hash 冲突发生的几种情况:
1.两节点key 值相同(hash值一定相同),导致冲突;
2.两节点key 值不同,由于 hash 函数的局限性导致hash 值相同,冲突;
3.两节点key 值不同,hash 值不同,但 hash 值对数组长度取模后相同,冲突;
get方法较为简单,这里就不在赘述。
(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
(2)扩容后数据存储位置的计算方式也不一样:
(3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(N)变成O(logN)提高了效率)。