青岛达内it培训 > 达内新闻
Java集合类源码解析:HashMap(基于JDK1.8)1
- 发布:青岛IT培训
- 来源:青岛IT培训
- 时间:2019-04-17 11:30
青岛IT培训的小编总结,今天我们来学习Java中较为常用的集合类 HashMap.
另外说明一下,本文的 HashMap 源码是基于Jdk1.8版本的,如果没有特别说明的话,之后的集合类源码解析都是1.8的版本。
HashMap的数据结构
打开HashMap源码文件,可以看到它是继承自 AbstractMap,并实现了Java集合的根接口Map,以及Cloneable和Serializable接口,所以HashMap可以被序列化。
HashMap的底层结构是哈希表的具体实现,通过相应的哈希运算就可以很快查询到目标元素在表中的位置,拥有很快的查询速度,因此,HashMap被广泛应用于日常的开发中。理想的情况就是一个元素对应一个Hash值,这样的查询效果是最优的。
但实际这是不可能的,因为哈希表存在“hash (哈希) 冲突” 的问题。当发生hash冲突时,HashMap采用 “拉链法” 进行解决,也就是数组加链表的结构。在HashMap的代码注释中,数组中的元素用 “bucket” (中文读作 桶) 来称呼,而哈希函数的作用就是将key寻址到buckets中的一个位置,如果一个 bucket 有多个元素,那么就以链表的形式存储(jdk1.8之前单纯是这样)。
这是HashMap的存储结构图:
关于 “拉链法” 和 “hash冲突” 的知识点有疑问的读者可以看下我之前的文章 数据结构:哈希表以及哈希冲突的解决方案 .
为了方便,下文的 “bucket” 都用 “桶” 替代。
深入源码
两个参数
在具体学习源码之前,我们需要先了解两个HashMap中的两个重要参数,“初识容量” 和 “加载因子”,
初识容量是指数组的数量。加载因子则决定了 HashMap 中的元素在达到多少比例后可以扩容 (rehash),当HashMap的元素数量超过了加载因子与当前容量的乘积后,就需要对哈希表做扩容操作。
在HashMap中,加载因子默认是0.75,这是结合时间、空间成本均衡考虑后的折中方案,因为 加载因子太大的话发生冲突的可能性会变大,查找的效率反而低;太小的话频繁rehash,降低性能。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
成员变量
好了,前面说了那么多,现在开始深入源码学习吧,先了解一下HashMap的主要的成员变量:
static final int DEFAULT_INITIAL_CAPACITY = 1 《 4;
static final int MAXIMUM_CAPACITY = 1 《 30;
static final float DEFAULT_LOAD_FACTOR = 0.75F;
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
transient HashMap.Node<K, V>[] table;
transient Set<Entry<K, V》 entrySet;
transient int size;
int threshold;
final float loadFactor;
可以看出,HashMap主要的成员变量比较多,有些变量还初始化了值,下面一个个来做解释。
DEFAULT_INITIAL_CAPACITY:默认初识容量 1 《 4 ,也就是16,必须是2的整数次方。
DEFAULT_LOAD_FACTOR:默认加载因子,大小为0.75.
MAXIMUM_CAPACITY:最大容量, 2^ 30 次方。
TREEIFY_THRESHOLD :树形阈值,大于这个数就要树形化,也就是转成红黑树。
MIN_TREEIFY_CAPACITY:树形最小容量。
table:哈希表的链接数组,对应桶的下标。
entrySet:键值对集合。
size:键值对的数量,也就是HashMap的大小。
threshold:阈值,下次需要扩容时的值,等于 容量*加载因子。
loadFactor:加载因子。
介绍玩变量,下面介绍HashMap的构造方法。
四个构造方法
HashMap共有四个构造方法,代码如下:
//加载默认大小的加载因子
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//加载默认大小的加载因子,并创建一个内容为参数 m 的内容的哈希表
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//添加整个集合
putMapEntries(m, false);
}
//指定容量和加载因子
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);
}
//指定容量,加载因子默认大小
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
不难发现,上面第三个构造函数可以自定义加载因子和容量,首先判断传入的加载因子是否符合要求,然后根据制定的容量执行 tableSizeFor() 方法,它会根据容量来指定阈值,为何要多这一步呢?
因为buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()函数会尝试修正一个整数,并转换为离该整数最近的2次幂。
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;
}
比如传入一个整数244,经过位移,或运算后会返回最近的2次幂 256
插入数据的方法:put()
在集合中最常用的操作是存储数据,也就是插入元素的过程,在HashMap中,插入数据用的是 put() 方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

