暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

HashMap源码分析-十分详细

肝帝笔记 2021-02-25
718

HashMap源码分析

 在学习HashMap之前我们带着问题去学习。

  1. HashMap的底层存储结构是怎样的啊?
  2. 为什么要引入红黑树,什么时候转变红黑树?
  3. 为什么加载因子默认0.75?
  4. 为什么退化为链表的阈值是6?
  5. 为什么HashMap底层树化标准的元素个数是8?
  6. HashMap的容量为什么需要是2的n次幂?
  7. HashMap的计算哈希值的算法是怎样的?
  8. 线程安全吗?为什么不安全?
  9. HashMap添加元素的过程?
  10. HashMap扩容的过程?
  11. HashMap为什么要自己重写序列化相关的方法?

本文基于JDK8

HashMap基本介绍

  HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现不是同步的,这意味着它不是线程安全的。它的key和value都可以为null。此外,HashMap中的映射不是有序的

  JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的("拉链法"解决冲突)。

  JDK1.8以后在解决哈希冲突时有了较大的变化,底层的数据结构改为了数组+链表+红黑树当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上(桶)的所有数据改为使用红黑树存储。

  需要注意:将链表转换成红黑树前会判断,即使阈值大于8,假如数组长度小于64,此时并不会将链表变为红黑树,而是选择进行数组扩容。这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。所以综上所述为了提高性能和减少搜索时间,底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树。当红黑树的节点少于6的时候,还是会变回链表。

  当然虽然增了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时。链表转换为红黑树时,效率也变的更高效。

小结:

(1)存取无序的

(2)键和值位置都可以是null,但是键位置只能是一个null

(3)键位置是唯一的,底层的数据结构控制键的

(4)JDK1.8前数据结构是:链表+数组   JDK.8之后是:链表+数组+红黑树

(5)阈值(边界值)>8并且数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。

添加元素过程例子

public class HashMapDemo01 {
    public static void main(String[] args) {
        HashMap<String,Integer> map = new HashMap<>();
        map.put("曹操",26);
        map.put("张辽",23);
        map.put("夏侯惇",25);
        map.put("曹操",23);  // 因为Key唯一,所以这行就是修改功能,覆盖
        System.out.println(map);
    }
}

测试结果:

{张辽=23, 曹操=23, 夏侯惇=25}

  (1)当使用空参构造方法创建集合对象的时候,在JDK8前,构造方法中默认创建一个长度是16的Entry[] table 用来存储键值对数据的。在JDK8以后不是在HashMap的构造方法底层创建数组了,是在第一次调用put方法时创建数组Node[] table用来存储键值对数据的。

  (2)假设向哈希表中存储“曹操-26”数据,根据曹操调用String类重写之后的hashCode()方法计算出值,然后结合数组长度采用某种算法计算出向Node数组中存储数据的空间索引值。如果计算出的索引空间(桶中)没有数据,则直接将“曹操-26”存储到数组中。举例:假如计算的索引是3。

  (3)假如向哈希表中存储“张辽-23”数据,假设结合数组长度求出的索引的长度为6,发现6位置没有元素,那么直接放到数组中。

  (4)向哈希表中存储数据“夏侯惇-25”,假设夏侯惇计算出的hashCode方法结合数组长度计算出的索引值也是3,那么此时数组空间不是null(桶位存在数据),此时底层会比较曹操和夏侯惇的hash值是否一致,如果不一致,则在此空间上划出一个节点来存储键值对数据“夏侯惇-25”。这这种方式就是拉链法。ps.假如hash值相等,还需进一步通过equal方法判断它们是否相等。

  (5)假设向哈希表中存储数据“曹操-23”,那么首先根据曹操调用hashCode方法结合数组长度计算出索引肯定是3。此时比较后存储在此索引位置的数据曹操和已经存在的数据的hash值是否相等,如果hash值相等,此时发生哈希碰撞。

  那么底层会调用曹操所属类String中的equals方法比较两个内容是否相等:

  • 相等:则将后添加的数据的value覆盖之前的value。

  • 不相等:那么继续向下和其他的数据的key进行比较,如果都不相等,则划出一个节点存储数据。

  此小结大概讲了下HashMap是如何添加元素的,当然隐藏了一些细节和特殊情况,先大致了解一下。

小结:

保证元素唯一的原理:哈希表结构存储数据,依赖于:hashCode与equals方法。(Object类中的)

  (1)首先调用元素的hashCode()方法计算该元素的哈希值

  (2)判断该哈希值对应的位置上是否有相同哈希值的元素

  (3)如果该哈希值对应的位置上没有相同的哈希值元素,那么就直接存储

  (4)如果该哈希值对应的位置上有相同的哈希值元素,那么就产生了哈希冲突

  (5)如果产生了哈希冲突,就得调用元素的equals()方法,跟该哈希值位置上的所有元素进行一一比较;

    如果该哈希值位置上任意一个元素与该元素相等,那么就新增,而是覆盖旧值。

    如果该哈希值位置上任意一个元素与该元素都不相等,那么就存储。

微信公众号:肝帝笔记

源码分析

