相信大家在面试的过程中,经常会被问到一个这样的问题:“你了解hashmap的底层原理吗?”,大多数初级人员或许只是了解它的底层数据结构是什么,基本的作用是什么,但是一旦问到扩容过程,put的过程,红黑树的变色和翻转(本篇不支持),大家难免就无法从容面对。
那我们就一块去看下hashmap的底层源码是什么样子的~~
开始之前请大家思考一下,我们常见的数据结构有哪些?典型的代表又有哪些呢?
01
常见数据机构
我们比较熟悉的应该是这几种:数组,链表(单向和双向),树形,图形
典型的代表:
数组:类似如下,典型代表是Arraylist和Vector
链表:典型代表是LinkedList
双向:
单向
红黑树:典型代表是hashmap(jdk1.8之前是数组加链表,1.8之后又增加了红黑树)
hashmap的结构组成是 :数组+链表+红黑树
02 HashMap的常见参数
我们可以先考虑这么几个问题:
既然hashmap的底层结构含有数组,那么我们应该知道,数组是要指定一个大小的,那么默认大小是多少呢?
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
DEFAULT_INITIAL_CAPACITY参数就是默认的大小值,就是16
既然有默认大小,那么长度有没有上限呢?
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
MAXIMUM_CAPACITY就是数组的上限值
默认长度是16,这个长度是有时满足不了我们的业务需求的,这也就意味着我们要扩容,很多人可能觉得只要数组长度达到16就开始扩容,俗话说未雨绸缪,既然知道存在长度可能不够的情况,那我们就要提前做准备才是!
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
DEFAULT_LOAD_FACTOR 加载因子,也就是说当容器使用了16*0.75=12,的时候,就开始扩容。
jdk8引入了红黑树,原因自然是因为红黑树拥有更高的效率,但是并没有抛弃掉链表。为什么呢?任何东西,存在即合理,红黑树在一定程度上比链表更高效,但是有时候链表更有优势!
这也就意味着它们之间可以进行转换,根据不同的场景选择不同的存储结构,,这才是合理的,那么什么时候才会转换呢?
链表转红黑树:
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
根据英文的意思就可以看出这是用于红黑树的,意味着当链表的长度>=8的时候,链表开始转换为红黑树。
红黑树转链表:
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
UNTREEIFY_THRESHOLD 红黑树转链表,即树的深度<=6的时候,会转化为链表。
转换就意味着会产生冲突,为了避免冲突,我们还需要能够成为树的最小数量
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
MIN_TREEIFY_CAPACITY,是树的节点最小数量,依据就是 4 * 数组长度(16)=64
03put的过程
明白了上面几个参数的意义就是,我们就开始尝试去看一下源码,我们就以put方法为例子,看看到底是怎么个意思。
我们看到 putVal(hash(key), key, value, false, true);
这有4个参数,前两个分别是key(key的hash值),value,第三个代表遇到重复值是否要覆盖,fasle是覆盖,最后一个参数是指插入结束后要不要创建新的模式,false代表是(可参考英文注解)
此时你或许会疑问,为什么要对key进行hash化,这涉及到hash算法,大家想一下,map的数组长度默认是16,而且map是无序的,也就意味着要在下标为0-15中随机产生而且人家更要考虑的均匀性,就是0-15不仅要随机,还要每个数字都要雨露均沾。目的就是尽量让这些数字产生的更加公平均匀。
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) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
看一下前几行的代码,首先定义了两个数组tab和p,以及两个int变量,n和i,我们继续往下看就知道作用了。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
table是常量,记录了map的数据,我们每插入一条数据,table就会多一条记录下来。
这块代码就是初始化,看看是不是第一次添加数据,如果是就通过resize()方法给tab初始化,并由变量n记录当前长度,此时我们已经看到了两个变量的作用。
Resize()方法的作用有2个,初始化数组和扩容,最后我们会一起看一下,此处先认识有这样一个操作 。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
数组下标值是 (n - 1) & hash 这行代码来获取的,为什么能实现雨露均沾,这个就涉及二进制的&运算,具体请关注往期文章
else后面的代码我并没有粘过来,为什么?因为这就涉及到一个常见面试点:hash碰撞
什么是hash碰撞?就是产生的hash值重复了,既然是产生1-16,那么重复的概率还是很高的,没有重复,就按照上面所写的,我新建一个节点就好了,但是重复了呢?就是else的内容了,在此之前我们先明确思路,再去看代码就简单的很了
1,要追加的地方,本身还没有链表,要添加的是第一个
2,我要追加的可能不是链表,可能是红黑树,那我直接转变成树的节点就好
3,后面有链表,那就需要我不停的去遍历,然后找到合适的位置,但是因为是新添加节点,我们还要考虑链表长度达到了8,就要转变成红黑树。
else {
Node<K,V> e; K k;
//情况1
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//情况2 转变为树的节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//情况3 循环查找链表查找位置
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
不知道根据上面的注释大家是否有了理解
++modCount;
if (++size > threshold)
resize();
最后几行,因为我们随时要记录长度,准备扩容,最后几行的目的就是来判断是否要扩容的.。
扩容,resize()也是经常被问到的,我们也去看下,还是先说思路;
1,扩容就是达到了指定长度后,每次扩容2倍,16会变成32,但是如果容器本身超过了最大限制,就没法扩容了
2,扩容后会对现有数据重新排序,为什么呢?和生活一样,我们住的空间大了,那我们的行李用品也要搬一些到新空间,别显得那么拥挤,但又不是全部搬走,会选一部分搬,至于怎么选呢?就体现出二进制算法的精妙之处了,具体如何实现,往后看
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//oldcap为0说明是初始化,不要忘记resize的作用是初始化和扩容
if (oldCap > 0) {
//长度超出了最大值,无法扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容后的值不超过最大值,就扩容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
//重新计算加载因子等
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;
刚才也提到扩容后重新排序的,感兴趣的自己去研究一下,下面是代码
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;
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;
但是要提醒的是,刚才说到得巧妙之处是
if ((e.hash & oldCap) == 0
此处的&运算就是精密之处,通过二进制运算来比较现有的与原来的不同,会产生0和1,0就留下,1就移走,移走后的位置下标是 原先位置+扩容长度
才能有限,源码就带大家看到这,那我们进入最后一个环节,问几个问题,看看能否回答?
1,hashmap的底层原理,数据结构是什么?
底层是哈希表(数组加链表),1.8之后引入了红黑树
2,能说一下hashmap的put过程吗?
1>传入key-value,并根据key求出哈希值,用于计算下标
2>查看是否冲突,不冲突就装入容器中,
3>冲突就追加到链表中,并且要查看是否达到链表阈值,达到要转换成红黑树
4>查看节点是否重复,重复就覆盖
5>查看容器是否要扩容
3,hashmap是如何获得下标的?
(n-1) & hash. 原理是高16位不变,与低16位做与或运算
4,能说下扩容吗?扩容后部分数组位置肯定要变化,变成什么了呢?
1>判断当前容量或扩容后的容量是否超出最大值,超出则无法扩容,否则扩容会增加2倍
2>重新遍历数组,然后将部分移动到新位置
(注意:resize还有初始化操作,如果记录的table常量是空就初始化)
位置就是 原有位置+扩容数量
5,hashmap中的链表太长,查找时间复杂度可能会达到0(n),如何解决?
引入红黑树就是为了解决这个问题
大家如果觉得有帮助,请关注公众号: 徒步归行 ,我们会奉献更多的技术文章