文章目录
HashMap数据结构概括
在Java8后,HashMap的数据结构如下所示:
在表中,最顶层是一个数组,每个数组元素下可以抽象为一个桶,桶中元素可能为链表,也可能为树节点,在源码中分别用Node和TreeNode表示。
元素属性
分析源码,HashMap的具体属性元素包括:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;
}
每一个节点元素都以Node或TreeNode的形式存在,从源码中看到,顶层数组的数据结构是Node<k,v>[] table
这是一个Node数组,数组大小根据threshold而定,在插入新元素超过threshold大小后,会触发扩容,每次扩容总是扩容到2的幂次方倍,如2,4,8……
其中Node和TreeNode的基础定义如下:
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
// key的哈希值
final int hash;
// 节点key
final K key;
// 节点值
V value;
// 链表的下一个节点
Node<K,V> next;
}
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
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;
}
值得一提,其中TreeNode继承自LinkedHashMap.Entry<K,V>而LinkedHashMap.Entry<K,V>又继承自Node。
构造函数、容量算法和哈希碰撞优化
HashMap存在多个重载构造函数,大多最后会调用:
/**
* 根据初始容量和负载因子构造一个空的HashMap
* @param initialCapacity 初始容量,最后容量会是比初始容量大的最小2幂次方值
* @param loadFactor 填充因子,当Map内元素量 >= threshold*loadFactor,会触发扩容操作
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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;
// 更新阈值,这里阈值暂且为容量,后续通过resize函数获取规整的table时,会乘以loadFactor,得到实际的扩容阈值
this.threshold = tableSizeFor(initialCapacity);
}
实际的容量计算是基于tableSizeFor(int cap)
方法,具体实现为:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
// 防止如果cap已经是2的幂次方,最终计算会是cap的两倍
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// n在[1,MAXIMUM_CAPACITY]之间,如果在区间内,需要加1来凑足2的幂次方。
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
>>>
表示无符号右移,高位全部用0填充,和>>
的区别是如果操作对象是负数,>>
高位用1填充,>>>
高位用0填充。|是位或,任何二进制位和1位或都等于1。
注意n是一个32位整型,这个函数的具体实现原理是将最高位1之前的所有位都变成1,以1xxxxxxx为例,其中x表示0或1,来看每步操作结果:
// n = 1xxxxxxxx
n |= n >>> 1;
// n = 11xxxxxxx
n |= n >>> 2;
// n = 1111xxxxx
n |= n >>> 4;
// n = 11111111x
n |= n >>> 8;
// n = 111111111
n |= n >>> 16;
// // n = 111111111
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
// n = 1000000000
从上面看到,n |= n >>> a,表示从n的最高位1往后的前a/2~a位都置为1。最后得到的是一个2的幂次方,最开始int n = cap - 1
,考虑cap=0b100,如果不-1,将得到0b1000,但实际0b100即为我们需要的结果
看到这里可能有疑问,为什么要将HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置,源码中抽象的算法如下::
index = (table.length - 1) & hash(key);
index表示key应在table的位置,table.length - 1相当于是一个低位掩码,假设table.length=10000,-1后变成01111,和哈希值取与,即最终取的是哈希值的低4位,假设哈希的低4位是分布均匀的,则可以保证key在数组容量内是分布均匀的,同时经过位运算得到,整体算法效率极高。
这里还有问题,散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。而如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就会出现部分不均匀的情况,这里的关注点主要在key的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值。h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。
这里考虑是假设我们直接用key的hashCode作为哈希值,经过(table.length - 1)取与,最多利用的仅仅hashCode的低16位,而hash()函数对hashCode高半区和低半区做异或,可以混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,同时还降低了冲突的可能性。
最后看Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。
HashMap有几个基础操作:put插入、get获取、remove移除、entrySet遍历。下面对这4个操作进行分析
put插入或更新Map元素
先看put的源码实现:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return 上一个key对应的value,可能为null,表示放的是null或不存在对应key-value
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal实现
put方法底层调用了核心基础方法putVal,大致实现过程如下:
- 先获取table长度,必要时进行扩容
- 根据key的hash定位key应在table数组的索引位i和首节点p
- 如果首节点位空,直接初始化一个新节点
- 判断key如果应为首节点,则记录为e
- 如果p instanceof TreeNode,则调用putTreeVal转换为树节点插入
- 否则p instanceof Node,遍历链表尝试插入key
- 遍历如果到了尾节点,则在尾部插入节点,并判断节点数是否达到TREEIFY_THRESHOLD阈值,如果达到,调用treeifyBin将链表转换为红黑树
- 如果在遍历过程找到替换节点,记录为e,并退出循环
- 如果e存在,则进行替换操作,并返回旧值
- 如果e不存在,说明进行了结构性变更(新增节点或红黑树化),则记录modCount,同时判定如果实际大小大于阈值则扩容。
具体实现源码如下:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
// n是table长度,i是key在table的索引位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,通过resize进行初始化
if ((tab = table) == null || (n =