继承关系

  • Cloneable空接口,表示可以克隆。创建并返回HashMap对象的一个副本。
  • Serializable序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
  • AbstractMap父类提供了 Map实现接口。以最大限度地减少实现此接口所需的工作。

  注意:通过上述继承关系我们发现一个很奇怪的现象,就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构?

  据java集合框架的创始人Josh Bloch,描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个的失误值得去修改,所以就这样存在下来了。

成员变量

(1)序列化版本号

private static final long serialVersionUID = 362498820763181265L;

(2)默认的初始化容量(必须是2的n次幂)

/**
 * HashMap的默认初始容量16,必须是2的n次幂,这是为了后续增加运算效率的前提
 */

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4// aka 16

(3)默认的加载因子,默认值是0.75

  假如我们默认创建的数组的长度是16,加载因子是0.75的情况下,数组会在HashMap存储的元素个数达到16*0.75为12的时候扩容。

/**
 * 默认加载因子0.75,当HashMap的元素个数大于0.75倍的容量时,就需要进行扩容
 */

static final float DEFAULT_LOAD_FACTOR = 0.75f;

(4)集合的最大容量,是2^30

/**
 * HashMap的最大初始容量,必须是2的n次幂,也就是 2^n <= 2^30
 */

static final int MAXIMUM_CAPACITY = 1 << 30;

(5)当链表的长度超过8的时候会转为红黑树

/**
 * 链表转换为红黑树的节点个数的阈值,除了这个条件,还需要HashMap的数组长度大于等于64(默认)
 */

static final int TREEIFY_THRESHOLD = 8;

(6)当链表的值小于6的时候会由红黑树转换为链表

/**
 * 红黑树恢复为链表的节点个数的阈值
 */

static final int UNTREEIFY_THRESHOLD = 6;

(7)当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于4 *TREEIFY_THRESHOLD (8),其实就是需要数组长度大于等于64

微信公众号:肝帝笔记

/**
 * The smallest table capacity for which bins may be treeified.
 * (Otherwise the table is resized if too many nodes in a bin.)
 * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
 * between resizing and treeification thresholds.
 * 
 * 链表转换为红黑树的HashMap底层数组的长度阈值
 */

static final int MIN_TREEIFY_CAPACITY = 64;

(8)table就是底层数组了(长度必须是2的n次幂)

/**
 * 结点数组,数组长度必须是2的n次幂
 */

transient Node<K,V>[] table;


  table在JDK1.8中我们了解到HashMap是由数组加链表加红黑树来组成的结构,其中table就是HashMap中的数组,JDK1.8之前数组类型是Entry<K,V>类型。从JDK1.8之后是Node<K,V>类型。只是换了个名字,都实现了 一样的接口:Map.Entry<K,V>。负责存储键值对数据的。

(9)用来存放缓存,记录key和value

/**
 * Holds cached entrySet(). Note that AbstractMap fields are used
 * for keySet() and values().
 * 存放具体元素的集合
 */

transient Set<Map.Entry<K,V>> entrySet;

(10)HashMap中存放元素的个数,size为HashMap中K-V的实时数量,不是数组table的长度

/**
 * 存放元素的个数,注意这个不等于数组的长度
 */

transient int size;

(11)用来记录HashMap的修改次数,并发修改异常就是这个值引起的

/**
 * The number of times this HashMap has been structurally modified
 * Structural modifications are those that change the number of mappings in
 * the HashMap or otherwise modify its internal structure (e.g.,
 * rehash).  This field is used to make iterators on Collection-views of
 * the HashMap fail-fast.  (See ConcurrentModificationException).
 * 每次扩容和更改Map结构的计数器
 */

transient int modCount;

(12)扩容临界值,用来调整大小下一个容量值,计算方式为(容量 × 加载因子)

注意:

  • 假如数组未初始化,threshold表示初始数组长度
  • 假如数组已经初始化了,threshold表示下一次扩容的临界值,capacity * load factor
/**
 * 下一次扩容的数组长度,capacity * load factor
 * 假如数组还没有初始化的时候,threshold表示初始数组长度
 */

int thresold;

(13)哈希表的加载因子

/**
 * 加载因子
 */

final float loadFactor;

  (1)loadFactor加载因子,是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响哈希操作到同一个数组位置的概率,计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数是去除以 capacity,capacity是桶的数量,也就是table的长度length。  (2)loadFactor太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为 0.75f,是官方给出的一个比较好的临界值。

  (3)当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容 这个过程涉及到rehash、复制数据等操作,非常消耗性能,所以开发中尽量减少扩容的次数,可以通过创建 HashMap集合对象时指定初始容量来尽量避免。

  (4)同时在HashMap的构造器中可以指定loadFactor。

变量部分问题

问题:为什么要引入红黑树?

  在JDK8之前HashMap的是数组+链表构成的,而JDK8之后是数组+链表+红黑树组成的。

  链表取元素是从头结点一直遍历到对应的结点,这个过程的复杂度是O(N) ,而红黑树基于二叉树的结构,查找元素的复杂度为O(logN) ,所以,当桶内元素个数过多时,用红黑树存储可以提高搜索的效率

  既然红黑树的效率高,那怎么不一开始就用红黑树存储呢?

  微信公众号:肝帝笔记

  这其实是基于空间和时间平衡的考虑,HashMap注释里有解释:大概的意思是说树结点占用大小是普通结点的两倍,只会在桶内有足够多的结点的时候才会去转为红黑树。

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. 

