HashMap从结构到源码
文章目录
一、哈希表结构
1、常用数据结构
1) 数组
数组 有序的元素序列,特点:寻址容易,插入和删除困难。
对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
2) 链表
链表 存储区间是非连续、非顺序的,特点:寻址困难,插入和删除容易。
对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
3) 哈希表
所谓的哈希表,简单的说就是散列,即将输入的数据通过hash函数得到一个key值,输入的数据存储到数组中下标为key值的数组单元中去。
在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)
2、哈希表常用知识
1) 桶
- 哈希表:在将键值对存入数组之前,将key通过哈希算法计算出哈希值,把哈希值作为数组下标,把该下标对应的位置作为键值对的存储位置,通过该方法建立的数组
- 桶:哈希表数组的存储位置
数组是通过整数下标直接访问元素,哈希表是通过字符串key直接访问元素,也就说哈希表是一种特殊的数组(关联数组),哈希表广泛应用于实现数据的快速查找
2) 哈希冲突
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。
3) 哈希冲突的解决方法
哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法
二、HashMap的结构
1、HashMap在JDK1.7和JDK1.8结构比较
JDK 1.7中HashMap采用了链地址法,也就是数组+链表的方式
JDK 1.8对HashMap进行了比较大的优化,底层实现由之前的“数组+链表”改为“数组+链表+红黑树”
2、关于红黑树的问题
1)JDK1.8为什么引入红黑树
JDK1.7中数组+链表的结构,即使哈希函数取得再好,也很难实现元素百分百均匀分布。
当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
针对这种情况,JDK 1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。
2)链表什么时候转换成红黑树
HashMap在jdk1.8之后引入了红黑树的概念,表示若桶中链表元素超过8时,会自动转化成红黑树;若桶中元素小于等于6时,树结构还原成链表形式。
原因:
红黑树的平均查找长度是log(n),长度为8,查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
3)生成红黑树长度为8,退化长度为6的原因
中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
三、HashMap源码解析
1、HashMap基本信息
HashMap中的基本参数
//全局常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量16
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子0.75
static final int TREEIFY_THRESHOLD = 8; // 链表节点转换红黑树节点的阈值, 9个节点转
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树节点转换链表节点的阈值, 6个节点转
static final int MIN_TREEIFY_CAPACITY = 64; // 转红黑树时, table的最小长度
transient Node<K,V>[] table; //Node<K,V>类的数组,里面的元素是链表,用于存放HashMap元素的实体
transient Set<Map.Entry<K,V>> entrySet;
transient int size; // Map中KV对的个数
transient int modCount; // 用来记录HashMap内部结构发生变化的次数
int threshold; // 阈值,决定了HashMap何时扩容,以及扩容后的大小,一般等于table大小乘以loadFactor 默认为12
final float loadFactor; // 负载因子 默认为0.75
1.1、常见问题
1) 默认阀值threshold为什么是12?
答:threshold = 16 * 0.75 = 12
//resize()方法中这一段代码是计算默认阈值的。
……
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;//设置默认容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//设置默认阈值
}
……
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
……
可见以上代码是当初始化的时候设置默认容量=16、默认阈值=12。
2) 每次扩容容器的大小是怎么变化的?
答:每次扩容:newCap = oldCap << 1 ,就是扩大2倍,首次扩容是当table数组个数等于默认阀值12的时候。
容量:16、32、64……
阀值:12、24、48……
其中size是table.length(数组的长度),而不是元素的个数。
详情见:HashMap中的算法
3) HashMap的加载因子为什么是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子是表示Hsah表中元素的填满的程度。
加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。
反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。
1.2、Node<K,V>类
Node<K,V>类是HashMap内部类,继承自 Map.Entry这个内部接口,它就是存储一对映射关系的最小单元,也就是说key,value实际存储在Node中。
transient Node<K,V>[] table
HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。
//Node类
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);
//按位异或^不同为真,数a两次异或同一个数b(a=a^b^b)仍然为原值a
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {//改为调用Object的equals
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
1.3、TreeNode<K,V>类
TreeNode<K,V>类是JDK1.8新增的红黑树,继承LinkedHashMap.Entry<K,V>
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; //节点的前一个节点
boolean red;//true表示红节点,false表示黑节点
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* 获取红黑树的根节点
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
/**
* 确保root是桶中的第一个元素,将root移到桶中的第一个
*/
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root){}
/**
* 查找hash为h,key为k的节点
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) { // 详见get相关
TreeNode<K,V> p = this; …… }
/**
* Calls find for root node.
*/ //获取树节点,通过根节点查找
final TreeNode<K,V> getTreeNode(int h, Object k) { // 详见get相关
return ((parent != null) ? root() : this).find(h, k, null);
}
/**
* 比较2个对象的大小
*/
static int tieBreakOrder(Object a, Object b) {}
/**
* 将链表转为二叉树
*/
finalvoid treeify(Node<K,V>[] tab) {} //根节点设置为黑色
/**
* 将二叉树转为链表
*/
final Node<K,V> untreeify(HashMap<K,V> map) {}
/**
* 添加一个键值对
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {}
/**
* 移除一个键值对
*/
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,boolean movable) {}
/**
* 将结点太多的桶分割
*/
finalvoid split(HashMap<K,V> map, Node<K,V>[] tab, intindex, intbit) {}
/* --------------------------------------------------*/
// Red-black tree methods, all adapted from CLR
//左旋转
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,TreeNode<K,V> p) {}
//右旋转
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,TreeNode<K,V> p) {}
//保证插入后平衡,共5种插入情况
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,TreeNode<K,V> x) {}
//删除后调整平衡 ,共6种删除情况
static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root,TreeNode<K,V> x) {}
/**
* 检测是否符合红黑树
*/
static <K,V> boolean checkInvariants(TreeNode<K,V> t) {}
2、HashMap核心方法
2.1、计算哈希桶数组索引位置
整个过程本质上就是三步:
- 拿到key的hashCode值
- 将hashCode的高位参与运算,重新计算hash值
- 将计算出来的hash值与(table.length - 1)进行&运算
hash(key):重新计算key的hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
根据hash值对(数组长度-1)取模运算:
table的大小:n = tab.length
桶的位置:(n - 1) & hash
2.2、put(K key, V value)
public V put(K key, V value) {
return 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;
if ((tab = table) == null || (n = tab.length) == 0) //table为空或length=0
n = (tab = resize()).length; //分配空间,初始化
// 通过hash值计算索引位置, 如果table表该索引位置节点为空则新增一个
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {// table表该索引位置不为空
Node<K,V> e; K k;
if (p.hash == hash &&// 判断p节点的hash值和key值是否跟传入的hash值和key值相等
((k = p.key) == key || (key != null && key.equals(k))))
e = p;// 如果相等, 则p节点即为要查找的目标节点,赋值给e
else if (p instanceof TreeNode)// 判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {// 走到这代表p节点为普通链表节点
for (int binCount = 0; ; ++binCount) // 遍历此链表, binCount用于统计节点数
if ((e = p.next) == null) {// p.next为空代表不存在目标节点则新增一个节点插入链表尾部
p.next = newNode(hash, key, value, null);
// 计算节点是否超过8个, 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果链表长度达到了8,且数组长度小于64,那么就重新散列,如果大于64,则创建红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e不为空则代表根据传入的hash值和key值查找到了节点,将该节点的value覆盖,返回oldValue
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;
}
put的逻辑:
- 校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
- 通过hash值计算索引位置,判断索引位置p是否为空,如果tab[i] == null【没碰撞】,直接新建节点添加。否则【碰撞】执行3操作。
- 判断p节点的hash值和key值是否跟传入的hash值和key值相等,如果相等则更新value,否则执行4操作
- 判断当前数组中处理hash冲突的方式为红黑树还是链表(check第一个节点类型即可),分别处理。
- 是红黑树,则按红黑树逻辑插入。
- 是链表,则遍历链表,看是否有key相同的节点,有则更新value值,没有则新建节点,此时若链表数量大于阀值8【9个】,则调用treeifyBin方法(此方法先判断table是否为null或tab.length小于64,是则执行resize操作,否则才将链表改为红黑树)
- 如果++size > threshold则进行扩容resize()。
2.3、resize()
扩容机制核心方法: Node<K,V>[] resize()
HashMap扩容有三种情况
- 默认构造方法初始化HashMap。HashMap一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。
- 指定初始容量的构造方法初始化HashMap。那么从下面源码可以看到初始容量会等于threshold,接着threshold = 当前的容量(threshold) * DEFAULT_LOAD_FACTOR。
- HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍
//Initializes or doubles table size,两倍扩容并初始化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;
if (oldCap > 0) {//数组长度>0 扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新数组长度 是原来的2倍,
// 临界值也扩大为原来2倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 带参数初始化
// 如果原来的thredshold大于0则将容量设为原来的thredshold
// 在第一次带参数初始化时候会有这种情况
newCap = oldThr;
else {//无参数初始化
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {// 在第一次带参数初始化时候会有这种情况
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//新的数组,长度为新的容量newCap
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果原来的table有数据,则将数据复制到新的table中
if (oldTab != null) {
// 根据容量进行循环整个数组,将非空元素进行复制
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 获取数组的第j个元素
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;
/*********************************************/
// (e.hash & oldCap) 得到的是 元素的在数组中的位置是否需要移动,示例如下
// 示例1:
// e.hash=10 0000 1010
// oldCap=16 0001 0000
// & =0 0000 0000 比较高位的第一位 0
//结论:元素位置在扩容后数组中的位置没有发生改变
// 示例2:
// e.hash=17 0001 0001
// oldCap=16 0001 0000
// & =1 0001 0000 比较高位的第一位 1
//结论:元素位置在扩容后数组中的位置发生了改变,新的下标位置是原下标位置+原数组长度
// (e.hash & (oldCap-1)) 得到的是下标位置,示例如下
// e.hash=10 0000 1010
// oldCap-1=15 0000 1111
// & =10 0000 1010
// e.hash=17 0001 0001
// oldCap-1=15 0000 1111
// & =1 0000 0001
//新下标位置
// e.hash=17 0001 0001
// newCap-1=31 0001 1111 newCap=32
// & =17 0001 0001 1+oldCap = 1+16
//元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
//参考博文:[Java8的HashMap详解](https://blog.csdn.net/login_sonata/article/details/76598675)
// 0000 0001->0001 0001
/*********************************************/
// 注意:不是(e.hash & (oldCap-1));而是(e.hash & oldCap)
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.8中 旧链表迁移新链表 链表元素相对位置没有变化; 实际是对对象的内存地址进行操作
注:HashMap是先插入数据再进行扩容的,但是如果是刚刚初始化容器的时候是先扩容再插入数据。