1.7的HashMap线程不安全问题,竟是因为它!

HashMap 1.7源码分析

干啥啥不行,吃饭第一名!立志要当一个有内涵的吃货!不读源码怎么行!趁着这股子热血,翻起身来就写一个bug!

这事还得从一段代码说起,纯属为了让它发生问题专门定制的!就长这样,你看,你品,你细品

一、问题描述

//JDK1.7

public class HashMapTest {
    public static void main(String[] args) {
        HashMapThread thread0 = new HashMapThread();
        HashMapThread thread1 = new HashMapThread();
        HashMapThread thread2 = new HashMapThread();
        HashMapThread thread3 = new HashMapThread();
        HashMapThread thread4 = new HashMapThread();
        System.out.println("javaVersion:"+System.getProperty("java.version"));
        thread0.start();
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();

    }
}


class HashMapThread extends Thread {
    private  static AtomicInteger atomicInteger =new AtomicInteger();
    private  static Map<Integer,Integer>map=new HashMap<>();

    @Override
    public void run() {
        while (atomicInteger.get()<1000000){
//            System.out.println(atomicInteger.get()+":Thread:"+Thread.currentThread().getName()+"    version:"+System.getProperty("java.version"));
            map.put(atomicInteger.get(),atomicInteger.get());
            atomicInteger.incrementAndGet();
        }
    }
}

可以看的出来,上面代码没干啥事,就是闲的没事整几个线程,put,往死里put!果不其然,没过几回合就扑gai了,照片如下

  • 死循环情况

在这里插入图片描述

  • 数组越界情况

在这里插入图片描述

二、问题分析

分析问题思路公式:

1、jps 查找进程

找到我们程序运行的进程id

在这里插入图片描述

2、jstack +进程id

jstack 是java虚拟机⾃带的⼀种堆栈跟踪⼯具,它可以生成当前java虚拟机内线程执行的线程快照,通过查看线程快照就可以查看该线程主要在进行的工作,定位到该线程出现⻓时间停顿的原因,如线程间死锁、死循环等等,详细信息如下:

C:\Users\不会>jps
4880 Jps
22968 KotlinCompileDaemon
6280
9608 Launcher
7036 HashMapTest

C:\Users\不会>jstack 7036
2021-03-10 11:25:33
Full thread dump Java HotSpot(TM) 64-Bit Server VM (24.80-b11 mixed mode):

"DestroyJavaVM" prio=6 tid=0x0000000003642800 nid=0x1e4 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Thread-3" prio=6 tid=0x000000000df52000 nid=0x4c68 runnable [0x000000000ed0f000]
   java.lang.Thread.State: RUNNABLE
        at java.util.HashMap.transfer(HashMap.java:601)
        at java.util.HashMap.resize(HashMap.java:581)
        at java.util.HashMap.addEntry(HashMap.java:879)
        at java.util.HashMap.put(HashMap.java:505)
        at HashMapTest.HashMapThread.run(HashMapThread.java:15)

"Service Thread" daemon prio=6 tid=0x000000000debc800 nid=0x557c runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread1" daemon prio=10 tid=0x000000000dea7000 nid=0x4c30 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"C2 CompilerThread0" daemon prio=10 tid=0x000000000dea6000 nid=0x22d8 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Monitor Ctrl-Break" daemon prio=6 tid=0x000000000dea5000 nid=0xd40 runnable [0x000000000e50e000]
   java.lang.Thread.State: RUNNABLE
        at java.net.SocketInputStream.socketRead0(Native Method)
        at java.net.SocketInputStream.read(SocketInputStream.java:152)
        at java.net.SocketInputStream.read(SocketInputStream.java:122)
        at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:283)
        at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:325)
        at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:177)
        - locked <0x00000007870f38c8> (a java.io.InputStreamReader)
        at java.io.InputStreamReader.read(InputStreamReader.java:184)
        at java.io.BufferedReader.fill(BufferedReader.java:154)
        at java.io.BufferedReader.readLine(BufferedReader.java:317)
        - locked <0x00000007870f38c8> (a java.io.InputStreamReader)
        at java.io.BufferedReader.readLine(BufferedReader.java:382)
        at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:64)

