Java集合类---HashMap源码分析

文章目录

  • 前言
  • 一、概述
  • 二、HashMap
    • 1.什么是HashMap?
    • 2.HashMap的简单应用
  • HashMap源码
    • HashMap的构造函数
      • HashMap(int initialCapacity, float loadFactor)
        • loadFactory
        • MAXIMUM_CAPACITY
        • tableSizeFor
      • 二、HashMap(int initialCapacity)
      • 三、HashMap()
      • 四、HashMap(Map m)
        • putVal()
        • 数组扩容resize()
    • 哈希冲突
      • 为什么会hash冲突?
      • hash冲突的几种情况
      • 如何解决hash冲突?
        • 开放地址法
        • 链地址法
    • HashMap树化条件
    • HashMap常用方法源码
      • get()
      • getNode()方法如下:
      • put()
      • remove()
  • 一些小知识
    • jdk1.7的HashMap与1.8的HashMap有什么不同?
      • 一、JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法
      • 二、扩容后数据存储位置的计算方式不一样:
      • 三、结构不同
    • HashMap为什么是线程不安全的?
      • HashMap 在并发时可能出现的问题主要是两方面:
      • 2、resize()而引起死循环
  • 总结


前言

本文主要讲述HashMap的底层原理,全篇JDK版本为JDK11,与JDK8并无多大变化


提示:以下是本篇文章正文内容,下面案例可供参考

一、概述

之所以把HashSet和HashMap放在一起讲解,是因为二者在Java里有着相同的实现,前者仅仅是对后者做了一层包装,也就是说HashSet里面有一个HashMap(适配器模式)。因此本文将重点分析HashMap。 HashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素;除该类未实现同步外,其余跟Hashtable大致相同;跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。
根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap采用的是冲突链表方式。

二、HashMap

1.什么是HashMap?

HashMap 是基于哈希表的 Map 接口是实现的。此实现提供所有可选操作,并允许使用 null 做为值(key)和键(value)。HashMap 不保证映射的顺序,特别是它不保证该顺序恒久不变。此实现假定哈希函数将元素适当的分布在各个桶之间,可作为基本操作(get 和 put)提供稳定的性能。在jdk1.7中的HashMap是基于数组+链表实现的,在jdk1.8中的HashMap是由数组+链表+红黑树实现的(不懂,一开始就讲那么难的谁受得了?没关系,继续往下看)

2.HashMap的简单应用

哈希映射是java中的一种数据结构,它使用对象来检索另一个对象,第一个对象是键,第二个对象是值,它们是作为java.util包中的HashMap类来实现的
可以通过调用其无参的构造函数来创建哈希映射:

HashMap map = new HashMap();
使用泛型来指明键和值的类,它们放在"<“和”>"字符内,而且类名使用逗号分隔,如下所示
HashMap map = new HashMap<>();

put(Object,Object)
通过调用带有两个参数(键和值)的put(Object,Object)方法,将对象存储到哈希映射中

map.put("Tom", 18);
1
这将一个键为Tom,值为18的条目存储到哈希映射中

get(Object)
通过调用get(Object)方法,同时将键作为其唯一的参数,可以从映射中检索对象

HashMap<String, Integer> map = new HashMap<>();
map.put("Tom", 18);
int age = map.get("Tom");
System.out.println(age);

如果没有发现匹配该键值,get()方法将返回一个null,处理这一潜在问题的另外一种方式是调用getOrDefault(Object,Object),如果作为第一个参数的键没有被找到,则默认范围第二个参数,如下面的语句所示

HashMap<String, Integer> map = new HashMap<>();
map.put("Tom", 18);
int number = map.getOrDefault("Tom", -1);
System.out.println(number);

remove(Object key)
通过调用remove方法删除属性值,只要传入对应的key即可

HashMap<String,Integer> map = new HashMap<>();
map.put("Tom",18);
map.put("lisa",17);
System.out.println("调用remove方法之前 "+map);//调用remove方法之前 {Tom=18, lisa=17}
map.remove("lisa");
System.out.println("调用remove方法之后 "+map);//调用remove方法之后 {Tom=18}

foreach()
下面用for循环语句使用条目集合和条目来访问map哈希映射中的所有键和所有值:

HashMap<String, Integer> map = new HashMap<>();
map.put("Tom", 18);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
	String key = entry.getKey();
	Integer value = entry.getValue();
	System.out.println("key:"+key);
	System.out.println("value:"+value);
}

containsKey(key)与containsValue(value)
通过containsKey(key)与containsValue(value)可以判断是否有键值,返回的是true或false的布尔值

HashMap<String, Integer> map = new HashMap<>();
map.put("Tom", 18);
System.out.println(map.containsKey("Tom"));
System.out.println(map.containsValue(2));

