HashMap详解

一、hashmap简介
hashmap是Java当中一种数据结构,是一个用于存储Key-Value键值对的集合,每一个键值对也叫作Entry。

二、JDK7的HashMap
1、JDK7时HashMap的数据结构
1、在JDK7之前,hashmap底层采用数组+链表的数据结构来存储数据
2、插入数据采用头插法,头插法效率更高,不需要去遍历链表。插入结点后将头结点移到数组下标的位置

什么是头插法?咱们看一副图你就了解了

每次都在头结点插入,其余的节点依次往下挪。那么这样就会造成一个问题,当扩容的时候,对每个节点重新rehash。假设重新计算hash后孙悟空和孙尚香依然还是在一条链上,但可能顺序变了,变成孙尚香—>孙悟空。而原来的孙悟空—>孙尚香这个指针又没有断开,这样就会形成环,最终会导致死锁

2、手写HashMap部分源码
public class MyHashMap<K,V>{
    private Entry[] table;
    private static Integer CAPACITY=8;
    private int N;  //元素个数
    public int size(){
        return N;
    }
    
    public  get(K key){
         int hash=key.hashCode(); //求出key的hashCode
        int i=hash%8;   //计算下标
        
        for(Entry<K<V> en=table[i];en!=null;en=en.next){
            if(entry.key.equals(key)){
                return entry.v;
            }
        }
    }
    
    public V put(K key,V value){
       
        int hash=key.hashCode(); //求出key的hashCode
        int i=hash%8;   //计算下标
        
        //如果存在相同的key,则更新值
        for(Entry<K<V> en=table[i];en!=null;en=en.next){
            if(entry.key.equals(key)){
                V oldValue=entry.v;
                entry.v=value;
                return oldValue;
            }
        }
        
        Entry entry=new Entry(key,value,table[i]);  //头插法
        table[i]=entry;    //将头结点放在数组的位置
        N++;
        return null;
    }
    
    //结点类
    class Entry<K,V>{
        private K key;
        private V value;
       private Entry<K,V> next;
       public Entry(K key,V velue,Entry<K,V> next){
           this.key=key;
           this.value=value;
           this.next=next;
       } 
       
       public K getKey(){
           return key;
       }
       public V getValue(){
           return value;
       }
        
    }
}
3、相关问题解答
为什么hashmap的容量是2的幂次方?
1、&运算速度快,至少比%取模运算块
2、(n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n
3、采用(n - 1) & hash来计算索引,当n为2的幂次方的时候,(n-1)转换成为二进制保证低位全是1

为什么JDK7中hashmap源码计算hashcode要右移?
求索引位置时保证高位也能参与位运算。为了保证求出来的散列值均匀。如果计算出来的索引扎堆,那么这就不算一个好的哈希算法

JDK7hashmap如何判断是否需要扩容?
有两个条件。第一个是当前元素个数大于或等于阈值。第二个是当前数组table[i]!=null

Hashmap是线程不安全的?
1.多线程环境下,如果有多个线程同时对一个数据进行操作,很有可能出现数据覆盖的情况。
2.扩容有可能发生死锁情况

hashMap允许key为null。如果key为null,则默认把这个数据放在索引为0的位置处

三、JDK8的HashMap部分源码
1、jdk8中hashMap数据结构
1、JDK8以后hashmap的底层数据结构由数据+链表+红黑树实现
2、jdk8以后插入数据采用尾插法。因为引入了树形结构,总是要遍历的

当进行put操作的时候,当链表的长度大于或等于8时,会将链表转化为红黑树
在进行remove操作的时候,当红黑树的节点个数小于或者等于6时,会将红黑树转化为链表
2、相关问题解答
为什么使用红黑树而不使用其他的树形结构?
hashMmap中不仅存在查询,还存在修改的操作。红黑树的查询和修改效率处在链表和完全平衡二叉树之间

hashMap怎么设置初始值的大小
如果你在创建的时候没有设置初始值大小,那么它的默认容量是16。
如果你设置了一个初始容量,它会先进行一个判断,判断这个值是不是2的次幂,如果不是将会把容量转化成为2的次幂大小。比如说你设置的容量是27,那么创建的HashMap实际容量是32。

jdk7和jdk8中HashMap的区别
1、jdk8中当链表长度大于8时会将链表转化成为红黑树
2、节点插入顺序不同,jdk7采用头插法,而jdk8采用尾插法
3、hash算法的简化
在jdk7中hash算法为

    final int hash(Object k) {
 
        int h = hashSeed;
 
        if (0 != h && k instanceof String) {
 
            return sun.misc.Hashing.stringHash32((String)  k);
 
        }
 
        h ^= k.hashCode();
 
        // This function ensures that hashCodes that  differ only by
 
        // constant multiples at each bit position have a  bounded
 
        // number of collisions (approximately 8 at  default load factor).
 
        h ^= (h >>> 20) ^ (h >>> 12);
 
        return h ^ (h >>> 7) ^ (h >>> 4);
 
    }
在jdk8中的hash算法为

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h =  key.hashCode()) ^ (h >>> 16);
    }

