京东二面:为何HashMap底层树化标准的元素个数是8?

京东二面:为何HashMap底层树化标准的元素个数是8?

通常的面试题

对于HashMap,咱们再熟悉不过了,平常开发最经常使用的Java集合类就是它了,并且面试的时候对于HashMap知识点基本是必问的,就拿我以前的面试经从来看,问的最多的无非是这么几个:node

  1. HashMap的底层存储结构是怎样的啊?
  2. 线程安全吗?为何不安全?
  3. 1.7和1.8版本的HashMap有什么区别?1.7的有什么隐患,什么缘由致使的?
  4. hashcode是惟一的吗?插入元素的时候怎么比较的?
  5. 跟HashTable,ConcurrentHashMap有什么区别?

对于这些问题,若是你看过一些博客,或者大概的浏览过源码的话,基本都能答出来,我以前参加过不少面试,也不多在HashMap这块失过手。面试

事实证实,我仍是年轻了点。有时候,你答的好不是由于你懂得多,而是人家问的不深,若是你没有对源码作深刻的了解和思考的话,别人稍微换个角度考察,你也许就会犯难了。算法

就好像标题上的题目,为何HashMap链表树化的标准是8个?说实话,尽管我以前也知道是树化的阈值是8,可是为何是这个数目我还真没仔细的思考过,借着这个机会,我也从新梳理了遍HashMap的源码,本文也算是一些新的思考点的总结吧。数组

HashMap的基本知识点

HashMap能够说是Java项目里最经常使用的集合类了,做为一种典型的K-V存储的数据结构,它的底层是由数组 - 链表组成,当添加新元素时,它会根据元素的hash值找到对应的"桶",也就是HashMap源码中Node<K, V> 里的元素,并插入到对应位置的链表中,链表元素个数过长时会转化为红黑树(JDK1.8后的版本),安全

京东二面:为何HashMap底层树化标准的元素个数是8?

为何要转成红黑树呢?

咱们都知道,链表取元素是从头结点一直遍历到对应的结点,这个过程的复杂度是O(N) ,而红黑树基于二叉树的结构,查找元素的复杂度为O(logN) ,因此,当元素个数过多时,用红黑树存储能够提升搜索的效率。数据结构

既然红黑树的效率高,那怎么不一开始就用红黑树存储呢?JDK的源码里已经对这个问题作了解释:less

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.

看注释里的前面四行就不难理解,单个 TreeNode 须要占用的空间大约是普通 Node 的两倍,因此只有当包含足够多的 Nodes 时才会转成 TreeNodes,这个足够多的标准就是由 TREEIFY_THRESHOLD 的值(默认值8)决定的。而当桶中节点数因为移除或者 resize (扩容) 变少后,红黑树会转变为普通的链表,这个阈值是 UNTREEIFY_THRESHOLD(默认值6)。dom

