为什么要学HashMap底层原理?
因为最近面试被问到
1.HashMap底层是怎么实现的?
2.数组的初始化长度是多少?
3.为什么数组的每次扩容长度是2的整数倍?
4.HashMap的什么时候扩容?
5.HashMap的加载因子是多少?
6.装载因子为什么是0.75?
等一系列问题,当时被问的哑口无言,面试老师说,这些都不知道,你还说你精通java。
之前自己学习觉得,会用就行了,为什么要了解他这些东西。emmemem...
好了,废话不多说,来说下这个底层原理吧,先上一段简单的代码,真的很简单。。。。
public static void main(String[] args) {
HashMap<Integer ,String> map =new HashMap<Integer ,String>();
map.put(1,"张三");
map.put(2,"李四");
map.put(4,"王二");
map.put(1,"非月");
System.out.println(map);
}
输出:{1=非月, 2=李四, 4=王二}
特此说明这里是讲解的jdk1.8,与jdk1.7有所不同。如果要了解jdk1.7,请看别的。
介绍之前,先来看看HashMap这个类,这个类实现了AbstractMap,Cloneable,Map接口,属于双列集合。这里实际上AbstractMap里面也实现了Map接口,但为什么又再去实现Map,这个咱也不知道大神为啥这么写,这个不是我们研究的,暂且先不提。
来看看这个类里面,定义了很多变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //这个“<<”代表左移4位,相当于2的4次方,值是16,这个表示数组的初始长度。
static final float DEFAULT_LOAD_FACTOR = 0.75f;//这个是加载因子,就是上面的最后一个问题的答案
static final int TREEIFY_THRESHOLD = 8;//用于判断树化的阈值
static final int UNTREEIFY_THRESHOLD = 6;//反树化的阈值
static final int MIN_TREEIFY_CAPACITY = 64;//当数组的格式达到八个,并且节点的个数达到64个时,就会树化
了解了这些,我们就来根据上面的代码。
第一句创建了一个HashMap,底层什么也没干,初始化了一下
loadFactor这个变量,源码如下:
接着,调用了put方法,里面调用了putVal方法,在这里我们看到,这里将传进来的k值,进行hash,得到一个hash值,然后将这个hash值,key,value传入了putVal
接下来看看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)
//判断tab是否为空,put第一个值得时候,这个tab肯定为空,所以第一次进入这里,调用resize()方法,此处实际上只是创建了一个Node数组
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果不为空,那就用下标(n - 1) & hash进行计算,这里第一次n=16,得到一个下标为i的值,看tab这个位置的值为空,就创建一个新的Node节点,这个Node是HashMap里面的一个内部类,这里创建是一个Node对象,将这个node对象直接放在数组中,从这里我们其实也看出来来了,jdk8的数组是一个Node类型的数组
tab[i] = newNode(hash, key, value, null);
else {
//不是第一次put,(n - 1) & hash位置也有值
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//判断将要put的值hash值是否与已经存在的元素的hash相同,并且k也相同,就会到①的位置,直接将要插入的值替换老的值,这也可以验证我们上面的代码,put了 map.put(1,"张三");和 map.put(1,"非月");但最终输出k为1的只有非月
e = p;
else if (p instanceof TreeNode)
//判断下一个节点是否是树节点,如果是树节点,就转换成数节点put进入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//否则直接插入链表,怎么插入呢,是原有元素的next值指向新的元素,所以是尾插法
p.next = newNode(hash, key, value, null);
//判断是否需要树化,个数是否大于等于8-1
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;
}
}
//① 进行替换,k是不变,v替换了
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;
}
通过上面的源码我们可以总结出:
1.调用put方法后,HashMap会将传入的k值进行hash,这样得到一个值,这个值就是他所在的Node[]中的位置。
2.判断Node[]这个数组是否为空,如果为空则调用resize()方法,具体在这里干了啥。
3.用下标i=(n-1)&hash判断这个位置是否有元素:
如果没有,直接创建一个Node对象,里面包含hash,key,value,next等,将这个新元素put到数组i位置。
如果有,在进行新元素和已有的元素的hsah值进行比较。
如果将要插入的元素和已经存在的元素的k的hash值相等,调用equals方法比较这两个k是否也相同,则将要插入的元素的v值替换原来的v值。
如果不相等,就判断是否是树类型的节点,如果是,则创建一个树节点,将树节点插入
如果不是树,则将这个新节点插入链表的尾部,然后判断是否需要树化。
下面来看下resize()
final Node<K,V>[] resize() {
//第一次进来,table应该等于null
Node<K,V>[] oldTab = table;
//第一次进来的话,oldTab等于0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//初始化树化阈值
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//判断数组的长度是否大于最大容量,就去int型的最大值,第一次肯定小于最大容量,
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//oldCap << 1 左移一位,相当于乘以2,这就保证了newThr 这个值永远是2的整倍数
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//设置数组的初始长度为16
newCap = DEFAULT_INITIAL_CAPACITY;
//设置扩容阈值16*0.75=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数组,长度为16,看最后,将数组直接返回出去了
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;
//判断当前索引j的位置是否存在元素e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//判断e元素后面是否还有元素,其实就是判断是否树化
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;
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.如果是第一次进来,直接创建数组大小为16的Node数组
2.如果当前Node的长度乘以2小于最大长度,并且当前数组的长度大于16,就重新给新的数组长度扩大原来的两倍
3.将原数组的数据复制到新数组,所有元素需要重新计算位置
下面来看看树化的代码
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//当数组的长度64时,进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将链表节点转化为树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
//树化的真正操作
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
//将root节点置为黑色(根据红黑树的定义)
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//判断插入节点在红黑树的哪边
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
//小于root节点,放在左边
if ((ph = p.hash) > h)
dir = -1;
//大于root节点,放在右边
else if (ph < h)
dir = 1;
//等于root节点,经过下面的方法尽心过多次判断,确认是否等于
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//根据dir判断放在左边还是右边
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
//放在左边(与root相等,也放在左边)
if (dir <= 0)
xp.left = x;
//放在右边
else
xp.right = x;
//进行平衡操作(下面过程省略)
root = balanceInsertion(root, x);
break;
}
}
}
}
//将隐藏的双向链表调整头结点
moveRootToFront(tab, root);
}
通过上面的源码介绍,我们来回答这几个问题:
1.HashMap底层是怎么实现的?
在jdk8中,底层时封装了一个Node数组+链表+红黑树
2.数组的初始化长度是多少?
Node数组的长度时初始化是16
3.为什么数组的每次扩容长度是2的整数倍?
第一:数组扩容主要是与hash有关,如果是单数,key 值hash后会更容易出现位置冲突,但是2的倍数就不容易出现,减少位置冲突。
第二:在resize方法中,代码:
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
注定了只能是2的整数倍
4.HashMap的什么时候扩容?
当数组的长度大于 初始值16*加载因子0.75=12的时候就开始扩容
5.HashMap的加载因子是多少?
加载因子是0.75
6.装载因子为什么是0.75?
当加载因子是1时,空间得到很好的应用,但是数据多了容易产生碰撞,而且链表会很大,耽误查询
如果加载因子时0.5,那么减少了对空间的利用,但是查询速度块
所以0.75是经验值。