Java 集合深入理解 :HashMap之实现原理及hash碰撞

Posted 踩踩踩从踩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 集合深入理解 :HashMap之实现原理及hash碰撞相关的知识,希望对你有一定的参考价值。

前言

哈希表(hashMap)又叫散列表

  • 是一种非常重要的数据结构基于map接口实现
  • 应用场景及其丰富,本地临时缓存,许多缓存技术(比如memcached)
  • 核心其实就是在内存中维护一张大的哈希表
  • 本文会对java集合框架中HashMap的实现原理进行讲解,从而达到深入理解哈希表

哈希表原理

在讨论哈希表原理之前,先看一下常用数据结构在新增,查找等基础操作对比执行性能
在这里插入图片描述
上图从数据结构上分析时间复杂度,明显看出哈希表的时间复杂度是很低的(不考虑冲突的情况下)

什么是哈希表

散列表(也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。

  • 它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数
  • 存放记录的数组叫做散列表。
  • 给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
    在这里插入图片描述

哈希冲突

  • 对应不同的关键字可能获得相同的hash地址,即 key1≠key2,但是f(key1)=f(key2)。这种现象就是哈希冲突
  • 这种冲突只能尽可能的减少,不能完全避免。因为哈希函数是从关键字集合和地址集合的映像,通常关键字集合比较大,而地址集合的元素仅为哈希表中的地址值。

在这里插入图片描述

实现示例

	public static void main(String[] args) {
		
		System.out.println("--------------hashmap--------------");
		HashMap maps=new HashMap();
		maps.put("1", "1");
		maps.put("6", "6");
		maps.put("3", "3");
		maps.put("7", "7");
		maps.put("2", "2");
		
		maps.keySet().stream().forEach(m->{
			System.out.println(maps.get(m));
		});
	}
--------------hashmap--------------
1
2
3
6
7

从上面对于这个例子来看

  • hashmap迭代器获取数据,并不是顺序的,而是根据hashcode的计算的地址 然后从首个节点进行往后遍历的

HashMap实现原理

以jdk1.8为例
hashmap的主干是Node数组,node是每个 数组的基本单元,里面包括hash值, key value 并包含 下个node节点(所以说 hashmap是一个单链表数组)

/**
*表,在第一次使用时初始化,并将其大小调整为必要的。分配时,长度总是2的幂。
*(在某些操作中,我们也允许长度为零,以允许当前不需要的引导机制。)
*/
transient Node<K,V>[] table;

Node 节点单元

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

在这里插入图片描述

  • 由上可以看出当不存在hash冲突,或者链表长度不大于1,插入 删除 查找时间复杂度为O(1)
  • 链表长度大于1,对于添加操作,其时间复杂度为O(n)
  • jdk1.8之后 为降低时间复杂度,链表长度大于8并且数组长度大于64时,自动降级成为为红黑树,时间复杂度降低为O(lgn)

全篇注释分析

	/**
	*基于哈希表的<tt>映射</tt>接口实现。这个实现提供了所有可选的map操作和许可证
	*<tt>null</tt>值和<tt>null</tt>键(哈希图类大致相当于<tt>哈希表</tt>,只是
	*这个类不保证地图的顺序;特别是,它不能保证将随时间保持不变。
	*
	*<p>此实现为基本操作(<tt>get</tt>和<tt>put</tt>),假定哈希函数在桶中适当分散元素。迭代结束
	*集合视图需要与视图的“容量”成比例的时间<tt>HashMap</tt>实例(bucket的数量)加上它的大小(bucket的数量键值映射)。因此,设置初始值是非常重要的
	*如果迭代性能不好,容量太高(或负载系数太低)都会导致时间复杂度降低。
	*
	*<p>HashMap的实例有两个参数影响其性能:<i>初始容量</i>和<i>负载系数</i>。这个<i>容量</i>是哈希表中的存储桶数,以及
	*容量只是创建哈希表时的容量。这个<i>加载因子</i>是允许哈希表的填充程度的度量
	*在其容量自动增加之前获取。当哈希表中的条目超过了加载因子和当前容量时,哈希表被重新格式化(即内部数据)结构)以便哈希表具有大约两倍的
	*铲斗数量。
	*
	*<p>一般来说,默认负载系数(.75)提供了良好的性能时间和空间成本之间的权衡。较高的值会降低
	*空间开销,但会增加查找成本(反映在大多数类的操作,包括<tt>获取</tt>和<tt>放置</tt>)。中的预期条目数
	*在安装时应考虑地图及其荷载系数设置其初始容量,以尽量减少再冲洗操作。如果初始容量大于最大条目数除以负荷系数,无需再灰分
	*行动永远不会发生。
	*
	*<p>如果要在HashMap中存储许多映射例如,使用足够大的容量创建它将允许存储映射要比让它执行更有效
	*根据需要自动重新灰化以增大桌子。注意,使用许多具有相同{@code hashCode()}的键肯定会减慢速度
	*降低任何哈希表的性能。当钥匙如果{@link Comparable},这个类可以使用
	*帮助打破关系的钥匙。
	*
	*<p><strong>请注意,此实现不同步。</strong>
	*如果多个线程同时访问哈希映射,则线程从结构上修改映射,它必须外部同步(结构修改是指任何操作
	*添加或删除一个或多个映射的;只是改变了价值与实例已包含的键关联的不是
	*结构修改)这通常由在自然封装地图的某个对象上进行同步。
	*
	*如果不存在这样的对象,则应该使用
	*{@link Collections#synchronizedMap Collections.synchronizedMap}
	*方法。这最好在创建时完成,以防止意外对地图的非同步访问:<pre>
	*Map m=Collections.synchronizedMap(新HashMap(…))</预处理>
	*
	*<p>这个类的所有“集合视图方法”返回的迭代器是否快速故障:如果在故障发生后的任何时间对地图进行结构修改
	*迭代器是以任何方式创建的,除了通过迭代器自己的
	*<tt>remove</tt>方法,迭代器将抛出{@link ConcurrentModificationException}。因此,面对
	*修改后,迭代器会快速而干净地失败,而不是冒着
	*在一个不确定的时间里的任意的,不确定的行为
	*未来。
	*<p>请注意,不能保证迭代器的快速失败行为一般说来,不可能在未来作出任何硬性保证
	*存在未同步的并发修改。失败快速迭代器尽最大努力抛出ConcurrentModificationException。
	*因此,编写依赖于此的程序是错误的其正确性例外:<i>迭代器的快速失败行为应仅用于检测错误。</i>
	* <p>This class is a member of the
	* <a href="{@docRoot}/../technotes/guides/collections/index.html">
	* Java Collections Framework</a>.
	*
	* @param <K> the type of keys maintained by this map
	* @param <V> the type of mapped values
	*
	* @author  Doug Lea
	* @author  Josh Bloch
	* @author  Arthur van Hoff
	* @author  Neal Gafter
	* @see     Object#hashCode()
	* @see     Collection
	* @see     Map
	* @see     TreeMap
	* @see     Hashtable
	* @since   1.2
	*/

注释分析

  • hashmap是基于map的实现,不保证数据的顺序,特别是,它不能保证将随时间保持不变
  • HashMap的实例有两个参数影响其性能,初始容量和负载系数 ,当哈希表中的条目超过了加载因子和当前容量时,哈希表被重新格式化(即内部数据)结构)以便哈希表具有大约两倍bucket的 数量(数组)
  • 许多具有相同{@code hashCode()}的键肯定会减慢速度降低任何哈希表的性能,因此选择使用
  • hashmap不同步,需要使用Collections.synchronizedMap来保证数据安全
  • 使用迭代器的快速失败机制抛出异常保证数据操作安全