"Attach Listener" daemon prio=10 tid=0x0000000003738000 nid=0x43c4 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Signal Dispatcher" daemon prio=10 tid=0x000000000de42800 nid=0x3f38 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

"Finalizer" daemon prio=8 tid=0x000000000be30000 nid=0x477c in Object.wait() [0x000000000de0e000]
   java.lang.Thread.State: WAITING (on object monitor)
        at java.lang.Object.wait(Native Method)
        - waiting on <0x0000000786ecddb0> (a java.lang.ref.ReferenceQueue$Lock)
        at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:135)
        - locked <0x0000000786ecddb0> (a java.lang.ref.ReferenceQueue$Lock)
        at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:151)
        at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:209)

"Reference Handler" daemon prio=10 tid=0x000000000be29800 nid=0x21bc in Object.wait() [0x000000000dd0f000]
   java.lang.Thread.State: WAITING (on object monitor)
        at java.lang.Object.wait(Native Method)
        - waiting on <0x0000000786ecda78> (a java.lang.ref.Reference$Lock)
        at java.lang.Object.wait(Object.java:503)
        at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:133)
        - locked <0x0000000786ecda78> (a java.lang.ref.Reference$Lock)

"VM Thread" prio=10 tid=0x000000000be24800 nid=0x4b24 runnable

"GC task thread#0 (ParallelGC)" prio=6 tid=0x0000000003658000 nid=0x24d0 runnable

"GC task thread#1 (ParallelGC)" prio=6 tid=0x000000000365a000 nid=0x1614 runnable

"GC task thread#2 (ParallelGC)" prio=6 tid=0x000000000365b800 nid=0x5a44 runnable

"GC task thread#3 (ParallelGC)" prio=6 tid=0x000000000365e000 nid=0x7c0 runnable

"GC task thread#4 (ParallelGC)" prio=6 tid=0x0000000003660800 nid=0xb6c runnable

"GC task thread#5 (ParallelGC)" prio=6 tid=0x0000000003662000 nid=0x8d0 runnable

"GC task thread#6 (ParallelGC)" prio=6 tid=0x0000000003665000 nid=0x1e70 runnable

"GC task thread#7 (ParallelGC)" prio=6 tid=0x0000000003666800 nid=0x4b8c runnable

"VM Periodic Task Thread" prio=10 tid=0x000000000dedf000 nid=0x3430 waiting on condition

JNI global references: 149

在这里插入图片描述

关键信息就是这句,根源也在 at java.util.HashMap.transfer(HashMap.java:601)中,记住这个方法transfer!

既然定位到问题发生在哪了,接下来就是看看为什么会发生这样的问题了,所以,Have to see下HashMap的源码了,看看这个transfer到底咋回事!

三、HashMap 1.7 源码解析

既然要看,那就从头捋把,先从HashMap 1.7的数据结构开始说起

1、数据结构

HashMap1.7采用数组加单链表的方式存储数据。如果把链表看作是一个桶(bucket)的话,那么数组中的元素存储的就是bucket桶的头节点,也就是链表的头节点(由于1.7中hashmap采用的是头插法,所以新插入的元素作为头节点,放到数组中);

由于元素插入HashMap时,具体的位置是由hash决定的(实际是hash & (length-1)得到的),会存在哈希冲突的情况,即一个位置可能会存放多个数据,链表就是负责解决哈希冲突问题,专门用来存储hash冲突的元素,链表中的每个节点是一个键值对,也就是Entry,结构如图所示

在这里插入图片描述

2、HashMap重要参数

知道了底层数据结构以后,我们再来认识一下,HashMap中比较重要的几个参数


	默认数组的长度16;可以手动指定,要求必须是2的幂即2^n(会通过算法找到大于等于给定数的最小的2的幂);
	后续可以扩容,扩容时为当前长度的2static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

  
	数组最大长度为230次方,如果超过该值,默认使用该值
    static final int MAXIMUM_CAPACITY = 1 << 30;

	
	默认负载因子,用来计算扩容阈值,
	假设长度为16,则当hashmap中的元素size=16*0.75=12的时候,就会扩容(当然还有别的条件,后面会详细的介绍,这里只是解释负载因子的作用)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;


	
	我们刚才提到的数组就是指这个table
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;


	HashMap中元素的数量,注意和CAPACITY区分,它们两个是不同的概念
    transient int size;

	
	扩容阈值 = 容量(数组长度) x 负载因子
    int threshold;

    实际用到的加载因子,默认是0.75,也可以手动指定
    final float loadFactor;

 
    transient int modCount;

   
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
小贴士