输出结果为:
true
false

isEmpty()
该方法用于判断是否有哈希值,返回的是一个布尔值

HashMap<String, Integer> map = new HashMap<>();
map.put("Tom",18);
System.out.println(map.isEmpty());

getOrDefault()
该方法在做算法的时候还是很好用的,作用是:
  当Map集合中有这个key时,就使用这个key值;
  如果没有就使用默认值defaultValue。

HashMap<String, String> map = new HashMap<>();
	map.put("name", "cookie");
	map.put("age", "18");
	map.put("sex", "女");
	String name = map.getOrDefault("name", "random");
	System.out.println(name);// cookie,map中存在name,获得name对应的value
	int score = map.getOrDefault("score", 80);
	System.out.println(score);// 80,map中不存在score,使用默认值80

HashMap源码

HashMap的构造函数

HashMap一共有四种构造函数

HashMap(int initialCapacity, float loadFactor)

具体解释可以看注释

 public HashMap(int initialCapacity, float loadFactor) {
 		// 当指定的 initialCapacity (初始容量) < 0 的时候会抛出 IllegalArgumentException 异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 当指定的 initialCapacity (初始容量)> MAXIMUM_CAPACITY时
        // initialCapacity = MAXIMUM_CAPACITY;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 当 loadFactory(负载因子)< 0 ,或者不是数字的时候会抛出 IllegalArgumentException 异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        // tableSizeFor主要返回一个离initialCapacity大且最近的一个2的幂次方整数
        // 举个例子 比如传入数据12 那么tableSizeFor就会返回一个比14大且2的幂次方整数,就是2的四次方16
        this.threshold = tableSizeFor(initialCapacity);
    }

可能一看有点懵,先解释一下一些东西:

loadFactory

loadFactory叫做负载因子,源码是这样的

	/**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

这就是构造函数中未指定时使用的负载因子。为什么是0.75f,是经过高数计算的,是时间和空间的权衡。找了一段解释

当负载因子是1.0的时候,也就意味着,只有当数组的8个值全部填充了,才会发生扩容。这就带来了很大的问题,因为Hash冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
负载因子是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。

MAXIMUM_CAPACITY

    /**
     * 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;

最大值是2的30次方。<< 相当于×2计算,>>相当于÷2运算。

tableSizeFor

介绍完前面的东西,第一个构造函数大部分阅读应该都没有问题。唯一有问题的可能是这个tableSizeFor。让我们来看看这个方法的源码:

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

看到这肯定更懵了,这啥玩意。首先先了解一下numberOfLeadingZeros方法是干什么的。

public static int numberOfLeadingZeros(int i) {
        // HD, Count leading 0's
        if (i <= 0)
            return i == 0 ? 32 : 0;
        int n = 31;
        if (i >= 1 << 16) { n -= 16; i >>>= 16; }
        if (i >= 1 <<  8) { n -=  8; i >>>=  8; }
        if (i >= 1 <<  4) { n -=  4; i >>>=  4; }
        if (i >= 1 <<  2) { n -=  2; i >>>=  2; }
        return n - (i >>> 1);
    }

这个方法给定一个int类型数据,返回这个数据的二进制串中从最左边算起连续的“0”的总数量。因为int类型的数据长度为32所以高位不足的地方会以“0”填充。
>> 与 >>> 的区别
现在来讲解一下 >> 与 >>> 的区别

  • 1、>> :按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补符号位,即正数补零,负数补1。符号位不变。

  • 2、>>> : 二进制右移补零操作符,左操作数的值按右操作数指定的位数右移,移动得到的空位以零填充
    举个:
    中心思想就是找到最左边的1的位置。拿i = 12举例
    12的32位二进制是:00000000 00000000 00000000 00001100
    第一个 if (i >= 1 << 16) { n -= 16; i >>>= 16; },1 << 16 相当以于2 的16次方不符合。
    第二个 if (i >= 1 << 8) { n -= 8; i >>>= 8; },1 << 8 相当以于2 的8次方不符合。
    第三个 if (i >= 1 << 4) { n -= 4; i >>>= 4; },1 << 4 相当以于2 的4次方不符合。
    第四个if (i >= 1 << 2) { n -= 2; i >>>= 2; }, 1 << 2 = 4 发现符合条件,把i左移两位,相当于00000000 00000000 00000000 00000011 n = 31 - 2 = 29
    此时无论什么数,最左边的一个1都处于倒数第二个位置,所以最后还需要n - (i >>> 1)多减一位,得到28。说明12的32位二进制左边有28位0
    这有什么用呢?再看回tableSizeFor
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);还是拿12举例,相当于int n = -1 >>> 28。我们进行翻译:
    -1的二进制位为32个1,右移28位相当于28个0,最后4个为1 ,结果为1111 等于15。最后一个三元运算符就不解释了。最终返回的值为16。
    这时候我们就知道tableSizeFor的作用了以及过程。 HashMap 要保证容量是 2 的整数次幂, 该方法实现的效果就是这个效果。

为什么容量必须为2的整数幂?

因为获取 key 在数组中对应的下标是通过 key 的哈希值与数组长度 -1 进行与运算,如:i = (n - 1) & hash
1、n 为 2 的整数次幂,这样 n-1 后之前为 1 的位后面全是 1,这样就能保证 (n-1) & hash 后相应的位数既可能是 0 又可能是 1,这取决于 hash 的值,这样能保证散列的均匀,同时与运算效率高
2、如果 n 不是 2 的整数次幂,会造成更多的 hash 冲突(为什么会冲突,稍后会讲解)

在Java11中,HashMap中key的hash值是由hash(key)方法计算的,hash方法代码如下:

	/**
	  * 计算key.hashCode()并扩展(XOR)哈希的更高位降低。 由于该表使用2的幂次掩码,因此仅在当前掩码上方的位上
	  * 变化的散列将总是相撞。 (其中一些示例是Float键集在小表中保存连续的整数。)所以我们应用变换以扩展较高位的
	  * 影响向下。 在速度,效用和比特扩散的质量。 因为许多常见的哈希集已经合理分配(因此不要从传播),并且因为我
	  * 们使用树来处理大量的箱中的碰撞,我们只是将减少系统损失的最便宜的方法,以及合并否则会影响最高位的影响由于
	  * 表的限制,从不用于索引计算。
  	*/
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