/**
* The bin count threshold for using a tree rather than list for a
* bin.  Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;

看到这里就不难明白了,红黑树虽然查询效率比链表高,可是结点占用的空间大,只有达到必定的数目才有树化的意义,这是基于时间和空间的平衡考虑。ide

为何树化标准是8个

至于为何树化标准的数量是8个,在源码中,上面那段笔记后面还有一段较长的注释,咱们能够从那一段注释中找到答案,原文是这样:函数

* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million

大概意思就是:若是 hashCode的分布离散良好的话,那么红黑树是不多会被用到的,由于各个值都均匀分布,不多出现链表很长的状况。在理想状况下,链表长度符合泊松分布,各个长度的命中几率依次递减,注释中给咱们展现了1-8长度的具体命中几率,当长度为8的时候,几率几率仅为0.00000006,这么小的几率,HashMap的红黑树转换几乎不会发生,由于咱们平常使用不会存储那么多的数据,你会存上千万个数据到HashMap中吗?

固然,这是理想的算法,但不妨某些用户使用HashMap过程致使hashCode分布离散不好的场景,这个时候再转换为红黑树就是一种很好的退让策略。

至于什么状况下会致使这样的场景,你们能够本身思考或网上找一下答案,我就再也不赘述了,省点力气。

京东二面:为何HashMap底层树化标准的元素个数是8?

首先说明一下,在HashMap中,决定某个对象落在哪个 “桶“,是由该对象的hashCode决定的,JDK没法阻止用户实现本身的哈希算法,若是用户重写了hashCode,而且算法实现比较差的话,就极可能会使HashMap的链表变得很长,就好比这样:

public class HashMapTest {
    public static void main(String[] args) {
        Map<User, Integer> map = new HashMap<>();
        for (int i = 0; i < 1000; i++) {
            map.put(new User("鄙人薛某" + i), i);
        }
    }

    static class User{

        private String name;

        public User(String name) {
            this.name = name;
        }

        @Override
        public int hashCode() {
            return 1;
        }
    }
}

咱们设计了一个hashCode永远为1的类User,这样一来存储到HashMap的全部User对象都会存放到同一个“桶”里,查询效率无疑会很是的低下,而这也是HashMap设计链表转红黑树的缘由之一,能够有效防止用户本身实现了很差的哈希算法时致使链表过长的状况。

hash方法

说到哈希算法,咱们再来扩充一个知识点,这也是我以为HashMap中很是牛逼的设计之一。

在HashMap的源码中,存储对象hashCode的计算是由hash() 方法决定的,hash() 是HashMap 中的核心函数,在存储数据时,将key传入中进行运算,得出key的哈希值,经过这个哈希值运算才能获取key应该放置在 “桶” 的哪一个位置,下面是方法的源码:

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

从代码中能够看出,传入key以后,hash() 会获取key的hashCode进行无符号右移 16 位,而后进行按位异或,并把运算后的值返回,这个值就是key的哈希值。这样运算是为了减小碰撞冲突,由于大部分元素的hashCode在低位是相同的,不作处理的话很容易形成冲突。

除了作16位位移的处理,在添加元素的方法中,HashMap还把该hash值与table.length - 1,也就是“桶”数组的大小作与运算,获得的结果就是对应的“桶”数组的下标,从而找到该元素所属的链表。源码里这样的:

// n的值是table.length
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

当查找不到对应的索引时,就会新建一个新的结点做为链表的头结点。那么这里为何要用 i = (n - 1) & hash 做为索引运算呢?

这实际上是一种优化手段,因为数组的大小永远是一个2次幂,在扩容以后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,以前提到过&运算只会关注n – 1(n =数组长度)的有效位,当扩容以后,n的有效位相比以前会多增长一位(n会变成以前的二倍,因此确保数组长度永远是2次幂很重要),而后只须要判断hash在新增的有效位的位置是0仍是1就能够算出新的索引位置,若是是0,那么索引没有发生变化,若是是1,索引就为原索引加上扩容前的容量。

用一张效果图来表示就是:

京东二面:为何HashMap底层树化标准的元素个数是8?

经过位运算,在每次扩容时都不用从新计算hash,省去了很多时间,并且新增有效位是0仍是1是带有随机性的,以前两个碰撞的Entry又有可能在扩容时再次均匀地散布开,达到较好的分布离散效果,不得不感叹,设计者的功底真是太牛逼了,几句看似简单的代码里面竟然包含了这么多的学问。

为何退化为链表的阈值是6

上面说到,当链表长度达到阈值8的时候会转为红黑树,可是红黑树退化为链表的阈值倒是6,为何不是小于8就退化呢?好比说7的时候就退化,恰恰要小于或等于6?

主要是一个过渡,避免链表和红黑树之间频繁的转换。若是阈值是7的话,删除一个元素红黑树就必须退化为链表,增长一个元素就必须树化,来回不断的转换结构无疑会下降性能,因此阈值才不设置的那么临界。

最后

HashMap的知识点还有不少,这里我也强烈你们去多看几遍源码,不光是为了应付面试,也是对本身能如何更好的使用HashMap能有更清晰的认知,毕竟它实在是太常见了,用的很差很容易就产生bug。并且,我以为JDK的源码真的有不少值得咱们开发者深刻研究的地方,就好比这个HashMap,它的真实代码量不算多,但很是的高效,最重要的是,它每一个版本都在不停的优化,每一行代码都是精雕细琢,看源码的时候我也一直在内心感叹,我要是也能写出那么牛逼的代码,那进京东什么的还算是事吗?

写在最后

欢迎你们关注个人公众号【风平浪静如码】,海量Java相关文章,学习资料都会在里面更新,整理的资料也会放在里面。

以为写的还不错的就点个赞,加个关注呗!点关注,不迷路,持续更新!!!

相关文章
相关标签/搜索