问题:为什么转换红黑树的阈值是8

  在HashMap中有一段注释说明:我们继续往下看:

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.  In 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

  TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够 多就是由TREEIFY_THRESHOLD的值决定的,当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树(还有数组长度大于等于64的条件),当长度降到6就转成普通bin

  这样就解释了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是 权衡,空间和时间的权衡。

  这段内容还说到:当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中, 几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的_为0.00000006,几乎是不可能事件,所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的Java每 一项改动和优化都是非常严谨和科学的。

  微信公众号:肝帝笔记

  也就是说:选择8是因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字

补充:

  Poisson分布,是一种统计与概率学里常见到的离散概率分布,泊松分布的概率函数为:

泊松分布

泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数。

不知道哪位大神说的下面的解释

  红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,平均链表的平均查找长度为n/2,当长度为8/2时,平均查找长虔为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log(6)=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

问题:为什么退化为链表的阈值是6?

  可能我们有疑问为什么不是小于8就退化呢?链表转换为红黑树是一个需要浪费时间和空间的操作,假如我们某个时刻桶内元素有7个,新增一个到达8个就转为红黑树了,假如此时又变为7个元素1了,又要转回链表,这样就很浪费性能。默认6转为链表是避免链表和红黑树之间频繁的转换

微信公众号:肝帝笔记

问题:为什么数组的长度必须是2的n次幂呢?如果输入值不是2的幂,比如10或怎样?

  HashMap构造方法还可以指定集合初始化容量大小

// 构造一个带有指定初始容量和默认加载因子(0.75)的空HashMap
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

  根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置。HashMap为了存取高效,要尽量较少哈希碰撞,就是要尽是把数据分配均匀,每个链表长度大致相同, 这个实现就需要一个算法把数据存到哪个桶中。

  这个算法实际就是取模hash%length
计算机中求余效率不如位移运算。所以源码中做了优化,

  使用hash&(length-1)
,而实际上hash%length
等价hash&(length-1)
的前提是数组length是2的n次幂。

问题又来了,为什么这样就能均匀分布减少碰撞呢?

  2的n次方实际就是1后面n个0,2的n次方减一实际就是n个1。

示例:

按位与运算(&)相同的二进制数位上都是1的时候,结果为1,否则为0。

# 为什么这样就能均匀分布减少碰撞呢
1)当数组长度是2的n次幂的时候,假如数组的长度是8
假如key的hash值为3 
3 & (8-1)

00000011
00000111
--------
00000011 = 3

假如key的hash值为2 
2 & (8-1)

00000010
00000111
--------
00000010 = 2

2)当数组长度不是2的n次幂的时候,假如数组的长度是9

假如key的hash值为3 
3 & (9-1)

00000011
00001000
--------
00000000 = 0


假如key的hash值为2 
2 & (9-1)

00000010
00001000
--------
00000000 = 0

# 如果数组长度不是2的n次幂,计算出的索引特別容易相同,极其容易发生hash碰撞,导致其余数组空间很大程度上并没有存储数椐,链表或者红黑树过长,效率降低

  当然假如不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了)

小结:

  (1)由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。

  (2)另一方面,一般我们可能会想通过%求余来确定位置,这样也可以,只不过性能不如&运算。而且当n是2的幂次方时:hash & (length -1) == hash % length

  (3)如果创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是大于等于该数的2的n次幂。

问题:为什么加载因子设置为0.75,初始化临界值是12?

  load Factor越趋近于1,那么数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。

  如果希望链表尽可能少些,要提前扩容,有的数组空间有可能一直沒有存储数据,加载因子尽可能小一些。

举例:

  • 加载因子是0.4。那么16 * 0.4 = 6 如果数组中满6个空间就扩容会造成数组利用率太低了。
  • 加载因子是0.9。那么16 * 0.9 = 14 那么这样就会导致链表有点多了。导致查找元素效率低。

  所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案

微信公众号:肝帝笔记

问题:什么时候退化为链表?

有两个地方会判断并退化成链表的条件

  1. remove时退化

    // 在 removeTreeNode的方法中
    // 在红黑树的root节点为空 或者root的右节点、root的左节点、root左节点的左节点为空时 说明树都比较小了
    if (root == null || (movable && (root.right == null || (rl = root.left) == null|| rl.left == null))) {
       tab[index] = first.untreeify(map);  // too small
        return;
    }
    123456

  2. 在扩容时 low、high 两个TreeNode 长度小于6时 会退化为树。

    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD) // 判断是否需要退化为链表
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            if (hiHead != null// (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD) // 判断是否需要退化为链表
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }

节点对象

普通结点:

static class Node<K,Vimplements Map.Entry<K,V{
    final int hash; // 哈希值
    final K key;
    V value;
    Node<K,V> next; // 指向链表的下一个节点

    // 省略方法...
}

树结点:

static final class TreeNode<K,Vextends 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; // 红黑树结点颜色标志
  
    // 省略...
}