关于负载因子大小的影响:

​ a. 加载因子越大意味着扩容阈值变大,也就是能装的元素越多,哈希冲突的几率可能会变大,哈希冲突几率变大意味着链表变 长,查找时会稍慢些,空间利用率提高了

​ b. 加载因子越小意味着扩容阈值变小,也就是能装的元素变少,哈希冲突的几率会变小、链表不会很长,查找效率不会变慢,

​ 但可能会导致扩容的频率提升,扩容时需要迁移数据,数据量大的话会比较耗时,空间利用率降低了

2、HashMap构造函数

对应的HashMap构造函数

	
	 默认无参构造函数(我称为构造函数0),实际上是调用构造函数2,参数使用的默认值,即容量为16,加载因子为0.7
	 
     对应场景:new HashMap<>();
     
    public HashMap() {               
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }


	构造函数1,同样调用构造函数2,使用指定的容量大小,加载因子仍然使用默认的0.75
	 
     对应场景:new HashMap<>(16); 
     
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }



	构造函数2:指定容量大小和加载因子的构造函数,即创建对象时我们自己指定了容量和负载因子
     
     对应场景:new HashMap<>(16,0.75f);
     
     
    public HashMap(int initialCapacity, float loadFactor) {
        1、指定的容量不能小于0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        
        2、HashMap的最大容量只能是MAXIMUM_CAPACITY,哪怕传入的容量大于默认的最大容量,即最大容量为2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        
        3、指定的加载因子不能小于0 且不能是nan类型
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
		4、使用指定的负载因子
        this.loadFactor = loadFactor;
        
        5、扩容阈值,需要注意的是,虽然赋了初始值为指定HashMap容量大小,但此处不是真正的扩容阈值,后面会重新计算该值  
        threshold = initialCapacity;
        
        6、暂时没有用到,留给子类扩展
        init();
    }


	 构造函数3:包含传入Map的构造函数
	 对应场景:
	   Map<String,String> map=new HashMap<>();
	   Map<String,String> map2=new HashMap<>(map);
	
    public HashMap(Map<? extends K, ? extends V> m) {
        
        最终还是调用构造函数2,根据传入map的size通过运算与默认容量16进行比较,选择二者间较大的作为新map的特定容量大小,
        加载因子使用默认的0.75
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        
        该方法用于初始化 数组 、扩容阈值、hash种子,下面会详细说明
        inflateTable(threshold);
		
        遍历传入的Map中的全部元素并依次添加到新HashMap中
        putAllForCreate(m);
    }

总结

除构造函数3以外,其它构造函数最终都会调用构造函数2,用于接收初始容量大小(capacity)、加载因子(Load factor),但没有初始化哈希表(数组table,真正初始化哈希表是在第1次添加键值对时,即第1次调用put()时)。

3、put流程分析

由于真正创建哈希表是在第一次put时完成的,所以我们先从put开始分析,源码如下

    public V put(K key, V value) {
        
		 1、 若哈希表table未初始化(即 table为空) 
         则使用构造函数时设置的阈值(即初始容量) 初始化 数组table
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        
         2、 判断key是否为空值null
 		 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
         (本质:key = Null时,hash值 = 0,故存放到table[0]中)
         该位置永远只有1个value,新传进来的value会覆盖旧的value
        if (key == null)
            return putForNullKey(value);
        
 		 3 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
         3.1 根据键值key计算hash值
        int hash = hash(key);
        
          3.2 根据hash值 最终获得 key对应存放的数组Table中位置
        int i = indexFor(hash, table.length);
        
         4. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            
		 4.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;//并返回旧的value
            }
        }

        modCount++;
        
		// 5. 若 该key不存在,则将“key-value”添加到table中
        addEntry(hash, key, value, i);
        return null;
    }
