JDK1.8版本HashMap源码原理分析
Posted hymKing
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK1.8版本HashMap源码原理分析相关的知识,希望对你有一定的参考价值。
HashMap是一个KV的容器对象,也是日常在开发当中非常常用的对象,比如我们可能经常做一些内存缓存的时候,很多时候就选用HashMap这种KV的数据结构。在实现原理上,是基于哈希表的Map接口实现,是常用的java集合之一,非线程安全的。
HashMap可以存储null的key和value,但是null作为键值只能有一个,做为值的话,可以是多个。这和Map的键要保持唯一性并不冲突。
一、HashMap的类图结构
二、概念、原理概述
Jdk1.8之前的HashMap是由数组+链表作为底层数据结构实现的,数组是hashMap的主体,链表则是为了解决哈希冲突而存在内部解决方案(拉链法)。
jdk1.8以后的HashMap在解决哈希冲突时有了较大的变化,引入了红黑树,以减少搜索时间。
先明确几个概念:
哈希表:指的就是hashMap;
哈希桶:HashMap的底层数据结构,即数组;
链表:Hash桶的下标装的是链表(或树型结构体);
节点:链表上的节点就是哈希表上的元素
哈希表元素容量:元素的总个数
哈希桶的容量:数组数组个数。
哈希桶的默认容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。
上图初步呈现了hashMap的实现结构,在进行增删改查操作的时候,首先要定位到元素所在桶的位置,桶的位置指的就是table数组元素的位置,之后再从桶元素所对应的链表中定位该元素。
比如我们要查找35的元素,先定位到数组中3元素的位置,然后通过链表定位到第三个元素,确定了35元素的位置。
HashMap的底层结构原理概述就如上所示,HashMap的基本操作就是对拉链式散列算法的一层包装,无论1.8版本后引入的红黑树,虽底层数据结构由【数组+链表】变成【数组+链表+红黑树】,核心的原理设计没变。
在jdk1.8中引入的红黑树,在链表的长度大于8并且哈希桶的长度大于等于64的时候【TODO】,会将链表进行树化。红黑树是一个自平衡的二叉查找树,查找效率会从链表O(N)降低为o(logn),大大提升查找效率。
详细分析,看接下来的源码分析
三、HashMap源码原理分析
3.1构造函数分析
/**
* 根据初始化容量、加载因子初始化一个空元素的Map
* @param initialCapacity 初始化容量
* @param loadFactor 负载因子
* @throws IllegalArgumentException 负数抛异常
*/
public HashMap(int initialCapacity, float loadFactor) {
...
//初始化容量超过了最大容量1 << 30(2的30次幂),则使用最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
...
//负载因子初始化:负载因子或者叫做扩容因子
this.loadFactor = loadFactor;
//HashMap进行扩容的阈值,实际上就是数组的长度
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 以16的数组容量和0.75的负载因子,进行默认初始化
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
上述4个构造函数,最常用的是第三个,就是以默认的方式进行初始化一个HashMap,最终会调用都构造函数1,构造函数构造的过程,就是对几个核心的成员变量做了初始化。
3.2 hashMap中桶的长度设计(数组的长度是如何计算的)?
this.threshold = tableSizeFor(initialCapacity);看一下这个方法:
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
5步或等和移位运算,最终会找到大于或等于 cap 的最小2的正数幂的值,举例说明一下:
比如构造过程中传入的是15,最终tableSizeFor后的值是16,如果是28,最终tableSizeFor的值是32,如果是64,则是64.
tableSizeFor()这个函数就是计算hashMap的长度,那么从设计层面为什么HashMap的长度设计成2的整数幂次方呢?
1、为了加快哈希计算
查找一个KEY在哈希表的那个桶中,需要计算hash(key)%桶的长度,%属于算术运算,算术运算的效率要低于&位运算符的效率,恰好,当被取余的数是2的n次幂的时候,可以用位&替代取余来提升效率。
如下,当b为2的n次方时,有如下替换公式(公式可自行验证):
a % b = a & (b-1) (b=2^n)
即:a % 2^n = a & (2^n-1)
2、2次幂必然是偶数,这种偶数设计能使得散列结果均匀,从而减少Hash冲突的可能性。
假设数组的长度length是奇数,length-1为偶数,最后一位是0,通过hash函数hash&(length-1)的结果最后一位肯定是0,即只能为偶数,这样任何hash值经过hash函数计算后的结果都是偶数,元素就只能被散列在偶数的下标位置上,这样既浪费了空间,同时可能带来2倍的hash冲突的可能性。
关于tableSizeFor()方法运算中大量的使用了位运算和逻辑运算的详细说明可以参考https://segmentfault.com/a/1190000039392972。
3.2 HashMap源码中的关键常量变量的声明部分
/**
* 最大的容量值
* MUST be a power of two(必须2的幂) <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化的链表数量阈值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 树拆分的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小的树化桶容量
*/
static final int MIN_TREEIFY_CAPACITY = 64;
以上都是简单的描述,在后续的章节分析中会得到这些常量的使用和设计。
3.3 hashMap的桶的的数据结构和源码实现
/**
* table数组, 第一次使用的时候初始化,必要的时候会进行扩容,扩容一般都是原来的2倍
*/
transient Node<K,V>[] table;
/**
* 基本的hash存储节点, 用于存储大量的entries.
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;//下一个节点
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) {
...
}
public final boolean equals(Object o) {
...
}
}
Node是HashMap的的一个静态内部类,实现了Map.Entry接口,本质上就是一个映射。final K key;V value; 类的成员变量中定义了key和value,是泛型化的,但是注意key被关键字final修饰了,虽然无论创建出来什么样的节点的key数据,程序不会出现问题,但是如果Key对应的对象本身应该也是不可变对象。
3.4.1HashMap的元素插入逻辑和源码实现分析
元素的插入流程是首先要定位需要插入的键值元素属于哪个通,定位到桶过后,判断当前的桶中是否已经有元素,如果桶为空,则直接将键值对存入即可。如果不为空,则需要将键值对接在链表的最后一个位置,或者更新键值对。
以上就是元素插入的核心流程,但由于hashMap是一个变长的集合,实际在插入的时候还有扩容机制,在1.8版本jdk中,还有树化过程和树拆过程。看一下源码,源码分析后,会画一个流程帮助理解:
/**
* 将键值对元素插入的桶中。
* 如果同种存在了相同key的键值对,则替换
*
* @param key
* @param value
*/
public V put(K key, V value) {
//实际会调用到以下5个参数的重载方法
return putVal(hash(key), key, value, false, true);
}
/**
* 实现了Map的put和其它相关方法
*
* @param key的hash
* @param key
* @param value
* @param onlyIfAbsent true的话不改变当前key的值
* @param evict 驱逐 这个参数是给定长的LinkedHashMap使用的,可以实现达到最大长度后移除元素
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;//n 桶的长度,待插入元素定位到的桶的位置索引
//如果table是空或者长度是0,初始化table数组(桶),
if ((tab = table) == null || (n = tab.length) == 0)
//首次插入:此条件第一次调用put的时候,会调用,实际tab初始化的过程在resize()函数中实现
n = (tab = resize()).length;//tab的长度,前面分析过tab的长度是2^n次方
if ((p = tab[i = (n - 1) & hash]) == null)
//首次插入or非首次插入头结点为空:
//首次插入的符合此条件,i = (n - 1) & hash i其实就是计算定位出来的当前待插入元素的索引
tab[i] = newNode(hash, key, value, null);//在桶中存入此节点作为头节点
else {
//非首次插入头节点非空:hash算法(i = (n - 1) & hash)定位到桶位置中存在元素
Node<K,V> e; K k;
//判定是否等于第一个节点P
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果节点的hash和键的值等于链表中第一个节点的值,则将e指向该节点
e = p;//存储元素
else if (p instanceof TreeNode)//p节点是红黑树节点,调用红黑树节点插入
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);
//插入后判断是否要将链表转化成红黑树,节点元素个数大于等于8的时候
//新追加了一个元素,所以实际元素个数的判断TREEIFY_THRESHOLD - 1=7
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//转换成红黑树
treeifyBin(tab, hash);
break;
}
//在链表的非第一个节点中,找到重复key的元素
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//p的应用指针后移1个,实际就是在遍历
}
}
//在链表中的第一个位置或是后续位置找到相同的key
if (e != null) { // existing mapping for key
//要插入的键值的键已经存在,更新value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//用于并发修改的一个异常判断,基本非线程安全集合类都有类似操作
if (++size > threshold)//大于阈值需要进行扩容
resize();
afterNodeInsertion(evict);//主要用于LinkedHashMap使用,这里不做说明了
return null;
}
上述源码中的注释已经添加的非常详细,然后我们在看如下流程,更清晰的理解元素插入的过程。
从流程图,能够清晰的看到,插入或是更新元素,以及链表转换红黑树的时机,桶的初始化和扩容都是调用resize方法,对照上面的代码注释,应该能很清晰的理解HashMap的元素插入过程。
3.4.2扩容机制
java中普通数组是定长的结构体,对于动态数组(非定长,有扩容机制)有ArrayList和HashMap。下面就看一下HashMap扩容机制的实现流程:
HashMap中,桶数组的长度均是2的n次幂,阈值的大小为桶的数组长度和负载因子的乘积。当hashMap中的键值对数超过阈值的时候,进行扩容。HashMap会按照当前桶数组的长度的2倍进行扩容,扩容后,阈值自然也变为了原来的2倍。扩容之后,要重新计算键值对元素的位置,并把他们移动到合适的位置上去。jdk1.8版本中扩容的核心方法是resize()方法,在插入元素的源代码中,已经做过部分源码的分析,resize()同时也承担着HashMap的桶的初始化工作。
/**
* 初始化or2倍table的大小
* @return the table
*/
final Node<K,V>[] resize() {
//旧的桶数组
Node<K,V>[] oldTab = table;
//旧的桶的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧的扩容阈值
int oldThr = threshold;
//定义新容量、新阈值
int newCap, newThr = 0;
//旧的容量的大于0
if (oldCap > 0) {
//超过容量的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;//整形的最大值
return oldTab;//旧的节点数组直接返回
}
//左移位运算,将newCap变为oldCap的2倍,即扩容两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值同样扩容2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
// 初始化容量存储在threshold中,这块不是很好理解,后面会再分析。【单独分析点1】
newCap = oldThr;
else { // 空桶,进行初始化
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//新阈值==0时,按照阈值公式进行计算
float ft = (float)newCap * loadFactor;//计算新阈值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);//赋值新阈值,并做好边界控制
}
threshold = newThr;//将新阈值赋值给成员变量
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的桶数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//============上面部分扩容过程就完成了===========
if (oldTab != null) {//非初始化过程,扩容后需要做数据迁移
for (int j = 0; j < oldCap; ++j) {//遍历旧的桶
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)//只有一个元素
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//如果是红黑树节点
//对红黑树进行拆分
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order:保存顺序
//低位头 低位尾
Node<K,V> loHead = null, loTail = null;
//高位头 高尾尾
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//遍历链表,并将链表节点按照顺序进行分组
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;
}
if (hiTail != null) {
hiTail.next = null;
//新桶中的相对高位
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上面的源码扩容桶部分的注释已经很详细,后面一部分是扩容后的数据迁移操作的算法,整体扩容过程实际上做了三件事情:
1、计算新桶数组的容量newCap和新阈值newThr。
2、根据计算出来的newCap创建新的桶数组,桶数组table也是在这里进行初始化的。
3、将键值对节点重新映射到新的桶数组里,如果节点是treeNode类型,则需要拆分红黑树,如果是普通节点,则节点按原顺序进行分组。
在计算newCap和新阈值newThr的代码注释中,有一行【单独分析点1】:
else if (oldThr > 0)
newCap = oldThr;
这种情况是怎么产生的呢,回顾一下构造函数:
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
通过传入初始化容量的构造函数,会产生上述的分支条件:
newCap=oldThr;等价于newCap=threshold=tableSizeFor(initialCapacity)。threshold=newThr=(float)newCap * loadFactor;这里面忽略了溢出的逻辑。
举个例子,如果调用构造函数hashMap(15),最终newCap=tableSizeFor(15)=16,thresHold=newThr=15*0.75=12;
得出的hashMap的首次容量和扩容阈值。这是一种初始状态,扩容的触发条件,是在前面分析的插入元素代码的尾部如下:
if (++size > threshold)
resize()Java中HashMap底层实现原理(JDK1.8)源码分析