构造函数

(1)空参构造方法,默认初始容量16和默认负载因子0.75

// 将默认的加载因子赋值给loadFactor,并没有创建数组,在第一次put的时候创建数组
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

(2)构造一个指定初始容量和默认负载因子0.75的HashMap

// 指定容量大小的构造方法,建议使用,减少hashMap扩容,因为扩容一次代价很大
// 会调用方法获取大于等于传入值的最小的2的n次幂
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

(3)构造一个指定初始容量和指定负载因子的HashMap

public HashMap(int initialCapacity, float loadFactor) {
    // 判断传入的容量initialCapacity是否小于0,小于0则抛出非法参数异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 判断传入的值initialCapacity是否大于集合的最大容量 2的30次方
    if (initialCapacity > MAXIMUM_CAPACITY)
        // 超过MAXIMUM_CAPACITY,就将MAXIMUM_CAPACITY赋值给initialCapacity
        initialCapacity = MAXIMUM_CAPACITY;
    // 判断传入的加载因子是否小于等于0 或者 加载因子是否是非数字值(NaN)
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    // 调用tableSizeFor获取和传入数组长度最近的2次幂的数值
    this.threshold = tableSizeFor(initialCapacity);
}

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

  tableSizeFor(initialCapacity)
判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次冪。这点上述已经讲解过。但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。

  有些人会觉得这里是一个bug,应该这样书写:this.threshold = tableSizeFor(initialCapacity) * this.1oadFactor;
这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)。但是,请注意,在:JDK8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算,put方法的具体实现我们下面会进行讲解。

微信公众号:肝帝笔记

下面分析这个算法:

(1)首先,为什么要对cap做减1操作。int n = cap-1;

  这是为了防止,cap已经是2的幂。如果cap已经是2的幂,又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍,如果不懂,要看完后面的几个无符号右移之后再回来看看, 下面看看这几个无符号右移操作:

(2)如果n这时为0了(经过了cap-1
之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作),这里只讨论n不等于0的情况。

假如传入的是10
1)首先减一,此时n为9
    
2)第一次右移
n |= n >>> 1;

00000000 00000000 00000000 00001001 // 9
00000000 00000000 00000000 00000100 // 9右移之后变为4
-----------------------------------
00000000 00000000 00000000 00001101 // 按位异或操作得到13

3)第二次右移
n |= n >>> 2;

00000000 00000000 00000000 00001101 // 13
00000000 00000000 00000000 00000011 // 13右移之后变为3
-----------------------------------
00000000 00000000 00000000 00001111 // 按位异或操作得到15

3)第三次右移
n |= n >>> 4;
00000000 00000000 00000000 00001111 // 13
00000000 00000000 00000000 00000000 // 13右移之后变为0
-----------------------------------
00000000 00000000 00000000 00001111 // 按位异或操作得到15

4)...后面的操作得到的还是15
    
5) return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
n+1之后就是16

注意:

容量最大也就是32bit的正数,因此最后n |= n >>> 16;
最多也就32个1(但是这已经是负数了。在执行 tableSizeFor之前,对initialCapacity做了判断,如果大于MAXIMUM_CAPACITY(2^30)
,则取 MAXIMUM_CAPACITY
。如果等于MAXIMUM_CAPACITY(2^30)
,会执行移位操作。所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY
。30个1,加一之后得到2^30。

但是,得到的这个Capacity却被赋值给threshold了,是数组扩容的阈值。

// 构造器中将获得到的Capacity值赋值给threshold了
this.threshold = tableSizeFor(initialCapacity);

(4)构造一个包含另一个Map的HashMap

// 构造一个映射关系与制定的Map相同的HashMap
public HashMap(Map<? extends K, ? extends V> m) {
    // 负载因子loadFactor变为默认的负载因子0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 调用putMapEntries方法 后面讲
    putMapEntries(m, false);
}

成员方法

只说重要的方法。无非就是增删改查。

put方法

put方法里面涉及了很多其他的方法,例如扩容等方法。

/**
 * 添加元素的方法
 */

public V put(K key, V value) {
    return putVal(hash(key), key, value, falsetrue);
}

求哈希值方法

static final int hash(Object key) {
    int h;
    // 假如key为null的时候,返回0,这也说明了HashMap是可以存储null键的
    // key不为null的时候,进行后面的无符号右移16位再与原来值异或操作获得hash值
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

  我们可以看到当我们传入的key为null的时候,会返回一个默认的哈希值0,这也是HashMap的key为什么可以存null的原因。

示例:

求出key的hash值的算法:h = key.hashCode()) ^ (h >>> 16)
求出key在桶中位置的算法:i = (n - 1) & hash
假设key.hashCode计算出的值:
1111 1111 1111 1111 1111 0000 1110 1010  就是h

首先h无符号右移16位  h >>> 16
0000 0000 0000 0000 1111 1111 1111 1111

与原来的h进行异或操作后:
1111 1111 1111 1111 1111 0000 1110 1010
0000 0000 0000 0000 1111 1111 1111 1111
---------------------------------------
1111 1111 1111 1111 0000 1111 0001 0101

再与数组的长度减一做与操作
1111 1111 1111 1111 0000 1111 0001 0101
0000 0000 0000 0000 0000 0000 0000 1111  数组长度16 - 1
---------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101  结果:5

简单来说就是:高16bit不变,低16bit和高16bit做了一个异或(得到的hashcode转化为32位二进制,前16位和后16位)

为什么要做这样的操作呢?

  如果n即数组长度很小,假设是16的话,那么n-1的二进制位1111,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。

说的不好看,看下例子:

假如我们不进行右移16再异或操作,而是直接使用hashCode方法获得的值进行按位与运算

求出key在桶中位置的算法:i = (n - 1) & hash
假设key.hashCode计算出的值 11111 1111 1111 1111 1111 0000 1110 1010
1111 1111 1111 1111 1111 0000 1110 1010
0000 0000 0000 0000 0000 0000 0000 1111  数组长度16 - 1
---------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101  结果:5

假设key.hashCode计算出的值 21111 1111 1111 0000 1111 0000 1110 1010
1111 1111 1111 0000 1111 0000 1110 1010
0000 0000 0000 0000 0000 0000 0000 1111  数组长度16 - 1
---------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101  结果:5

  微信公众号:肝帝笔记

  当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了。我们发现高位发生了很大的变化,但是求出来的索引还是5,就会发送哈希冲突,造成这个桶很拥挤。所以hash方法将高低位都利用起来,从而解决了这个问题。

问题:哈希表底层采用何种算法计算哈希值?还有哪些算法可以计算出哈希值?

  底层采用key的hashCode方法的值结合数组长度进行无符号右移16位(>>>)、按位异或(^)、按位与(&)计算出索引。

  还可以采用:平方取中法、求余数、伪随机数法。例如取余法:

10 % 8 = 2
11 % 8 = 3    

  那么为什么HashMap底层不使用这些方法呢?因为下面的几种算法的效率比较低,而位运算的效率比较高。

真正添加元素的方法

08put流程
/**
 * 真正添加元素的方法
 *
 * @param hash key的哈希值
 * @param key 
 * @param value 
 * @param onlyIfAbsent if true, don't change existing value 如果为true代表不更改现有的值
 * @param evict false表示初始化状态
 * @return 返回添加之前的原值,null或者其他
 */

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict)
 