3.1 inflateTable分析
	put中调用
	if (table == EMPTY_TABLE) {
    	inflateTable(threshold);
    }



	初始化哈希表
    private void inflateTable(int toSize) {
        // 根据传入的值找到大于等于该值的最小的2的幂,如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
        toSize用的是threshold,默认为16
        int capacity = roundUpToPowerOf2(toSize);
		
        重新计算阈值 threshold = 容量 * 加载因子  
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        
        使用计算后的初始容量(已经是2的幂) ,作为数组长度初始化数组table
        table = new Entry[capacity];
        
        哈希种子,默认为0,可以手动指定
        initHashSeedAsNeeded(capacity);
    }
3.1.1 找最小的2的幂

这里关于这个roundUpToPowerOf2函数说一下,也就是给定一个正整数,如何快速的找到>=该数的最小2的次幂,这里用到的解决办法是利用补码的「移位」和「按位或」操作,这种做法它有一个思路在里面,

对于任意一个整数,若它为 2 的幂,则会有一个特点:它的二进制数(补码)只有一位最高位是 1,其它位全是 0 。

比如:

64 :0100 0000

32 :0010 0000

16 :0001 0000

8 :0000 1000

根据这个特点,我们就可以这样做,找到给定数的临近的高位,然后把其它低位变成0之后,那么这个数就是我们要找的最小的2的幂。

所以这里就分为两种情况,

方式一、

核心步骤:

  • 从高位开始找,首1的位置
  • 所有低位变1(低位是0或1不要紧,如何把所有低位都变成1呢?我们后面再说)
  • 加1

演示1:

  • 比如给定一个数17,它的二进制补码为(这里只写8位):0001 0001,期望得到的数为32

  • 低位变1:0001 1111

  • 加1:0010 0000

那么临近0001 0001最小的2的幂即为0010 0000,也就是32,找到成功!

演示2:

  • 给定数为16,二进制补码为:0001 0000,期望得到的数为16
  • 低位变1:0001 1111
  • 加1:0010 0000

找到的数为32,错误,失败!那为什么会这样呢?我们不妨回头来分析下

当给定的数本身不是2的幂时,那么该数经过一番运算之后,对应二进制的最高位需要向高位移动一位,就得到了我们想要找的数;

但是对于给定的数本身是2的幂时,对应二进制的最高位不需要移动就是我们想要的数,但是该数经过运算之后,高位向前移动了一位,所以结果不是我们想要的。

方式二、

找到问题后,我们需要想办法解决这个问题,从数学思想中我们可以得到启发,即一个数减1之后,再加上个1,对该数不会发生变化,所以我们可以这样做,

核心步骤:

  • 先对给定的数减1
  • 找首1的位置
  • 所有低位变1
  • 加1

演示1:

  • 给定数为16,二进制补码为:0001 0000,期望得到的数为16
  • 减1:0000 1111
  • 低位变1:0000 1111
  • 加1:0001 0000

找到的数为16,成功!

演示2:

  • 比如给定一个数17,它的二进制补码为(这里只写8位):0001 0001,期望得到的数为32

  • 减1:0001 0000

  • 低位变1:0001 1111

  • 加1:0010 0000

那么临近0001 0001最小的2的幂即为0010 0000,也就是32,找到成功!

变1操作

对于补码的「变 1」操作只需要通过多次执行「移位」+「按位或」操作即可。

具体执行的次数取决于我们给定数的范围,比如int 占 4 个字节,即 32 位,最高位为符号位,所以我们操作的位数最高应到 32 位,如下所示:

给定数字为17,二进制补码为0000 0000 0000 0000 0000 0000 0001 0001(我们只需要关注低八位即可,高位的0都是补位的没有实际意义)

Step 1: 对给定的数减 1 (针对本身就是 2 的幂的数)

n:0000 0000 0000 0000 0000 0000 0001 0000

Step 2: n |= n >> 1,对于 Step 1 的结果数 n 的补码,使得与最高位(含)紧邻的 2 位低位为 1;

n >> 1=0000 0000 0000 0000 0000 0000 0000 1000

n=n|(n >> 1) = 0000 0000 0000 0000 0000 0000 0001 1000

Step 3: n |= n >> 2,对于 Step 2 的结果数 n 的补码,使得与最高位(含)紧邻的 4 位低位为 1;

n >> 2 =0000 0000 0000 0000 0000 0000 0000 0110

n=n|(n >> 2) = 0000 0000 0000 0000 0000 0000 0001 1110

