Java架构直通车——Java8 HashMap详解

之前了解过Java并发编程实战——并发容器之ConcurrentHashMap(JDK 1.8版本),其实已经对HashMap做了一个大致的了解,这里我们来解释一下HashMap一些相关的问题。

Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,使用synchronized+CAS来保证线程安全性。

1. HashMap 初始大小为何是 16?

每当插入一个元素时,我们都需要计算该值在数组中的位置,即p = tab[i = (n - 1) & hash](重点在于&运算)。

当 n = 16 时,n - 1 = 15,二进制为 1111,这时和 hash 作与运算时,元素的位置完全取决与 hash 的大小。

倘若不是 16,如 n = 10,n - 1 = 9,二进制为 1001,这时作与运算,很容易出现重复值,如 1101 & 1001,1011 & 1001,1111 & 1001,结果都是一样的,所以选择 16 以及每次扩容都乘以二的原因也可想而知了。

2. 懒加载

我们在 HashMap 的构造函数中可以发现,哈希表 Node[] table 并没有在一开始就完成初始化;观察 put 方法可以发现:

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

当发现哈希表为空或者长度为 0 时,会使用 resize 方法进行初始化,这里很显然运用了 lazy-load 原则,当哈希表被首次使用时,才进行初始化。

3. 树化

Java8 中,HashMap 最大的变动就是增加了树化处理,当链表中元素大于等于 8,这时有可能将链表改造为红黑树的数据结构,为什么我这里说可能呢?

final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
    int n, index; HashMap.Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //......
}

我们可以观察树化处理的方法 treeifyBin,发现当tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY为 true 时,只会进行扩容处理,而没有进行树化;MIN_TREEIFY_CAPACITY 规定了 HashMap 可以树化的最小表容量为 64,这是因为当一开始哈希表容量较小是,哈希碰撞的几率会比较大,而这个时候出现长链表的可能性会稍微大一些,这种原因下产生的长链表,我们应该优先选择扩容而避免这类不必要的树化。

那么,HashMap 为什么要进行树化呢?我们都知道,链表的查询效率大大低于数组,而当过多的元素连成链表,会大大降低查询存取的性能;同时,这也涉及到了一个安全问题,一些代码可以利用能够造成哈希冲突的数据对系统进行攻击,这会导致服务端 CPU 被大量占用。

4. 扩容resize()

扩容方法同样是 HashMap 中十分核心的方法,同时也是比较耗性能的操作。

我们都知道数组是无法自动扩容的,所以我们需要重新计算新的容量,创建新的数组,并将所有元素拷贝到新数组中,并释放旧数组的数据。

与以往不同的是,Java8 规定了 HashMap 每次扩容都为之前的两倍(n*2),也正是因为如此,每个元素在数组中的新的索引位置只可能是两种情况,一种为不变,一种为原位置 + 扩容长度(即偏移值为扩容长度大小);

在这里插入图片描述

5. get(Object key)方法

final HashMap.Node<K,V> getNode(int hash, Object key) {
    HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        //检查当前位置的第一个元素,如果正好是该元素,则直接返回
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            //否则检查是否为树节点,则调用 getTreeNode 方法获取树节点
            if (first instanceof HashMap.TreeNode)
                return ((HashMap.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;
}

主要就四步:

  1. 哈希表是否为空或者目标位置是否存在元素
  2. 是否为第一个元素
  3. 如果是树节点,寻找目标树节点
  4. 如果是链表结点,遍历链表寻找目标结点

总结:面试时如何介绍HashMap呢?

HashMap简单的来说,就是用的底层数组+链表或者红黑树的形式实现的。单链表和红黑树之间的转换条件是,底层数组容量大于64的时候并且单链表长度大于8的时候,这样就可以进行一个转换。因为在底层数组长度比较小的时候,Hash冲突会比较频繁,更可能出现长链表,这时候会优先考虑扩容而不是树化
对于扩容来说。初始时候,采用一个懒加载的方式初始化底层数组,也就是对于数组有实际操作的时候才进行初始化,这个初始化默认长度是16。对于这个数组,有负载因子默认是0.75,当哈希桶占用量超过负载因子乘以底层数组长度的时候,就会进行一个二倍的扩容。进行二倍扩容的考虑是:对于每个元素,要么就在当前的位置,要么就是当前位置+扩容长度的位置,非常好计算。

总结:面试时如何介绍ConcurrentHashMap呢?

HashMap本身是线程不安全的,在并发场景下,当两个以上的线程同时插入数据,并发现数组超过负载因子而产生rehash操作,并且在rehash的时候产生了上下文切换,可能导致HashMap出现闭环,从而之后的查询数据可能导致死循环,CPU达到100%。
所以ConcurrentHashMap为了提供线程安全的HashMap,它在早期版本采用段锁的方式进行加锁,也就是将原本HashMap的数组分成几个段,每一段单独有一个可重入锁(ReentractLock),所以当线程在操作某一段的数据的时候,并不影响其他线程的操作其他段。通过这种方式要做两次哈希操作,即第一次找到对应的段,第二次找到对应段上的位置。并且每一个段上有单独负载因子,用于扩容。
(早期ConcurrentHashMap采用数组+链表的形式,之后也是采用数组+链表/红黑树的形式。)
在之后的版本,为了向前兼容,虽然保留了段Segment,但是实际上是使用改良后的Synchronized(Synchronized锁升级)以及CAS来做并发操作。

ConcurrentHashMap参考:Java架构直通车——锁分段技术:ConcurrentHashMap

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值