【Java集合】HashMap的put()源码详解以及JDK1.7与JDK1.8的区别

目录

一、put()方法的作用和执行流程

二、put()和putVal()源码

2.1 实现为空的方法

2.2 treeifyBin()方法

三、对比JDK1.7的put()方法源码

3.1 JDK1.7的put()方法执行流程

3.2 JDK1.7的put()方法源码

3.3  jdk1.7和jdk1.8的区别


一、put()方法的作用和执行流程

HashMap 只提供了 put 用于添加元素,putval也是使用的默认修饰符,因此只能被本类或者该包下的类访问到,所以putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。

对 putVal 方法添加元素的分析如下:

  1. 如果定位到的数组位置没有元素,就直接插入。
  2. 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。

二、put()putVal()源码

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}


 /**
 * Implements Map.put and related methods.
 * 实现了map的put和相关方法
 * @param hash  key的hash值(key的hash高16位+高16位与低16位的异或运算)
 * @param key 键
 * @param value 值  
 * @param onlyIfAbsent onlyIfAbsent为true的时候不要修改已经存在的值,如果onlyIfAbsent为false,当插入的元素已经在HashMap中已经拥有了与其key值和hash值相同的元素,仍然需要把新插入的value值覆盖到旧value上。如果nlyIfAbsent为true,则不需要修改
 * @param evict evict如果为false表示构造函数调用
 * @return 返回旧的value值(在数组桶或链表或红黑树中找到存在与插入元素key值和hash值相等的元素,就返回这个旧元素的value值),如果没有发现相同key和hash的元素则返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    // tab用来临时存放数组table引用   p用来临时存放数组table桶中的bin
    // n存放HashMap容量大小   i存放当前put进HashMap的元素在数组中的位置下标
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        // e记录当前节点  k记录key值
        Node<K,V> e; K k;
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
                e = p;
        // hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
        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 ),执行 treeifyBin 方法
                    // 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
                    // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
					// 树化操作
                        treeifyBin(tab, hash);
                    // 跳出循环  此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
                    break;
                }
                // 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 若相等,则不用将其插入了,直接跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        // 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
        if (e != null) {
            // 记录e的value,也就是旧value值
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 替换旧值时会调用的方法(默认实现为空)
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
    ++modCount;
    // 实际大小大于阈值则扩容    ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
    if (++size > threshold)
        resize();
    // 插入成功时会调用的方法(默认实现为空)
    afterNodeInsertion(evict);
    // 没有找到原有相同key和hash的元素,则直接返回Null
    return null;
}

2.1 实现为空的方法

这里顺带说一下下面三个方法

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

在putVal()方法中替换旧值和插入成功的时候都调用了上面其中的两个方法,这三个方法是HashMap类中的方法,但是我们查看源码后会发现这三个方法都是空的方法。其实这三个方法是为继承HashMapLinkedHashMap服务的。

  • LinkedHashMap 是 HashMap 的一个子类,它保留插入的顺序,如果需要输出的顺序和输入时的相同,那么就选用 LinkedHashMap

比如LinkedHashMap中被覆盖的afterNodeInsertion方法,用来回调移除最早放入Map的对象。这三个方法会在以后的LinkedHashMap章节里面讲到。

2.2 treeifyBin()方法

将所有的节点转换成树形节点,并且将链接的链表线索化,即为每个二叉树的节点添加前驱和后继节点,形成线索,构造出双链表结构。再调用treeify()方法构造红黑树结构关系。

/**
 * 将数组指定位置的索引里的链表转为红黑树。通过两步完成:
 * 1、为每个节点添加前驱和后继节点,形成双向链表结构,并且将每个节点都转换成红黑树节点TreeNode
 * 2、调用treeify()方法构造红黑树结构关系
 * 
 * @param tab:数组
 * @param hash:要将这个hash值所在的索引上的链表转换为红黑树
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    // n:当前数组长度,index:hash经过计算得到的索引,e:index索引位置的元素
    int n, index; Node<K,V> e;
    // 当前数组为空或者当前数组长度小于数组转为红黑树的阈值64时,需要扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 计算得到索引index,并且取出来index索引对应的节点e,并且 e 不是null,
    else if ((e = tab[index = (n - 1) & hash]) != null) { 
        // hd 存头节点,e表示当前遍历到的链表节点,p表示当前遍历到的已经转换成树节点的节点,tl表示当前遍历到的已经转换成树节点的上一个节点
        TreeNode<K,V> hd = null, tl = null;
        
        // 从e节点开始遍历链表
        do {
            // 将链表节点e转红黑树节点p
            TreeNode<K,V> p = replacementTreeNode(e, null);
            // 如果是第一次遍历,将头节点赋值给hd
            if (tl == null)
                hd = p;
            // 如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性
            else {
                // 当前节点的prev属性设为上一个节点
                p.prev = tl; 
                // 上一个节点的next属性设置为当前节点   
                tl.next = p;    
            }
            // 将p节点赋值给tl
            tl = p;
        } while ((e = e.next) != null); //后移,找下一个节点,再继续遍历
        
        // 将table该索引位置赋值为hd头节点,如果该节点不为空,则以头节点(hd)为根节点, 构建红黑树
        if ((tab[index] = hd) != null)
            // treeify()是内部类TreeNode的方法,这个在TreeNode那篇文章里有详细的讲解
            hd.treeify(tab);
    }
}

// For treeifyBin 将指定的链表节点转为树节点
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

三、对比JDK1.7put()方法源码

3.1 JDK1.7put()方法执行流程

对于JDK1.7的 put 方法的分析如下:

  1. 如果定位到的数组位置没有元素 就直接插入。
  2. 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和链表上的 key 比较,如果存在key 相同的节点就直接覆盖,没有相同的节点就采用头插法将元素插入链表。与JDK1.8链表插入元素的不同点就在于1.8是尾插法,1.7是头插法

流程图:

3.2 JDK1.7put()方法源码

首先贴一下JDK1.7中HashMap成员属性与1.8相比不同的两个,在put()源码中会出现

//HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态  
static final Entry<?,?>[] EMPTY_TABLE = {};  
//空的存储实体  table是真正存储元素的数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; 

 put()方法源码:

/**
 * 将“key-value”添加到HashMap中 
 * @return 如果插入的key在HashMap中已存在,将新插入的value替换旧value,并且返回旧value
 *         如何插入的key在HashMap中不存在,则返回Null
 */