Step 4: n |= n >> 4,对于 Step 3 的结果数 n 的补码,使得与最高位(含)紧邻的 8 位低位为 1;(PS: 若 n 的最高位没有超过 8 位,则只需将所有低位变成 1 即可,此时后续「移位」和「按位或」的操作不会影响这一步的结果。对于其它高位,依次类推)

n >> 4 = 0000 0000 0000 0000 0000 0000 0000 0001

n=n|(n >> 4) = 0000 0000 0000 0000 0000 0000 0001 1111

Step 5: n |= n >> 8,对于 Step 4 的结果数 n 的补码,使得与最高位(含)紧邻的 16 位低位为 1;

Step 6: n |= n >> 16,对于 Step 5 的结果数 n 的补码,使得与最高位(含)紧邻的 32 位低位为 1;

Step 7: 返回加 1 的结果

n=0000 0000 0000 0000 0000 0000 0010 0000=32

即大于17且临近的最小的2的次幂数为32

roundUpToPowerOf2源码解析

有了这个前置知识后,我们回过头来看roundUpToPowerOf2这个函数,源码如下

	private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

如果number即给定的数大于等于MAXIMUM_CAPACITY即最大容量,则return最大容量的值;

否则进行下一步判断,如果大于1,则return该数临近的最小的2的次幂,否则return1;

Integer.highestOneBit方法就是负责找到该数临近的最小的2的次幂,它是这样做的

	//步骤1
	Integer.highestOneBit((number - 1) << 1)

	//步骤2
	public static int highestOneBit(int i) {
        // HD, Figure 3-1
        i |= (i >>  1);
        i |= (i >>  2);
        i |= (i >>  4);
        i |= (i >>  8);
        i |= (i >> 16);
        return i - (i >>> 1);
    }


步骤1、先减1,再左移1位

这样做完之后,就已经确定了我们想要得到的结果对应二进制最高位为1 的位置,即从该”1“所在位置之后的低位全部变为0后对应的十进制数,就是我们想要找的那个所谓最小的2的次幂。

步骤2、把高位首1之后的全部低位变成0

如何把1后面的低位全部变为0呢?这里还是分为两步

首先把1后面的低位全部变成1,即我们前面提到的移位+按位或的方式;

然后用n-(n>>1) 就实现了这样的目的

举个例子

以19、32为例子(这里仅写8位),19补码=0001 0011 32补码=0010 0000

步骤1 : (n-1)<<1

19 经过步骤1后 : 0010 0100 此时从高位开始找首次出现的1,找到之后从该位置开始,所有低位变为0,那么这个数就是我们要找的数,即0010 0000

32 经过步骤1后 : 0011 1110 此时从高位开始找首次出现的1,找到之后从该位置开始,所有低位变为0,那么这个数就是我们要找的数,即0010 0000

步骤2:把高位首1之后的全部低位变成1

​ i |= (i >> 1);
​ i |= (i >> 2);
​ i |= (i >> 4);
​ i |= (i >> 8);
​ i |= (i >> 16);

19经过步骤2后:0011 1111

32经过步骤2后:0011 1111

步骤3:把高位首1之后的全部低位变成0

n-(n>>1) 也就是把高位首1空出来,其它位做减法,就实现了变0操作

19经过步骤3后:0010 0000=32

32经过步骤3后:0010 0000=32

3.2 putForNullKey分析
	put方法中判断调用,即HashMap中的key可以为空
    if (key == null)
            return putForNullKey(value);
        
    
	若key为空,存放到数组table 中的第1个位置,即table [0],因为key=null,hash值为0
	private V putForNullKey(V value) {
        
         遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对
         1. 若有:则用新value 替换 旧value;同时返回旧的value值
        
        思考:这里为什么要遍历呢?
        就是因为table[0]这里可能存的不止是key为null的数据,也有可能某个key不为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++;
        
         2 若无key==null的键,那么调用addEntry(),将空键对应的值封装到Entry中,并放到table[0]中,		 
        也就是将 key-value 添加到HashMap中
        第一个参数为key的hash值,null所以传0
        第二个参数,key为null
        第三个参数,value值
        第四个参数,bucketIndex,存放的位置     
        等下单独分析该方法
        addEntry(0, null, value, 0);
        return null;
    }
        