{
    // tab:底层存数据的数组
    // p:哈希值命中数组的位置的元素
    // n:数组的长度
    // i:哈希值命中数组的位置的索引
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 将节点数组赋值给tab
    // 当数组为空或者数组长度为0的时候,就调用resize()方法进行扩容
    // 并将扩容后的数组的长度赋值给变量n
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 因为数组table长度是2的n次幂,所以(n - 1) & hash这个运算和%取模运算等价
    // 这样做是为了提高效率
    // 假如tab[i]的位置为null,也就是没有元素,没发生哈希碰撞,就直接创建节点放入该位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 此处是发生了哈希碰撞
        // e:
        // k:哈希命中位置的元素的key
        Node<K,V> e; K k;
        // 发生哈希碰撞可能是因为两个key对象一样
        // 还有一种可能就是key不一样,但是恰好哈希值一样
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 发生了哈希碰撞,且两个key对象相等
            e = p;
        else if (p instanceof TreeNode)
            // 假如此时的p节点已经是树节点了,调用putTreeVal方法添加元素
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
   // 遍历链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // 假如p的下一个节点为null,就创建一个节点 
                    p.next = newNode(hash, key, value, null);
                    // 若binCount此时大于等于8,就需要将链表转成红黑树
                    // 但并不是一定会转出红黑树,还需要数组长度至少为64,后面讲
                    if (binCount >= TREEIFY_THRESHOLD - 1// -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 依次和链表的每个节点做比较,如果两个key对象相等导致的哈希碰撞
                // 两个key相等,直接退出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; // 链表指针后移
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 根据onlyIfAbsent来决定是否更改旧值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 由LinkedHashMap实现
            return oldValue; // 返回旧值
        }
    }
    ++modCount;
    // 假如自增后的元素个数大于扩容阈值,则进行扩容操作
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict); // 由LinkedHashMap实现
    return null;
}

  只要两个元素的key计算的哈希码值相同就会发生哈希碰撞。JDK8前使用链表解决哈希碰撞。jdk8之后使用链表+红黑树解决哈希碰撞。

问题:简述put方法过程?

put方法是比较复杂的,实现步骤大致如下:

  (1)首先通过哈希算法集合数组长度算出Key在数组中的位置,即计算元素放在哪个桶里。

  (2)如果此桶还没有元素,即没有发送哈希碰撞,则直接插入。

  (3)如果此桶上有元素了,即发生了哈希碰撞,则需要进行处理。调用key所属类的equals方法,依次判断该桶下链表或者红黑树的所有key是否和要添加的这个key重复。假如重复则直接覆盖相同key的value值。假如不重复:

  A:如果该桶使用了红黑树进行处理冲突,则调用红黑树方法插入数据。

  B:如果该桶还是使用的链表来处理哈希冲突,如果长度达到临界值则把链表转变为红黑树。

  (4)如果Size大于扩容阈值threshold则进行扩容。

