JDK1.8源码之HashMap(上)

Posted Ideology First

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK1.8源码之HashMap(上)相关的知识,希望对你有一定的参考价值。

概述:在jdk1.8之前,HashMap的数据结构是链表+数组,当hash值相同的元素较多时,查询效率较低。在jdk1.8中,HashMap的数据结构变为了数组+链表+红黑树,当链表长度达到8时,会转变为红黑树(但是不一定会转换,后面会讲到)。

下面开始HashMap源码之旅,深度思考其设计。

基本数据结构
首先看一看HashMap中重要的数据结构
//链表static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; }        //...}
//红黑树static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); }        //...}
//存储元素的tabletransient Node<K,V>[] table;

重要的参数
//默认数组容量 16 - MUST be a power of two.static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//最大容量 - MUST be a power of two <= 1<<30.static final int MAXIMUM_CAPACITY = 1 << 30;//默认负载因子.static final float DEFAULT_LOAD_FACTOR = 0.75f;//链表转为红黑树的阈值static final int TREEIFY_THRESHOLD = 8;//红黑树转为链表的阈值static final int UNTREEIFY_THRESHOLD = 6;//可以被树化的最小表容量static final int MIN_TREEIFY_CAPACITY = 64
思考:
1、为什么链表要转换为红黑树?为什么不从一开始就用红黑树?
    链表查询 从头结点一直遍历到对应的结 点,时间 复杂 度是O( N) 而红黑树基于二叉树的结构,查找元素的时间 复杂 度为 O (log N) ,所以,当元素个数过多时,用红黑树存储可以提高搜索的效率。
    既然红黑树的效率高,那怎么不一开始就用红黑树存储呢?这其实是基于空间和时间平衡的考虑,JDK的源码里已经对这个问题做了解释:
     * 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)。

所以这是基于时间和空间的平衡考虑


上面说到,当链表长度达到阈值8的时候会转为红黑树,但是红黑树退化为链表的阈值却是6,为什么不是小于8就退化呢?比如说7的时候就退化,偏偏要小于或等于6?这主要是一个过渡,避免链表和红黑树之间频繁的转换。如果阈值是7的话,删除一个元素红黑树就必须退化为链表,增加一个元素就必须树化,来回不断的转换结构无疑会降低性能,所以阈值才不设置的那么临界。


2、为什么树化阈值是8?链表化阈值是6?

    在源码的注释中有这么一段:

     * Ignoring variancethe expected occurrences of list size k are     * (exp(-0.5) * pow(0.5k) / 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的红黑树转换几乎不会发生。

3、为什么默认负载因子是0.75而不是其他?
    负载因子0.75是 对空间和时间效率的一个平衡选择,除非在时间和空间比较特殊的情况下,不要轻易修改。如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

构造函数
构造函数可能较少人会关注,如果使用无参构造器,则会使用 DEFAULT_INITIAL_CAPACITY  和  DEFAULT_LOAD_FACTOR,HashMap还提供了另外两个构造:
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR);}
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity);}
那如果我们给构造器传参new HashMap<>(10);此时HashMap的数组长度是多少呢?这就需要看看 tableSizeFor 这个方法了。
//Returns a power of two size for the given target capacity.static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16;        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}
这个方法返回的是 大于输入参数且最近的2的整数次幂的数,下面简单分析一下这个算法。
n的最高位为1,将n无符号右移一位并与原值按位与,则将最高位和其紧邻的右边一位也置为1,例如 1xxxxx | 01xxxx 得到 11xxxx ,再将上一步得到的结果进行无符号右移两位再进行按位与,则将最高位紧邻的右边三位都置为了1,例如  11xxxx | 0011xx 得到 1111xx ,依次进行操作。由于容量最大为2^30(在调用这个方法之前判断了),所以执行完位操作后,最多有30个1,最后加1即是2^30。
在这个方法中,首先对cap进行了减1 ,这样可以避免cap如果本已是2的整次幂,而最后所得的结果为cap的2倍。
但是在构造器里能看到,tableSizeFor这个方法所得的结果最后赋值给了threshold( 当HashMap的size到达threshold这个阈值时会扩容),在构造器里并没有初始化table,真正初始化table的地方在put方法中,下面会讲到。

hash算法和寻址算法
在讲HashMap的put和get操作之前,先讲一讲在jdk1.8中HashMap中的hash算法和寻址算法。
hash算法源码:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
首先获取了key的hash值,再将其进行无符号右移16位,然后与原始hash值按位异或,最后得到这个key的hash值。这样运算能让低16位具有高低16位的特性,减少碰撞冲突。
为什么能减少hash冲突呢?这就涉及寻址算法,其源码如下:
(n - 1) & hash
n为table的长度,即数组长度,一般是一个不太大的数,为2的整次幂,假如现在table的长度为64,即 0100 0000, n - 1 则为 0011 1111,再与hash值按位与,来找到对应的数组位置。如果现在有两个key,它们的原始hash值分别是
0000 0000 0000 0101 0011 0010 0010 10000000 0000 0101 0000 0011 0010 0010 1000
如果直接将原始hash值进行 (n - 1) & hash操作,则结果相同(冲突)
0000 0000 0000 0101 0011 0010 0010 1000&                             0011 1111---------------------------------------                              0010 1000                               0000 0000 0101 0000 0011 0010 0010 1000&                             0011 1111---------------------------------------                              0010 1000
可见,高位的特征并未被使用到。但是如果将 原始hash值进行无符号右移16位,然后与原始hash值按位异或得到其hash值,将最终所得的hash值用来寻址,以上述例子继续看一看结果。
先进行无符号右移和异或操作:
 0000 0000 0000 0101 0011 0010 0010 1000^ 0000 0000 0000 0000 0000 0000 0000 0101 --------------------------------------- 0000 0000 0000 0101 0011 0010 0010 1101  0000 0000 0101 0000 0011 0010 0010 1000^ 0000 0000 0000 0000 0000 0000 0101 0000 --------------------------------------- 0000 0000 0101 0000 0011 0010 0111 1000
上述分别得到了两个key的最终hash值,下面看一看寻址:
0000 0000 0000 0101 0011 0010 0010 1101&                             0011 1111---------------------------------------                              0010 1101
0000 0000 0101 0000 0011 0010 0111 1000& 0011 1111---------------------------------------                              0011 1000        
最后寻址算法所得是两个不同的值,即可减少hash冲突。
同时jdk1.8中寻址算法用位运算代替了取模,提高了运算效率。

下一篇将会讲到HashMap的put、get、resize、线程安全相关的,敬请期待!~


以上是关于JDK1.8源码之HashMap(上)的主要内容,如果未能解决你的问题,请参考以下文章

JDK1.8源码分析之HashMap

JDK1.8源码分析之LinkedHashMap

JDK1.8 源码分析之HashMap

Map源码解析之HashMap源码分析

集合框架JDK1.8源码分析之HashMap 转载

HashMap源码之构造函数--JDK1.8