HashMap简介
平日不练习,面试挑灯读!
从源码的角度解析HashMap的关键操作,愿开卷有益!
后面会从架构设计、逻辑等角度再写一篇,届时将链接放过来,展现一个更裸感的HashMap…^_^
HashMap是最常用的集合类之一,也是面试时的高频考察点。其本质上K-V结构,使用多个数组和链表来存储数据及关联关系。所有数据以K的哈希值作为哈希表中的位置索引,再以K的equals函数得出两个值是否相同,若相同则覆盖旧的value,不相同则追加到当前哈希值对应的单链表或红黑树中。
HashMap在jdk2.0时由官方引入到jre,采用数组+单链表;
在jdk8时增加红黑树,超过8个时单链接变红黑树,低于6个时红黑树变单链表。
写在前面:红黑树部分还未深入研究,下述内容不会涉及红黑树较多的细节,争取尽快补充进来
类图
图1,HashMap Diagram
存储结构示意
图2,存储结构。源文件
通过K计算出的hash座位散列表的索引,决定存储位置。
元素先以单链表结构存储(如3和6),当同一个槽位的元素增长达到8个时(容器长度大于64),将单链表转为红黑树(如12);
但当元素减少达到6个时,会转为单链表(如3);
7个为中间态,保持当前结构不变
- 6和8的设计,是在大量尝试下平衡空间与时间的最优解。这样可以增加一个缓冲带,防止同一个槽位的频繁增删,导致频繁转换(每次装换都需要消耗很多性能)。
- 而且根据数据统计超过8个的概率非常小,容器中hash槽的使用分布遵循泊松分布。这应该得益于容器的扩容和元素数量挂钩,而不是和使用的槽位挂钩的设计。即使只有一个槽位在用,只要元素数量超过阈值,一样会扩容。
- 容量大于64才开启红黑树,是因为红黑树占用空间的大小约是普通节点的两倍,
主要特点
- K-V结构,且K和V都可为空,K为空时哈希值为0
- K为数组,V为单链表或红黑树(n增长达到8个转红黑树,n减少达到6个转单链表,6~8之前未中间态,保持原结构)
- 无序。
观源码后个人认为,应该还是有序的,不会出现没有写操作情况下,两次遍历出来的顺序不同。
因为从图2可知,顺序由hash和插入顺序这两个因素决定。可尝试控制hash值和链表顺序来达到 有序 目的。`
- 线程不安全。效率高,但不可控
- 数组自动扩容。初始为0,默认初始大小16,扩容系数0.75,每次扩容当前大小的2倍。
解释下0和16。在不指定大小时初始为0,当第一次写操作时会触发扩容,扩容后大小就是默认初始大小16
- 结尾加一个解释:HashMap中所有最大最小阈值等,都不包含等于指定的值(开区间。如超过8个转红黑树,而不是达到8个)
了解主要变量、函数、方法
(此处作为前置概念的了解,相关代码在下面可能还会出现,可略过直接看下一段源码逻辑解析)
属性及常量
重点关注
/**
* 散列表(或称hash表),也可称为HashMap的容器,用来存储put进来的hash值的数组
* 当一个对象put进来,计算其hash对应的值作为索引插入对应位置
*
* **************** 疑惑待解:假设插入20个相同hash的元素,table实际只用了1个位置,但为什么也要从16扩容到32?
*/
transient Node<K,V>[] table;
/**
* 元素集合,Node的集合对象
* 链表时为Node类对象,红黑树时为TreeNode类对象
*/
transient Set<Map.Entry<K,V>> entrySet;
容量相关
/**
* 默认初始容量 - 容量大小必须是2的倍数
* 默认16,可通过构造函数传入
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 容器的最大容量,如果初始化指定的超过该值,则以该值为准
* 最大值:1073741824
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的扩容系数,put时当前size+插入元素数量超过该值时触发扩容。
* 每次容器大小变化后都会重新计算并将阈值赋值给threshold,之后每次与threshold对比判断即可
* 默认0.75,可通过构造函数传入
*
* *********** 疑惑待解:当向同一个槽位插入第9个元素时就扩容到32,第10个时扩到了64,……。扩容前threshold没有变成9或10
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
int threshold;
/**
* 扩容系数。默认为DEFAULT_LOAD_FACTOR(0.75),可通过HashMap的构造函数自定义
*/
final float loadFactor;
链表与红黑树
/**
* 链表转红黑树阈值
* 链表中元素数量大于等于8时,链表转红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转链表阈值
* 红黑树中元素数量小于等于6时,红黑树转为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 红黑树可用的最小哈希表长度,只有table.length大于64是,才会解锁红黑树功能
*/
static final int MIN_TREEIFY_CAPACITY = 64;
关键内部类
class Node<K,V>
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) {……}
}
class TreeNode<K, V>
// LinkedHashMap.java文件,TreeNode的父类完整代码
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); }
}
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; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {……}
}
- 红黑树 -> 单链表
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null); //假设当前的q就是最后一个元素,创建一个尾节点
if (tl == null)
//第一次循环时p实际是root节点,且tl为null。此时将代表root节点的对象p赋值给hd存储起来
hd = p;
else
tl.next = p;
tl = p;// 把当前节点当做下一个的父节点
}
return hd;
}
HashMap构造函数
- 指定初始属性方式初始化
/**
* 全部使用默认配置
* 实例化时容器为空,第一次写时扩容到16
* 指定扩容系数为默认值0.75
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* initialCapacity:自定义容器大小,有2个注意点:
* 1. 不是2的幂数则向上找最近的幂数作为初始大小,规则见tableSizeFor(initialCapacity)
* 2. 如果放入大量数据,避免发生多次扩容,建议用n/0.75作为初始值
* loadFactor:指定扩容系数,默认0.75
*/
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;
// 此变量为扩容阈值(如容量16则阈值12),但因HashMap没有存大小的变量,所以用阈值暂时记录
// 实际的阈值,会在第一次写操作时通过resize()计算出来,代码见下
this.threshold = tableSizeFor(initialCapacity);
}
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还未初始化,所以首次会进来先初始化操作
n = (tab = resize()).length;
……
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
// 初始时newThr=0
int newCap, newThr = 0;
if (oldCap > 0) {……}
else if (oldThr > 0) // initial capacity was placed in threshold
// 此时将构造函数中的初始容量作为容器大小
newCap = oldThr;
else {……}
if (newThr == 0) {
// 计算扩容阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
// 将阈值赋值给成员变量
threshold = newThr;
……
}
- 克隆方式
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// clone、putAll等克隆类型的操作,都使用的putMapEntries函数
putMapEntries(m, false);
}
源码逻辑解析
put(不含红黑树部分)
先说一下put的几个特点
- 尾插法。与jdk7的头插法不同,尾插法可最大化保证数据结构安全,在多线程中头插法有可能导致整个链表丢失,二尾插法最多只会丢失自身
- 当前槽位元素数量>=8时,转红黑树
- 当前槽位元素数量<=6时,转单链表
- 日常开发中,建议使用String、Integer这样的类作为key
final修饰的类,具有不可变性,保障了key的不可变更,避免因作为key的对象计算hash值依赖的变量变化,导致脏数据出现
接下来上代码,相关的说明,都写在对应的代码位置了
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 先计算key的hash
static final int hash(Object key) {
int h;
// 如果key为null,则hash=0
// 用key对象的hashCode()获取到hash值
// h >>> 16 取出hash的高16位
// 用hash异或其高16位作为最终的hash值
// 高低16位异或运算是为了打散key的位置(扰动处理),使其尽可能分散(补充一句:所有的操作,都是为了提高分布的随机性和均匀性,减少hash冲突)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// tab:即成员变量table。hash组成的数组,也称作散列表。此处用局部变量,可防止多线程中扩容时数据混乱,但可能造成此次put的元素丢失
// p:当前hash对应的单链表的root对象
// n:hash表的长度
// i:当前hash对应的索引值
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 首次写入,先初始化容器和属性值
if ((tab = table) == null || (n = tab.length) == 0)
// 默认参数下,table长度16,扩容系数0.75,扩容阈值threshold=16*0.75=12
n = (tab = resize()).length;
// 计算出table的索引位置赋值给p。假设此时哈希表长度16,假设hash=1010101010101010
// 1010101010101010(43690)
// & 1111(15)
// -----------------------
// 0000000000001010(10)
// 如果索引10的位置是null
if ((p = tab[i = (n - 1) & hash]) == null)
// 实例化一个Node对象放入10的位置
// 这个元素此后将作为链表的root节点
tab[i] = newNode(hash, key, value, null);
else { // 已有与当前hash相同的元素了
// e:临时存储要插入的元素的Node实例
Node<K,V> e; K k;
// 如果hash相等,key.equals(o)也为true,则表示新值覆盖旧值
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循环,是为了遍历单链表,找到尾节点插入到它后面
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;
}
// 忽略。与前面判断是否相等一样,此处只是安全保障,正常情况下不会走进来
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
p = e;
}
}
// 是否覆盖旧值,一般都是覆盖。只有通过putIfAbsent放入时不覆盖旧值
// 注意,如果是覆盖旧值,改变的只是Node中的value,而不是重新构建整个Node
if (e != null) {…… e.value = value; ……}
}
++modCount; // ???注释中说用于快速失败,后续再研究
if (++size > threshold)
// 如果增加了这个元素后,超过扩容阈值,则进行扩容
resize();
// ???插入后处理。LinkedHashMap时才用到,似乎是头插法
afterNodeInsertion(evict);
return null;
}
小结
用key的hash值,通过高低16位异或运算尽量使插入的元素打散平均,再取到当前哈希表的索引。
如果key的hash和equals同时相等则表示覆盖已有元素,否则追加到链表尾部。
插入链表是通过遍历找到末尾节点实现,也就意味着要遍历整个链表。
之后判断是否要转红黑树。
– put(红黑树)-暂略
resize(不含红黑树部分)
- 先插入,后扩容
- 扩容时先扩容器,再放元素。
- 尽可能避免了并发时容器重复扩容的问题,但此时另一个线程可能获取不到数据
- 且扩容中别的线程put的数据会丢失
- 槽位计算中是否移位。这一段特别说明下,取自代码
if ((e.hash & oldCap) == 0)
和newTab[j] = loHead;
、newTab[j + oldCap] = hiHead;
/* 此处是否移位,通过if ((e.hash & oldCap) == 0)控制,true表示原位不动
* 举例说明:原容器长度16,hash=5和37都对应槽位5,扩容到32后就对应两个槽位了
-- 5 & 16 = 5
00101
& 10000
00000 = 0
-- 而21&16 = 16
10101
& 10000
10000 = 16
由此可知,原先小于旧容器长度的,或运算后为0,其它的位置向后移原容器长度位置
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;// 旧哈希表数组对象
intoldCap = (oldTab == null) ? 0 : oldTab.length;// 当前数组长度(不是元素个数)
int oldThr = threshold; // 旧的扩容阈值
int newCap, newThr = 0; // 新的容器大小(哈希表数组长度),和新的扩容阈值
// 如果当前容量已超过最大值(Integer的最大值的一半1073741824),则不再扩容。
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;// 将扩容阈值设到最大,以示不再触发扩容
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;// 计算扩容后的阈值(不是容器大小,HashMap不记录容器大小,只记录扩容阈值)。左位移一位比乘以2更快
} else if (oldThr > 0) // 如果当前容器是空的,则使用构造函数传入的初始大小
newCap = oldThr;
else { // 当前容器是空的,且没有指定初始大小,则使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;// 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);// 0.75 * 16 = 12
}
// *** 将新的阈值生效
threshold = newThr;
// 创建新的容器,并赋值给局部变量方便操作。至于原先的容器,由于没有了引用会被自动回收
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 注意::此时容器是空的,get操作找不到数据
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {// 此处e表示当前槽位的root节点
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 {
// loHead和loTail,分别表示原位置不变的root、后续节点
Node<K,V> loHead = null, loTail = null;
// 表示需要移到新位置的节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;// 首次进来e为root节点,后面由do-while(...)重新将next赋值给自己
// 是否需要移到高位判断,详细可见看resize中的描述部分
if ((e.hash & oldCap) == 0) {
// 不移位。假如原先容器16个,hash=5,现在仍然5
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 移位。假如原先容器16个,hash=21,槽位从5改到21
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;
// 和上面需要移位的相配合。j + oldCap = 5 + 16 = 21
newTab[j + oldCap] = hiHead;
}
}
}
}
}
}
– resize - 链表转红黑树-暂略
java.util.HashMap.TreeNode#split
get
先从整体梳理一下get的实现思路:
- 通过hash确定table的索引位置
- 判断第一个节点是不是目标元素(hash相等且key.equals为true)
- 使用Node的next指针获取第二个节点。从此后开始,单链表与红黑树的获取方法不一样了
- 先判断是不是红黑树,如果是则使用TreeNode的getTreeNode函数(后面单独讲)
- 不是红黑树的话,循环单链表。要么hash相等且key.equals为true,要么循环到最后返回null
说明下,从第二个元素处开始判断是否为TreeNode类型,并不是第2个节点才是TreeNode的实例,而是第一个节点前两个节点无论Node还是TreeNode,都可以当做Node来操作。但第三个节点的获取开始红黑树就出现岔口,不能再用一种玩法了
public V get(Object key) {
Node<K,V> e;
// hash在put中已讲过
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) { // 此时first不是root节点,而是第二个节点了
if (first instanceof TreeNode)// 红黑树有自己封装的方法,后面讲
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { // 循环寻找元素,要么找到后return直接返回结果,要么没有next了退出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
remove(不含红黑树)
- 如果红黑树的数量小于等于6,在split中会通过untreeify函数降级为单链表
- 注意:remove不会触发resize,即使全部移除(
clean()
),也不会使table缩小
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* matchValue:是否value也要相等才移除
* movable:移除时不移动其它节点。红黑树专用,后面再讲
*/
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
// 特别说明下p:目标节点的父节点(单链表中next指针挂着目标节点的那个节点)
Node<K,V>[] tab; Node<K,V> p; int n, index;
if (……) {
// 获取逻辑省掉了,此处说明下这几个变量
// node:key对应的Node节点对象
// e:node.next
// k和v,即我们存入的键与值
Node<K,V> node = null, e; K k; V v;
// 获取逻辑,和get思路相同,不再赘述
……
// 判断是否获取到了目标节点,同时matchValue控制是否value值也要相等才移除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode) // 红黑树
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) // 要移除的是root节点,则直接将第二个元素提到首位
tab[index] = node.next;
else
// 如果移除的是中间节点,将目标节点的链接断开,使其成为“无主状态”
// 如果移除的是尾节点,那么父节点指向null(目标节点的next=null)
p.next = node.next;
++modCount;// 记录修改的次数
--size;// 元素数量-1
afterNodeRemoval(node);
return node;
}
}
return null;
}
– remove(红黑树)-暂略
面试题收集
与jdk7异同
差异项 | jdk 8 | jdk 7 | 描述 |
---|---|---|---|
插入位置 | 头插 | 尾插 | 多线程中头插法可能导致整个链表丢失,或者循环链表 |
扩容时机 | 先插入后判断 | 先判断并扩容,而后插入 | |
初始化 | resize() | inflateTable() | 啥区别? |
存储结构 | 单链表+红黑树 | 单链表 | 数结构占用空间多,但搜索效率高 |
key=null | 仅使其hash=0 | 通过putForNullKey函数特殊处理,相当于创建了一个对象 | |
hash扰动次数 | 2次(1次位运算+1次异或运算) | 9次(4次位运算+5次异或) | |
hash获取方式 | 对象本身的hashCode() | String类型自己算hash |
扩展阅读
- hash冲突的解决方法
答:开放定址法、链地址法、再哈希法、建立公共溢出区。
待补充
一点疑惑
- 原先k的哈希变了后,脏数据如何处理?
通过resize函数可知,脏数据会一直存在。只有clean时才能清理 - 为什么移出元素时不缩小容器?
- get时,红黑树应该在root节点就两分了,那么root.next是谁?
- 初始默认配置状态下,向同一个槽位插入第9个元素时会扩容到32,第10个时扩到64