3.3 计算存放数组 table 中的位置
	
	put方法中调用

	现根据key计算hash值
	int hash = hash(key);

	根据hash值计算 key对应存放在数组Table中的位置
    int i = indexFor(hash, table.length);
        
    

	
	一、hash方法
	final int hash(Object k) {
        hash种子初始为0
        int h = hashSeed;
        
        hashSeed为0表示禁用备选哈希
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        
         将key 转换成 哈希码(hash值)操作  = 使用hashCode() + 4次位运算 + 5次异或运算,称为9次扰动
        扰动函数作用:使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即不同key生成同1个hash值)
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    

	 二、indexFor方法
	将对哈希码扰动处理后的结果 与(数组长度-1)进行&运算,最终得到存储在数组table的位置(即数组下标、索引)
	static int indexFor(int h, int length) {
        return h & (length-1);
    }


小贴士

关于前面为什么容量要找最小2的幂,就是因为在indexFor方法计算元素存储位置时,通过容量length-1与key对应的hashcode值进行与运算要用到(2的幂带来的好处,map扩容时也有用到)。

假设容量为默认的16,那么它的数组下标对应的为0-15,通过hash & (length-1)就可以保证计算的索引下标为0-15,所以它相当于是hash % length的效果,但效率更高。

比如 hash值为:1100 0011,容量为16:0001 0000,length-1:0000 1111

hash & (length-1):0000 0011,所以存放位置为table[3]

3.4、确保key的唯一性

计算出key具体应该存放的位置后,为了保证key的唯一性,并没有直接插入链表当中,而是要先看一下,该位置中的链表是否存在该key,如果存在的话则用新的value替换旧的value,然后返回旧的value,put执行结束。

	    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;
            }
        }
3.5、addEntry-准备入链
	put方法中调用
	addEntry(hash, key, value, i);

	
	i也就是bucketIndex,它就是之前indexFor函数算出来的要存在table中的具体位置
	void addEntry(int hash, K key, V value, int bucketIndex) {
        
        插入之前,需要判断一下,是否需要扩容,即先扩容,再插入
        扩容的条件,一个是我们前门提到过的HashMap中元素的数量大于等于阈值,另一个就是该位置不为null,
        只有同时满足这两个条件才会扩容      
        if ((size >= threshold) && (null != table[bucketIndex])) {
            
            扩容时,以当前长度的2倍进行扩容
            resize(2 * table.length);
            
            重新计算key的hash值
            hash = (null != key) ? hash(key) : 0;
            
            扩容后,要重新算下该key在table中的位置
            bucketIndex = indexFor(hash, table.length);
        }
		
        入链
        createEntry(hash, key, value, bucketIndex);
    }

3.5.1、 扩容-resize
	
	void resize(int newCapacity) {
        
        先保存下旧的数组
        Entry[] oldTable = table;
        
        保存旧的数组长度
        int oldCapacity = oldTable.length;
        
        如果旧的数组长度已经为最大值了,则停止扩容
        if (oldCapacity == MAXIMUM_CAPACITY) {
            
            将扩容阈值设为Integer.MAX_VALUE即2147483647
            threshold = Integer.MAX_VALUE;
            return;
        }
		
        创建新的哈希表,长度为原table的2倍
        Entry[] newTable = new Entry[newCapacity];
        
        将旧table中的数据转移到新扩容的table中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        
        让哈希表指向新扩容的table
        table = newTable;
        
        重新计算扩容阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
3.5.1.1、数据迁移–transfer
void transfer(Entry[] newTable, boolean rehash) {
    
		获取新table的容量    
        int newCapacity = newTable.length;
    
    
 		遍历旧table,将旧数组上的元素全部转移到新数组中
    	外层循环遍历table,内层循环遍历链表
        for (Entry<K,V> e : table) {
            while(null != e) {
                
                转移之前,先记录下一个要转移的元素
                Entry<K,V> next = e.next;
                
                如果更改了哈希种子,需要重新计算hash,默认为false
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                
                计算元素在新table中的位置
                int i = indexFor(e.hash, newCapacity);
                
                因为采用的是头插法,将新表中该位置上原有的数据放到即将插入数据的后面
                意味着,如果某个位置上的链表元素转移到新table中时,如果仍然为同一个链表,那么在新表中为逆序存储
                e.next = newTable[i];
                
                将元素转移到新table中
                newTable[i] = e;
                
                进行下一次迁移,如此不断循环,直到遍历完数组上的所有数据元素
                e = next;
            }
        }
    }
数据丢失问题

在并发场景下,如果多个线程一起执行扩容即resize方法,就会出现丢失元素的问题,我们来试着分析一下:

  • 多个线程执行resize方法,意味着每个线程都会创建一个newTable,需要注意的是它是局部对象,也就是说各个线程之间不可见,当迁移完数据后,最后会对全局table进行覆盖,意味着会覆盖其它线程的操作,也就是说之前在新表插入的数据会被后来的线程覆盖掉,造成数据丢失;
  • 再有,当前线程迁移数据时,其它线程插入的数据可能会落到已遍历过的链表中,当线程迁移完数据后会将全局table指向新表即newTable,相当于弃用了原来的table,所以后插入到原table中的数据也就丢失了;
死循环问题

在并发执行 put()操作时,一旦出现扩容情况,则容易出现环形链表,带来的后果就是当再次在获取数据、遍历链表时就会形成死循环即死锁的状态,这里我们画图说明下环形链表产生的过程。

1)、假设现在table长度设为4,此时哈希表中有三个元素a、b、c且它们都在table[0]这个位置上,如下图所示