public V put(K key, V value) {
	// 1. 若 哈希表未初始化(即 table为空) 
	// 则使用构造函数进行初始化 数组table  
    if (table == EMPTY_TABLE) {
        // 分配数组空间
        // 入参为threshold,此时threshold为initialCapacity initialCapacity可以是构造方法中传入的大小,如果构造方法没有指定HashMap容量大小,则使用默认值1<<4(=16)
        inflateTable(threshold);
    }
    
	// 2. 判断key是否为空值null
	// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
	// (本质:key = Null时,hash值 = 0,故存放到table[0]中)
	// 该位置永远只有1个value,新传进来的value会覆盖旧的value
    if (key == null)
        return putForNullKey(value);
    
	// 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
	// a. 根据键值key计算hash值
    int hash = hash(key);    
	// b. 根据hash值 最终获得 key对应存放的数组Table中位置
    int i = indexFor(hash, table.length);

    
	// 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
	      // 3.1 若该key已存在(即 key-value已存在 ),则用新value替换旧value,并返回旧value
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            // 调用value的回调函数,该函数为空实现
            e.recordAccess(this);
            return oldValue;
        }
    }
    // 结构性修改,记录HashMap被修改的次数。保证并发访问时,若HashMap内部结构发生变化,快速响应失败
    modCount++;
       // 3.2 若 该key不存在,则将“key-value”添加到table中
    addEntry(hash, key, value, i);
    return null;
}

1. 初始化哈希表

真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()时,而不是在构造函数中。

inflateTable()方法用于初始化HashMap,即初始化数组(table)、扩容阈值(threshold)。

inflateTable的源码如下:

/**
 * 初始化hash表
 * @param toSize 指定HashMap容量大小
 */
private void inflateTable(int toSize) {
    // 1. capacity必须是2的次幂,将传入的容量大小toSize转化为:大于传入容量大小toSize的最小的2的次幂
    // 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
    int capacity = roundUpToPowerOf2(toSize);
    // 2. 重新计算阈值 threshold = 容量 * 加载因子  
    // 取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 3. 使用计算后的初始容量(已经是2的次幂) 为table分配空间,即初始化数组table(作为数组长度)
    // 即 哈希表的容量大小 = 数组大小(长度)
    table = new Entry[capacity];
    // 选择合适的Hash因子(即Hash种子),好的Hash种子能提高计算Hash时结果的散列性
    initHashSeedAsNeeded(capacity);
}

 inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。其实现如下:

/**
 * 找到大于传入容量大小的最小的2的次幂
 */
private static int roundUpToPowerOf2(int number) {  
//若 容量超过了最大值,初始化容量设置为最大值 ;否则,设置为:大于传入容量大小的最小的2的次幂
return number >= MAXIMUM_CAPACITY  ? 
    MAXIMUM_CAPACITY  : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
} 

roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值。

       

2.当 key ==null时,将该 key-value 的存储位置规定为数组table 中的第1个位置,即table [0]

/**
 * 将key为null的value值放入table[0]上
 */