扩容方法

  在初始化数组或者需要扩容的时候就会调用resize
方法
,会根据不同的构造方法来对数组进行初始化。

  在扩容的时候需要将旧元素按照新的数组长度进行路由寻址,由于HashMap的容量是2的n次幂,且每次扩容的时候是扩容原容量的两倍,所以旧元素的位置只可能是在原位置或者原位置间隔2的n次幂偏移。

09resize流程
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 旧数组长度,初始化的时候oldCap的值为0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧的扩容阈值
    // 空参构造初始化的时候threshold的值为0
    // 有参构造初始化时此时threshold的值代表数组的长度
    int oldThr = threshold;
    int newCap, newThr = 0;
    // table的长度不为0
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 如果原容量已经达到最大容量了,无法进行扩容,直接返回旧数组
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 假如旧的容量扩容两倍后的大小小于最大容量,且旧容量大于等于默认容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
             // 阈值也变为原来的两倍
            newThr = oldThr << 1// double threshold
    }
    // 构造方法指定容量initialCapacity的,将新的容量设置为旧threshold
    // 在构造方法中有行代码 this.threshold = tableSizeFor(initialCapacity);
    else if (oldThr > 0// initial capacity was placed in threshold
        newCap = oldThr;
    // 构造方法中没有指定容量,也就是空参构造器,则使用默认值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // newThr可能为0是因为调用了有参构造函数,设置了initialCapacity的
    // 需要重新赋值newThr
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr; // 空参构造时threshold真正是在此处赋值的
    @SuppressWarnings({"rawtypes","unchecked"})
    // 创建新的数组,HashMap的数组是在第一次添加元素时创建的
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // 赋值新数组给table
    if (oldTab != null) {
        // 遍历旧数组,重新计算位置,放入新数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null// 清空旧表的引用,help GC
                if (e.next == null)
                    // e.next为空,说明原数组此节点下面没有挂链表,直接赋值
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 红黑树,里面会有红黑树退为链表的操作
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 拆链表
                    Node<K,V> loHead = null, loTail = null// lo低位链表
                    Node<K,V> hiHead = null, hiTail = null// hi高位链表
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            // 扩容后还在同一个桶
                            if (loTail == null)
                                loHead = e; // 低位链表第一添加元素
                            else
                                loTail.next = e; // 低位链表尾部添加元素
                            loTail = e; // 低位尾部指针后移
                        }
                        else {
                            // 扩容后不在同一个桶
                            if (hiTail == null)
                                hiHead = e; // 高位链表第一次添加元素
                            else
                                hiTail.next = e; // 高位链表尾部添加元素
                            hiTail = e; // 高位尾部指针后移
                        }
                    } while ((e = next) != null); // 链表指针后移
                    if (loTail != null) {
                        loTail.next = null;
                        // 低位链表放在原位置
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 高位链表放在偏移oldCap位置
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容方法解释:

  可能大家对扩容时重新计算哈希值并移动元素位置的操作不太理解。

  微信公众号:肝帝笔记

  在扩容的过程会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。

  HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的(n-1)&hash
的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到**”原位置+旧容量”**这个位置。

rehash过程分析

  初始化一个HashMap,给一个初始容量8,此时的元素的存储示意如下

HashMap<Integer, String> map = new HashMap<>(8);
map.put(1,"开");
map.put(9,"源");
map.put(17,"节");
map.put(25,"流");

  假如后面添加元素,达到了HashMap的扩容阈值,那么数组的长度将会变为16,此时会进行重新计算哈希,重新路由寻址的操作。

  哈希值路由寻址的算法等价于求余,显然,上面四个元素都存在数组的索引为1的桶位。扩容之后新数组长度是16,采用e.hash & oldCap
来决定位置

  • 假如值为1,扩容后不在一个桶内。
  • 假如值为0,扩容后在一个桶内,新桶在原索引+oldCap(原位置+旧容量)

  正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1个bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了 rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。

问题:什么时候会调用这个扩容resize方法呢?

  第一:在数组初始化的时候会调用resize
方法

  第二:在HashMap中元素个数达到了扩容阈值时,回调用resize
方法进行扩容

  当HashMap中的元素个数超过 数组大小(数组长度) * loadFactor(负载因子) 时,就会进行数组扩容,loadFactor的 默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当 HashMap中的元素个数超过16 × 0.75 = 12 (这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为 2 × 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能

  微信公众号:肝帝笔记

  当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表

转换红黑树的方法

  链表转换红黑树不仅仅需要链表长度大于等于8,还需要底层数组的长度大于等于64。假如底层数组的长度小于64,此时并不会去转换红黑树,而是去扩容底层数组。这样做是因为数组长度太小了非常容易产生哈希碰撞,而维护红黑树也是有代价的。

/**
 * 替换指定哈希表的索引处桶中的所有链接节点,假如表太小就扩容
 */

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // table数组为null或者长度小于阈值64,就进行扩容,不转换红黑树
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 当table数组的index位置有元素时,才转成红黑树
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd: 头节点
        // tl: 指针
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 将链表节点转换成树节点TreeNode
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                // 第一遍循环时:hd指向头节点
                hd = p;
            else {
                // 后面的循环:依次将遍历到的节点放在上一个节点的后面
                p.prev = tl;
                tl.next = p;
            }
            tl = p; // 后移
        } while ((e = e.next) != null);
        // 让桶中的第一个元素,即数组中的元素指向新建的红黑树的节点,
        // 以后这个桶里的元素就是红黑树,而不是链表数据结构了
        if ((tab[index] = hd) != null)
            // 将链表进行树化
            hd.treeify(tab);
    }
}

