map 长度_Java底层源码解析·容器篇——Map

该系列主要介绍java.util包下的各种容器。

传送门:

Java底层源码解析·容器篇——List

Java底层源码解析·容器篇——Set

先来一张java.util包下容器的分类图:

4068b306c6b7a0f205005c764b84895e.png

Map有下面几种实现类:HashMap、LinkedHashMap、TreeMap、HashTable等。它主要定义了下列这些方法:

int 

和Collection相比,Map引入了键值对的概念,因此方法大多针对键值对进行操作。Map的每一个键值对都是用Entry对象来存储的,所以我们先来看看Entry是什么:

Entry

Entry是一个接口,定义了以下的方法:

K 

我们可以将Entry理解为一个二元组,一般至少会有两个成员变量,key和value。不同的Map实现类使用了不同的Entry实现类。我们在介绍具体Map的时候再看各自的Entry实现类有什么特点。


HashMap

我们知道HashMap的底层实现是使用数组+链表+红黑树的方式。

transient Node<K,V>[] table;

我们先来看看Node,HashMap有两个内部类Node和TreeNode,分别实现了Entry接口,Node拥有下列四个成员变量:

static 

next变量很好理解,指向下一个Node对象。hash变量则存储了key的hash值。

TreeNode间接继承了Node,相比Node多了下面这些成员变量。

static 

显然,基础的Node类是用来构建链表的,而TreeNode是用来构建红黑树的。

下面我们来逐一分析HashMap各个方法的底层源码:

查询get

public 

根据key获取元素的流程根据以上源码大致可以归纳为以下几步:

  1. 根据key计算一个hash值。
  2. 根据hash值计算数组下标。
  3. 判断当前数组位置是否有Node,如果没有则直接返回null。
  4. 如果有Node,判断该Node是否满足要求,如果满足则直接返回该Node的value
  5. 如果不满足,判断当前的Node是否是TreeNode,如果是,说明该位置存储的是红黑树,用树获取节点的方式去获取指定节点。
  6. 如果不是TreeNode,说明该位置是链表,遍历链表获取指定节点。

下面我们展开介绍其中几个需要注意的点:

  • 计算hash值,计算方法如下:
static 

简单来说如果key是null,则统一返回0。不然的话根据key的hashCode()函数获取一个hash值a,然后将a无符号右移获得新的hash值b,最后将a和b做异或运算得到最终的hash值。

为什么要这样做可以参考文章《HashMap中的hash算法总结》。简单来说就是将hash值a的高区特征与低区特征混合起来,使得在计算数组下标的时候能够减少Hash冲突。

  • 计算数组下标,计算方式如下:
(n - 1) & hash

其中n是HashMap底层数组的长度。这里的计算非常有趣,HashMap的数组长度初始值为16,每次进行扩容时,长度翻倍:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
...
final Node<K,V>[] resize() {
    ...
    newCap = oldCap << 1
    ...
}

因此HashMap的数组长度始终为2的幂次,这样子n-1的二进制就能保证低位全部为1,计算出的数组下标就能覆盖0~n-1。试想一下如果n不为2的幂次,例如17,则n-1的二进制位000...10000,那只能产生0或16的数组下标,其他1~15的下标就都浪费了!

  • 判断节点是否满足要求
if(first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))

这里需要注意的一点是:同一个数组下标里的节点它们的hash值不一定相等!因为数组下标实际上只是hash值的低位而已,这里首先用hash值判断能够提高比较效率。

添加put