private V putForNullKey(V value) {  
    // 遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对
    // 1. 若有:则用新value 替换 旧value;同时返回旧的value值
    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;  
        }  
    } 
}

从此处可以看出:

  • HashMap的键key 可为null(区别于 HashTablekey 不可为null
  • HashMap的键key 可为null且只能为1个,但值value可为null且为多个

 

3.key≠null的时候,计算keyHash并根据Hash值计算对应在table中的下标

hash()方法:

/**
 * 源码分析1:hash(key)
 * 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
 * JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
 * JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
 */
// JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作  = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
    h ^= k.hashCode(); 
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

 通过hash函数得到散列值后,再通过indexFor进一步处理来获取实际的存储位置,其实现如下:

 /**
 * 函数源码分析2:indexFor(hash, table.length)
 * JDK 1.8中实际上无该函数,但原理相同,即具备类似作用的函数
 */
static int indexFor(int h, int length) {  
    // 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)
    return h & (length-1); 
}

 4.当key≠null时,得到存储的下标位置后,我们就可以将元素放入HashMap中

先判断链表中是否已经存在与要插入元素的key相同的元素,如果有,直接用要插入的新value覆盖旧value。如果没有,则调用addEntry()方法将元素插入:

/**
 * 添加链表元素   
 * 作用:添加键值对(Entry )到 HashMap中
 * @param bucketIndex 元素要插入到数组table的索引位置(下标)
 */
void addEntry(int hash, K key, V value, int bucketIndex) {  
    // 1. 插入前,先判断容量是否足够
    // 1.1 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
    if ((size >= threshold) && (null != table[bucketIndex])) {  
        resize(2 * table.length); // a. 扩容2倍
        hash = (null != key) ? hash(key) : 0;  // b. 重新计算该Key对应的hash值
        bucketIndex = indexFor(hash, table.length);  // c. 重新计算该Key对应的hash值的存储数组下标位置
    }  
    // 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
    createEntry(hash, key, value, bucketIndex);  
}  


/**
 * 创建元素,并将新元素添加到HashMap中 
 * 作用: 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
 */  
void createEntry(int hash, K key, V value, int bucketIndex) { 
    // 1. 把table中该位置原来的Entry保存  
    Entry<K,V> e = table[bucketIndex];
    // 2. 使用头插法讲元素插入到链表中,新元素成为链表头节点,新元素的next节点为原链表头节点。这保证了新插入的元素总是在链表的头  
    table[bucketIndex] = new Entry<>(hash, key, value, e);  
    // 3. 哈希表的键值对数量计数增加
    size++;  
} 

通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。扩容会在后面的章节讲解。

3.3  jdk1.7jdk1.8的区别


相关文章:【Java集合】HashMap的resize()源码详解以及JDK1.7与JDK1.8的区别 

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashMapJava中常用的一种数据结构,用于存储键值对。在JDK1.7和JDK1.8中,HashMap的扩容机制有以下区别JDK1.7中的HashMap扩容机制: 1. 初始化:HashMap初始化时会创建一个Entry数组,数组大小为2的n次方(默认为16)。 2. 增加元素:当往HashMap中添加元素时,会根据key的hashCode()方法计算出数组下标,如果该位置已经有元素,则将新的元素插入到链表的头部,否则直接插入数组。 3. 扩容:当HashMap中元素个数超过负载因子(默认为0.75)*数组大小时,就需要进行扩容。扩容时会将数组大小翻倍,并将原有的元素重新分配到新数组中。在JDK1.7中,扩容时采用头插法,即将链表的结点插入到新数组对应的链表头部。 4. 并发问题:在并发环境下,由于头插法可能会导致链表成环,所以需要进行额外的处理来避免死循环。在JDK1.7中,采用了synchronized关键字来对put操作进行同步。 JDK1.8中的HashMap扩容机制: 1. 初始化:HashMap初始化时会创建一个Node数组,数组大小为2的n次方(默认为16)。 2. 增加元素:当往HashMap中添加元素时,会根据key的hashCode()方法计算出数组下标,如果该位置已经有元素,则将新的元素插入到链表或红黑树的尾部,否则直接插入数组。 3. 扩容:当HashMap中元素个数超过负载因子(默认为0.75)*数组大小时,就需要进行扩容。扩容时会将数组大小翻倍,并将原有的元素重新分配到新数组中。在JDK1.8中,扩容时采用了尾插法,即将链表或红黑树的结点插入到新数组对应的链表或红黑树的尾部。 4. 红黑树:在JDK1.8中,当链表长度达到一定阈值(默认为8)时,会将链表转化为红黑树,从而提高HashMap的查找效率。 5. 并发问题:在JDK1.8中,使用了CAS和synchronized来对put操作进行同步,从而提高了并发性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值