/**
 * 将Node节点(链表节点)转换成TreeNode节点(红黑树节点)
 */

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

/**
 * 1. 将头结点作为root节点,然后依次将next节点插入到根节点,转变红黑树。
 * 2. 再插入时候key比较
 *    1. 如果key实现了comparable接口,通过实现方式比较
 *    2. 否则比较key的hashCode
 *    3. 否则比较key的class.getName
 *    4. 否则比较key的System.identityHashCode比较
 * 3. 最后树化后,取出root节点(TreeNode),放到entry下标位置
 */

final void treeify(Node<K,V>[] tab) {
    // 树的根节点
    TreeNode<K,V> root = null;
    // x是当前节点,next是后继节点
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        // 当前节点的直接后继节点
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        // 如果根节点为空,就把当前节点设置为根节点
        if (root == null) {
            x.parent = null;
            x.red = false// 根节点是黑色
            root = x;
        }
        // 不是根节点
        else {
            // k:当前遍历的节点的key值 h:当前遍历节点的哈希值
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            for (TreeNode<K,V> p = root;;) {
                // p:当前遍历的节点 pk:当前遍历节点的key值 ph:当前遍历节点的哈希值
                int dir, ph;
                K pk = p.key;
                // dir用来指示x节点与p的比较,-1表示比p小,1表示比p大
                // 不存在相等情况,因为HashMap中是不存在两个key完全一致的情况
                if ((ph = p.hash) > h)
                    // 根节点的哈希值大于当前节点的哈希值
                    dir = -1;
                else if (ph < h)
                    // 根节点的哈希值小于当前节点的哈希值
                    dir = 1;
                // 如果hash值相等,那么判断k是否实现了comparable接口
                // 如果实现了comparable接口就使用compareTo进行进行比较
                // 如果仍旧相等或者没有实现comparable接口,则在tieBreakOrder中比较
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    // kc:当前节点key的class k:当前节点的key pk:根节点的key
                    dir = tieBreakOrder(k, pk);

                TreeNode<K,V> xp = p;
                // 假如dir小于等于0,p就指向p的左子节点的引用
                // 大于0,p指向右子节点的引用
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    // 指定移动后的p节点的父节点
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    // 做平衡操作
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);
}

添加一个Map方法

putMapEntries

/**
 * 添加一个map集合
 *
 * @param m the map
 * @param evict false when initially constructing this map, else
 * true (relayed to method afterNodeInsertion).
 */

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size(); // 代添加的map的大小
    if (s > 0) {
        if (table == null) { // pre-size
            // 底层数组未初始化时,需要计算初始容量
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            // 参数为Map的构造函数调用时,threshold为0
            // putAll调用,假如是第一次添加元素,threshold为0
            if (t > threshold)
                // 因为threshold可能小于t,需将初始容量为2的n次幂
                threshold = tableSizeFor(t);
        }
        // 此处数组已经初始化过了,判断是否需要扩容
        else if (s > threshold)
            resize();
        // 循环添加数据
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

问题来了,为什么float ft = ((float)s / loadFactor) + 1.0F;
这一行要加一操作?

  s/loadFactor
的结果是小数,加1.0F
(int)ft
相当于是对小数做一向上取整以尽可能的保证更大容量,更大的容容量能够减少resize的调用次数。所以+ 1.0F是为了获取更大的容量。

  例如:假如插入集合的元素个数是6个,那么6/0.75是8,是2的n次幂,那么新的数组大小就是8了。然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致后续在存储元素的时候,容量可能不够,还得继续扩容,那么性能降低了,而如果+1呢,数组长度直接变为16了,这样可以减少数组的扩容。

获取方法

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    // tab:数组引用
    // first:路由寻址命中的元素,是链表的头结点
    // e:指针,指向每次遍历链表的结点
    // n:数组长度
    // k:每次遍历链表的结点的key
    Node<K,V>[] tab; 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 && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            // 头结点就是要找的key,直接返回
            return first;
        // 此时要找的结点在链表或者红黑树中
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                // 获取红黑树结点
                return ((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);
        }
    }
    // 未找到返回null
    return null;
}

删除方法

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, nullfalsetrue)) == null ?
        null : e.value;
}

/**
 * Implements Map.remove and related methods
 *
 * @param hash 要删除的key的哈希值
 * @param key 要删除的key
 * @param value 待匹配的值
 * @param matchValue 为true,则需要匹配value
 * @param movable 为false则删除时无需移动其它结点
 * @return the node, or null if none
 */

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable)
 