为什么在jdk8中要简化hash算法:jdk8之前之所以hash方法写的比较复杂,主要是为了提高散列行,进而提高遍历速度,但是jdk8以后引入红黑树后大大提高了遍历速度,继续采用复杂的hash算法也就没太大意义,反而还要消耗性能,因为不管是put()还是get()都需要调用hash()

4、扩容不同,在jdk7中,发生扩容,它会把原来所有的元素重新计算hash。再插入到新的位置。而jdk8中则是直接copy过去,要么位置不变,要么位置更改为索引+原数组长度
原文链接:https://blog.csdn.net/weixin_45119323/article/details/108659506

把书读薄

在Java 7中HashMap实现有1000多行,到了Java 8中增长为2000多行,虽然代码行数不多,但代码中有比较多的位运算,以及其他的一些细枝末节,导致这部分代码看起来很复杂,理解起来比较困难。但是如果我们跳出来看,HashMap这个数据结构是非常基础的,我们大脑中首先要有这样一幅图:

高端的面试从来不会在HashMap的红黑树上纠缠太多

这张图囊括了HashMap中最基础的几个点:

  1. Java中HashMap的实现的基础数据结构是数组,每一对key->value的键值对组成Entity类以双向链表的形式存放到这个数组中
  2. 元素在数组中的位置由key.hashCode()的值决定,如果两个key的哈希值相等,即发生了哈希碰撞,则这两个key对应的Entity将以链表的形式存放在数组中
  3. 调用HashMap.get()的时候会首先计算key的值,继而在数组中找到key对应的位置,然后遍历该位置上的链表找相应的值。

当然这张图中没有体现出来的有两点:

  1. 为了提升整个HashMap的读取效率,当HashMap中存储的元素大小等于桶数组大小乘以负载因子的时候整个HashMap就要扩容,以减小哈希碰撞,具体细节我们在后文中讲代码会说到
  2. 在Java 8中如果桶数组的同一个位置上的链表数量超过一个定值,则整个链表有一定概率会转为一棵红黑树。

整体来看,整个HashMap中最重要的点有四个:初始化数据寻址-hash方法数据存储-put方法,扩容-resize方法,只要理解了这四个点的原理和调用时机,也就理解了整个HashMap的设计。

3. 把书读厚

在理解了HashMap的整体架构的基础上,我们可以试着回答一下下面的几个问题,如果对其中的某几个问题还有疑惑,那就说明我们还需要深入代码,把书读厚。

  1. HashMap内部的bucket数组长度为什么一直都是2的整数次幂
  2. HashMap默认的bucket数组是多大
  3. HashMap什么时候开辟bucket数组占用内存
  4. HashMap何时扩容?
  5. 桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
  6. Java 8中为什么要引进红黑树,是为了解决什么场景的问题?
  7. HashMap如何处理key为null的键值对?

3.1 new HashMap()

在JDK 8中,在调用new HashMap()的时候并没有分配数组堆内存,只是做了一些参数校验,初始化了一些常量

  1. public HashMap(int initialCapacity, float loadFactor) {

  2. if (initialCapacity < 0)

  3. throw new IllegalArgumentException("Illegal initial capacity: " +

  4. initialCapacity);

  5. if (initialCapacity > MAXIMUM_CAPACITY)

  6. initialCapacity = MAXIMUM_CAPACITY;

  7. if (loadFactor <= 0 || Float.isNaN(loadFactor))

  8. throw new IllegalArgumentException("Illegal load factor: " +

  9. loadFactor);

  10. this.loadFactor = loadFactor;

  11. this.threshold = tableSizeFor(initialCapacity);

  12. }

  13. static final int tableSizeFor(int cap) {

  14. int n = cap - 1;

  15. n |= n >>> 1;

  16. n |= n >>> 2;

  17. n |= n >>> 4;

  18. n |= n >>> 8;

  19. n |= n >>> 16;

  20. return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

  21. }