HashMap中存储数据的table的index是由key的hash值决定的
在HashMap中存储数据的时候,我们希望数据能够均匀地分布,以防止哈希冲突
自然地我们就会想到用 % 取余操作来实现

Java集合类---HashMap源码分析_第1张图片

取余(%)操作:如果除数是2的幂次方则等价于其除数减一的与操作(&) &操作要快于%操作

如果还是不清楚,可以自己跑一下代码debug一下。很快就能了解的。

二、HashMap(int initialCapacity)

//构造一个初始容量为 initialCapacity,负载因子为 0.75的HashMap
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

三、HashMap()

//使用默认的初始容量构造一个空的HashMap(16)和默认负载系数(0.75)。
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

四、HashMap(Map m)

//构造一个和指定Map有相同的mappings的HashMap,初始容量能充足的容下指定的Map,负载因子为0.75
public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

出现一个新方法putMapEntries,点进去看看

   //将m的所有元素存入到该HashMap实例中
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    
    //获取 m 中元素的个数
    int s = m.size();
    
    //当 m 中有元素的时候,需要将Map中的元素放入此HashMap实例中
    if (s > 0) {
        
        //判断table是否已经初始化,如果table已经初始化,则先初始化一些变量(table的初始化指在put的时候)
        if (table == null) { // pre-size
            
            //根据待插入的Map的大小(size)计算要创建的 HashMap 容量
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
            
            //将要创建的 HashMap 容量存在 threshold 中
            if (t > threshold)
                //threshold是一个整型变量
                threshold = tableSizeFor(t);
        }
        
        //如果table初始化过了,因为别的函数也会调用到它,所以有可能HashMap已经被初始化过了
        //判断待插入的 Map 的 大小(size),如果size > threshold ,则先进行 resize() 扩容
        else if (s > threshold)
            resize();
        
        //然后开始遍历,待插入的 Map ,将每一个 插入到该HashMap实例中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            
            //然后调用 putVal 函数进行元素的插入操作
            putVal(hash(key), key, value, false, evict);
        }
    }
}

又出现一个putVal(),再点进去看看

putVal()

// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度
    // 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    else {// 数组该位置有数据
        Node<K,V> e; K k;
        // 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
        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) {
                // 插入到链表的最后面(Java7 是插入到链表的最前面)
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
                    // 会触发下面的 treeifyBin,也就是将链表转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果在该链表中找到了"相等"的 key(== 或 equals)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
                    break;
                p = e;
            }
        }
        // e!=null 说明存在旧值的key与要插入的key"相等"
        // 对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

让我们看一下HashMap的结构
Java集合类---HashMap源码分析_第2张图片
具体的都在注释了。主要的扩容源码在下面内容了。