put方法没有做多余的操作,只是传入 key 和 value 还有 hash 值 进入到 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;
//哈希表如果为空,就做扩容操作 resize()
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize())。length;
//要插入位置没有元素,直接新建一个包含key的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果要插入的桶已经有元素,替换
else {
Node<K,V> e; K k;
//key要插入的位置发生碰撞,让e指向p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//没碰撞,但是p是属于红黑树的节点,执行putTreeVal()方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p)。putTreeVal(this, tab, hash, key, value);
//p是链表节点,遍历链表,查找并替换
else {
//遍历数组,如果链表长度达到8,转换成红黑树
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;
}
// 找到目标节点,退出循环,e指向p
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 节点已存在,替换value,并返回旧value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果超出阈值,就得扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
代码看上去有点复杂,参数有点乱,但理清逻辑后容易理解多了,源码大概的逻辑如下:
先调用 hash() 方法计算哈希值
然后调用 putVal() 方法中根据哈希值进行相关操作
如果当前 哈希表内容为空,做扩容
如果要插入的桶中没有元素,新建个节点并放进去
否则从要插入的桶中第一个元素开始查找(这里为什么是第一个元素,下面会讲到)
如果没有碰撞,赋值给e,结束查找
有碰撞,而且当前采用的还是 红黑树的节点,调用 putTreeVal() 进行插入
链表节点的话从传统的链表数组中查找、找到赋值给e,结束
如果链表长度达到8,转换成红黑树
最后检查是否需要扩容
put方法的代码中有几个关键的方法,分别是:
hash():哈希函数,计算key对应的位置
resize():扩容
putTreeVal():插入红黑树的节点
treeifyBin():树形化容器
前面两个是HashMap的桶链表操作的核心方法,后面的方法是Jdk1.8之后有关红黑树的操作,后面会讲到,先来看前两个方法。
哈希函数:hash()
hash() 方法是HashMap 中的核心函数,在存储数据时,将key传入中进行运算,得出key的哈希值,通过这个哈希值运算才能获取key应该放置在 “桶” 的哪个位置,下面是方法的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从源码中可以看出,传入key之后,hash() 会获取key的hashCode进行无符号右移 16 位,然后进行按位异或,并把运算后的值返回,这个值就是key的哈希值。这样运算是为了减少碰撞冲突,因为大部分元素的hashCode在低位是相同的,不做处理的话很容易造成冲突。
之后还需要把 hash() 的返回值与table.length - 1做与运算,得到的结果即是数组的下标(为什么这么算,下面会说),在上面的 putVal() 方法中就可以看到有这样的代码操作,
table.length - 1就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1的大部分高位都为0),只保留低位,这样一来就总是只有最低的几位是有效的,就算你的hashCode()实现得再好也难以避免发生碰撞。这时,hash()函数的价值就体现出来了,它对hash code的低位添加了随机性并且混合了高位的部分特征,显着减少了碰撞冲突的发生。
另外,在putVal方法的源码中,我们可以看到有这样一段代码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
上面的注释也说明了,这是检测要插入位置是否有元素,没有的话直接新建一个包含key的节点,那么这里为什么要用 i = (n - 1) & hash 作为索引运算呢?
这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n
– 1(n =
数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。
这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开,真可谓是非常精妙的设计。
以上就是青岛IT培训给大家做的内容详解,更多关于UI的学习,请继续关注青岛IT培训
最新开班时间
- 北京
- 上海
- 广州
- 深圳
- 南京
- 成都
- 武汉
- 西安
- 青岛
- 天津
- 杭州
- 重庆
- 哈尔滨
- 济南
- 沈阳
- 合肥
- 郑州
- 长春
- 苏州
- 长沙
- 昆明
- 太原
- 无锡
- 石家庄
- 南宁
- 佛山
- 珠海
- 宁波
- 保定
- 呼和浩特
- 洛阳
- 烟台
- 运城
- 潍坊
Java集合类源码解析:HashMap(基于JDK1.8)1
- 发布:青岛IT培训
- 来源:青岛IT培训
- 时间:2019-04-17 11:30
青岛IT培训的小编总结,今天我们来学习Java中较为常用的集合类 HashMap.
另外说明一下,本文的 HashMap 源码是基于Jdk1.8版本的,如果没有特别说明的话,之后的集合类源码解析都是1.8的版本。
HashMap的数据结构
打开HashMap源码文件,可以看到它是继承自 AbstractMap,并实现了Java集合的根接口Map,以及Cloneable和Serializable接口,所以HashMap可以被序列化。
HashMap的底层结构是哈希表的具体实现,通过相应的哈希运算就可以很快查询到目标元素在表中的位置,拥有很快的查询速度,因此,HashMap被广泛应用于日常的开发中。理想的情况就是一个元素对应一个Hash值,这样的查询效果是最优的。
但实际这是不可能的,因为哈希表存在“hash (哈希) 冲突” 的问题。当发生hash冲突时,HashMap采用 “拉链法” 进行解决,也就是数组加链表的结构。在HashMap的代码注释中,数组中的元素用 “bucket” (中文读作 桶) 来称呼,而哈希函数的作用就是将key寻址到buckets中的一个位置,如果一个 bucket 有多个元素,那么就以链表的形式存储(jdk1.8之前单纯是这样)。
这是HashMap的存储结构图:
关于 “拉链法” 和 “hash冲突” 的知识点有疑问的读者可以看下我之前的文章 数据结构:哈希表以及哈希冲突的解决方案 .
为了方便,下文的 “bucket” 都用 “桶” 替代。
深入源码
两个参数
在具体学习源码之前,我们需要先了解两个HashMap中的两个重要参数,“初识容量” 和 “加载因子”,
初识容量是指数组的数量。加载因子则决定了 HashMap 中的元素在达到多少比例后可以扩容 (rehash),当HashMap的元素数量超过了加载因子与当前容量的乘积后,就需要对哈希表做扩容操作。
在HashMap中,加载因子默认是0.75,这是结合时间、空间成本均衡考虑后的折中方案,因为 加载因子太大的话发生冲突的可能性会变大,查找的效率反而低;太小的话频繁rehash,降低性能。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
成员变量
好了,前面说了那么多,现在开始深入源码学习吧,先了解一下HashMap的主要的成员变量:
static final int DEFAULT_INITIAL_CAPACITY = 1 《 4;
static final int MAXIMUM_CAPACITY = 1 《 30;
static final float DEFAULT_LOAD_FACTOR = 0.75F;
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
transient HashMap.Node<K, V>[] table;
transient Set<Entry<K, V》 entrySet;
transient int size;
int threshold;
final float loadFactor;
可以看出,HashMap主要的成员变量比较多,有些变量还初始化了值,下面一个个来做解释。
DEFAULT_INITIAL_CAPACITY:默认初识容量 1 《 4 ,也就是16,必须是2的整数次方。
DEFAULT_LOAD_FACTOR:默认加载因子,大小为0.75.
MAXIMUM_CAPACITY:最大容量, 2^ 30 次方。
TREEIFY_THRESHOLD :树形阈值,大于这个数就要树形化,也就是转成红黑树。
MIN_TREEIFY_CAPACITY:树形最小容量。
table:哈希表的链接数组,对应桶的下标。
entrySet:键值对集合。
size:键值对的数量,也就是HashMap的大小。
threshold:阈值,下次需要扩容时的值,等于 容量*加载因子。
loadFactor:加载因子。
介绍玩变量,下面介绍HashMap的构造方法。
四个构造方法
HashMap共有四个构造方法,代码如下:
//加载默认大小的加载因子
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//加载默认大小的加载因子,并创建一个内容为参数 m 的内容的哈希表
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//添加整个集合
putMapEntries(m, false);
}
//指定容量和加载因子
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);
}
//指定容量,加载因子默认大小
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
不难发现,上面第三个构造函数可以自定义加载因子和容量,首先判断传入的加载因子是否符合要求,然后根据制定的容量执行 tableSizeFor() 方法,它会根据容量来指定阈值,为何要多这一步呢?
因为buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()函数会尝试修正一个整数,并转换为离该整数最近的2次幂。
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;
}
比如传入一个整数244,经过位移,或运算后会返回最近的2次幂 256
插入数据的方法:put()
在集合中最常用的操作是存储数据,也就是插入元素的过程,在HashMap中,插入数据用的是 put() 方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