tableSizeFor的作用是找到大于cap的最小的2的整数幂,我们假设n(注意是n,不是cap哈)对应的二进制为000001xxxxxx,其中x代表的二进制位是0是1我们不关心,

n |= n >>> 1;执行后n的值为:

高端的面试从来不会在HashMap的红黑树上纠缠太多

可以看到此时n的二进制最高两位已经变成了1(1和0或1异或都是1),再接着执行第二行代码:

高端的面试从来不会在HashMap的红黑树上纠缠太多

可见n的二进制最高四位已经变成了1,等到执行完代码n |= n >>> 16;之后,n的二进制最低位全都变成了1,也就是n = 2^x - 1其中x和n的值有关,如果没有超过MAXIMUM_CAPACITY,最后会返回一个2的正整数次幂,因此tableSizeFor的作用就是保证返回一个比入参大的最小的2的正整数次幂。

在JDK 7中初始化的代码大体一致,在HashMap第一次put的时候会调用inflateTable计算桶数组的长度,但其算法并没有变:

 
  1. // 第一次put时,初始化table

  2. private void inflateTable(int toSize) {

  3. // Find an power of 2 >= toSize

  4. int capacity = roundUpToPowerOf2(toSize);

  5. threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

  6. table = new Entry(capacity);

  7. initHashSeedAsNeeded(capacity);

  8. }

这里我们也回答了开头提出来的问题:

HashMap什么时候开辟bucket数组占用内存?答案是在HashMap第一次put的时候,无论Java 8还是Java 7都是这样实现的。这里我们可以看到两个版本的实现中,桶数组的大小都是2的正整数幂,至于为什么这么设计,看完后文你就明白了。

3.2 hash

在HashMap这个特殊的数据结构中,hash函数承担着寻址定址的作用,其性能对整个HashMap的性能影响巨大,那什么才是一个好的hash函数呢?

  • 计算出来的哈希值足够散列,能够有效减少哈希碰撞
  • 本身能够快速计算得出,因为HashMap每次调用get和put的时候都会调用hash方法

下面是Java 8中的实现:

  1. static final int hash(Object key) {

  2. int h;

  3. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

  4. }

这里比较重要的是(h = key.hashCode()) ^ (h >>> 16),这个位运算其实是将key.hashCode()计算出来的hash值的高16位与低16位继续异或,为什么要这么做呢?

我们知道hash函数的作用是用来确定key在桶数组中的位置的,在JDK中为了更好的性能,通常会这样写:

index =(table.length - 1) & key.hash();

回忆前文中的内容,table.length是一个2的正整数次幂,类似于000100000,这样的值减一就成了000011111,通过位运算可以高效寻址,这也回答了前文中提到的一个问题,HashMap内部的bucket数组长度为什么一直都是2的整数次幂?好处之一就是可以通过构造位运算快速寻址定址。

回到本小节的议题,既然计算出来的哈希值都要与table.length - 1做与运算,那就意味着计算出来的hash值只有低位有效,这样会加大碰撞几率,因此让高16位与低16位做异或,让低位保留部分高位信息,减少哈希碰撞。

我们再看Java 7中对hash的实现:

  1. final int hash(Object k) {

  2. int h = hashSeed;

  3. if (0 != h && k instanceof String) {

  4. return sun.misc.Hashing.stringHash32((String) k);

  5. }

  6. h ^= k.hashCode();

  7. // This function ensures that hashCodes that differ only by

  8. // constant multiples at each bit position have a bounded

  9. // number of collisions (approximately 8 at default load factor).

  10. h ^= (h >>> 20) ^ (h >>> 12);

  11. return h ^ (h >>> 7) ^ (h >>> 4);

  12. }

