Java中HashMap原理与分析

HashMap的底层数据结构

HashMap是以Key-Value的方式进行数据结构存储的一种数据结构。
JDK1.7采用的是数组+链表,使用Entry类存储key和value
JDK1.8采用的是数组+链表/红黑树,使用Node类存储key和value。

HashMap扩容为什么总是2的次幂

HashMap扩容主要是给数组扩容的,因为数组长度不可变,而链表长度是可变长度。在HashMap的源码中可以看到HashMap在扩容时选择了位运算,向集合中添加元素时,会使用(n-1)&hash的计算方法来得出该元素在集合中的位置。只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)中的n是容量,(n-1)的2进制也就是11111***1111这样形式的,这样与添加元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。

HashMap扩容死循环问题

JDK1.7中HashMap采用头插法拉链表,所谓头插法,也就是说新插入的数据会从链表的头节点进行插入。
Java中HashMap原理与分析_第1张图片

HashMap正常情况下的扩容就是这样一个过程,旧HashMap的节点会依次转移到新的HashMap中,旧HashMap转移链表元素的顺序是A,B,C,而新HashMap使用的是头插法插入,所以扩容完成后最终在新HashMap中链表元素的顺序是C,B,A。
导致死循环的原因:
Java中HashMap原理与分析_第2张图片

第一步:线程启动,有线程T1和线程T2都准备对HashMap进行扩容操作,此时T1和T2执行都是链表头节点A,而T1和T2的下一个节点分别是T1.next和T2.next,它们都是执行B节点。
Java中HashMap原理与分析_第3张图片
第二步:开始扩容,这时候,假设线程T2的时间片用完,进入休眠状态,而线程T1开始执行扩容操作,一直到线程T1扩容完成后,线程T2才被唤醒。
Java中HashMap原理与分析_第4张图片
T1完成扩容之后
Java中HashMap原理与分析_第5张图片
因为HashMap扩容采用的是头插法,线程T1执行之后,链表中的节点顺序发生了改变。但线程T2对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2指向的是A节点,T2.next指向的是B节点。
Java中HashMap原理与分析_第6张图片
当线程T1执行完成之后,线程T2恢复执行时,死循环就发生了。
Java中HashMap原理与分析_第7张图片
因为T1执行完扩容之后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之前的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成了死循环。
3、解决方案
避免HashMap发生死循环的常用解决方案有三个:
1)、使用线程安全的ConcurrentHashMap替代HashMap,个人推荐使用此方案。

2)、使用线程安全的容器Hashtable替代,但它性能较低,不建议使用。

3)、使用synchronized或Lock加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。

由链表到红黑树的转变

为了解决JDK1.7中的死循环问题,在JDK1.8中新增了红黑树。
当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。

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

这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。

    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    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);
        }
    }

HashMap的put方法和get方法

put方法中,在存储k-v键值对的时候,我们首先会调用一个hash方法,然后通过这个方法,可以计算出Key的Hash值,从而得到一个10进制的数字,用这个数字和数组的长度减一去取模,就可以得到一个结果,也就是数组的下标,然后我们根据这个下标去找到数组中存储的这个单向链表,然后把链表中的每一个Key和要插入的Key进行一个equals()的比较,如果是相等的话,就直接更新这个value值,也就是覆盖,如果不相等的话把新的k-v值put()到这个链表中,在Put的过程中,我们当哈希表中存储键值对超过数组长度乘以负载因子的时候,就会对这个数组扩容为两倍,还有就是在插入链表的时候,如果链表长度超过了我们默认阈值为8的时候,结点的数据结构就会自动转化为一个红黑树的结构。
get()方法中,首先会先去调用hash方法,然后对key进行计算,用这个数字和数组的长度减一取模,也就是数组的下标,然后我们再遍历这个下标对应的链表元素,再进行equals的比较,如果key相同的话就把这个元素取回并返回给用户。

HashMap扩容机制

不管是JDK1.7或JDK1.8当put方法执行的时候,如果table为空,则执行resize()方法扩容。默认长度是16。
阈值:threshold = 容量 * 负载因子
默认情况下,HashMap的负载因子是0.75。因此,threshold的值等于容量的75%。

例如,如果HashMap的容量是16,那么threshold的计算结果为:
threshold = 16 * 0.75 = 12
JDK1.7扩容必须同时满足两点:

JDK1.7 是先扩容,在添加。具体put是否扩容需要两个条件:

  1. 存放新值的时候当前已有元素的个数必须大于等于阈值
  2. 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)

扩容方法是在addEntry方法中

void addEntry(int hash, K key, V value, int bucketIndex) {
    //1、判断当前个数是否大于等于阈值
    //2、当前存放是否发生哈希碰撞
    //如果上面两个条件否发生,那么就扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
      //扩容,并且把原来数组中的元素重新放到新数组中
      resize(2 * table.length);
      hash = (null != key) ? hash(key) : 0;
      bucketIndex = indexFor(hash, table.length);
    }
 
    createEntry(hash, key, value, bucketIndex);
  }

扩容使用的是头插法
扩容之后对table的调整
table容量变为2倍,所有的元素下标需要重写计算,newIndex=hash&(newLength-1)

JDK1.8 HashMap两种扩容的情况。

  1. 当map实际数量等于threshold容量的阈值时,会进行两倍扩容。
    Java中HashMap原理与分析_第8张图片

  2. 当map中数组中某个桶的链表长度大于树形化阈值TREEIFY_THRESHOLD=8时,
    并且map元素的数量小于树形化最小容量MIN_TREEIFY_CAPACITY=64时候,容量进行两倍扩容。
    Java中HashMap原理与分析_第9张图片

