前言
终于来到了HashMap咯!给我的感觉就是面试必备啊- -源码面前,了无秘密
HashMap
早就听闻HashMap牛逼~快速存取,那么它是如何做到的呢?让我们一步一步地去揭开它的面纱~它不是线程安全的!有modCount了~没错又是它~
维护的属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,默认容量初始16就是有16个桶
static final int MAXIMUM_CAPACITY = 1 << 30;// 最大容量1的30次方。**注意**!!!容量必须是2的倍数!之后的内容会告诉你为什么
static final float DEFAULT_LOAD_FACTOR = 0.75f;//装载因子0.75就是容量达到0.75的时候自动扩容
static final int TREEIFY_THRESHOLD = 8;// 转换成树结构的阀值8,就是说,桶的个数超过8的时候进行转换
static final int MIN_TREEIFY_CAPACITY = 64;// 当table容量超过64的时候,就是有不止64个键值对的时候,才能转换成树
transient Node<K,V>[] table;//存放结点的数组,每一个index就是一个桶,这一点概念很重要!
transient int size;// 键值对的数量
int threshold;// 阀值,作用是hashMap容量达到它的时候扩容
所用的数据结构
数组+链表+红黑树
构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
默认构造函数里面,只指定了装载因子,其它都暂时是null这对接下来resize的几个if语句有作用,勿忘!
主要起作用的就是它HashMap(int, float)
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;
}
当时看到这个小算法的时候吓我一跳0.0,>>> 无符号右移,第一次看见,这个算法的作用就是得到最接近这个cap的一个2的倍数,比如5穿进去出来就是8,厉害了我的哥
put–键值对存入
大概的步骤
对key的hashCode()做hash,然后再计算index;
如果没碰撞(就是算出来的index不一样)直接放到bucket里;
如果碰撞了,以链表的形式存在buckets后;
如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
如果节点已经存在就替换old value(保证key的唯一性)
如果bucket满了(超过load factor*current capacity),就要resize。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第一件事情做hash(运算)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// p用来保存桶的头结点的
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果hash表为空,调用resize完成初始化,n代表长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果要插入的元素没有元素,新建一个节点放进去,i=(n-1)&hash算出index索引,此时p指向插入元素的那个桶的头结点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果p头结点的key和要插入的节点key一样,则替换,e用来保存被替换的那个节点的
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 {
// 链表的遍历并且插入,binCount记当前桶的结点个数
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;
}
}
// 存在e,也就是说有结点被替换出来了,返回被替换的结点
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 检查 在没有替换而是新加入的情况下,超过了阀值,则resize,扩容,每次扩容是原来的两倍
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
关键来了!优化之一
我们都知道,当来了一个新的键值对的时候,我们要选择存放的位置,i = (n - 1) & hash便是这个位置的算法。朴素点说就是,一共那么多个桶,往里面按顺序放入东西一个一个来,我们第一个想到的就是取模运算,便能找到位置,但是这个&便是一个高效的算法。hash函数中右移16位的作用就在于减少碰撞,举例子
解释一下为何要这么做,hashcode得到的结果那么长,单纯用它和n-1做运算,实际用到的就最后那么小几位,而这小几位一样的数很多的,这里的目的就是避免了它,然而还是有一些问题,这个比较复杂我们暂且放一放
resize扩容和初始化
final Node<K,V>[] resize() {
// oldTab 指向原来的table
Node<K,V>[] oldTab = table;
// 保存旧table的个数和阀值,如果是new HashMap,则这里的table是null!!!也没有threshld 也=0;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
// 新容量和阀值
int newCap, newThr = 0;
// table>0表明已经添加了元素
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新容量为原来的2倍后小于最大容量,并且旧容量比16大
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容成原来的2倍
newThr = oldThr << 1; // double threshold
}
// 如果旧容量为0并且旧阀值>0,说明已经创建了hash表,但是没有添加元素
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 什么都没有,阀值和表都没,比如默认构造函数的时候
else { // zero initial threshold signifies using defaults
// 容量为16
newCap = DEFAULT_INITIAL_CAPACITY;
// 新阀值 12
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) {
// 旧的桶置位空,因为table里面每一项就代表一个桶
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
//loHead代表老位置的头节点,loTail代表老结点的尾部,hiHead代表新增加的的头部,hiTail代表新增加的尾部,换句话说,一个头指针一个尾指针
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 还记得吗?(n-1)&的时候是把hash值的位数降了1位,现在不减1,用来判断高位是否为1,还是0从来决定新的位置,一会用图来解答
// 如果是0还放在老位置,一开始入一个节点的时候,head和tail都指向第一个,之后,再来一个,尾指针后移。
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 如果是1 就放在新的位置
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 让table的j位置存放链表的表头
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
对于resize的讲解我们算是结束了,大致就是三个小个分支,分别对应了三种情况
原来的table已经用过了,意思就是已经有原来的容量了,此时扩容是2倍,并且阀值也是2倍增长,这种情况出现在什么时候?不断往hashMap表中加元素的时候,到达了阀值
有阀值,但是没有表,这是一种初始化状态下,此时容量变成阀值的大小,新阀值为容量的0.75倍,出现在什么状态下呢?new(xxx)指明初始容量的时候 比如,我一开始指定了20容量,但是事实上它会经历几个转换过程
- 先 计算出最接近20的2的指数倍,作为旧阀值,然后将旧阀值作为newCap新容量,之后 算出新阀值0.75*newCap,所以啊~我们指定的容量并不是真正的容量- -
- 没有阀值,也没有表,也是一种初始化状态下,new()啥都没有,此时默认用16+12的组合
所以,可以看出来0.0 原来装填因子。。。用处不是那么大,初始化的时候有用也就是说,只干一次活,之后就直接成倍增长了。经过测试,不管是初始化16+12组合,扩容后就是32+24的组合,还是指定容量的扩容,都是两倍增长,一扫装填因子每次达到0.75再扩容的假象。至少我的实验结果是这个样子。
关于复制元素有几个示意图:
get获取
public V get(Object key) {
Node<K,V> e;
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 && // always check first node
((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;
}
明白了put,get就简单了,无非就是判断头节点,头节点判断完了之后遍历后面的节点,略过略过~不过需要记住,树的查找是O(logn),而链表的查找是O(n)还是红黑树哦~
个人觉得精髓部分就是put 和 resize还有hash这三个部分了,不得不说,实在是太牛比了!
总结
PS:都知道HashMap是无序的,这个无序的意思需要搞清楚,这里的无序指的是我插入的顺序不代表就是输出的顺序,而不是说,多次输出HashMap的元素位置就是随机变动的。举例子:
HashMap<String, Integer> hashMap2 = new HashMap<>(20);
hashMap2.put("3", 3);
for (int i = 4; i < 27; i++)
{
hashMap2.put(String.valueOf(i), i);
}
hashMap2.put("111", 1111);
输出:
{22=22, 23=23, 24=24, 25=25, 26=26, 111=1111, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16, 17=17, 18=18, 19=19, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 20=20, 21=21}
并不是我们输入的顺序,但是多次输出结果的顺序还是这个。看源码之后,散列提供了第一次位置的随机性,但是!位置第一次确定之后,就不能改了,不会因为你的输出而改变。JDK9是这样的不过。。python3好像是随机出现的,很奇怪