在这里插入图片描述

2)、为了方便说明,负载因子设为1,现在有t1、t2两个线程同时向table[0]这个位置put第四个元素,此时两个线程都创建了新的table,如下图所示

在这里插入图片描述

3)、t0和t1创建新表之后,都要执行transfer方法迁移数据,假设t0执行完 Entry<K,V> next = e.next;这句时间片用完了,轮到t1执行,此时需要记一下,t0的状态,e=a,next=e.next=b

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;
            }
        }
    }

4)、t1开始执行,假设a、b、c三个节点在新表的位置仍然为table[0],由于迁移的时候从旧表的头部开始,插入新表后的顺序刚好是旧表的逆序,如下图所示,

在这里插入图片描述

5)、这时t0恢复开始执行,过程如下:

1、已经执行完了 Entry<K,V> next = e.next这句,此时e=a,next=e.next=b,继续向下;
2、e.next = newTable[i]; 此时newTable[i]还没有数据为null,那就是把a的next指向了null;
3、newTable[i] = e 把节点a放到了数组i位置,也就是newTable[i] 指向了a;
4、e = next;把变量e赋值为b;

第一次while循环执行完后,效果如下图所示

在这里插入图片描述

6)、第一次while执行完后,e=b,不为null所以继续执行,第二次while循环执行过程如下,

1、执行Entry<K,V> next = e.next这句,此时e=b,next=e.next=a,继续向下;
2、e.next = newTable[i]; 此时newTable[i]指向的是a,所以把b的next指向了a;
3、newTable[i] = e 把节点b放到了数组i位置,也就是newTable[i] 指向了b;
4、e = next;把变量e赋值为a;

第二次while循环执行完后,效果如下图所示

在这里插入图片描述

7)、第二次while执行完后,e=a,不为null所以继续执行,第三次while循环执行过程如下,

1、执行完Entry<K,V> next = e.next这句,此时e=a,next=e.next=null,所以变量next指向null;
2、e.next = newTable[i]; 此时newTable[i] 指向的是b,所以把a的next指向了b,这样a和b就形成了一个环;
3、newTable[i] = e 把节点a放到了数组i位置,也就是newTable[i] 指向了a
4、e = next;把变量e赋值为null,所以下次就不会再执行while循环;

第三次while循环执行完后,最终效果如下图所示

在这里插入图片描述

所以当再次对table[0]位置进行遍历的时候,就会发现死循环现象,这就是我们开头演示发生死循环的原因!

关于死循环和数据丢失这是不是一个BUG 呢?咋不修复呢?

并不是,sun公司的负责人表达的意思是 “ 我们设计HashMap本来的作用就不应该是应对高并发情况的,在高并发的情况下,我们有另外一个更好用的ConcurrentHashMap 去应对”,所以问题的本身在我们不在人家,还说啥呢!

