hashmap是我们开发中用的最常见的集合之一,但是我们是否有了解过他的底层呢?这篇文章我将带您了解一下hashmap的底层
位运算
在学习hashmap底层原理的时候,我们必须要掌握位运算,别问为什么,往下面看自然知晓
我们知道,计算机用的是二进制,也就是010101…这样的字符,当我们输入某一个数字的时候,存入计算机里面的也是二进制譬如说
- 0-》00000000
- 1-》00000001
- 2-》00000010
- 3-》00000011
- …
&(按位与)
参加运算的两个二进制数,同时为1,才为1,否则为0。举例 3&5=1。
|(按位或)
参加运算的两个二进制数,一个为1就为1,否则为0。2 | 4=6
^(按位异或)
参加运算的两个二进制数,位不同则为1,位相同则为0。6^7=1
<< (左位移运算符)
将二进制码整体左移指定位数,左移后空出来的位用“0”填充,例如 -5 << 2 = -20
例如:2<<4
00000010->00100000=16
“>>”(右位移运算符)与 >>(无符号右位移运算符)
例如:16>>4
00100000->00000010=2
好了,现在了解了位运算,那么我们来了解一下hashmap
hashmap的概述
- HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。
- HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap中的映射不是有序的。
- jdk1.8 之前 HashMap 由 数组 + 链表 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)。
- jdk1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为 8 )并且当前数组的长度大于 64时,此时此索引位置上的所有数据改为使用红黑树存储。
HashMap 特点:
- 存储无序;
- 键和值位置都可以是 null,但是键位置只能存在一个 null;
- 键位置是唯一的,是由底层的数据结构控制的;
- jdk1.8 前数据结构是链表+数组,jdk1.8 之后是链表+数组+红黑树;
- 阈值(边界值)> 8 并且桶位数(数组长度)大于 64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询;
HashMap存储数据的过程
测试代码
public static void main(String[] args) {
HashMap map=new HashMap();
map.put("student1","萧炎");
map.put("student2","林动");
map.put("student3","牧尘");
map.put("student4","古尘沙");
System.out.println(map);
}
结果
执行流程分析:
-
首先程序读到HashMap map=new HashMap();的时候,并不会马上创建一个数组,而是在我们第一次使用hashmap自己的put方法的时候,创建一个长度为16的数组,也叫作桶Node[]table ,这个用来存储数据
-
当我们需要存入一个数据,比方说(key=a,value=3),首先会先调用重写的String的hashcode方法,计算出对应的hash数值,然后根据数组的长度和某种算法,找到这组数据应该对应的桶位数,就是数组的下标,例如0,1,2,3,4,5…然后查看这个桶里面是否有存其他的数据,如果没有,那么就直接把数据存入桶中,我们加入这一次存在‘3’这个位置
-
当我们又再一次的调用put方法,存入(key=b,value=4)的时候,假设这次算出来的又是存在三号位,这个时候,三号位已经有一个数据了,这个时候会判断两个数据的hash值是否相同,如果不相同,那我们这个时候就会在这个桶下生成一个链表,用来存储数据,这个叫做拉链法
-
如果相同的话则会对两个数据进行一次判断
数据相同:直接覆盖
数据不同:从该桶位的链表开始,一直往下比,直到出现不同的时候,便存在不同的地方的下一个位置,如果这个时候链表长度超过了8,那么链表就会转化成红黑树 -
在不断的添加新数据的时候,如果某一时刻超过了阈值,并且那个时候要存入数据的地方刚好不为空,那么,我们就要扩容了,每次扩容都是在原来的基础上,扩大2倍,原因后面会讲。
jdk1.8 中引入红黑树的进一步原因:
- jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。
- 针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
HashMap继承体系
从继承体系可以看出:
- HashMap 实现了Cloneable接口,可以被克隆。
- HashMap 实现了Serializable接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
- HashMap 继承了AbstractMap,父类提供了 Map 实现接口,具有Map的所有功能,以最大限度地减少实现此接口所需的工作。
存储结构
在Java中,HashMap的实现采用了(数组 + 链表 + 红黑树)的复杂结构,数组的一个元素又称作桶。
在添加元素时,会根据hash值算出元素在数组中的位置,如果该位置没有元素,则直接把元素放置在此处,如果该位置有元素了,则把元素以链表的形式放置在链表的尾部。
当一个链表的元素个数达到一定的数量(且数组的长度达到一定的长度)后,则把链表转化为红黑树,从而提高效率。
数组的查询效率为O(1),链表的查询效率是O(k),红黑树的查询效率是O(log k),k为桶中的元素个数,所以当元素数量非常多的时候,转化为红黑树能极大地提高效率。
HashMap基本属性与常量
基本属性,常量一览
/*
* 序列化版本号
*/
private static final long serialVersionUID = 362498820763181265L;
/**
* HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 最大的容量为2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的装载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化阈值,当一个桶中的元素个数大于等于8时进行树化
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 树降级为链表的阈值,当一个桶中的元素个数小于等于6时把树转化为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当桶的个数达到64的时候才进行树化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* Node数组,又叫作桶(bucket)
*/
transient Node<K,V>[] table;
/**
* 作为entrySet()的缓存
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 元素的数量
*/
transient int size;
/**
* 修改次数,用于在迭代的时候执行快速失败策略
*/
transient int modCount;
/**
* 当桶的使用数量达到多少时进行扩容,threshold = capacity * loadFactor
*/
int threshold;
/**
* 装载因子
*/
final float loadFactor;
- 容量:容量为数组的长度,亦即桶的个数,默认为16 ,最大为2的30次方,当容量达到64时才可以树化。
- 装载因子:装载因子用来计算容量达到多少时才进行扩容,默认装载因子为0.75。
- 树化:树化,当容量达到64且链表的长度达到8时进行树化,当链表的长度小于6时反树化。
Hashmap属性解释
DEFAULT_INITIAL_CAPACITY
集合的初始化容量(必须是 2 的 n 次幂):
// 默认的初始容量是16 1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
提问:为什么是2的次幂呢?如果输入值不是 2 的幂比如 10 会怎么样?
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- HashMap 构造方法可以指定集合的初始化容量大小,根据上述讲解我们已经知道,当向 HashMap 中添加一个元素的时候,需要根据 key 的 hash 值,去确定其在数组中的具体位置。HashMap 为了存取高效,减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现的关键就在把数据存到哪个链表中的算法。
- 这个算法实际就是取模,hash % length,而计算机中直接求余效率不如位移运算。所以源码中做了优化,使用 hash & (length - 1),而实际上 hash % length 等于 hash & ( length - 1) 的前提是 length 是2 的 n 次幂。(这段话是摘抄传智播客锁哥的,这个解释确实很完美!)
例如,数组长度为 8 的时候,3 & (8 - 1) = 3,2 & (8 - 1) = 2,桶的位置是(数组索引)3和2,不同位置上,不碰撞。
再来看一个数组长度(桶位数)不是2的n次幂的情况:
数组长度为9 hash为3
00000011 3
00001000 8
————————
00000000 0
数组长度为9 hash为5
00000101 5
00001000 8
————————
00000000 0
数组长度为9,hash为6
00000101 5
00001000 8
————————
00000000 0
由此可见,如果不是2的次幂,hash值很容易一模一样,这样会经常产生哈希碰撞,导致性能下降,所以,这里采用的是2的次幂
为什么要用2的次幂小结:
- 由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
- 另一方面,一般我们可能会想通过%求余来确定位置,这样也可以,只不过性能不如&运算。而且当n是2的幂次方时: hash & (length1) == hash % length
- 因此,HashMap容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
如果创建HashMap对象时,输入的数组长度length是10,而不是2的n次幂会怎么样呢?
static final int tableSizeFor(int cap) {
int n = cap - 1