实现注意事项

在分析该篇文章前我们看一下实现的注意事项

/*
*实现注意事项。
*
*这个映射通常充当一个装箱的哈希表,但是当箱子变得太大时,它们会被转化为垃圾箱
*树节点,每个树节点的结构与java.util.TreeMap。大多数方法尝试使用普通的垃圾箱,但是适用时,中继到树节点方法(仅通过检查
*节点的实例)。树状物箱可以穿过与其他方法一样使用,但还支持更快的查找当人口过剩时。然而,由于绝大多数垃圾箱
*正常使用不会过多,检查是否存在在表方法的过程中,树容器可能会被延迟。
*树仓(即其元素均为树节点的仓)是主要按hashCode排序,但在tie的情况下,如果是两个元素属于相同的“C类<C>”,
*然后使用compareTo方法进行排序(我们通过反射保守地检查泛型类型以验证这个——请参见方法CompariableClassfor)。增加的复杂性
*在提供最坏情况O(logn)时,树仓的数量是值得的当键具有不同的哈希或
*因此,在这种情况下,性能会优雅地下降hashCode()方法的意外或恶意使用
*返回分布不均匀的值,以及哪些键共享一个哈希码,只要它们也是
*可比(如果这两个都不适用,我们可能会浪费一个月的时间在时间和空间上两个因素与不采取行动相比
*注意事项。但已知的唯一案例来自于糟糕的用户编程实践已经非常缓慢,这使得
*差别不大。)
*因为树节点的大小是常规节点的两倍,所以我们仅当容器包含足够的节点以保证使用时才使用它们
*(参见TREEIFYèu阈值)。当它们变得太小(由于移除或调整大小)它们被转换回普通垃圾箱。在
*使用分布良好的用户hashcode,树容器很少使用。理想情况下,在随机哈希码下
*箱中的节点遵循泊松分布
* (http://en.wikipedia.org/wiki/Poisson_distribution)带着
*默认调整大小的参数平均约为0.5  阈值为0.75,但由于调整粒度。忽略方差,预期列表大小k的出现次数为(exp(-0.5)*pow(0.5,k)/阶乘(k))。第一个值是:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
*更多:不到千万分之一
*树bin的根通常是它的第一个节点。然而,有时(当前仅在Iterator.remove上),根可能位于其他位置,但可以通过父链接恢复(方法TreeNode.root())。
*所有适用的内部方法都接受哈希代码作为参数(通常由公共方法提供),允许
*它们可以在不重新计算用户哈希码的情况下互相调用。大多数内部方法也接受“tab”参数,即通常是当前表,但可能是新表或旧表
*调整大小或转换。当垃圾箱列表被树化、拆分或未经审核时,我们会保留它们具有相同的相对访问/遍历顺序(即字段
*Node.next)以更好地保留局部性,并简化处理调用
*迭代器.remove。在插入时使用比较器,以保持总订购量(或尽可能接近此处要求)再平衡,我们比较类和身份密码系紧断路器。
*简单模式和树模式之间的使用和转换是由于子类LinkedHashMap的存在而变得复杂。看到了吗对于定义为在插入时调用的钩子方法,
*允许LinkedHashMap内部否则的话,它们将与这些力学无关(这也是要求将映射实例传递给某些实用程序方法
*可能会创建新节点。)
*像基于SSA的编码风格这样的并发编程很有帮助避免在所有扭曲指针操作中出现别名错误。
*/