数组扩容resize()

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) { // 对应数组扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 将数组大小扩大一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 将阈值扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // 对应使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
        newCap = oldThr;
    else {// 对应使用 new HashMap() 初始化后,第一次 put 的时候
        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;

    // 用新的数组大小初始化新的数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // 如果是初始化数组,到这里就结束了,返回 newTab 即可

    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 { 
                    // 这块是处理链表的情况,
                    // 需要将此链表拆成两个链表,放到新的数组中,并且保留原来的先后顺序
                    // loHead、loTail 对应一条链表,hiHead、hiTail 对应另一条链表,代码还是比较简单的
                    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;
                        // 第二条链表的新的位置是 j + oldCap,这个很好理解
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

哈希冲突

为什么会hash冲突?

就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key,value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经有人先来了。就是说这个地方要挤一挤啦。这就是所谓的hash冲突啦

hash冲突的几种情况

两个节点的key值相同(hash值一定相同),导致冲突
两个节点的key值不同,由于hash函数的局限性导致hash值相同,导致冲突
两个节点的key值不同,hash值不同,但hash值对数组长度取模后相同,导致冲突

如何解决hash冲突?

解决hash冲突的方法主要有两种,一种是 开放地址法,另一种是 链地址法

开放地址法

开放地址法的原理很简单,就是当一个 Key 通过 hash 函数获得对应的数组下标已被占用的时候,我们可以寻找下一个空档位置

比如有个 Entry6 通过 hash 函数得到的下标为 2,但是该下标在数组中已经有了其它的元素,那么就向后移动 1 位,看看数组下标为 3 的位置是否有空位

Java集合类---HashMap源码分析_第3张图片
但是下标为 3 的数组也已经被占用了,那么久再向后移动 1 位,看看数组下标为 4 的位置是否为空
Java集合类---HashMap源码分析_第4张图片
OK,数组下标为4的位置还没有被占用,所以可以把Entry6存入到数组下标为4的位置。这就是开放寻址的基本思路,寻址的方式有很多种,这里只是简单的一个示例

链地址法

链地址法也正是被应用在了 HashMap 中,HashMap 中数组的每一个元素不仅是一个 Entry 对象,还是一个链表的头节点。每一个 Entry 对象通过 next 指针指向它的下一个Entry 节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可

Java集合类---HashMap源码分析_第5张图片

HashMap树化条件

putval()方法中,有这么一行代码,表示要进行树化操作

 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
         treeifyBin(tab, hash);

其中有一个TREEIFY_THRESHOLD常量:

 static final int TREEIFY_THRESHOLD = 8;

这表示当链表长度一到8时,就会进行跳转到treeifyBin
HashMap树化是由专门的方法treeifyBin
但是是直接树化吗?

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        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;
            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);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

从方法中可以明显看出,树化有两个条件

  1. 链表长度大于等于TREEIFY_THRESHOLD(默认为8)

  2. 哈希数组的长度大于等于MIN_TREEIFY_CAPACITY(默认为64),否则进行扩容操作

  3. 如果链表长度小于6,则会退化成链表

HashMap常用方法源码

get()

相对于上面所讲的,get操作和put操作就比较简单了,根据key获取hash值,其他没什么可说的,有值就返回value,没有值返回null,直接进入 getNode()

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

getNode()方法如下:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    
    	//这个table与putVal中使用的table是一样的,简单的说就是只要使用了put操作就可以进行get操作
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            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;
    }

put()

put操作只要使用的函数就是 putVal(好熟悉的feel),也就是上面所讲的,这里不再解释

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

remove()

remove(key) 方法 和 remove(key, value) 方法都是通过调用removeNode的方法来实现删除元素的

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

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 ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            
            // 此元素只有一个元素
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                //此处是一个红黑树
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                
                //此处是一个链表,遍历链表返回node
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        //分不同情形删除节点
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

一些小知识

jdk1.7的HashMap与1.8的HashMap有什么不同?

一、JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法

那么为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题

二、扩容后数据存储位置的计算方式不一样:

在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)
而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式

三、结构不同

JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(N)变成O(logN)提高了效率)

HashMap为什么是线程不安全的?

HashMap 在并发时可能出现的问题主要是两方面:

1、put()操作的时候导致的多线程数据不一致
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为

2、resize()而引起死循环

这种情况发生在HashMap自动扩容时,当2个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7中,会出现resize前后元素顺序倒置的情况)。接下来再想通过get()获取某一个元素,就会出现死循环

注:JDK7使用是hash值与数组长度-1 这个掩码进行与运算,得到Entry元素的新下标位置,得到的结果直接就是下标位置 ;
Jdk1.8中是使用hash值与 数组长度 进行与运算,得到的是0 或者非零。如果是0
表示新位置下标不变,如果不是0那么表示位置有变动,如果有变动下标位置是原位置加上数组长度。

总结

参考自:
https://www.pdai.tech/md/java/collection/java-map-HashMap&HashSet.html#java8-hashmap
https://blog.csdn.net/Woo_home/article/details/103146845

你可能感兴趣的:(Java,java,链表,hashmap)