JDK1.7 HashMap源码解析+常见问题

基础

由于HashMap底层是哈希表/散列表(数组+链表)+红黑树(1.8以后链表长度大于8并且容量大于64会转换),所以这里你要掌握数据结构,熟练掌握数组链表的相关知识,至于红黑树,你也要清楚一些基本的概念,更深层次的我觉得没必要深入研究(有点复杂!!!)

JDK1.7

不管是JDK1.7还是1.8,总体来说就是增(put)删(remove)改(replace(1.7版本没有这个方法))查(get)四个方法,这几个方法搞懂了,其他方法也自然很好理解!

image-20200824144751991

变量说明

// 1. 容量(capacity): HashMap中数组的长度
// a. 容量范围:必须是2的幂 & <最大容量(2的30次方)
// b. 初始容量 = 哈希表创建时的容量
// 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 最大容量 =  2的30次方(若传入的容量过大,将被最大值替换)
static final int MAXIMUM_CAPACITY = 1 << 30;

// 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
// a. 加载因子越大、填满的元素越多 = 空间利用率高、但冲突的机会加大、查找效率变低(因为链表变长了)
// b. 加载因子越小、填满的元素越少 = 空间利用率小、冲突的机会减小、查找效率高(链表不长)
// 实际加载因子
final float loadFactor;
// 默认加载因子 = 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量) 
// a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
// b. 扩容阈值 = 容量 x 加载因子
int threshold;

// 4. 其他
// 存储数据的Entry类型 数组,长度 = 2的幂
// HashMap的实现方式 = 拉链法,Entry数组上的每个元素本质上是一个单向链表
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  
// HashMap的大小,即 HashMap中存储的键值对的数量
transient int size;

构造函数

第二个构造是重载第一个,第四个直接将map传入进去,第三个是我们最常用的!

但是这里建议:我们很清楚自己的数据量多少时,最好指定大小,避免频繁扩容影响性能(后续分析源码会说明)

image-20200820172001683

  • 如果大于最大容量,就赋值为最大的2^30(1<<30)
  • 阈值=加载因子*传入的容量(容量在上面会调整为2的次方)
  public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //如果大于最大容量,就赋值为最大的2^30(1<<30)                                           
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
		//这里还没有真正的初始化,真正的初始化在put第一个元素的时候,使用inflateTable
        this.loadFactor = loadFactor;
        //不是真正的阈值!
        threshold = initialCapacity;
        init();
    }
  //直接将map传入构造函数
  public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        putAllForCreate(m);
    } 
  //我们最常用的最习惯的方式,什么都不指定,采用默认的
  public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

put

  • 哈希table如果是空的,则调用inflateTable进行初始化:

    • 找到大于传入容量值的2的最小次幂;
    • 重新计算阈值 threshold = 容量 * 加载因子
    • 使用计算后的初始容量(已经是2的次幂) 初始化数组table(作为数组长度)
  • 如果key为空,就调用putForNullKey方法,下面会介绍

  • 对key的hashcode进行了扰动处理:做了9次扰动处理 = 4次位运算 + 5次异或运算,以获得更好的散列值;然后对table数组长度取摸

  • 如果计算出来的索引发生了哈希冲突(即两个索引经过hash函数后取模后相等),就遍历链表判断是不是同个key,是的话覆盖旧值,不是的话就调用addEntry方法:

    • 插入前,先判断容量是否足够
    • 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
    • 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中:
      • 在table中该位置新建一个Entry:将原头结点位置(数组上)的键值对 放入到(链表)后1个节点中、将需插入的键值对 放入到头结点中(数组上)-> 从而形成链表( 即在插入元素时,是在链表头插入的,table中的每个位置永远只保存最新插入的Entry,旧的Entry则放入到链表中(即 解决Hash冲突))
   public V put(K key, V value)
		// 1. 若 哈希表未初始化(即 table为空) 
        // 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table  
        if (table == EMPTY_TABLE) { 
        	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) {
            Object k;
		// 3.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++;

		// 3.2 若 该key不存在,则将“key-value”添加到table中
        addEntry(hash, key, value, i);
        return null;
    }
    
    //初始化hash表
   private void inflateTable(int toSize) {  
    
    // 1. 将传入的容量大小转化为:>传入容量大小的最小的2的次幂
    // 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
    int capacity = roundUpToPowerOf2(toSize);->>分析1   

    // 2. 重新计算阈值 threshold = 容量 * 加载因子  
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);  

    // 3. 使用计算后的初始容量(已经是2的次幂) 初始化数组table(作为数组长度)
    // 即 哈希表的容量大小 = 数组大小(长度)
    table = new Entry[capacity]; //用该容量初始化table  

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

    
  //添加链表元素	    
 void addEntry(int hash, K key, V value, int bucketIndex) {
 		//插入前,先判断容量是否足够
 		//若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
    	if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);  
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
		//若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
        createEntry(hash, key, value, bucketIndex);
    } 