第一段说明为什么要使用红黑树
红黑树的插入、删除和遍历的最坏时间复杂度都是log(n),因此,在意外或者恶意使用导致hashCode()方法返回值的分布很糟糕,以及在那些许多key共享一个hashCode的情况下,只要Key具有可比性,性能的下降将会是"优雅"的。(如果这两种方法都不适用,与不采取预防措施相比,我们可能会浪费大约两倍的时间和空间。但目前所知的唯一案例来自于糟糕的用户编程实践,这些实践已经非常缓慢,以至于没有什么区别。)

第二段说明链表转化为红黑树的阈值设置为8
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以选择8。

默认属性分析

初始化容量(数组的容量大小)

/**
     * 默认初始容量-必须是2的幂
     */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; aka 16

为什么默认初始容量-必须是2的幂,

  • 做位与运算(&)时,设置容量为2的幂指数,避免了hash碰撞产生链表,使得结果可以均匀分布 ,提高了查询效率(i = (n - 1) & hash) 确定下标 index
  • 是16如果是8也可以均匀分布,是小于10的,研究了所有集合包的类,基本都大于10的,这个也是考虑我们在使用的大多数,而且其实都给我们提供设置了初始化容量的构造方法

最大容量(数组的容量大小)

	/**
	*如果隐式指定了更高的值,则使用最大容量由带参数的构造函数之一。
	*必须是2的幂<=1<<30。
	*/
    static final int MAXIMUM_CAPACITY = 1 << 30;

为什么最大容量1 << 30,我想它有两个点值得思考

  • 减少hash碰撞 ,HashMap在确定链表数组下标Index的时候,采用(i = (n - 1) & hash),所以length为2的指数幂的时候才能较均匀的分布元素,所以HashMap规定了其容量必须是2的n次方
  • HashMap规定了其容量是2的n次方,所以作者采用的是位运算<<来控制HashMap的大小,并且因此提高了Java的处理速度;HashMap内部是由数组构成,Java的数组下标是由Int表示的;所以容量肯定是不大于int最大值,进而采用1 << 30 1 073 741 824 值