Java 7中为了避免hash值的高位信息丢失,做了更加复杂的异或运算,但是基本出发点都是一样的,都是让哈希值的低位保留部分高位信息,减少哈希碰撞。

3.3 put

在Java 8中put这个方法的思路分为以下几步:

  1. 调用key的hashCode方法计算哈希值,并据此计算出数组下标index
  2. 如果发现当前的桶数组为null,则调用resize()方法进行初始化
  3. 如果没有发生哈希碰撞,则直接放到对应的桶中
  4. 如果发生哈希碰撞,且节点已经存在,就替换掉相应的value
  5. 如果发生哈希碰撞,且桶中存放的是树状结构,则挂载到树上
  6. 如果碰撞后为链表,添加到链表尾,如果链表长度超过TREEIFY_THRESHOLD默认是8,则将链表转换为树结构
  7. 数据put完成后,如果HashMap的总数超过threshold就要resize

具体代码以及注释如下:

  1. public V put(K key, V value) {

  2. // 调用上文我们已经分析过的hash方法

  3. return putVal(hash(key), key, value, false, true);

  4. }

  5. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

  6. boolean evict) {

  7. Node<K,V>[] tab; Node<K,V> p; int n, i;

  8. if ((tab = table) == null || (n = tab.length) == 0)

  9. // 第一次put时,会调用resize进行桶数组初始化

  10. n = (tab = resize()).length;

  11. // 根据数组长度和哈希值相与来寻址,原理上文也分析过

  12. if ((p = tab[i = (n - 1) & hash]) == null)

  13. // 如果没有哈希碰撞,直接放到桶中

  14. tab[i] = newNode(hash, key, value, null);

  15. else {

  16. Node<K,V> e; K k;

  17. if (p.hash == hash &&

  18. ((k = p.key) == key || (key != null && key.equals(k))))

  19. // 哈希碰撞,且节点已存在,直接替换

  20. e = p;

  21. else if (p instanceof TreeNode)

  22. // 哈希碰撞,树结构

  23. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

  24. else {

  25. // 哈希碰撞,链表结构

  26. for (int binCount = 0; ; ++binCount) {

  27. if ((e = p.next) == null) {

  28. p.next = newNode(hash, key, value, null);

  29. // 链表过长,转换为树结构

  30. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

  31. treeifyBin(tab, hash);

  32. break;

  33. }

  34. if (e.hash == hash &&

  35. ((k = e.key) == key || (key != null && key.equals(k))))

  36. // 如果节点已存在,则跳出循环

  37. break;

  38. // 否则,指针后移,继续后循环

  39. p = e;

  40. }

  41. }

  42. if (e != null) { // existing mapping for key

  43. // 对应着上文中节点已存在,跳出循环的分支

  44. // 直接替换

  45. V oldValue = e.value;

  46. if (!onlyIfAbsent || oldValue == null)

  47. e.value = value;

  48. afterNodeAccess(e);

  49. return oldValue;

  50. }

  51. }

  52. ++modCount;

  53. if (++size > threshold)

  54. // 如果超过阈值,还需要扩容

  55. resize();

  56. afterNodeInsertion(evict);

  57. return null;

  58. }

相比之下Java 7中的put方法就简单不少

  1. public V put(K key, V value) {

  2. // 如果 key 为 null,调用 putForNullKey 方法进行处理

  3. if (key == null)

  4. return putForNullKey(value);

  5. int hash = hash(key.hashCode());

  6. int i = indexFor(hash, table.length);

  7. for (Entry<K, V> e = table[i]; e != null; e = e.next) {

  8. Object k;

  9. if (e.hash == hash && ((k = e.key) == key

  10. || key.equals(k))) {

  11. V oldValue = e.value;

  12. e.value = value;

  13. e.recordAccess(this);

  14. return oldValue;

  15. }

  16. }

  17. modCount++;

  18. addEntry(hash, key, value, i);

  19. return null;

  20. }

  21. void addEntry(int hash, K key, V value, int bucketIndex) {

  22. Entry<K, V> e = table[bucketIndex]; // ①

  23. table[bucketIndex] = new Entry<K, V>(hash, key, value, e);

  24. if (size++ >= threshold)

  25. resize(2 * table.length); // ②

  26. }

