最近跟两个正在找工作的同学聊天,说起集合,都是面试的重灾区,必问的选项,而且在实际的面试中并不会单独提问某一个问题,而是围绕核心知识连环炮提问。所以背面试题治标不治本,还是得读一读源码。谁让这是个面试造火箭,工作拧螺丝的市场氛围,就连CSDN的首页第二张轮播图都在蹭这个热点:
本文主要包括两部分:
-
HashMap面试必问(总结了一些常见面试题)
-
JDK1.7 & JDK1.8 关于HashMap原理分析
这部分主要是通过断点debug来分析HashMap中常见操作的过程,但由于步骤繁多,只记录了关键步骤,建议读者也在自己电脑上debug一遍,了解详细流程。(计算机是一门实践性很强的学科,看的再多也不如自己亲自操作一遍,当然理论也同样重要)
长文警告!!!
1,HashMap面试必问
这是笔者在一篇博客中找出来的,很有代表性,实际的面试提问中不会按部就班的问,而是千变万化,所以除了把面试题背住之外,一定要花点时间看看源码具体实现,虽然不会360度无死角,但对源码总体有个大概的把握,回答起来就知道哪些知道哪些不知道,一来方便查漏补缺,二来也能更加灵活的回答问题。
示例性提问(真实场景下):
-
你看过JDK的源码吗?
看过。
-
HashMap是如何通过put添加元素的?
根据key计算hash值,再将hash值转换为数组下标。
-
底层数组默认的长度为多少?
默认为16。
-
什么时候会触发扩容机制?
元素个数超过阈值就会触发扩容机制,并且是在新增元素发生hash冲突的情况下。
-
扩容时,直接将数据从原数组平移到新数组可以吗?
不行,需要重新计算hash值(更正,是重新计算index值,而不是重新计算hash值,hash值只与key相关,index与table.length相关)
-
为什么需要重新计算hash值?
因为数组扩容了,从hash值转换为数组下标这个过程就发生了变化,同时,获取value这个过程也会发生变化。所以必须重新计算,不然之前保存的元素就无法访问。
一般性问题(建议背住,而后融会贯通):
-
什么是HashMap?
HashMap是基于Map接口的实现,主要用于存储键值对(1.7通过Entry对象封装键值对,1.8通过Node封装键值对)
-
HashMap采用了什么数据结构?
1.7:数组+链表
1.8:数组+链表+红黑树
-
HashMap是如何解决hash冲突的问题的?
链表。
-
hash冲突和index冲突的关系?
hash冲突就会导致index冲突,indexFor方法的两个参数一个是hash值,另外一个是table.length。
-
HashMap的put方法是如何实现的?
先通过key计算hash值,再通过indexFor方法转换为数组下标。
-
HashMap的扩容机制是什么样的?
HashMap默认初始容量为16,加载因子为0.75,实际存储大小为12。hashMap容量达到12并且当前加入的元素产生hash冲突时时,进行初始容量的2倍扩容
-
为什么初始容量为16?
HashMap重写的hash采用的是位运算,目的是使key到index的映射分布更加均匀
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 也解释了为什么hash允许空值,实际上当key为null时,自动转换为0
-
-
为什么链表使用头插法?
HashMap的发明者认为,后插入的Entry被查找的可能性更大。
-
hashMap中的链表是单链表还是双链表?
单链表
final int hash; final K key; V value; Node<K,V> next;
-
扩容阈值threshold被赋值了几次?
- 调用构造函数被赋值,初始化容量大小(默认为16)
- 数组为空,初始化数组时,被赋值为初始化容量*加载因子(默认为12)
-
hash冲突插入链表的方式?
1.7:采用头插法:作者认为,后插入的会被优先访问
1.8:采用尾插法:避免链表死循环
-
hashMap允许key为null值吗?
允许一个key为null,会转换为数组下标0。当出现第二个key为null,其value会自动覆盖第一个null的值。
-
hashMap中链表过长会导致什么问题?
查询效率降低。时间复杂度为O(n)【需要遍历链表】
-
jdk7中的HashMap存在哪些问题?
-
链表过长导致查询效率降低
-
扩容导致的死循环
-
线程不安全(个人认为这不是问题,而是在设计上就没有考虑这个,线程安全就会导致效率降低,本质上是效率和安全之间的取舍)
-
-
jdk7和jdk8处理hash冲突的区别?为什么?
jdk7计算hash值的运算是非常复杂的,因为如果产生了hash冲突是用链表来进行存储的,效率比较慢,所以在设计上要尽可能避免冲突。
jdk8计算hash值的方法相对简单,因为采用了红黑树的结构,即使发生了hash冲突,也可以通过转换为红黑树来提高效率。
-
为什么加载因子是0.75而不是其他值?
因为加载因子参与indexFor数组下标的计算,return h & (length-1);
其数值会影响index是否发生冲突,同时也会影响空间利用率,默认情况下table长度为16,但只能存12个值。
所以这个加载因子是在index冲突和空间利用率之间寻求的一个平衡点。
-
HashMap是否可以存放自定义对象?
可以,因为HashMap使用了泛型。
-
为什么JDK8引入红黑树?
由于hash冲突导致链表查询非常慢,时间复杂度为O(n),引入红黑树后链表长度为8时会自动转换为红黑树,以提高查询效率O(logn)。
-
Java集合中ArrayList,LinkedList,HashMap的时间复杂度分别为多少?
ArrayList基于数组实现,基于下标查询的话时间复杂度为O(1),如果基于内容查找需要遍历的话,时间复杂度为O(n)。
LinkedList基于链表实现,查询效率为O(n)
HashMap在不考虑Hash冲突没有形成链表的情况下时间复杂度为O(1),形成链表后时间复杂度为O(n)
2,Debug源码的心得体会
【关注核心步骤,选择性忽略】
JDK是一个相当庞大的系统,把所有的类和原理全部弄清楚是相当有难度的,所以在debug源码的时候,如果遇见了不相关的类,忽略就是了。
然而单看HashMap源码(2300行)也是一个较为庞大的代码量,所以对其中不重要或者不常用的方法,最好先选择性忽略。比如计算hash值的各种位运算,研究起来还是得废一些功夫的,这个可以在把握了HashMap的大致框架后再做精细化的研究。
总的来说,先重点关注核心步骤,选择性忽略更加具体的实现,逐个击破,从而提高阅读效率。
ps:建议把1.7和1.8的jdk都装上,切换着分析。
3,JDK 1.7
3.1 用debug分析一个元素是如何加入到HashMap中的【jdk1.7】
创建一个Main.java类
HashMap<String,String> hashMap = new HashMap<>(16);
hashMap.put("x","x");
hashMap.put("y","y");
在创建HashMap对象上打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
调用构造函数:
this方法,初始值判空异常(初始值不能小于0大于最大值),加载因子判空异常,
threshold被初始化容量赋值(threshold为扩容阈值)
在插入第一个元素上打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
public V put(K key, V value) {
//判断数组是否为空,如果为空进行初始化,inflateTable初始化方法见下文①
//threshold:扩容的阈值(当前元素个数超过这个数值就会进行扩容)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//判断key是否为空
if (key == null)
//hashMap处理空值的方法②
return putForNullKey(value);
//计算key的hash值(主要是各种位运算)
int hash = hash(key);
//i就是将key的hash值再进行一次转换得出的数组下标
int i = indexFor(hash, table.length);
//同样是个处理hash冲突的头插算法
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//添加元素③
addEntry(hash, key, value, i);
return null;
}
①inflateTable初始化容量方法:
private void inflateTable(int toSize) {
//向上舍入为2的幂
int capacity = roundUpToPowerOf2(toSize);
//重点:threshold在初始化构造函数时默认为16,在初始化数组时,乘以加载因子被二次赋值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//初始化数组容量
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
②hashMap处理空值的方法
private V putForNullKey(V value) {
//处理key为null值的hash冲突,采用头插法(null会自动转为0)
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
③addEntry添加元素
void addEntry(int hash, K key, V value, int bucketIndex) {
//hash扩容(size代表元素个数,如果元素大于threshold【默认是12】,则会进行扩容)
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//④
createEntry(hash, key, value, bucketIndex);
}
④
void createEntry(int hash, K key, V value, int bucketIndex) {
//bucketIndex就是put方法中计算出的数组下标i
//难点:如果未发生hash冲突,table[bucketIndex]则为空,e也为空,table[bucketIndex]等于最新插入的元素
//如果发生了hash冲突,也就是table[bucketIndex]并不为空,table[bucketIndex]就头插到链表中
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
3.2 用debug分析HashMap是如何get到一个元素的【jdk1.7】
还是先编写测试用例:
ps:测试的代码都不复杂,关键是要关注底层是如何实现的
HashMap<String,String> hashMap = new HashMap<String, String>(3);
hashMap.put("x","x");
hashMap.put("y","y");
hashMap.put("z","z");
hashMap.get("z");
打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
public V get(Object key) {
if (key == null) //判空
return getForNullKey();
Entry<K,V> entry = getEntry(key);
//判空,否则返回value
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
//判断数组是否为空
if (size == 0) {
return null;
}
//判断key是否为空,为空则返回0,否则计算hash值
int hash = (key == null) ? 0 : hash(key);
//遍历链表,获取Entry对象
for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {
Object k;
//核心:hash相等并且key相等才能返回entry,否则继续遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
3.3 用debug分析HashMap是如何扩容的?【jdk1.7】
编写测试用例:给的初始值为3,根据2的幂计算,HashMap初始化容量为4,扩容阈值为3,也就是在执行 hashMap.put("m","n");时会发生扩容:
HashMap<String,String> hashMap = new HashMap<String, String>(3);
hashMap.put("x","x");
hashMap.put("y","y");
hashMap.put("z","z");
hashMap.put("m","n");
打上断点:
debug运行,强制进入方法内部(Alt+Shift+F7):
判断数组是否为空。false
。。。(此处省去一些步骤)
运行到addEntry方法对size和threshold进行判断,此时size为3,满足条件。(ps:除了当前大小大于等于阈值之外,当前元素计算出的数组下标也必须与之前的元素产生hash冲突才能扩容)
【坑点】:size是元素总个数,而不是数组占用个数,比如只占用了一个数组位置,但是链表长12,还是会扩容,其目的是使得hash分布的更均匀
resize方法对数组table进行两倍扩容,当前table.length = 4.
resize方法:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity)); //将数据移至新数组⑤
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
⑤将数据移至新数组
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历链表
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); ///重新计算数组下标
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
3.4 HashMap 1.7 中多线程下扩容的死循环问题
问题描述:jdk1.7在多线程并发的情况下会由于链表的头插法导致扩容的死循环问题,在1.8中已经被解决。
问题代码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//table是全局变量,多线程的情况下,由于没有任何锁的机制,多个线程可以同时获取到table
for (Entry<K,V> e : table) {
//遍历链表
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//重新计算hash值
int i = indexFor(e.hash, newCapacity);
//头插法插入链表
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
图片描述:假设有A,B,C,D四个元素组成的链表,在扩容的时候,遍历链表A最先被移过去,其次是B,C,D,假设在进行扩容前,同时有两个线程获取到了全局变量table,T1线程扩容进行到了如图所示的步骤,正准备移动D过去。T2线程此时获取到的table的仍然扩容前的指向。所以T2读取到的table可能是A指向B,B同时指向A,这种情况下,遍历链表就会导致死循环。
e.next = newTable[i];
newTable[i] = e;
e = next;
一个元素的移动过程(index冲突),newTable[i]是已经移到新table中的数组下标对应的元素,如下图所示,C这个时候就是newTable[i],e
就是D,那么过程就是D指向了C,然后把e也就是D元素赋给newTable[i],此时这个链表的头结点就是D。最后一行代码相当与e = e.next。继续遍历链表。
4,JDK1.8
1.8相对于1.7有很多改进,比如采用了新的数据结构红黑树,链表改为尾插法等等。相对来说,1.8的代码量较1.7更多,故下文会部分省略代码,只展示程序运行过的步骤。
4.1 用debug分析第一个元素是如何加入到HashMap中的【jdk1.8】
切换到jdk1.8,继续debug
计算hash函数:hash(key),1.8中同样允许null值,会自动转换为0
jdk1.7中计算hash的方法
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
jdk1.8中计算hash的方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
jdk1.7中计算hash值的方法相对比较复杂,主要是因为要尽可能的避免hash冲突,因为链表的遍历是很慢的。但jdk1.8中因为引入了红黑树,即使hash冲突很高,也可以通过转换红黑树来提高查询效率。(所以hash的运算就相对简单,毕竟运算也是要耗费资源的)
核心方法: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;
//初始化扩容 ,resize方法见下文
if ((tab = table) == null || (n = tab.length) == 0)
//n为扩容后的容量,本次情况下为4,上文中HashMap的初始化容量设为3,根据hashMap规则,容量只能为2^n
n = (tab = resize()).length;
//&优先级高于=,看了半天没明白啥意思,1.7中将hash转换为index的过程用indexFor方法封装起来了,其实是一样的:h&(length-1)
//如果当前位置是空的,直接赋值给数组
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//这里包括转换为链表或红黑树,下文再分析
else {
**************
}
//修改次数+1
++modCount;
//若当前size+1后的值大于扩容阈值,执行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
//hashMap扩容方法
final Node<K,V>[] resize() {
//获取到当前table,table是全局变量
Node<K,V>[] oldTab = table;
//计算当前table的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取当前扩容阈值(threshold=capacity*loadFactor)
int oldThr = threshold;
//初始化新的容量和扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//若当前容量大于最大容量(10亿多)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//左移运算符优先级高于赋值运算符,左移1位相当于乘以2,newCap相当于旧容量2倍扩容
//另外一个判断条件:当前容量大于默认容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的扩容阈值翻倍
newThr = oldThr << 1; // double threshold
}
//若当前扩容阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//将当前扩容阈值赋值给新容量
newCap = oldThr;
//若当前容量为0且扩容阈值为0,这种情况是在没有给hashmap任何初始值的时候发生的
else { // zero initial threshold signifies using defaults
//默认容量为16
newCap = DEFAULT_INITIAL_CAPACITY;
//默认的扩容阈值为默认的负载因子乘以默认初始化容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//若新的扩容阈值为0
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
table = newTab;
//在为空初始化容量时,并不会进入分支,下文再补充注释
if (oldTab != null) {
*******
}
//返回新的键值对数组
return newTab;
}
ps:1.8中使用Node代替Entry,换了个名,然后hash加上了final修饰
4.2 用debug分析HashMap扩容情况【jdk1.8】
测试用例如下:HashMap的初始容量给到3,实际容量为4,扩容阈值为3,在添加第四个元素的时候进行扩容
进入方法内部:
重点关注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;
//table为空时初始化的扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//若当前数组下标并未有元素,直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//形成链表
else {
Node<K,V> e; K k;
//若key冲突,直接替换value(key相同,hash值一定相同)
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 {
//遍历当前table[i]所在的链表
for (int binCount = 0; ; ++binCount) {
*******
}
}
}
++modCount;
//当前size为3,加1后大于扩容阈值,进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize()扩容:
final Node<K,V>[] resize() {
//获取到当前table,table是全局变量
Node<K,V>[] oldTab = table;
//计算当前table的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取当前扩容阈值(threshold=capacity*loadFactor)
int oldThr = threshold;
//初始化新的容量和扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//若当前容量大于最大容量(10亿多)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//左移运算符优先级高于赋值运算符,左移1位相当于乘以2,newCap相当于旧容量2倍扩容
//另外一个判断条件:当前容量大于默认容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的扩容阈值翻倍
newThr = oldThr << 1; // double threshold
}
//若当前扩容阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//将当前扩容阈值赋值给新容量
newCap = oldThr;
//若当前容量为0且扩容阈值为0,这种情况是在没有给hashmap任何初始值的时候发生的
else { // zero initial threshold signifies using defaults
//默认容量为16
newCap = DEFAULT_INITIAL_CAPACITY;
//默认的扩容阈值为默认的负载因子乘以默认初始化容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//若新的扩容阈值为0
if (newThr == 0) {
//计算新的扩容阈值:在新容量小于最大容量且计算后的扩容阈值小于最大容量的情况下,新的扩容阈值为新容量乘以负载因子,否则为最大容量
float ft = (float)newCap * loadFactor;
//此时新的扩容阈值为6,容量为8
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
table = newTab;
//上文补充,此时旧数组并不为空 ***************************************************************************//
if (oldTab != null) {
//遍历旧数组,遍历计算下标放入新数组中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//null会直接转化为0,所以不需要计算
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
//为链表节点,需要进行重hash分布(就是数组下标的重新计算,一天天的,就不整个人话)
Node<K,V> loHead = null, loTail = null; //用于数组下标为0的节点
Node<K,V> hiHead = null, hiTail = null; //用于数组下标发生变化的节点
Node<K,V> next;
do {
next = e.next;
//将当前元素的hash值与老表的容量进行与运算,相当于计算数组下标,若等于0,则扩容后的下标仍然是0
if ((e.hash & oldCap) == 0) {
//若loTail为空,表示该节点为链表上的第一个节点(loTail表示链表尾),将节点赋给loHead
if (loTail == null)
loHead = e;
//若loTail不为空,表示当前节点并非是链表的第一个节点,可将e赋给链表尾loTail的下一个指向,此时表尾lotail后连接的是e
else
loTail.next = e;
//将e赋给链表尾,1.8中使用了尾插法,而1.7中使用的是头插法
loTail = e;
}
//处理数组下标非0的节点
else {
//同理:使用尾插法连接节点
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null); //这个循环就是遍历链表,直到下一个为null
//如果loTail不为空,说明老数组中的数组下标在新数组中也有使用
if (loTail != null) {
//将链表尾的下一个指向置为空
loTail.next = null;
//将链表头赋值给新数组的元素
newTab[j] = loHead;
}
//如果hiTail不为空,说明这是非0的数组下标,
if (hiTail != null) {
//将链表尾的下一个指向置为空
hiTail.next = null;
//新数组下标为原来的数组下标+旧容量(666)
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的键值对数组
return newTab;
}
4.3 用debug分析链表的形成过程【jdk1.8】
编写测试用例,(???如何模拟更多的hash冲突???)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//table为空时初始化的扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//若当前数组下标并未有元素,直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//形成链表**************************************************
else {
Node<K,V> e; K k;
//若key冲突,直接替换value(key相同,hash值一定相同)
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);
//排除了key覆盖和红黑树,剩下的就是链表了
else {
//遍历当前table[i]所在的链表
for (int binCount = 0; ; ++binCount) {
//若链表当前节点的下一个节点为空,说明已到链表尾,break退出循环
if ((e = p.next) == null) {
//退出循环前,把新元素加到链表尾部
p.next = newNode(hash, key, value, null);
//若链表节点数量大于等于8,转换为红黑树(binCount从0开始计算,到7的时候已经是第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;
}
}
}
++modCount;
//当前size为3,加1后大于扩容阈值,进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4.4 用debug分析get元素的过程【jdk1.8】
getNode()
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) {
//判断第一个节点的hash值和key是否相等,若相等,直接返回,否则进入链表遍历
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;
}
4.5 用debug分析删除元素的过程【jdk1.8】
removeNode()
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//一个if看得都费劲,p节点是根据hash和key计算出的待删除的节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//若p的hash和key都吻合,直接赋值节点node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//说明p所在节点为一个链表
else if ((e = p.next) != null) {
//判断链表是否转换成了红黑树
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//若未转换为红黑树,则遍历链表,直到key和hash都吻合,赋值给node
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//删除node
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//判断node是否为红黑树节点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//判断node节点是否为链表的第一个节点,若是,将当前链表的下一个节点指向赋给数组
else if (node == p)
tab[index] = node.next;
//最后一种情况就是node节点在链表中间,将头节点的下一个节点指向node的下一个节点。
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
//返回node
return node;
}
}
return null;
}
get和remove的思路
两者大体思路相同,先根据传入的key计算hash,再依次通过:第一个元素是否命中,链表是否为红黑树,遍历链表的思路寻找对应的节点元素删除或返回。
4.6 关于红黑树。核心就是自平衡!
红黑树基于二叉查找树实现,在此基础上做了优化。
二叉查找树又称二叉搜索树,二叉排序树
关键规则如下:左子树的值=<根节点=<右子树的值,左右子树遵守同样的规则
二叉查找树的平衡问题:
红黑树的核心功能就是自平衡。
红黑树的规则:
-
节点为红色或黑色
-
根节点是黑色
-
叶子节点(NIL)是黑色
-
如果一个节点是红色的,则它的子节点必须是黑色的。
-
任一节点到其子树的叶子节点的路径都包含相同的黑色节点
新插入的节点是这样的:
若向当前树中插入14,则为:并不会引起红黑树的变化
但若插入节点为21:违反了红黑树的红色节点的子节点都为黑色
与规则发生冲突时,红黑树需要进行调整,调整有两种方式:变色和自旋(自旋又分为左旋和右旋)
变色:比如新添加一个红色节点到一个红色节点下就会产生变色的情况。
左旋:当前节点变为左节点,当前节点的右节点变为父节点(把右节点的子树的左节点往左子树挪)
右旋:当前节点变为右节点,当前节点的左节点变为父节点(把左节点的子树的右节点往右子树挪)
4.7 hashMap树化原理
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;
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,转换为红黑树
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;
}
树化方法 treeifyBin(tab, hash);
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//若table为空或者tab的长度小于树化最小长度,优先扩容
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; //定义红黑树的头结点和尾结点
//遍历链表,最终结果:hd为表头,tl为表尾
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);
//将hd赋给数组
if ((tab[index] = hd) != null)
//树化方法
hd.treeify(tab);
}
}
treeify
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
//遍历链表,this在第一次循环代表hd
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
//初始化根节点
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;
if ((ph = p.hash) > h)
dir = -1; //为p的左子树
else if (ph < h)
dir = 1; //为p的右子树
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//判断p的子树是否为空(赋值和判断同时进行,666),若不为空,则在其子树下继续循环。最后到达叶子节点,插入节点
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x); //自平衡
break;
}
}
}
}
moveRootToFront(tab, root);
}
本文篇幅已经过长,关于红黑树,之后会专门写一篇文章研究1.8中的实现。