扩容加载因子

  /**
     * 构造函数中未指定时使用的负载因子。
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

加载因子是表示Hsah表中元素的填满的程度

  • 加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.
  • 反之,加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.
  • 加载因子和初始化容量相乘使用得到扩容阈值;根据业务使用,太小容易造成扩容,重新存储链表空间很耗时间的

链表转换树的阈值

  /**
     * 这个值是为存储箱(链表)使用树不是列表的存储箱计数阈值,当将元素添加到至少有这么多节点的容器中时,容器将转换为树
     * 并且值必须大于2,且至少应为8,以符合中的假设关于转换回普通链表的树木移除收缩。
     */
    static final int TREEIFY_THRESHOLD = 8;

这个阈值至少大于8的原因

  • 和hashcode碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡。
  • 从注释解释在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为7的概率0.00000094 ,当元素个数为8时,为0.00000006;更小,概率不到千万分之一;证明大于8概率是非常小的,还是存在 ;hash碰撞次数多,并且遍历效率也会降低

为什么不一开始就直接使用红黑树而不用阈值

  • 红黑树中的TreeNode是链表中的Node所占空间的2倍,红黑树的查找效率为o(logN),要优于链表的o(N);
  • 当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。

要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。

树转换链表的阈值

	/**
	*存储过程中取消检测(拆分)存储箱的存储箱计数阈值
	*调整大小操作。应小于阈值,并且多数6目筛下用收缩检测去除。
	*/
    static final int UNTREEIFY_THRESHOLD = 6;

为什么不直接用上面 TREEIFY_THRESHOLD 进行树的收缩
想的主要原因还是防止频繁的转换,从而导致速率降低

最小树容量

  /**
   *最小的树容量,箱子可以被树化。(否则,如果bin中的节点太多,则会调整表的大小。)
   *应至少为4*TREEIFY_THRESHOLD阈值以避免冲突在调整大小和树化阈值之间。 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
   */
    static final int MIN_TREEIFY_CAPACITY = 64;

桶中的Node被树化时最小的hash表容量。

  • 也就是链表变红黑树的条件除了链表的长度需要大于8,并且 数组容量大于64。
  • 这个值不能小于 4 * TREEIFY_THRESHOLD(8)

属性分析

下面属性就不怎么分析,在实现hashmap时,具体用这些属性进行存储记录,都是比较基础,需要在应用时才能看到使用地方

  /**
     * 以node节点的数组,用于扩容 数组等
     */
    transient Node<K,V>[] table;
     /**
     * 保留缓存的 entrySet(). 请注意,使用了AbstractMap字段
     * 的 keySet() and values() 方法
     */
    transient Set<Map.Entry<K,V>> entrySet;
    transient int size;
    
   transient int modCount;
    /**
     * 要调整大小的下一个大小值(容量*负载系数)。
     *
     * @serial
     */
    // (序列化后javadoc描述为true。
    // 此外,如果尚未分配表数组,则
    // 字段保存初始数组容量,或零表示
    // 默认容量(初始容量)
   int threshold;

构造方法分析

无参构造方法

  • 直接new hashmap时,和其他集合有一点不太一样的是,并没有调用其他有参构造方法对容量值 等默认值进行赋值。
  • 而这里只对加载因子做了赋值,这种方式是适配元素创建散列表
 /**
     * 用默认的初始容量构造一个空的<tt>HashMap</tt>
     * (16) 和默认加载因子为 (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段均为默认值
    }

有参构造方法

  • 指定一个空的hashmap,并指定出 容量及 默认负载系数
  • 传入的容量(initialCapacity)一定要小于默认容量(MAXIMUM_CAPACITY) 否则也是按默认容量进行赋值,在构造参数时,threshold这个值相当于默认容量 ,只有在 数据改变时,这个参数扩容的临界值,=容量*填充因子
  • 对于hashmap 直接传入Map的,putMapEntries 方法解析,我放到解析 putAll 方法时解析
    /**
     * 用指定的初始值构造一个空的<tt>HashMap</tt>
     * 容量和默认负载系数(0.75)。
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

 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);
    }
  public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

tableSizeFor方法
该方法用于进行找到大于等于initialCapacity的最小的2的幂,结合上面的容量解释就想得明白,提高查询效率 减少hash碰撞

  /**
     * 返回给定目标容量的二次幂。
     */
    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
比如5的二进制是1015>>>2表示右移2位,变成001,即为1

按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。
从另一个角度来分析,它向右移动2 位,其实就是除以2 的2 次方

然后在理解该代码的意思

  • 对n右移1位:001xx…xxx,再位或:011xx…xxx
  • 对n右移2为:00011…xxx,再位或:01111…xxx
  • 此时前面已经有四个1了,再右移4位且位或可得8个1
  • 同理,有8个1,右移8位肯定会让后八位也为1。

