最近跟两个正在找工作的同学聊天,说起集合,都是面试的重灾区,必问的选项,而且在实际的面试中并不会单独提问某一个问题,而是围绕核心知识连环炮提问。所以背面试题治标不治本,还是得读一读源码。谁让这是个面试造火箭,工作拧螺丝的市场氛围,就连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中的实现。

Debug HashMap的更多相关文章

  1. Java基础:HashMap中putAll方法的疑惑

    最近回顾了下HashMap的源码(JDK1.7),当读到putAll方法时,发现了之前写的TODO标记,当时由于时间匆忙没来得及深究,现在回顾到了就再仔细思考了下 @Override public v ...

  2. jmeter sampler maven项目排错记

    eclipse 创建的maven项目,引入jar包之后出现红色叹号,一直找不到原因,连main方法都无法运行,提示找不到类: 错误: 找不到或无法加载主类 soapsampler.SoapSample ...

  3. JAVA源码分析-HashMap源码分析(一)

    一直以来,HashMap就是Java面试过程中的常客,不管是刚毕业的,还是工作了好多年的同学,在Java面试过程中,经常会被问到HashMap相关的一些问题,而且每次面试都被问到一些自己平时没有注意的 ...

  4. (转)Java集合框架:HashMap

    来源:朱小厮 链接:http://blog.csdn.net/u013256816/article/details/50912762 Java集合框架概述 Java集合框架无论是在工作.学习.面试中都 ...

  5. 关于Android中ArrayMap/SparseArray比HashMap性能好的深入研究

    由于网上有朋友对于这个问题已经有了很详细的研究,所以我就不班门弄斧了: 转载于:http://android-performance.com/android/2014/02/10/android-sp ...

  6. Java你可能不知道的事(3)HashMap

    概述 HashMap对于做Java的小伙伴来说太熟悉了.估计你们每天都在使用它.它为什么叫做HashMap?它的内部是怎么实现的呢?为什么我们使用的时候很多情况都是用String作为它的key呢?带着 ...

  7. java8中hashMap

    摘自:http://www.importnew.com/20386.html 简介 Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMa ...

  8. Java集合框架:HashMap

    转载: Java集合框架:HashMap Java集合框架概述   Java集合框架无论是在工作.学习.面试中都会经常涉及到,相信各位也并不陌生,其强大也不用多说,博主最近翻阅java集合框架的源码以 ...

  9. Java 集合系列 09 HashMap详细介绍(源码解析)和使用示例

    java 集合系列目录: Java 集合系列 01 总体框架 Java 集合系列 02 Collection架构 Java 集合系列 03 ArrayList详细介绍(源码解析)和使用示例 Java ...

随机推荐

  1. Python数据结构-树与树的遍历

    树:是一种抽象的数据类型 树的作用:用来模拟树状结构性质的数据集合 树的特点: 每个节点有零个或者多个节点 没有父节点的节点,叫做根节点 每一个根节点有且只有一个父节点 除了根节点外,每个节点可以分成 ...

  2. JVM类加载机制小结

    这篇文章我们关注一个问题:Java程序是怎么进入JVM并执行的?经常写Java程序的小伙伴应该都听说过类加载机制,在<深入理解Java虚拟机>里周老师已经讲的很清楚了,这篇随笔把之前的笔记 ...

  3. css制作简单loading动画效果【css3 loading加载动画】

    曾经以为,loading的制作需要一些比较高深的web动画技术,后来发现大多数loading都可以用“障眼法”做出来.比如一个旋转的圆圈,并不都是将gif图放进去,有些就是画个静止图像,然后让它旋转就 ...

  4. web开发,前后分离接口规范

    1. 前言 随着互联网的高速发展,前端页面的展示.交互体验越来越灵活.炫丽,响应体验也要求越来越高,后端服务的高并发.高可用.高性能.高扩展等特性的要求也愈加苛刻,从而导致前后端研发各自专注于自己擅长 ...

  5. C++栈(stack)、队列(queue)、链表(list)的常用函数

    C++队列Queue是一种容器适配器,它给予程序员一种先进先出(FIFO)的数据结构.1.back() 返回一个引用,指向最后一个元素2.empty() 如果队列空则返回真3.front() 返回第一 ...

  6. SQL基础随记3 范式 键

    SQL基础随记3 范式 键 什么是范式?哈,自己设计会使用但是一问还真说不上来.遂将不太明晰的概念整体下   什么是 & 分类 范式(NF),一种规范,设计数据库模型时对关系内部各个属性之间的 ...

  7. URL编码转换函数:escape()、encodeURI()、encodeURIComponent()讲解

    转自:https://www.cnblogs.com/douJiangYouTiao888/p/6473874.html 函数出现时间:         escape()                ...

  8. 什么?你还不会通过纯js提交表单?

    如果程序已经封装好了, 不管后台是java .asp.net   .还是php   ?这个时候你的客户突然追加说我要 追加表单验证?   what  妇产科    怎么办? submit  自带刷新效 ...

  9. [JAVA]使用字节流拷贝文件

    import java.io.*; /** * @Description: * @projectName:JavaTest * @see:PACKAGE_NAME * @author:郑晓龙 * @c ...

  10. ELKF-分布式日志收集分析平台搭建 最小化 配置过程 - 查看收集日志(windows10下搭建)

    前言 Elasticsearch是与名为Logstash的数据收集和日志解析引擎以及名为Kibana的分析和可视化平台一起开发的.这三个产品被设计成一个集成解决方案,称为“Elastic Stack” ...