所以关于如何防止死循环和数据丢失的问题,这里有一个口诀,来,大声跟我一起念!避免多线程使用,避免多线程使用,避免多线程使用!重要的口诀来三遍;

3.5.2、createEntry-真正的入链
	创建一个新的entry,放入数组中,也就是链表的头部
	void createEntry(int hash, K key, V value, int bucketIndex) {
        
        先记录一下,哈希表中该位置上原来的元素,也就是当前链表的头节点
        Entry<K,V> e = table[bucketIndex];
        
        将新建的entry放入table中,同时将该位置上原来的元素放到新元素的后面,也就是头插法
        这就是我们前面提到的,新元素在链表的头部插入,table中的元素存放的永远是最新插入的元素,也就是链表的头节点
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        
        HashMap存放元素的个数加1
        size++;
    }


	Entry的构造方法,在这里完成了新元素.next--->旧链表的头节点
	Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
    }
3.6、总结put全流程

1、首先判断哈希表也就是table是否被初始化,如果没有则先初始化,也就是inflateTable方法,该方法除了负责初始化哈希表外,还确定了哈希表长度、扩容阈值、哈希种子;

2、判断元素的key是否为null,如果为null则调用putForNullKey方法处理,将元素放到table[0]的位置存储,存储之前先遍历是否存在key值为null的元素,如果存在则用新的value替换旧的value,然后返回旧的value,put流程结束;

3、如果key不为null,则通过hash函数经过9次扰动计算出key的hash值;然后通过indexFor函数根据hash值计算(hash & (length-1))该值在table中存储的位置,也就是数组下标;

4、确保key的唯一性,遍历该位置上的链表,查找是否存在该key,存在则用新value替换旧value,并返回旧value,put流程结束

5、如果之前没有存储过该key,则调用addEntry方法,添加这个元素;添加之前先检查是否需要扩容,扩容的两个条件,大于等于扩容阈值并且该位置不为null;

6、如果需要扩容,则调用resize方法进行扩容,扩容时为原数组长度的2倍;

7、扩容后调用transfer方法迁移数据;迁移时,通过遍历原表中的全部元素,同样采用头插法插入到新表中;迁移之后重新计算扩容阈值、要插入元素key的hash值以及在新表中的位置;

8、不管是否需要扩容,最后都会调用createEntry方法创建entry存放要插入的元素,以头插法放到table中,最后HashMap元素容量+1,至此put流程分析完毕!

4、get流程分析

实际上分析完put之后,get部分就相当easy了!不信你看,

	public V get(Object key) {
        
        判断key是否为null,如果为null则调用getForNullKey方法处理,
        也就是到哈希表table[0]的位置遍历链表查找key为null的值
        if (key == null)
            return getForNullKey();
        
        如果key不为null,则调用getEntry方法处理
        Entry<K,V> entry = getEntry(key);
		
        判断最后查找的结果,找到返回对应的value,没找到返回null
        return null == entry ? null : entry.getValue();
    }
4.1、getForNullKey分析
	
	private V getForNullKey() {
        
        首先判断下HashMap中是否有元素,如果都没有元素,那肯定就是没有了
        if (size == 0) {
            return null;
        }
        
        如果HashMap中有元素,那就遍历table[0]上的链表,
        因为put的时候,key为null只能放这,其它位置连看都懒得看,就是这么豪横!
        找到就返回value值,找不到就拉倒,给你个null自己看着办把!
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
4.2、getEntry分析
	final Entry<K,V> getEntry(Object key) {
        
        首先判断下HashMap中是否有元素,如果都没有元素,那肯定就是没有了,能偷点懒就偷点懒
        if (size == 0) {
            return null;
        }

        如果HashMap中有元素,那就费点劲了,首先我要根据你的key值计算你应该存储的下标位置,
        然后遍历该位置上的链表,其它位置上的链表我都不用看,用了hash就是这么拽!
        找到就返回对应的entry,找不到就拉倒,返回个null
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            
            光hash值相等还不行,我还得看看你们到底是不是同一个对象,
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

行了差不多了,最常用的put、get操作就顺着看完了,关于HashMap 1.7源码就先到这了,下次接着看1.8!古德奈,艾薇巴蒂!

每日一皮

你穿什么都漂亮,因为你的美只有你自己才能发现

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值