这里有一个小细节,HashMap允许putkey为null的键值对,但是这样的键值对都放到了桶数组的第0个桶中。

3.4 resize()

resize是整个HashMap中最复杂的一个模块,如果在put数据之后超过了threshold的值,则需要扩容,扩容意味着桶数组大小变化,我们在前文中分析过,HashMap寻址是通过index =(table.length - 1) & key.hash();来计算的,现在table.length发生了变化,势必会导致部分key的位置也发生了变化,HashMap是如何设计的呢?

这里就涉及到桶数组长度为2的正整数幂的第二个优势了:当桶数组长度为2的正整数幂时,如果桶发生扩容(长度翻倍),则桶中的元素大概只有一半需要切换到新的桶中,另一半留在原先的桶中就可以,并且这个概率可以看做是均等的。

高端的面试从来不会在HashMap的红黑树上纠缠太多

通过这个分析可以看到如果在即将扩容的那个位上key.hash()的二进制值为0,则扩容后在桶中的地址不变,否则,扩容后的最高位变为了1,新的地址也可以快速计算出来newIndex = oldCap + oldIndex;

下面是Java 8中的实现:

  1. final Node<K,V>[] resize() {

  2. Node<K,V>[] oldTab = table;

  3. int oldCap = (oldTab == null) ? 0 : oldTab.length;

  4. int oldThr = threshold;

  5. int newCap, newThr = 0;

  6. if (oldCap > 0) {

  7. // 如果oldCap > 0则对应的是扩容而不是初始化

  8. if (oldCap >= MAXIMUM_CAPACITY) {

  9. threshold = Integer.MAX_VALUE;

  10. return oldTab;

  11. }

  12. // 没有超过最大值,就扩大为原先的2倍

  13. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&

  14. oldCap >= DEFAULT_INITIAL_CAPACITY)

  15. newThr = oldThr << 1; // double threshold

  16. }

  17. else if (oldThr > 0) // initial capacity was placed in threshold

  18. // 如果oldCap为0, 但是oldThr不为0,则代表的是table还未进行过初始化

  19. newCap = oldThr;

  20. else { // zero initial threshold signifies using defaults

  21. newCap = DEFAULT_INITIAL_CAPACITY;

  22. newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

  23. }

  24. if (newThr == 0) {

  25. // 如果到这里newThr还未计算,比如初始化时,则根据容量计算出新的阈值

  26. float ft = (float)newCap * loadFactor;

  27. newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?

  28. (int)ft : Integer.MAX_VALUE);

  29. }

  30. threshold = newThr;

  31. @SuppressWarnings({"rawtypes","unchecked"})

  32. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

  33. table = newTab;

  34. if (oldTab != null) {

  35. for (int j = 0; j < oldCap; ++j) {

  36. // 遍历之前的桶数组,对其值重新散列

  37. Node<K,V> e;

  38. if ((e = oldTab[j]) != null) {

  39. oldTab[j] = null;

  40. if (e.next == null)

  41. // 如果原先的桶中只有一个元素,则直接放置到新的桶中

  42. newTab[e.hash & (newCap - 1)] = e;

  43. else if (e instanceof TreeNode)

  44. ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

  45. else { // preserve order

  46. // 如果原先的桶中是链表

  47. Node<K,V> loHead = null, loTail = null;

  48. // hiHead和hiTail代表元素在新的桶中和旧的桶中的位置不一致

  49. Node<K,V> hiHead = null, hiTail = null;

  50. Node<K,V> next;

  51. do {

  52. next = e.next;

  53. if ((e.hash & oldCap) == 0) {

  54. if (loTail == null)

  55. loHead = e;

  56. else

  57. loTail.next = e;

  58. loTail = e;

  59. }

  60. else {

  61. if (hiTail == null)

  62. hiHead = e;

  63. else

  64. hiTail.next = e;

  65. hiTail = e;

  66. }

  67. } while ((e = next) != null);

  68. if (loTail != null) {

  69. loTail.next = null;

  70. // loHead和loTail代表元素在新的桶中和旧的桶中的位置一致

  71. newTab[j] = loHead;

  72. }

  73. if (hiTail != null) {

  74. hiTail.next = null;

  75. // 新的桶中的位置 = 旧的桶中的位置 + oldCap, 详细分析见前文

  76. newTab[j + oldCap] = hiHead;

  77. }

  78. }

  79. }

  80. }

  81. }

  82. return newTab;

  83. }

