今天抽时间来把HashMap源码来给大家分享下,好久没和大家分享技术知识点了,手速和脑速感觉都有点跟不上了,今天就简单的和大家来分享下HashMap源码;记住哦,直接分享源码,可能会比较枯燥,比较乏味,但是干货和知识点还是非常多,收益将会大大滴,来吧,老规矩,废话少说,撸起袖子,直接上干货,开干!
1、HashMap简介
java在数据结构中映射接口中定义了一个java.util.Map接口,Map接口主要有四个实现类,分别是HashMap、LinkedHashMap、HashTable和TreeMap,它们之间关系如下图所示:
1.1、下面来简单介绍下HashMap、HashTable、LinkedHashMap和TreeMap各自特点
1、HashMap:HashMap作为Map中最为常用的一种,它是根据键的hashcode值对数组长度取模来存储数据;一般情况下可以直接定位到所要查找的值,因此查找速度非常快,但遍历的数据顺序不一定;HashMap存储的键只允许有一条为null,但是记录的值允许多条为null,且存储的键必须唯一,不可重复;HashMap非线程安全,即任意时刻允许多个线程同时写HashMap,可能出现数据不一致问题,如果需要使用线程安全的话,则可以使用Collections.synchronizedMap()方法或者使用ConcurrentHashMap。
2、HashTable:HashTable很多功能与HashMap功能相似,在代码编写中几乎不怎么使用HashTable,它是线程安全的,为了线程安全问题,它只能抛弃查找效率问题,查找效率比较慢;并发性不如ConcurrentHashMap,因为ConcurentHashMap采用分段锁去保证其安全性。建议:在不需要线程安全性问题时,使用HashMap,在需要线程安全性问题时,使用ConcurrentHashMap。
3、LinkedHashMap:LinkedHashMap继承了HashMap,换言之HashMap是其父类,它保存了记录插入顺序在使用迭代器iterator遍历时,输出的顺序就是插入的顺序。
4、TreeMap:TreeMap实现SortedMap接口,可以把保存的记录的key进行排序,其默认是key的升序进行排列,当然也可以使用自定义的排序器进行排序,当使用iterator遍历时,得到的顺序是已经排过序的;使用TreeMap时,key必须是实现compareTo接口,否则会报不知道如何排序异常,这点注意。
四个类对比:
类名 | 安全性 | 顺序问题 |
---|
HashMap | 不安全 | 无序 | HashTable | 安全 | 无序 | LinkedHashMap | 不安全 | 有序(记录添加顺序) | TreeMap | 不安全 | 有序(key进行排序) |
注意:key值唯一,且最多只有一个为null,但是value可以有多个null值。
在以上四种类中,HashMap使用频率最高,因此在接下来中我将围绕HashMap源码来深入讲解HashMap存储结构、常用方法、扩容和安全性。
2、内部实现
为了弄清HashMap,首先要知道HashMap是什么?也就是它的存储结构,其次要知道它能做什么?也不是它的功能实现。
2.1、存储结构
从结构上来进行分析,HashMap就是数组+链表+红黑树来实现的(jdk1.8版本)。
PS:jdk1.8的时候引进红黑树,以前版本就是数组+链表。
存储结构如下图所示:
看了上图,我们要思考两个问题:1、数据底层到底存储的是什么?2、这样存储有什么优点?
1)从jdk源码中得知,HashMap有个非常重要的手段,就是Node<K,V>[] table,即哈希桶数组,下面我们来看下源码:
Node是HashMap的一个内部类,其实现了Map.Entry<K,V>接口,本质就是一个映射(键值对),上图中的每一个五角星就是代表一个Node对象。
2)、HashMap就是使用哈希表来进行存储;哈希表为了解决哈希冲突,可以采用开放地址法或者链地址法,java中的HashMap就是采用后者链地址法来解决哈希冲突。链地址法就是采用数组+链表的形式,当添加的元素算出hsah后,就会将该数据放到对应的数组下表元素后面的对应的链表上。例如下面代码;
Map<String, String> map = new HashMap<>();
map.put("china","中国");
先将key值china计算出该hashcode值,然后再通过hash算法的后两步高位运算和取模运算来定位该key值应该存放数组位置,如果该数组位置已经存放了其他元素,也就是不为空,那么此时就是发生了哈希碰撞;hash算法越好,计算出结果越均匀,发生哈希碰撞概率越小,Map的存取效率越高;否则相反。
如果哈希桶数组很大,那么即使较差的hash算法也会分散比较均匀;否则哈希桶数组较小,那么较好的hash算法也会发生多次hash碰撞,因此就需要时间和空间成本之间权衡,其实就是根据实际情况来决定哈希桶数组大小,并拥有较好的hash算法来减少哈希碰撞。拥有较好的Hash算法和扩容机制,那么能够来降低Map的hash碰撞,且减少哈希桶数组占用内存。
我们先来看下HashMap源码中部分重要属性字段:
1、Capacity数组长度:HashMap中Node[] table初始化长度为16,哈希桶数组的长度必须为2的n次方,之所以这样设计是为了在取模和扩容时做了优化(提高计算index索引效率),同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与的过程。
2、loadFactor:负载因子,默认值为0.75,0.75是对时间和空间效率的一个平衡选择,建议大家一般不要修改,除非在特殊情况下,比如在时间与空间特殊情况下。比如对内存空间很多,对时间要求效率高,那么可以选择降低加载因子;相反对内存空间少,时间效率没有太大要求,那么可以增大加载因子。
3、threshold:阈值,这个我们暂且叫它阈值,它的值为table数组长度 * 加载因子 (threshold = capacity * loadFactor),当插入到Node[]元素个数大于该阈值时,那么HashMap将调用resize()方法扩容,扩容后的HashMap是是原来容量的2倍,注意哈,我这里说的版本是jdk1.8的,1.8之前的版本可不是以这种方式进行扩容。
4、size:是HashMap中实际存在的键值对数量,注意:Node[] table数组长度为capacity,容纳最大键值对阈值threshold区别。
5、modCount:用来记录HashMap内部结构发生变化次数,注意:内部结构发生变化是指内部结构,例如当put一个新键值对时是结构发生变化;当更改一个已经存在的key所对应value值时,那么不称为结构发生改变,这点要注意。
6、 TREEIFY_THRESHOLD 和 MIN_TREEIFY_THRESHOLD:即使hash算法和负载因子设计的再好,也避免不了链表过长的问题,那就导致效率变慢,影响HashMap性能,于是在JDK1.8时引入了红黑树,来增加HashMap性能;当链表长度大于TREEIFY_THRESHOL(TREEIFY_THRESHOL值为8),且Node[] table数组长度大于MIN_TREEIFY_THRESHOLD(MIN_TREEIFY_THRESHOLD为64)时,将会触发链表转化为红黑树。利用红黑树快速增删改查的特点来增加HashMap性能。注意:触发链表转换红黑树机制条件必须满足两个,链表长度大于8且Node[] table数组长度大于64,两者缺一不可。
2.2、构造方法源码分析
接下来我列出构造函数方法:
1、无参构造
无参构造时只是将默认加载因子赋值给该加载因子。
2、有参构造——只传Node[] table数组长度
3、有参构造——传值Node[] table数组长度initialCapacity,和加载因子。
PS:注意第2个和第3个有参构造中tableSizeFor(initialCapacity)方法,该方法你无论传何种数值,那么都会将数组长度变为最近的2^n 次方;简言之该方法根据容量计算出扩容的临界值,比如容量是3,3<2^2 =4,所以临界值是2^2 =4,再比如容量是60,60<2^6=64,所以临界值是64。
4、有参构造——传值为Map对象
2.3、功能实现-方法
在接下来的文章中将主要围绕根据key获取哈希桶数组索引位置、put、get等方法和HashMap如何扩容来进行深入讲解。
2.3.1、确定哈希桶数组(table)索引位置
增加、删除和查找键值对需要定位到哈希桶数组索引位置,这是非常关键的一步,HashMap的数据存储是数组+链表的方式来进行存储,因此我们希望HashMap里的元素尽量分布均匀些,使得每个哈希桶数组上只有一个元素,那么当我们在定位键值对的时候,直接知道该元素所在哈希桶索引位置即可,不需要进行去遍历链表,那么这样查询效率大大的提高了。HashMap定位数组索引位置,直接决定hash方法的离散性能。下面我来分析下定位哈希桶数组位置的源码:
注意哈,这个只计算出key的hash值,还没有定位key所在的哈希桶数组位置。定位Key所在哈希桶位置在1.8之前通过indexFor()方法,但是1.8版本的时候删除了该方法,该方法的功能在其它方法完善了。比如put()方法,部分源码如下:
我这里拿1.7版本来列出完整的定位哈希桶数组位置的三步,第一步:对key进行hash运算,取出hashCode值;第二步:对hashCode高位运算;第三步:取模运算。源码如下:
static final hash(Object key){
int h;
return (key == null ? 0 :(h = key.hashCode()) ^ (h >>> 16));
}
static int indexFor(int h, int length){
return h & (length-1);
}
这里的第三步取模运算设计方法是非常巧妙的,取模运算本来是非常消耗性能的,但是HashMap中运用hash & (length - 1)来获得该对象在哈希桶数组中所在位置,而HashMap底层数组长度总是2的n次方(这里即使你在创建HashMap的时候给定长度不是2的n次方,但是在底层源码中会将长度更改成2的n次方),当length是2的n次方的时候,h & (length-1)的结果是等于h%length(对length取模运算),&运算要比%运算有更高的效率。
2.3.2、put()方法
put()方法这里就直接给大家讲解源码吧,直接在源码注解中进行讲解。
put()方法源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
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)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
下面我来给大家展示下put方法逻辑展示图:
2.3.3、get()方法
在使用HashMap中除了使用比较多put()方法之外就是常用的get()方法,下面我来给大家讲解下get()方法源码:
get()方法:
getNode()方法:
2.3.4、HashMap扩容机制
HashMap扩容(resize)就是重新计算HashMap的容量,向HashMap中源源不断的添加元素,当HashMap内部内部数组中无法承受其数组对象时,那么就会对其扩容。
触发扩容机制有三个条件:
1、当往HashMap中添加第一个元素时,HashMap的table数组为空,那么将会触发扩容机制。如果是空构造,不传容量大小,那么将会使用默认容量大小(DEFAULT_INITIAL_CAPACITY = 16),如果在构造时指定了HashMap长度,如果指定长度是2的n次幂,那么其长度就是指定的长度;如果不是2的n次幂,那么会将长度更改为比它大的最近的2的n次幂。
2、当HashMap中对象超过起阈值(threshold = 容量 * 加载因子),将会触发扩容机制。
3、当链表长度 > 8 时,且HashMap的table数组 < 64,那么就会触发扩容机制。
在HashMap扩展中有个最重要的问题,就是扩展后原来对象如何存放进扩展后的HashMap中。
在jdk1.7的时候,采用的是当扩展后将重新计算存放的索引位置,且总是将元素放进头部,也就是头插法。
下面举个例子说明扩容的过程,假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
在1.8版本中对其进行改进,经过检测发现,因为HashMap每次扩展是2的n次幂,那么原来元素的要么存放在原位置中,要么在原位置上再移动2的n次幂位置,看下图就明白了。
因为在重新计算hash之后,因为n变为了原来2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
这个设计非常的巧妙,即省去了重新计算hash值的时间,而且同时,由于新增的 1 bit 是0还是1可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的 bucket 了,这就是JDK1.8新增的优化点。
总结:
1、扩容是个非常消耗性能的操作,所以在构造HashMap的时候要正确给出一个大小值,避免经常进行扩容,这样能减小内存开销。
2、HashMap是非线程安全的。在并发情况下建议使用ConcurrentHashMap,它是分段锁。
3、jdk1.8中引入了红黑树,大大增加了其性能。
4、当链表大于8时,并不是直接转换成红黑树,它只有将table的长度大于64时才会进行转换成红黑树,否则将触发扩容机制,转换成原来长度的2倍,这个要非常注意。
好了,今天的分享就到此结束吧,这篇文章写了好久好久,可把我恶心死了,如有哪些不懂的地方或者那里有错的,希望大家给我指出,好了,我们下期见,拜拜。
|