在学习HashMap之前,我们可以先思考,如果是你来设计HashMap底层数据结构你会怎么做?
大胆猜测
Map用于保存具有映射关系的数据,每个key对应着唯一的value,所以key要尽量保证唯一性,以此来保证查找的高效。
根据上面的一句话,我们可以先设计一个对象用来保存key-value这种键值对。
public class Node{
private Object key;
private Object value;
}
见代码知其意,我们用Node来完成键值对的封装抽象,接下来就是设计怎么存储一组Node?使用数组?链表?树等等,在纠结这个问题时,我们可以想想那句话——Map需要保证高效的查找,所以我们优先选择支持随机访问查找效率是O(1)的数组结构。
public class Map{
private Node[] nodes;
public Object put(Object key, Object value){}
public Object get(Object key){}
}
这玩意好像跟Hash没啥关系,不急,我们继续研究那句话——key要尽量保证唯一性。保证唯一性,HashMap,这两个关键词合在一块,想必你已经想到了,没错,使用对象的HashCode来做key可以保证唯一性,其实就是数据结构中的哈希表。数组是通过下标来插入和查找的,hashcode返回的数怎么转变成下标呢?最简单的办法就是使用hashcode%nodes.length取余。
哈希表结构中的哈希冲突的问题不可避免,首先需要明白hash冲突是什么?简单来说就是key值相同了导致计算出的数组下标值相同,更准确来说,key值相同肯定会导致冲突,但是冲突并不一定需要key值相同,基于上面的设计举个例子,如果nodes数组的大小是10,现有两个Node的key的是hashcode=1和hashcode=11,在取余算法后的结果都是1,我们就可以认为这是发生了hash冲突。
数组+取余算法肯定是不能够处理这种异常情况的,其实业界对此早有很多解决方法,常用的hash冲突解决方案有链地址法、再哈希法,开放地址法,建立公共溢出区等,java中的HashMap采用的就是链地址法,一般具体实现是数组+链表+取余算法,jdk1.8中是实现方案则是数组+链表+红黑树+位运算来完成数组下标计算和数据存储,前序已经铺垫的差不多了,咱们正式开始来说一说HashMap。
进入源码
Map<String,String> hashMap = new HashMap<>();
hashMap.put("1","张三");
String name = hashMap.get("1");
这是HashMap的最简单的使用,创建、放入、取出,简简单单的三行代码后面有“亿”点的的细节,咋们看看这后面都发生了什么。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
单纯看代码的话好像只是给loadFactor赋了个默认值(0.75),咋们通过OpenJDK官方提供的JOL(Java Object Layout)工具,看看此时的hashmap都有哪些东西。
java.util.HashMap object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) bd 37 00 f8 (10111101 00110111 00000000 11111000) (-134203459)
12 4 java.util.Set AbstractMap.keySet null
16 4 java.util.Collection AbstractMap.values null
20 4 int HashMap.size 0
24 4 int HashMap.modCount 0
28 4 int HashMap.threshold 0
32 4 float HashMap.loadFactor 0.75
36 4 java.util.HashMap.Node[] HashMap.table null
40 4 java.util.Set HashMap.entrySet null
44 4 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从这个打印中我们可以看到,直接new出来的hashmap很多属性都是使用的是数据类型的默认值,还可以看到一个空的hashmap的大小是48 bytes,其中12 bytes是对象头信息,4 bytes是对其填充,hashmap的内容占据36 bytes。其中的AbstractMap.keySet和AbstractMap.values来自父类AbstractMap的两个属性,经验比较丰富的同学可能已经想到了,没错,hashmap常用的keySet()和values()方法就是在AbstractMap中实现的。还可以看到HashMap.table中是null,也就是说这个时候hashmap并没有进行底层数据结构的初始化,这就是懒加载思想,真正使用时方才进行内存的申请和初始化。
我们看看这些属性都是干啥用的。
/**
*底层数组,在第一次使用时初始化,并将大小调整为必要的。
*在分配时,长度总是2的幂。(在某些操作中,我们也允许长度为零目前不需要的引导机制。)
*/
transient Node<K,V>[] table;
/**
* 保存缓存entrySet()。注意,AbstractMap字段用于keySet()和values()。
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* 包含的键值对的数量。
*/
transient int size;
/**
* 结构化修改指的是改变HashMap中映射的数量或修改其内部结构(如rehash)。
* 该字段用于使HashMap的集合视图上的迭代器快速失败。(见ConcurrentModificationException)。
*/
transient int modCount;
/**
* 要调整大小的下一个大小值(容量*负载因子)。
* 此外,如果表数组还没有被分配,这字段表示数组的初始容量,或者0表示数组的容量DEFAULT_INITIAL_CAPACITY。
*/
int threshold;
/**
* 负载因子。
*/
final float loadFactor;
这儿有个疑问,如果我们设置了默认大小会发生什么呢?我们来看一看
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
调用重载方法
public HashMap(int initialCapacity, float loadFactor) {
// 参数校验,保证initialCapacity是合法的
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 最大值就是MAXIMUM_CAPACITY(1 << 30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 这儿将initialCapacity处理了一下赋值给了threshold,后面用于底层数组初始化
this.threshold = tableSizeFor(initialCapacity);
}
看一下tableSizeFor做了什么
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;
}
这儿其实是返回了一个大于cap且距离cap最近的2的幂,如果是cap小于等于0则返回1,如果是3返回4,10返回16。
回到上面,我们接着往下走,看看put方法做了什么。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
我们先看看hash方法做了什么
static final int hash(Object key) {
int h;
// 如果key是null则hashCode为0,否则h^h>>>16,这儿与高16位异或是为了让高位参与运算,增加数据的唯一性,数据分布的更加均匀、散列
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
回到put方法可以看到其实put方法直接调的内部的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;
// hash后对应到数组上的点未被使用过
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);
// 如果链表的长度大于等于8,尝试转成红黑树(treeifyBin方法进去后还有一个判定,需要底层数组的长度大于等于64)
// 这个需要注意e是从p.next开始计数的,所以链表长度需要+1,所以这儿的判定链表长度其实是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;
}
}
// 上面判定出插入的key,value和某个Node相同
if (e != null) { // existing mapping for key
V oldValue = e.value;
// put()和putIfAbsent()将会有不同的判定
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 留口API,方便扩展
afterNodeAccess(e);
return oldValue;
}
}
// modCount+1
++modCount;
// 先插入,后扩容
if (++size > threshold)
resize();
// 留口API,方便扩展
afterNodeInsertion(evict);
return null;
}
一般比较关心的问题是链表和红黑树的转化,还有就是如果扩容。顺着代码,我们先看看resize方法。
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) {
// 超过Integer.MAX_VALUE后不继续扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 扩容一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 如果new的时候设置了初始容量,其实底层数组还未被进行初始化,threshold的值会是初始数组的大小,并且保证threshold是初始化容量距离最近的2的幂
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 直接new没有传任何参数cap和threshold是0,这儿开始进行初始化
else { // zero initial threshold signifies using defaults
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果是初始化就不用进行数据迁移了
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
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;
// 高位区链表,位置变动,index+=oldCap
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 移动指针
next = e.next;
// 如果(e.hash & oldCap) == 0则index不变
if ((e.hash & oldCap) == 0) {
// 设置低位区链表头节点
if (loTail == null)
loHead = e;
// 将节点添加到低位区链表尾部
else
loTail.next = e;
// 将尾节点指针指到当前节点
loTail = e;
}
// 如果(e.hash & oldCap) != 0,则index+=oldCap
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;
}
红黑树的转化因为比较复杂,咋们后续有时间再另开一篇详细讲解(其实是我也没仔细研究过这块)。
我们接着往下看get方法,这应该是比较简单的。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
调用内部的getNode方法,我们点进去看看。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果底层数组未进行初始化,直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
// 如果数组上该点有数据则进行链表或树的遍历
(first = tab[(n - 1) & hash]) != null) {
// 如果第一个节点满足条件直接返回
if (first.hash == hash && // always check first node
// 这里调用equals方法判断是否一致,这儿也体现了为什么要一块重写hash和equals方法
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 红黑树遍历
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 链表遍历
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
知识小结
- new HashMap无论是否传initialCapacity底层并不会初始化数组。
- 一个空的HashMap占用48 bytes。
- hashMap是第一次put的时候底层才调用resize进行数组初始化,如果设置了initialCapacity数组大小就是initialCapacity,否则则是默认值16。
- hashMap通过tableSizeFor将传进来的initialCapacity会改为大于initialCapacity且最靠近initialCapacity的2的幂。
- hash方法返回值一致,但是equals方法返回false在hashMap中也是认为不想等的,所以记得二者一块重写。
- JDK1.8的hashMap是先插入,后扩容。
- hashMap每次扩容后大小是原来的2倍。
- 扩容后链表中节点要么在原地链表上要么在加上原数组大小的链表上。
- 如果链表的长度大于等于8,尝试转成红黑树,treeifyBin方法进去后还有一个判定,需要底层数组的长度大于等于64才会转换成红黑树,否则只是扩容。