Java 7中的resize方法相对简单许多:

  1. 基本的校验之后new一个新的桶数组,大小为指定入参
  2. 桶内的元素根据新的桶数组长度确定新的位置,放置到新的桶数组中
 
  1. void resize(int newCapacity) {

  2. Entry[] oldTable = table;

  3. int oldCapacity = oldTable.length;

  4. if (oldCapacity == MAXIMUM_CAPACITY) {

  5. threshold = Integer.MAX_VALUE;

  6. return;

  7. }

  8. Entry[] newTable = new Entry[newCapacity];

  9. boolean oldAltHashing = useAltHashing;

  10. useAltHashing |= sun.misc.VM.isBooted() &&

  11. (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);

  12. boolean rehash = oldAltHashing ^ useAltHashing;

  13. transfer(newTable, rehash);

  14. table = newTable;

  15. threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

  16. }

  17. void transfer(Entry[] newTable, boolean rehash) {

  18. int newCapacity = newTable.length;

  19. for (Entry<K, V> e : table) {

  20. //链表跟table[i]断裂遍历,头部往后遍历插入到newTable中

  21. while (null != e) {

  22. Entry<K, V> next = e.next;

  23. if (rehash) {

  24. e.hash = null == e.key ? 0 : hash(e.key);

  25. }

  26. int i = indexFor(e.hash, newCapacity);

  27. e.next = newTable[i];

  28. newTable[i] = e;

  29. e = next;

  30. }

  31. }

  32. }

4. 总结

在看完了HashMap在Java 8和Java 7的实现之后我们回答一下前文中提出来的那几个问题:

  1. HashMap内部的bucket数组长度为什么一直都是2的整数次幂答:这样做有两个好处,第一,可以通过(table.length - 1) & key.hash()这样的位运算快速寻址,第二,在HashMap扩容的时候可以保证同一个桶中的元素均匀地散列到新的桶中,具体一点就是同一个桶中的元素在扩容后一般留在原先的桶中,一般放到了新的桶中。
  2. HashMap默认的bucket数组是多大答:默认是16,即时指定的大小不是2的整数次幂,HashMap也会找到一个最近的2的整数次幂来初始化桶数组。
  3. HashMap什么时候开辟bucket数组占用内存答:在第一次put的时候调用resize方法
  4. HashMap何时扩容?答:当HashMap中的元素熟练超过阈值时,阈值计算方式是capacity * loadFactor,在HashMap中loadFactor是0.75
  5. 桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?答:当同一个桶中的元素数量大于等于8的时候元素中的链表转换为红黑树,反之,当桶中的元素数量小于等于6的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗
  6. Java 8中为什么要引进红黑树,是为了解决什么场景的问题?答:引入红黑树是为了避免hash性能急剧下降,引起HashMap的读写性能急剧下降的场景,正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的hashCode方法,可以保证HashMap的读写复杂度不会低于O(lgN)public int hashCode() {
    return 1;
    }
  7. HashMap如何处理key为null的键值对?答:放置在桶数组中下标为0的桶中

HashMap线程不安全问题体现在哪
1. 多线程put导致元素丢失
1.1 源码分析
1.2 举例
2. put和get并发时,可能导致get为null
2.1 源码分析
3. 1.7多线程下扩容死循环


1. 多线程put导致元素丢失
多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。

1.1 源码分析
我们来看下JDK 1.8 中 put 方法的部分源码,重点看黄色部分:


1.2 举例
假设线程1和线程2同时执行put,线程1执行put(“1”, “A”),线程2执行put(“5”, “B”),假如都冲突在table[1]这里了。注:下面的例子,只演示了 #1 和#2代码的情况,其他代码也会出现类似情况。

正常情况下,put完成后,table的状态应该是下图中的任意一个。

下面来看看异常情况:
两个线程都执行了#1处的if ((p = tab[i = (n - 1) & hash]) == null)这句代码。
此时假设线程1 先执行#2处的tab[i] = newNode(hash, key, value, null),那么table会变成如下状态:

紧接着线程2 执行tab[i] = newNode(hash, key, value, null),此时table会变成如下状态:

这样一来,元素A就丢失了。

2. put和get并发时,可能导致get为null
线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。

2.1 源码分析
我们来看下JDK 1.8 中 resize 方法的部分源码,重点看黄色部分:

在代码#1位置,用新计算的容量new了一个新的hash表,#2将新创建的空hash表赋值给实例变量table。
注意此时实例变量table是空的,如果此时另一个线程执行get,就会get出null。

3. 1.7多线程下扩容死循环
JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。

来源:https://zhuanlan.zhihu.com/p/345237682?ivk_sa=1024320u
————————————————

                            版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
                        
原文链接:https://blog.csdn.net/yzx3105/article/details/127448171

  • 15
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashMap是Java中最常用的哈希表实现之一,它基于哈希表实现了Map接口。以下是HashMap源码的详细解释: HashMap内部是由一个数组和链表组成的,数组的每个元素称为桶,每个桶存储一个链表(可能为空),链表中的每个节点都是一个键值对(key-value pair)。 以下是HashMap的主要属性: ```java transient Node<K,V>[] table; // 存储元素的数组 transient int size; // 元素大小 int threshold; // 扩容阈值 final float loadFactor; // 负载因子 ``` 其中,table是一个transient修饰的Node数组,存储HashMap中的元素;size表示HashMap中元素的个数;threshold表示HashMap的扩容阈值,即当元素个数达到这个值时就需要扩容;loadFactor是负载因子,用于决定HashMap何时需要扩容。 以下是HashMap的主要方法: 1. put(K key, V value) :将指定的键值对添加到HashMap中,如果键已经存在,则更新对应的值。 2. get(Object key):获取指定键对应的值,如果键不存在则返回null。 3. remove(Object key):从HashMap中删除指定的键值对,如果键不存在则返回null。 4. clear():从HashMap中删除所有的键值对。 5. resize():扩容HashMap,将table的大小增加一倍。 6. hash(Object key):计算键的哈希值。 7. getNode(int hash, Object key):获取指定键的节点。 8. putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict):实际执行put操作的方法,会根据指定的参数决定是否更新已有键的值、是否删除过期键等。 HashMap的put方法实现如下: ```java public V put(K key, V value) { // 计算键的哈希值 int hash = hash(key); // 计算键在table数组中的索引 int i = indexFor(hash, table.length); // 遍历桶中的链表,查找指定键 for (Node<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; } } // 如果指定键不存在,则创建新的节点,并将其添加到桶的链表中 modCount++; addEntry(hash, key, value, i); return null; } ``` 在这个方法中,我们首先计算键的哈希值,然后计算键在table数组中的索引。接着,我们遍历桶中的链表,查找指定键,如果键已经存在,则更新对应的值。否则,我们创建新的节点,并将其添加到桶的链表中。 HashMap的get方法实现如下: ```java public V get(Object key) { // 计算键的哈希值 int hash = hash(key); // 计算键在table数组中的索引 int i = indexFor(hash, table.length); // 遍历桶中的链表,查找指定键 for (Node<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { // 如果找到指定键,则返回其对应的值 return e.value; } } // 如果指定键不存在,则返回null return null; } ``` 在这个方法中,我们首先计算键的哈希值,然后计算键在table数组中的索引。接着,我们遍历桶中的链表,查找指定键,如果找到指定键,则返回其对应的值。 HashMap的remove方法实现如下: ```java public V remove(Object key) { // 计算键的哈希值 int hash = hash(key); // 计算键在table数组中的索引 int i = indexFor(hash, table.length); // 遍历桶中的链表,查找指定键 Node<K,V> prev = table[i]; Node<K,V> e = prev; while (e != null) { Node<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { modCount++; size--; if (prev == e) { table[i] = next; } else { prev.next = next; } e.recordRemoval(this); return e.value; } prev = e; e = next; } // 如果指定键不存在,则返回null return null; } ``` 在这个方法中,我们首先计算键的哈希值,然后计算键在table数组中的索引。接着,我们遍历桶中的链表,查找指定键,如果找到指定键,则从链表中删除节点,并返回其对应的值。否则,我们返回null。 以上就是HashMap源码的详细解释。HashMap是一个非常常用且实用的数据结构,它的实现原理也非常值得深入学习和理解。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值