void createEntry(int hash, K key, V value, int bucketIndex) { 
    // 把table中该位置原来的Entry保存  
    Entry<K,V> e = table[bucketIndex];
    // 在table中该位置新建一个Entry:将原头结点位置(数组上)的键值对 放入到(链表)后1个节点中、将需插入的键值对 放入到头结点中(数组上)-> 从而形成链表
    // 即 在插入元素时,是在链表头插入的,table中的每个位置永远只保存最新插入的Entry,旧的Entry则放入到链表中(即 解决Hash冲突)
    table[bucketIndex] = new Entry<>(hash, key, value, e);  
    //哈希表的键值对数量计数增加
    size++;  
}      

这里有个 modCount++代表修改的次数,比如add、remove、clear等方法都会+1,源码里还有个expectedModCount,如果在普通for each遍历的时候修改就会出现ConcurrentModificationException异常(快速失败机制),因为这时候不会自增expectedModCount,而modCount!=expectedModCount就会抛出这个异常,具体可参考我的博客 https://blog.csdn.net/belongtocode/article/details/107981590

for (String item : list) {
	if (item.equals("1")) {
		System.out.println(item);
		list.remove(item);
	}
}

HashMap计算hash对key的hashcode进行了做了9次扰动处理 = 4次位运算 + 5次异或运算,以获得更好的散列值(也叫做扰动函数),然后对table数组长度取摸

注意两点:

  • 做了9次扰动处理 = 4次位运算 + 5次异或运算,获得更好的散列值,减少哈希冲突
  • 采用&运算取模,和%对比,运算效率更高!