综上可得,该算法让最高位的1后面的位全变为1。最后再让结果n+1,即得到了2的整数次幂的值了。由于int是32位,所以>>>16便能满足。
在这里插入图片描述
下面有两个需要注意的点

  • 不断右移位进行或运算,一直右移到16位进行运算的原因都是为了取到最近的 2的整数次幂的值,在这里 容量为12,在右移两位时,已经计算出最近的 2的整数次幂 后面的不需要了,在继续移动16位进行对比的原因,也是判断容量的大小
  • 为什么要int n = cap - 1;让cap-1再赋值给n的目的是另找到的目标值大于或等于原值,我在推算时,就想过直接用8去算,如果不减1,则该算法运算出来则容量为16

其次这种方法的效率非常高,也是得益于位运算操作

重要的put方法

从put方法中可以看出 分为两个步骤 一是根据key计算出hash值 、二 增加value值

  /**
     * 将指定值与此映射中的指定键相关联。
     * 如果映射以前包含键的映射,则旧的值被替换。
     * 
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

计算hashcode值

   /**
     * 计算key.hashCode()并将哈希的高位(XOR)展开降低。因为这个表使用了两个掩蔽的幂,所以
     * 仅在当前掩码上方的位中变化的哈希将总是碰撞(在已知的例子中有一组浮点键把连续的整数放在小表格里。)所以我们
     * 应用扩展高位影响的变换向下。在速度、效用和效率之间有一个折衷钻头扩展质量。因为许多常见的散列
     * 已经合理分配(因此不要从中受益因为我们用树来处理大量的在箱子里发生碰撞时,我们只是对箱子里的一些移位位进行异或运算
     * 减少系统性损失的最便宜的方法,以及合并最高位的影响,否则由于表边界的原因,不能在索引计算中使用
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

扰动函数
只做一次16位右位移异或混合,而不是四次。具体的可以看看
JDK 源码中 HashMap 的 hash 方法原理是什么?
putVal方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //声明局部变量  tab(散列表),  p 链表节点,n 记录散列表的长度  i 得到数组指针
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //  将 成员属性中的散列表节点赋值给局部变量 并判断数组节点下是否为空,(也就是判断数组节点下是否是第一次增加数据)
        if ((tab = table) == null || (n = tab.length) == 0)
        //resize 方法创建一个二次幂整数的散列表 (默认为16)
            n = (tab = resize()).length;
        //判断当前指针下的table数组值是否为空并赋值给局部p链表,(n - 1) & hash等价于hash % n ,jdk1.7就是这么写的 取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)
        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 && //判断插入数据的 hash 和当前位置hash是否相等
            //判断插入key值和当前节点的key值相等,那就替换值
                ((k = p.key) == key || (key != null && key.equals(k))))
                //直接替换了节点
                e = p;
            else if (p instanceof TreeNode)//当前节点的 key 和要插入的 key 不一样,判断当前节点是红黑树
                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);//创建一个新节点赋值到末尾节点
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 判断节点是否超过默认阈值
                            treeifyBin(tab, hash);//超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容而不转换树
                        break;
                    }
                    //如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,只做替换,并跳出
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { //key已经存在
                V oldValue = e.value;//将当前节点的值赋值给 oldvalue
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value; //将当前要插入的 value 替换当前的节点里面值
                afterNodeAccess(e); //回调以允许LinkedHashMap发布操作   主要是通知 LinkedHashMap将节点移到最后一个
                return oldValue;
            }
        }
        ++modCount;//操作数增加1
        if (++size > threshold)
            resize();//如果当前的 hash表的长度已经超过了当前 hash 需要扩容的长度,然后进行重新扩容(首次增加数据不会走这里,在2步骤已经扩容)
        afterNodeInsertion(evict);//调用LinkedHashMap判断是否把可能把老大除掉
        return null;
    }


上面的注释做一个拆分

  1. 声明局部变量 tab(散列表

    以上是关于Java 集合深入理解 :HashMap之实现原理及hash碰撞的主要内容,如果未能解决你的问题,请参考以下文章

    Java 集合深入理解 (十四) :Hashtable实现原理研究

    深入理解JAVA集合系列:HashMap源码解读

    java集合之HashMap与ConcurrentHashMap的自我理解

    深入理解Java编程性能调优——深入浅出HashMap的设计与优化

    深入理解JAVA集合系列三:HashMap的死循环解读

    深入理解JAVA集合系列三:HashMap的死循环解读