鉴于篇幅,本篇主要分析JDK1.7源码,关于JDK1.8部分的源码分析放入下篇文章
Java源码分析——HashMap(JDK1.8)
HashMap在JDK1.7与JDK1.8中有较大的差别,下文就先以JDK1.7为例剖析源码,然后再与JDK1.8比较二者之间的差别。
首先看与HashMap有直接关系的类与接口:
HashMap 是基于哈希表的 Map 接口的实现,以 Key-Value 的形式存在,即存储的对象是 Entry (同时包含了 Key 和 Value) 。在HashMap中,其会根据hash算法来计算key-value的存储位置并进行快速存取。特别地,HashMap最多只允许一条Entry的键为Null(多条会覆盖),但允许多条Entry的值为Null。对于Cloneable接口就不再赘述;而Serializable接口则表明HashMap类可被序列化。
一、HashMap数据结构
众所周知,数组的特点是寻址容易,插入和删除困难;而链表的特点是寻址困难,插入和删除容易。而HashMap的插入删除以及寻址都可以在常数复杂度内解决,这是因为HashMap采用了拉链法,即它的内部实现是一个链表数组。
以下是HashMap的部分字段,其中HashMap.Node[ ]即是我们刚才所说的链表数组中的数组,然后我们继续往下看
我们可以发现内部类Node内有hash哈希值,key、value键值对,以及指向Node下一个节点的指针next,所以就知道每个table数组中的每个元素Node都是一个链表的头节点,这也就是我们刚刚说的链表数组。
另外,有几点要提的是table[ ]数组的长度一定为2的n次方,具体原因下文会详细阐述。
二、HashMap()构造函数
public HashMap(int initialCapacity, float loadFactor) {
//初始容量不能小于 0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//初始容量不能超过 2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//负载因子不能小于 0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// HashMap 的容量必须是2的幂次方,超过 initialCapacity 的最小 2^n
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
//负载因子
this.loadFactor = loadFactor;
//设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行自动扩容操作
threshold = (int)(capacity * loadFactor);
// HashMap的底层实现仍是数组,只是数组的每一项都是一条链
table = new Entry[capacity];
init();
}
public HashMap(Map<? extends K, ? extends V> m) {
// 初始容量不小于 16
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
在这里,我们提到了两个非常重要的参数:初始容量和负载因子,这两个参数是影响HashMap性能的重要参数。其中,容量表示哈希表中桶的数量 (table 数组的大小),初始容量是创建哈希表时桶的数量;而负载因子则是table数组中已存在的数据与整个table数组长度的比值,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。
三、HashMap存取键值对
1、HashMap 的存储
public V put(K key, V value) {
//当key为null时,调用putForNullKey方法,并将该键值对保存到table的第一个位置
if (key == null)
return putForNullKey(value);
//根据key的hashCode计算hash值
int hash = hash(key.hashCode());
//计算该键值对在数组中的存储位置(哪个桶)
int i = indexFor(hash, table.length);
//在table的第i个桶上进行迭代,寻找 key 保存的位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该条链上是否存在hash值相同且key值相等的映射,若存在,则直接覆盖 value,并返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue; // 返回旧值
}
}
modCount++; //修改次数增加1,快速失败机制
//原HashMap中无该映射,将该添加至该链的链头
addEntry(hash, key, value, i);
return null;
}
首先,判断key是否为null,若为null,则直接调用putForNullKey()方法;若不为空,则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,则查找是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头。此外,若table在该处没有元素,则直接保存。
private V putForNullKey(V value) {
// 若key==null,则将其放入table的第一个桶,即 table[0]
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
// 若已经存在key为null的键,则替换其值,并返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++; // 快速失败
addEntry(0, null, value, 0); // 否则,将其添加到 table[0] 的桶中
return null;
}
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中。
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
这里的哈希方法我们不必深究,只需要知道key通过哈希方法得到的值尽可能分布得均匀,即遇到两个不同key的hash相等的可能性很小。
static int indexFor(int h, int length) {
return h & (length-1); // 作用等价于取模运算,但这种方式效率更高
}
这里我们来解释为什么table[ ]数组的长度必须为2的n次方。因为对于2、4、8、16、32…2^n这样的数组,它们的二进制-1的值一定是1、11、111、1111…这样之后,它们再与一个数执行与操作,那么得到的值一定是hash与length的模(即hash除以length的余数),这样我们就能保证通过每个键值对的哈希值,而把这些键值对均匀地放入长度为 2^n的数组。
void addEntry(int hash, K key, V value, int bucketIndex) {
//获取bucketIndex处的链表
Entry<K,V> e = table[bucketIndex];
//将新创建的 Entry 链入 bucketIndex处的链表的表头
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//若HashMap中元素的个数超过极限值 threshold,则容量扩大两倍
if (size++ >= threshold)
resize(2 * table.length);
}
通过上述源码我们可以清楚地了解到 链的产生时机。HashMap 总是将新的Entry对象添加到bucketIndex处,若bucketIndex处已经有了Entry对象,那么新添加的Entry对象将指向原有的Entry对象,并形成一条新的以它为链头的Entry链;但是,若bucketIndex处原先没有Entry对象,那么新添加的Entry对象将指向 null,也就生成了一条长度为 1 的全新的Entry链了。HashMap 永远都是在链表的表头添加新元素。此外,若HashMap中元素的个数超过极限值 threshold,其将进行扩容操作,一般情况下,容量将扩大至原来的两倍,即保证length为2的n次方。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 若 oldCapacity 已达到最大值,直接将 threshold 设为 Integer.MAX_VALUE
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return; // 直接返回
}
// 否则,创建一个更大的数组
Entry[] newTable = new Entry[newCapacity];
//将每条Entry重新哈希到新的数组中
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor); // 重新设定 threshold
}
随着HashMap中元素的数量越来越多,发生碰撞的概率将越来越大,所产生的子链长度就会越来越长,这样势必会影响HashMap的存取速度。为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理,该临界点就是HashMap中元素的数量在数值上等于threshold(table数组长度*加载因子)。但是,不得不说,扩容是一个非常耗时的过程,因为它需要重新计算这些元素在新table数组中的位置并进行复制处理。
void transfer(Entry[] newTable) {
// 将原数组 table 赋给数组 src
Entry[] src = table;
int newCapacity = newTable.length;
// 将数组 src 中的每条链重新添加到 newTable 中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null; // src 回收
// 将每条链的每个元素依次添加到 newTable 中相应的桶中
do {
Entry<K,V> next = e.next;
// e.hash指的是 hash(key.hashCode())的返回值;
// 计算在newTable中的位置,注意原来在同一条子链上的元素可能被分配到不同的子链
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
特别需要注意的是,在重哈希的过程中,原属于一个桶中的Entry对象可能被分到不同的桶,因为HashMap 的容量发生了变化,那么 h&(length - 1) 的值也会发生相应的变化。
2、HashMap 的读取
public V get(Object key) {
// 若为null,调用getForNullKey方法返回相对应的value
if (key == null)
// 从table的第一个桶中寻找 key 为 null 的映射;若不存在,直接返回null
return getForNullKey();
// 根据该 key 的 hashCode 值计算它的 hash 码
int hash = hash(key.hashCode());
// 找出 table 数组中对应的桶
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//若搜索的key与查找的key相同,则返回相对应的value
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
因为整个HashMap内层是一个数组,所以通过在、可以在O(1)时间复杂度内查询到对应的key。另外如果发生哈希碰撞,则会在对应的数组位置处遍历相应的链表,所以整个HashMap的寻址可以在O(1+a)的时间复杂度内解决。(a为链表的长度)
private V getForNullKey() {
// 键为NULL的键值对若存在,则必定在第一个桶中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
// 键为NULL的键值对若不存在,则直接返回 null
return null;
}
因此,调用HashMap的get(Object key)方法后,若返回值是 NULL,则存在如下两种可能:
(1) 该 key 对应的值就是 null;
(2) HashMap 中不存在该 key。
四、JDK1.8新特性
1、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;
}
2、HashMap()构造函数
putMapEntries()方法与JDK1.7中不同,是将m的所有元素存入本HashMap实例中。
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s为m的实际元素个数
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 已初始化,并且m元素个数大于阈值,进行扩容处理
else if (s > threshold)
resize();
// 将m中的所有元素添加至HashMap中
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);
}
}
}
关于resize()扩容方法在后续详细分析。
2、hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先获取对象的hashCode()值,然后将hashCode值右移16位,然后将右移后的值与原来的hashCode做异或运算。
因为h是key的hashCode值,所以h的高16位也是有值的,所以在hash方法中将key的hashCode右移16位在与自身异或,使得高位也可以参与hash,更大程度上减少了碰撞率。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
HashMap.Node[] tab;
int n;
if ((tab = this.table) == null || (n = tab.length) == 0)
n = (tab = this.resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//获取位置
tab[i] = newNode(hash, key, value, null);
...
}
在putVal源码中,我们通过(n-1)&hash获取该对象的键在hashmap中的位置。其中n表示的是hash桶数组的长度,并且该长度为2的n次方,这样(n-1)&hash就等价于hash%n。
3、HashMap存储
public V put(K key, V value) {
return this.putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤①:tab为空则创建
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖value
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// 步骤④:判断该链为红黑树
// hash值不相等,即key不相等;为红黑树结点
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤⑤:该链为链表
// 为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 步骤⑥:超过最大容量 就扩容
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
putVal方法执行过程如下:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
从源码中可知,JDK1.8中出现哈希碰撞插入数据是在链表尾部,这与JDK1.7中是不同的。
4、HashMap读取
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;
// table已经初始化,长度大于0,根据hash寻找table中的项也不为空
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;
}
鉴于篇幅,JDK1.8中HashMap的resize()扩容方法、转化为红黑树方法等将在下篇中继续分析。