HashMap
以下分析基于JDK1.8
0. 哈希表数据结构
数据的物理存储结构主要分两种: 顺序存储和链式存储:
- 顺序存储: 在内存中分配连续的内存空间存储数据, JAVA中的数组就是顺序存储结构的典型应用. 顺序存储结构对于指定位置的数据访问非常快速只需要首地址加上偏移地址即可访问, 如果对C/C++的指针有了解就非常容易理解了.复杂度为O(1),但对于插入删除略微复杂, 扩容也比较麻烦.
- 链式存储: 在内存存储是分散的不相关联的, 上一个元素会保存下一个元素的内存地址, 这样依次形成链, 这样的数据结构就可以避免顺序存储带来的扩容,插入删除效率低下的问题, 但又会导致访问效率变低, 访问特定元素需要遍历链表, 复杂度O(n).
鱼与熊掌可兼得乎? 既需要插入删除效率又可以提高访问效率, 有的. 就是本文讲的哈希表, 哈希表这种数据结构采用的存储方式将顺序存储和链式存储相结合, 它的数据结构是…上图!!!
前面是一个长度为7的数组,采用的是顺序存储结构, 后面的链表显然就是链式储存结构.
当要向数据结构保存数据值得时候, 比如保存10, 将10保存到内存中获取到10的内存地址, 将10%7(数组的长度)=3,然后将保存12的内存地址保存到数组table的index3里面. 接着保存24, 24%7=3, 这时候查看数组table的index3中已经有元素, 就将24的内存地址保存到3后面的数据结构中去, 具体根据实现的不同也有不同的插入方式有尾插或者首插.
这里最重要的就是10%7或者24%7的这个映射的算法, 笔者这里只是用简单的方式举例说明, 实际上有多种算法进行这种K的映射计算,它也有另外一个名字叫散列函数, 实际在选择散列函数的时候应该尽量选择能分布均匀的散列方式, 让插入的值大体均匀接在数组后面的链式结构里面, 如果散列不合理, 所有值都接在数组同一个元素后面就失去了这种数据结构的意义, 那样和链式存储无异.hash表后面的链式存储不一定是图中的链表,也可以是其它链式存储的数据结构, 比如JDK8也就是后面要讲的HashMap就有采用红黑树.
哈希表就是通过把K-V这种数据结构使用散列函数对K进行运算取整然后对数组长度取余得到的数组下标, 将V
保存到对应数组下标后面链式存储结构里面.
1. 类的继承结构
HashMap的父类AbstractMap实现了Map接口, Map接口JAVA集合的两个顶级接口之一, 大多数的JAVA集合类都继承自Map或者另外一接口Collection, 像List相关类都是实现了Collection接口. 说回Map, Map接口定义了K-V键值对这种数据结构的基础操作方法, 如get,put,size等常用方法.HashMap的父类AbstactMap是一个抽象类, 实现Map接口的部分方法.
2. HashMap构造方法
HashMap()
HashMap(int initialCapacity)
HashMap(int initialCapacity, float loadFactor)
HashMap(Map<? extends K, ? extends V) m)
- 先介绍几个HashMap的重要属性字段或者概念:
- loadFactor 载荷因子, 这个参数用来衡量HashMap的存储的数据满或不满, 默认值为0.75.
- threshold 阈值, 当大于这个值HashMap会触发扩容, 这个值是通过loadFactor * capacity得到的. 实时的容量是size
- capacity 容量, 指的是数组长度, 在HashMap中是Node类型, HashMap的内部类. 上面第二个和第三个构造函数也会传入这个值, 但是容量并不一定是使用者传入的值, 比如你传入initialCapacity=10, 并不表示生成的实例的capacity为10.
- size 表示往map里装入了多少元素.
- 扩容: 扩容指的是map中存储的数据越来越多, 导致哈希表后面的链式存储结构越来越长, 从而又回归到纯链式结构存储的弊端, 这个时候就需要进行扩容, 就指的是将前面的table数组的长度扩大, 然后对K重新进行散列排布, 从而降低后面的链式存储数据的长度. 这个过程会涉及到数组的扩容迁移和K值得重新散列.
- 构造函数源码分析主要分析第三个, 第二个最终都是调用的第三个.
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);
}
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;
}
前面几步都是对传入的参数initialCapacity做校验, 必须>0, 如果大于int MAXIMUM_CAPACITY = 1 << 30
就取MAXIMUM_CAPACITY, 给装载因子赋值. 冠军是最后一步 tableSizeFor(initialCapacity)这里就是上文说的capacity不一定等于传入的initialCapacity的主要原因. tableSize这个方法是将我们传入的initialCapacity参数转换大于initalCapacity的最小为2的次方的数, 比如传入10最终的capacity就为16, 这样做的目的是优化效率, 在上面说的根据数组长度求余的时候本应该是hash%length但是在length为2的n次方的时候, hash%length==hash&(length-1),在HashMap的源码中处处可见的位运算代替乘除.关于位运算和直接取余笔者测试了一下还是有点点差距的.
HashMap(),HashMap(int initialCapacity)的loadFactor都是采用默认值0.75f. 可见这里并没有做其它工作就简单的设置loadFactor, capacity. HashMap真正的初始化工作是在第一次put存储值的时候.
3. final Node<K,V>[] resize()方法
3.1 resize()初始化
put方法内部调用的putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/**
1. table参数就是hash表的前面的数组结构, 如果table等于null或者table长度为0,就需要调用resize方法对table进行初始化
**/
if ((tab = table) == null || (n = tab.length) == 0)
/**
2. 调用resize方法进行初始化
**/
n = (tab = resize()).length;
// ...
}
// 初始化或者两倍扩容
final Node<K,V>[] resize() {
/**
3. 初始化HashMap相关的参数, threshold, capacity
**/
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // 如果原table存在就扩容
if (oldCap >= MAXIMUM_CAPACITY) { // 判断旧的容量是否已经是最大, 如是就修改阈值threshold到最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) // 旧的容量不是最大, 那么旧扩大两倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) //
newCap = oldThr;
else { // 默认的无参构造函数就是走这里
newCap = DEFAULT_INITIAL_CAPACITY; // 默认16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 默认12
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/**
4. 创建table数组, 也就上面介绍哈希表中的table
**/
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 这里是当旧的oldTab存在的时候要进行数据的迁移.
}
return newTab;
}
resize()初始化方法根据构造函数传入的参数或者默认的参数值进行table数组的创建工作或者进行二倍扩容table数组的创建. HashMap的扩容每一次都是2倍的方式!!!
3.2 resize扩容
扩容本来是应该后面分析的,但是扩容的部分代码也在resize方法内所有就一并分析.
final Node<K,V>[] resize() {
// 前面是table生成的相关逻辑代码, 上一小节已经分析过.
/**
1. 遍历旧的table, 将旧table下的所有值都迁移到新的table下
**/
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
// 当前遍历节点e
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 当前桶后面没有链式数据就直接进行散列迁移到新的table中
// e.hash & (newCap - 1)这就是前面说的取余操作
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果当前节点是一个树节点表示此节点后面后面的链式数据已被转化为红黑树了
// 就要采用红黑树的迁移规则, 有可能分裂成两颗红黑树,也有可能转链表
// 红黑树这里不做细致的讲解, 在HashMap中有一个字段UNTREEIFY_THRESHOLD判断是否需要红黑树转链表
// 节点数小于等于6的时候就需要转链表.
((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, 重新计算位置和原来一样, 所以原位置 2, 其它位置
这里就是通过这个逻辑判断语句判断等于0就是原位置, 不等于0就是其它位置, 原因呢?
HashMap 的cap都是二的n次方, 扩容的newCap一般情况为2的n+1次方
前面章节说过hash & (length - 1) == hash % length 要满足上面前提条件才成立
计算元素在新数组下的位置 idx2=hash&(2oldCap - 1), 而元素在原数组下的位置idx1=hash&(oldCap - 1)
2oldCap - 1和oldCap -1举例如oldCap=16
那么它们分别为01111 0111, 我们分别用这两个数和其它任何hash数作与运算, 发现只有第3bit会影响结果.
因为它们其它位都是1的, 与运算不会影响其它位结果
那就可以转化为判断hash值得第3bit为是否为0, 如果为0表示还在原位, 如果不为0表示在其它位置.
如hash为0101 1000 如何判断它的第3bit是否为0. 0101 0100 & 1000 = 0000 为0 表示在原位
又如hash 0101 1100 判断 0101 1100 & 1000 = 1000 不为0 在其它位
仔细观察发现两个hash相与的值1000 正好是16 oldCap 是不是很巧合很神奇, 其实故意这样设计的
所以通过hash & oldCap == 0 判断是否在原位
那么另外一个问题, 非0情况的其它位置又在哪呢, 这里就不细讲, 其实仔细观察可以发现其实就在原位 + 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);
/**
最后将两个链式结构分别挂在新的table的相应位置
**/
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 这里j + oldCap 就是说的其它位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
4. V put(K key, V value)
put方法内部主要调用的putVal, 前面讲到调用resize()下面接着分析putVal方法
// onlyIfAbsent true 不会替换已经存在的val
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;
// (n - 1) & hash计算当前存入的key对应在数组的位置下标
// 如果当前位置没有元素, 就直接把val存在数组当前下标位置, 1233321
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果当前数组下标已有元素
Node<K,V> e; K k;
// 判断要存入的元素的hash和当前数组下标存的元素是否相等
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);
// 判断链表长度是否大于等于8 如果是就要进行红黑树转换
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 判断是否需要替换已存在的元素
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// modCount 记录修改次数, 防止在迭代的时候修改元素, fail-fast
// 快速失败, 在使用迭代器迭代元素的时候都会检测这个值, 如果发现不一样就会抛出异常
++modCount;
// 如果容量大于了阈值又要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
5. HashMap线程不安全
其实这个问题, 好像面试挺喜欢问题的, 我觉得没啥必要, 它本来就给你提供了线程安全的Map, 还问什么, JDK8的HashMap线程不安全体现在可能覆盖值, 在上文1233321位置, 可以用ctrl+f搜, if ((p = tab[i = (n - 1) & hash]) == null)
想想这句在多线程环境下会发生什么. A线程put值一对key-value, B线程也put一对key-value, 巧合的是它们散列到了同一个数组位置, 然后A运行到这句判断之后, 切换到了B线程, B直接插入它的kv, 然后切换回A因为A已经判断过了, 所以也直接插入. 就覆盖了B线程的键值对.
6. 延伸点
HashSet内部持有一个HashMap. HashSet保存不会重复的元素, 也是通过HashMap来实现的, 只不过HashMap用的是K-V, 而HashSet屏蔽了HashMap的V, HashSet存的元素实际是HashMap中的K, value都是一个常量private static final Object PRESENT = new Object();
7. 做个总结
首先红黑树插入查找和平衡还是挺复杂, 就算单独的内容就不在这里细讲了. 说说大概流程, 首先创建HashMap对象可以指定相关的属性, 如容量capacity和loadFactor, cap并不会设置为传入的值, 只会设置为离传入的值最近的比它的二的n次方的数字, 前提是没超过最大范围. 然后loadFactor的默认值是0.75f, 这个值是配合容量计算阈值threshold, HashMap会根据实时的size是否大于threshold, 如果是就需要扩容.
put的时候, 当put完毕链表长度大于等于8的时候会转红黑树, 在扩容的时候红黑树少于等于6的时候会转链表,它的插入采用的是尾插法,作为对比的JDK1.7是头部插入, 当然JDK1.7在HashMap还有其它区别笔者一开始工作接触的就是1.8没有接触过1.7的源码. 同样这里虽然没有展示, 在remove方法内部的调用栈中会判断红黑树的元素个数, 在少于等于6的时候同样需要转换成链表.
另外关于遍历的时候的fail-fast. 应减少在多线程环境下对HashMap进行插入删除操作. 通过迭代器本身的remove方法并不会引起快速失败.