/**

根据上述源码,插入元素的流程大致可以归纳为以下几步:

  1. 根据键值对的key计算hash值。
  2. 判断底层数组是否为null,如果为null,先初始化底层数组。
  3. 根据hash值和数组长度计算数组下标。
  4. 判断当前位置是否为空,为空则直接生成新节点放入该位置。
  5. 如果该位置不为空,则查看头结点是否满足key的要求,满足的话修改头结点的value值。
  6. 如果不满足,判断该节点是否为TreeNode,如果是说明为红黑树,用树的方式添加或修改节点。
  7. 如果不为TreeNode,则说明为链表,遍历链表判断是否存在指定key的节点,如果存在则修改value,不存在则新生成节点,并挂在链表的尾部。并判断插入新节点后的链表长度是否大于阈值(默认为8),如果是的话,将链表转换为红黑树。
  8. 判断插入新元素后的HashMap大小是否大于阈值,如果大于阈值对HashMap进行扩容。

下面我们展开介绍其中几个需要注意的点:

  • 判断底层数组是否为null,如果为null,先初始化底层数组。从这里我们可以看出,HashMap是用懒汉模式进行底层数组的初始化的。直到插入第一个元素,才对底层数组进行初始化,而不是在构造函数里进行初始化。
  • 判断插入新节点后的链表长度是否大于阈值(默认为8),如果是的话,将链表转换为红黑树。我们从前面对查询的分析也可以看出来,对于发生Hash冲突的元素,如果存储的数据结构为链表,则需要从头到尾遍历。而如果存储结构为红黑树,则可以用类似二分的方式找到指定位置,因此发生Hash冲突的元素过多时,将链表转化为红黑树能够提高后续查找的效率。
  • 判断插入新元素后的HashMap大小是否大于阈值。阈值默认为负载因子与当前数组大小的乘积。

我们注意到上述源码中有下列两个方法:

afterNodeAccess

这两个方法在HashMap中是空方法,不做任何操作,而在LinkedHashMap中有具体的实现。

扩容resize

扩容是指HashMap里面的元素超过了阈值、较多元素会发生Hash冲突导致HashMap效率下降时,进行底层数组扩容的操作。扩容的代码比较复杂,归纳起来主要做的事情有两件:

  1. 计算新的数组大小,并更新阈值,生成新的数组。
  2. 遍历旧数组,将旧的数组里的节点移动到新的数组中。
  • 计算新的数组大小,并更新阈值

数组大小大于等于MAXIMUM_CAPACITY时,数组不继续扩容弄。其他情况,都将扩容为原先的两倍。阈值也会同步扩容为原先的两倍,边界情况会进行特殊处理,阈值最大为Integer.MAX_VALUE

  • 遍历旧数组,将旧的数组里的节点移动到新的数组中

这里涉及到两个问题:1. 怎么计算节点在新数组里的位置。2. 怎么移动。

首先我们用节点的hash值与旧数组的长度例如16进行&操作,有两个结果:0或者16。对应新数组的位置分别为“旧数组的位置”与“旧数组的位置+旧数组的长度”。

if 

根据这两个结果,我们能够将红黑树或者链表分成两个子树或者两个子链表。然后分别将两部分放入对应的位置即可。

其他方法都和get以及put原理类似,这里不再多做赘述。


LinkedHashMap

LinkedHashMap继承了HashMap。我们知道用迭代器遍历HashMap的时候是无序的,而LinkedHashMap遍历是有序的,它维护了一个“访问链表”,且有两种顺序:插入顺序、访问顺序,可以在调用构造函数时进行指定,默认为插入顺序。

插入顺序很好理解,访问顺序是指每次访问某个节点,都将该节点移动到末尾,最后一个遍历。

LinkedHashMap实现了自己的Entry:

/**

添加了before和after两个成员变量,来维护“访问链表”。

上面HashMap的put操作,我们讲到HashMap调用了两个空方法,这两个空方法在LinkedHashMap里进行了重写,以维护“访问链表”。

void 

实际上LinkedHashMap与HashMap非常类似,多出来的操作都是在对节点进行增删改查操作后对“访问链表”的对应维护,没有什么特别复杂的地方,所以不做别的展开。


TreeMap

TreeMap的底层使用红黑树实现。在遍历的时候能够按照传入的Comparator进行排序。和普通红黑树主要的区别在于TreeMap中的节点存储了key和value两个值,且节点按照key进行排序。这里不做继续展开。


HashTable

Hashtable底层是用数组+链表的方式实现的,现在已经弃用,和HashMap主要存在以下几点区别:

  • 继承的父类不同

Hashtable继承自Dictionary类,而HashMap继承自AbstractMap类。但二者都实现了Map接口。

  • 线程安全性不同

Hashtable是线程安全的,而HashMap是非线程安全的。Hashtable的方法基于synchronize实现线程安全。

  • key和value是否允许null值

HashMap允许key和value为null,而Hashtable不允许。

public 
  • 迭代器实现不同

Hashtable和HashMap都实现了Iterator接口,而Hashtable因为历史原因还实现了Enumeration接口。

  • hash值不同

Hashtable直接使用key的hashCode,而HashMap会重新计算hash值。下面是Hashtable计算数组下标的方式:

int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
  • 数组初始化和扩容方式不同

首先Hashtable在调用构造函数时就进行了底层数组的初始化,而HashMap是在第一次插入元素是进行底层数组的初始化的。然后Hashtable的数组初始大小为11,而HashMap为16。最后Hashtable扩容时,大小变为原先的两倍加一,而HashMap扩大为原先的两倍。

public 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值