否则树形化阈值8并且map元素个数大于64时,进行链表转红黑树。

扩容总结:
jdk1.7的时候先检查是否需要进行扩容,再插入数据
当满足两个条件,存放新的元素时,已经存在的元素个数必须大于阈值。存放新的元素时,与已经存在的元素发生hash碰撞(新元素key计算hash值换算出来的数组下标的位置上已经存在元素),满足这两个条件就会进行扩容。扩容后数据会根据hash值重新计算索引的位置,然后将数据存放到对应的位置上。
jdk1.8先插入数据,再检查是否需要扩容。当map实际数量等于阈值时和链表元素大于等于8,数组容量小于64时,会进行两倍扩容。当链表元素大于等于8,并且数组的容量达到64时,则将链表结构转换为红黑树结构。当我们在删除元素后,当红黑树中的节点小于等于6时,则红黑树结构转换为链表结构。

HashMap 在扩容时为什么通过位运算 (e.hash & oldCap) 得到下标?

举个例子,假设table原长度是16(由于源码中for循环时table长度从0开始所以-1),扩容后长度是32,那么一个hash值在扩容前后的table下标是怎么计算的:
Java中HashMap原理与分析_第10张图片
hash值的每个二进制位用abcde来表示,那么hash和新旧table按位与的结果,最后4位显然是相同的,唯一可能出现的区别就是第5位,也就是hash值的b所在的那一位,如果b所在的那一位是0,那么新table按位与的结果和旧table结果相同,反之如果b所在那一位是1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制就是旧table的长度
换言之,hash值的新散列下标是不是需要加上旧table长度,只需要看看hash值第5位是不是1就行了,位运算的方法就是hash值和10000(也就是旧table长度)来按位与,其结果只可能是10000或者00000。
所以,e.hash & oldCap,就是用于计算位置b到底是0还是1用的,只要其结果是0,则新散列下标就等于原散列下标,否则新散列坐标要在原散列坐标的基础上加上原table长度。

HashMap是否线程安全,为什么

JDK1.7中,由于多线程对HashMap的扩容,HashMap采用头插法,新插入的数据会从链表的头节点进行插入。这样在多线程的情况下,容易造成死循环的问题。
JDK1.8中,由于多线程对HashMap进行Put操作。假设两个线程A,B都在进行put操作,并且hash函数计算出的插入下标相同,当线程A由于时间片耗尽被挂起,而线程B得到时间片后在该下标处插入元素,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所以不会再进行判断,而是直接进行插入,这就导致线程B插入的数据被线程A覆盖了,从而线程不安全。

HashMap和HashTable的区别

  1. HashTable线程安全,key和value不能是null,否则会报空指针异常。
  2. HashMap线程不安全,key和value可以是null
  3. HashMap初始容量16,扩容直接翻倍
  4. HashTable初始容量11,扩容翻倍+1
  5. HashTable直接使用对象的hashCode,HashMap需要重写计算hash值
    Java中HashMap原理与分析_第11张图片

解决Hash碰撞的方法有哪些?

常见的解决方法有:

  1. 开发地址法:在发生哈希碰撞时,通过一定的算法在哈希表中寻找一个空闲的位置,并将元素插入到该位置。
  2. 链表哈希表:在每个哈希表的元素位置上,存储一个链表,哈希碰撞时,将元素插入到相应的链表中。
  3. 再哈希法:如果一个哈希函数产生的哈希值发生了碰撞,就再次使用另一个哈希函数计算哈希值

ConcurrentHashMap的数据结构

在jdk1.7中,ConcurrentHashMap的数据结构是由Segments数组+HashEntry数组+链表实现的。
在jdk1.8中,选择了和HashMap相同的Node数组+链表+红黑树结构,采用CAS+Synchronized来保证并发安全性。

  1. ConcurrentHashMap是如何保证线程安全?
    ConcurrentHashMap使用分段锁的方式来实现线程安全,它将一个大的哈希表分成多个小的哈希表,每个小的哈希表都有自己的锁。这样,不同的线程可以同时访问不同的小哈希表,从而避免了多个线程同时竞争一个锁的情况,提高了并发性能。
  2. ConcurrentHashMap的扩容机制是怎么样的?
    CurrentHashMap的扩容机制与HashMap类似,它会在已经存放个数达到阈值时进行扩容。扩容的过程中,ConcurrentHashMap会将原来的小哈希表逐一复制到新的大哈希表中,这个过程中仍然可以保证线程安全。扩容后,ConcurrentHashMap会继续使用分段锁的方式来维护新的小哈希表。
  3. ConcurrentHashMap的get()方法是否需要加锁?
    ConcurrentHashMap的get()方法不需要加锁,因为它是线程安全地,并发访问时,ConcurrentHashMap使用了volatile和CAS等机制来保证数据的一致性和可见性。
  4. ConcurrentHashMap与Hashtable有什么区别?
    ConcurrentHashMap和Hashtable都是线程安全的哈希表,但是它们有很大的区别,ConcurrentHashMap使用了分段锁的方式来提高并发性能,而Hashtable使用了一个全局锁来保证线程安全,所以并发性能比ConcurrentHashMap差很多。此外ConcurrentHashMap允许空键和空值,而Hashtable不允许。另外,ConcurrentHashMap支持更多的操作,比如ConcurrentHashMap支持的批量操作和原子操作等,Hashtable不支持。

你可能感兴趣的:(Java,java,开发语言)