{
    // tab:数组
    // p:路由寻址命中的元素
    // n:数组长度
    // index:命中位置索引
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        // node:要删除的结点
        // e:遍历到的每个结点
        // k:遍历的结点的key
        // v:遍历的结点的value
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 命中的第一个结点,也就是链表的头结点就是要找的结点
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                // 获得红黑树结点
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 此时要找的结点在链表中,遍历链表找到结点
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 删除操作
            if (node instanceof TreeNode)
                // 删除红黑树结点
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                // 要删除是头结点,将链表的第二个元素作为新的头结点
                tab[index] = node.next;
            else
                // 要删除的是链表中间或者尾部的结点
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    // 不存在该结点直接返回null
    return null;
}

序列化相关

  序列化的目的是将Java对象序列化,在某个时刻能够将该对象反序列化,而且一般来讲序列化和反序列化所在的机器是不同的,因为序列化最常用的场景就是跨机器的调用(把对象转化为字节流,才能进行网络传输),而序列化和反序列化的一个最基本的要求就是,反序列化之后的对象与序列化之前的对象是一致的。

  HashMap有自己实现的writeObject和readObject方法。

  这两个方法私有的好处是,子类也可以重写这两个方法,而不受HashMap重写的影响。

  至于为什么要重写这两个方法,是因为对于同一个Key,在不同的JVM实现中计算得出的Hash值可能是不同的。Hash值不同导致的结果就是:有可能一个HashMap对象的反序列化结果与序列化之前的结果不一致

微信公众号:肝帝笔记

  即有可能序列化之前,某个元素本来放在数组的第0个位置,而反序列化值后,根据Key获取元素的时候,可能需要从数组为2的位置来获取,而此时获取到的数据与序列化之前肯定是不同的。

  所以为了避免这个问题,HashMap的处理方法是

  1. 将可能会造成数据不一致的元素使用transient关键字修饰,从而避免JDK中默认序列化方法对该对象的序列化操作。不序列化的包括:Node[ ] table、size、modCount等。
  2. 自己实现writeObject和readObject方法,从而保证序列化和反序列化结果的一致性。

那好我们来看看是如何重写这两个方法的:

  1. 首先,HashMap序列化的时候不会将保存数据的数组序列化,而是将元素个数以及每个元素的Key和Value都进行序列化。
  2. 在反序列化的时候,重新计算Key和Value的位置,重新填充一个数组。
private void writeObject(java.io.ObjectOutputStream s)
    throws IOException 
{
    int buckets = capacity(); // 获得桶的个数,也就是数组的长度
    // Write out the threshold, loadfactor, and any hidden stuff
    s.defaultWriteObject();
    s.writeInt(buckets); // 写入数组长度
    s.writeInt(size); // 写入集合元素个数
    internalWriteEntries(s); // 将key value分别写入
}

private void readObject(java.io.ObjectInputStream s)
    throws IOException, ClassNotFoundException 
{
    // Read in the threshold (ignored), loadfactor, and any hidden stuff
    s.defaultReadObject();
    reinitialize(); // 重置那些transient的变量
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new InvalidObjectException("Illegal load factor: " +
                                         loadFactor);
    // 读出数组长度,但是没有用到
    s.readInt();                // Read and ignore number of buckets
    // 读出元素个数
    int mappings = s.readInt(); // Read number of mappings (size)
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                         mappings);
    else if (mappings > 0) { // (if zero, use defaults)
        // Size the table using given load factor only if within
        // range of 0.25...4.0
        // 计算新建的数组长度
        float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
        float fc = (float)mappings / lf + 1.0f;
        int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                   DEFAULT_INITIAL_CAPACITY :
                   (fc >= MAXIMUM_CAPACITY) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor((int)fc));
        float ft = (float)cap * lf;
        threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                     (int)ft : Integer.MAX_VALUE);

        // Check Map.Entry[].class since it's the nearest public type to
        // what we're actually creating.
        SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].classcap);
        @SuppressWarnings({"rawtypes","unchecked"})
        // 创建新的数组
        Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
        table = tab;

        // Read the keys and values, and put the mappings in the HashMap
        // 读出key value 重新计算哈希值并添加到新的数组中去
        for (int i = 0; i < mappings; i++) {
            @SuppressWarnings("unchecked")
            K key = (K) s.readObject();
            @SuppressWarnings("unchecked")
            V value = (V) s.readObject();
            putVal(hash(key), key, value, falsefalse);
        }
    }
}

小结

  JDK8的HashMap的底层数据结构是数组+链表+红黑树,相对于JDK8之前的版本引入的红黑树,在桶位元素较多的时候,查询起来比较慢,故此引入红黑树来增加查询效率。

  HashMap的数组长度必须是2的n次幂,为了配合位运算提高哈希路由的效率。底层数组是在第一次添加元素的时候才去初始化的。

  当HashMap中元素的个数达到扩容阈值后,会调用扩容方法创建一个新的数组,长度为旧长度的两倍,再扩容过程中有个重要的事要做,那就是重新计算哈希,重新路由寻址,在此过程中假如重新路由之后,某个红黑树的结点个数小于6了,红黑树就会退化为链表。ps.在删除结点的时候也会进行判断是否需要退化为链表。

微信公众号:肝帝笔记

参考

为什么HashMap要自己实现writeObject和readObject方法?

HashMap底层树化标准?


文章转载自肝帝笔记,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论