put方法没有做多余的操作,只是传入 key 和 value 还有 hash 值 进入到 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;
//哈希表如果为空,就做扩容操作 resize()
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize())。length;
//要插入位置没有元素,直接新建一个包含key的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果要插入的桶已经有元素,替换
else {
Node<K,V> e; K k;
//key要插入的位置发生碰撞,让e指向p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//没碰撞,但是p是属于红黑树的节点,执行putTreeVal()方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p)。putTreeVal(this, tab, hash, key, value);
//p是链表节点,遍历链表,查找并替换
else {
//遍历数组,如果链表长度达到8,转换成红黑树
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;
}
// 找到目标节点,退出循环,e指向p
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 节点已存在,替换value,并返回旧value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果超出阈值,就得扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
代码看上去有点复杂,参数有点乱,但理清逻辑后容易理解多了,源码大概的逻辑如下:
先调用 hash() 方法计算哈希值
然后调用 putVal() 方法中根据哈希值进行相关操作
如果当前 哈希表内容为空,做扩容
如果要插入的桶中没有元素,新建个节点并放进去
否则从要插入的桶中第一个元素开始查找(这里为什么是第一个元素,下面会讲到)
如果没有碰撞,赋值给e,结束查找
有碰撞,而且当前采用的还是 红黑树的节点,调用 putTreeVal() 进行插入
链表节点的话从传统的链表数组中查找、找到赋值给e,结束
如果链表长度达到8,转换成红黑树
最后检查是否需要扩容
put方法的代码中有几个关键的方法,分别是:
hash():哈希函数,计算key对应的位置
resize():扩容
putTreeVal():插入红黑树的节点
treeifyBin():树形化容器
前面两个是HashMap的桶链表操作的核心方法,后面的方法是Jdk1.8之后有关红黑树的操作,后面会讲到,先来看前两个方法。
哈希函数:hash()
hash() 方法是HashMap 中的核心函数,在存储数据时,将key传入中进行运算,得出key的哈希值,通过这个哈希值运算才能获取key应该放置在 “桶” 的哪个位置,下面是方法的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从源码中可以看出,传入key之后,hash() 会获取key的hashCode进行无符号右移 16 位,然后进行按位异或,并把运算后的值返回,这个值就是key的哈希值。这样运算是为了减少碰撞冲突,因为大部分元素的hashCode在低位是相同的,不做处理的话很容易造成冲突。
之后还需要把 hash() 的返回值与table.length - 1做与运算,得到的结果即是数组的下标(为什么这么算,下面会说),在上面的 putVal() 方法中就可以看到有这样的代码操作,
table.length - 1就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1的大部分高位都为0),只保留低位,这样一来就总是只有最低的几位是有效的,就算你的hashCode()实现得再好也难以避免发生碰撞。这时,hash()函数的价值就体现出来了,它对hash code的低位添加了随机性并且混合了高位的部分特征,显着减少了碰撞冲突的发生。
另外,在putVal方法的源码中,我们可以看到有这样一段代码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
上面的注释也说明了,这是检测要插入位置是否有元素,没有的话直接新建一个包含key的节点,那么这里为什么要用 i = (n - 1) & hash 作为索引运算呢?
这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n
– 1(n =
数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。
这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开,真可谓是非常精妙的设计。
以上就是青岛IT培训给大家做的内容详解,更多关于UI的学习,请继续关注青岛IT培训
最新开班时间
- 北京
- 上海
- 广州
- 深圳
- 南京
- 成都
- 武汉
- 西安
- 青岛
- 天津
- 杭州
- 重庆
- 厦门
- 哈尔滨
- 济南
- 福州
- 沈阳
- 合肥
- 郑州
- 长春
- 苏州
- 大连
- 长沙
- 昆明
- 温州
- 太原
- 南昌
- 无锡
- 石家庄
- 南宁
- 中山
- 兰州
- 佛山
- 珠海
- 宁波
- 贵阳
- 保定
- 呼和浩特
- 东莞
- 洛阳
- 潍坊
- 烟台
- 运城