这里说一定结论:``若一个数m满足:m=2的n次幂 那么k % m = k & (m-1),结果始终在0~m-1`

System.out.println(12 % 16);//12
System.out.println(12 & 15);//12
System.out.println(18 & 15);//2
System.out.println(18 % 16);//2
  //HashMap计算hash对key的hashcode做了9次扰动处理 = 4次位运算 + 5次异或运算,以获得更好的散列值,然后对table数组长度取摸  
  static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
 //对table数组长度取摸   
 static int indexFor(int h, int length) {
        return h & (length-1);
    }    

put key为null的值,由于null没有hashcode,会进行特殊处理,默认放到table[0]位置,即哈希桶的第一个位置

  //put key为null的值,由于null没有hashcode,默认设置为0,放在哈希桶的第一个位置	    
  private V putForNullKey(V value) {
  		//如果多次put key为null的键值对,新值会覆盖原来的值
        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;
    }
  //添加链表元素	    
  void addEntry(int hash, K key, V value, int bucketIndex) {
 		//插入前,先判断容量是否足够
 		//若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
    	if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);  
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
		//若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
        createEntry(hash, key, value, bucketIndex);
    }      

resize

  • 判断原来哈希表的容量是不是等于最大容量2^30,如果是的话,将阈值赋为Integer.MAX_VALUE整型最大值,不需要进行后续的扩容了!
  • 创建新的哈希表,这里newCapacity容量是原来的2倍
  • 将旧的哈希表数据进行转移:首先遍历原来的哈希表所有元素;然后遍历每个哈希桶上的链表,重新hash定位新的下标放进去(所以扩容耗性能!!)
  • 现在的阈值也要改为扩容后的哈希表的容量*加载因子
void resize(int newCapacity) {  
    
    // 1. 保存旧数组(old table) 
    Entry[] oldTable = table;  

    // 2. 保存旧容量(old capacity ),即数组长度
    int oldCapacity = oldTable.length; 

    // 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
  
    // 4. 根据新容量(2倍容量)新建1个数组,即新table  
    Entry[] newTable = new Entry[newCapacity];  

    // 5. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 
    transfer(newTable); 

    // 6. 新数组table引用到HashMap的table属性上
    table = newTable;  

    // 7. 重新设置阈值  
    threshold = (int)(newCapacity * loadFactor); 
} 

//将旧数组上的数据(键值对)转移到新table中,从而完成扩容
//按旧链表的正序遍历链表、在新链表的头部依次插入
void transfer(Entry[] newTable) {
      // 1. src引用了旧数组
      Entry[] src = table; 

      // 2. 获取新数组的大小 = 获取新容量大小                 
      int newCapacity = newTable.length;

      // 3. 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中
      for (int j = 0; j < src.length; j++) { 
      	  // 3.1 取得旧数组的每个元素  
          Entry<K,V> e = src[j];           
          if (e != null) {
              // 3.2 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
              src[j] = null; 

              do { 
                  // 3.3 遍历 以该数组元素为首 的链表
                  // 注:转移链表时,因是单链表,故要保存下1个结点,否则转移后链表会断开
                  Entry<K,V> next = e.next; 
                 // 3.4 重新计算每个元素的存储位置
                 int i = indexFor(e.hash, newCapacity); 
                 // 3.5 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中
                 // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
                 e.next = newTable[i]; 
                 newTable[i] = e;  
                 // 3.6 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
                 e = next;             
             } while (e != null);
             // 如此不断循环,直到遍历完数组上的所有数据元素
         }
     }
 }
   

get

  • key为null,调用对应的getForNullKey方法:key为null,默认是放在哈希桶的第一个位置table[0],然后遍历这个位置的链表找到key为null的value返回
  • 同put类似,取得hash值、取模获取索引下标table[i]
  • 遍历table[i]这个位置的链表,找到了就返回
   public V get(Object key) {
   		//1.key为null,调用对应的getForNullKey方法
        if (key == null)
            return getForNullKey();
        //2.当key ≠ null时,去获得对应值    
        Entry<K,V> entry = getEntry(key);
        //3.entry等于null说明没找到,则返回null值
    	return null == entry ? null : entry.getValue();  
    }
  //key为null,默认是放在哈希桶的第一个位置table[0],然后遍历这个桶的链表找到key为null的value返回  
  private V getForNullKey() {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    } 
    
 final Entry<K,V> getEntry(Object key) {  
    if (size == 0) {  
        return null;  
    }  
    // 1. 根据key值,通过hash()计算出对应的hash值
    int hash = (key == null) ? 0 : hash(key);  

    // 2. 根据hash值计算出对应的数组下标
    // 3. 遍历 以该数组下标的数组元素为头结点的链表所有节点,寻找该key对应的值
    for (Entry<K,V> e = table[indexFor(hash, table.length)];  e != null;  e = e.next) {  

        Object k;  
        // 若 hash值 & key 相等,则证明该Entry = 我们要的键值对
        // 通过equals()判断key是否相等
        if (e.hash == hash &&  
            ((k = e.key) == key || (key != null && key.equals(k))))  
            return e;  
    }  
    return null;  
}     

remove

  • 调用对应removeEntryForKey,找到了返回移除的key的value值,没有就返回null
  • removeEntryForKey:
//调用对应的方法,找到了返回移除的key的value值,没有就返回null
public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

final Entry<K,V> removeEntryForKey(Object key) {
		//key为null,hash值为0,取模后索引下标也是0,
        int hash = (key == null) ? 0 : hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //保存当前链表节点的前置节点,用于后续操作
        Entry<K,V> prev = table[i];      
        Entry<K,V> e = prev;
		//遍历链表
        while (e != null) {
            Entry<K,V> next = e.next;
            Object k;
            //判断hash值和key是否相等
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                //说明删除的是第一个节点
                if (prev == e)
                    table[i] = next;
                //说明是删除的是中间节点    
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            //指向下一个节点,继续查找
            prev = e;
            e = next;
        }

        return e;
 }

replace???

jdk1.7没有这个方法!!!找了半天我才发现???

常见问题

1、为什么容量要2的次幂

首先取key的hashcode进行扰动处理,5次异或+4次位运算,这是为了提高散列性,让其随机均匀分布,然后取模是采用&运算hash&length-1,若一个数m满足:m=2的n次幂 那么k % m = k & (m-1),如果是2的次幂,比如默认的初始容量就是16,length-1的2进制就是1111,这样进行与&运算的时候能充分利用每一位,如果不是2的次幂,length-1的2进制肯定有0的地方,0与&任何都是0,定位索引下标的时候就容易重复,就会加大哈希冲突的概率,其次&运算想比%运算效率更高!

因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。
而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。
所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。下标定位是在数组长度范围内,不会越界

所以:

1、提高随机分布的散列性,减少哈希冲突;

2、提高运算效率!!!

2、为什么是最大容量是2的30,而不是2的31

由于int类型限制了该变量的长度为4个字节共32个二进制位,按理说可以向左移动31位即2的31次幂。但是事实上由于二进制数字中最高的一位也就是最左边的一位是符号位,用来表示正负之分(0为正,1为负),所以只能向左移动30位,而不能移动到处在最高位的符号位!

3、为什么要用红黑树而不是其他的树?既然红黑树那么好,为啥hashmap不直接采用红黑树,而是当大于8个的时候才转换红黑树?

1)想比链表的时间复杂度O(n),树是O(lg n),如果普通的树,当极端情况下,就是一个单链表了,所以普通的树肯定不行。

主要原因树红黑树恢复平衡时需要的旋转次数比Avl树更少!!!AVL树的话,在AVL树中查找通常更快,但这是以更多旋转操作导致更慢的插入和删除为代价的(高度平衡!)。红黑树牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率略高于AVL树。这两个都给O(log n)查找,但平衡AVL树可能需要O(log n)旋转,而红黑树将需要最多两次旋转使其达到平衡(尽管可能需要检查O(log n)节点以确定旋转的位置)。旋转本身是O(1)操作,因为你只是移动指针。

由于维护这种高度平衡所付出的代价比从中获得的效率收益还大,故而实际的应用不多,更多的地方是用追求局部而不是非常严格整体平衡的红黑树。当然,如果应用场景中对插入删除不频繁,只是对查找要求较高,那么AVL还是较优于红黑树。

2)为什么采用6和8进行红黑树和链表转化

通过源码我们得知HashMap源码作者通过泊松分布算出,当桶中结点个数为8时,出现的几率是亿分之6的,因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,因为转化为树还需要时间和空间,所以此时没有转化成树的必要。

既然个数为8时发生的几率这么低,我们为什么还要当链表个数大于8时来树化来优化这几乎不会发生的场景呢?

首先我们要知道亿分之6这个几乎不可能的概率是建立在什么情况下的 答案是:建立在良好的hash算法情况下,例如String,Integer等包装类的hash算法、如果一旦发生桶中元素大于8,说明是不正常情况,可能采用了冲突较大的hash算法,此时桶中个数出现超过8的概率是非常大的,可能有n个key冲突在同一个桶中,此时再看链表的平均查询复杂度和红黑树的时间复杂度,就知道为什么要引入红黑树了,

举个例子,若hash算法写的不好,一个桶中冲突1024个key,使用链表平均需要查询512次,但是红黑树仅仅10次,红黑树的引入保证了在大量hash冲突的情况下,HashMap还具有良好的查询性能

选择6和8的原因是:
  中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

JDK1.7和1.8相关问题

JDK1.7和1.8中HashMap为什么是线程不安全的?

前言

只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap。但是为什么HashMap是线程不安全的呢,之前面试的时候也遇到到这样的问题,但是当时只停留在知道是的层面上,并没有深入理解为什么是。于是今天重温一个HashMap线程不安全的这个问题。

首先需要强调一点,HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。

扩容引发的线程不安全

HashMap的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,JDK1.7中HashMaptransfer函数如下:

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

这段代码是HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。

扩容造成死循环和数据丢失的分析过程

假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
img
正常扩容后的结果是下面这样的:
img
但是当线程A执行到上面transfer函数的第11行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:

img

此时线程A中:e=3、next=7、e.next=null
img
当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移
img
重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTabletable都是最新的,也就是说:7.next=3、3.next=null。

随后线程A获得CPU时间片继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:
img
接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:
img
执行下一次循环可以发现,next=e.next=null,所以此轮循环将会是最后一轮循环。接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行结果如下图所示:
img
上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。

并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。

JDK1.8中的线程不安全

根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。

为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:

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) // 如果没有hash碰撞则直接插入元素
            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);
                        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;
    }

其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。

总结

HashMap的线程不安全主要体现在下面两个方面:
1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

参考:

https://blog.csdn.net/swpu_ocean/article/details/88917958

https://www.cnblogs.com/developer_chan/p/10450908.html

JDK1.7和1.8区别总结

img

1.数组+链表改成了数组+链表或红黑树;

2.表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;

3.在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容(好像1.7版本不一样,也不一样???我的源码是插入完在判断)

4.扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;

5.初始化方式:1.7有单独的inflateTable函数,1.8集成在扩容函数resize(好像1.7版本不一样,也不一样???我的源码是没有这个函数)

6.hash值的计算方式:1.7是8次扰动=4次位运算+5次异或计算。1.8是2次扰动=1次异或+一次位运算。(做了优化)

原因:第1、2、4条分析

  1. 防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

  2. 因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环形链表;会造成死循环、数据丢失!

A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环

  1. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?(采用高位1的话就是+上原来的容量,0的话就是原来的位置)

这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?

扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。

因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;

第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Apple_Web

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值