HashMap是java中常用的一个集合, 也是面试大公司几乎必问的一个知识点. 无论是为了面试还是提升能力, 或是学习源码的编程思想, 了解它的底层原理是很重要的.
要了解HashMap的底层原理, 就得深入理解它的源码. 本文将逐行逐行解释HashMap的put()方法及其涉及到的原理和思想, 希望对大家有帮助.
本次我们讨论的是JDK1.8的HashMap. 如果哪里说得有错误或是不足, 欢迎批评指正.
首先, HashMap有三个构造器. 通常我们都是用的第三个无参构造器.
而第一个构造器是自定义了数组容量和负载因子(后面会解释它们是什么), 第二个构造器自定义了数组容量. 值得注意的是, 无论给的自定义容量是多少, 第457行调用的tableSizeFor()方法总会将它变成一个2的次方数, 它的目的接下来也会讲到.
一般来说, 添加key和value值都是用的这个put方法(主要因为参数少, 简便).
但实际上, put方法会调用另一个叫做putVal的方法, 而真正的运行代码基本都在这个putVal方法中实现.
在调用putVal方法传参数时, put方法调用hash()方法对key做了处理之后再传入.
第339行对key进行了判断: 如果传入key为null, 则返回值为0; 如果不为null, 先调用Object类的hashCode()方法.
这是Object类中hashCode()的实现, 只有这一行.
注意这个native关键字, native关键字指的是本地方法, 它们不是由java实现的, 而是用其他语言实现的(如c, c++等). 这个hashCode()方法可以理解能将任意对象转换成一串长数字(也称散列码, 是根据对象的某些信息, 如存储地址和字段形成的).
注: 同一个对象的hashCode()值肯定相同(即equals()==true的两个对象, hashCode()的值必须相同), 不同对象的hashCode()值不一定要求相同, 但提高不同对象的散列度可以提升哈希表的性能. 下面是hashCode()方法上方的注释原文, 在Object类中可以看到, 我就大概概括了一下.
这是它的底层实现代码(Oracle的JDK中看不到, OpenJDK或其他开源JRE是可以找到对应的代码. 仅作了解就可以, 不影响HashMap的源码理解):
回到上面的第339行代码后半段
这句话的意思是, 先将key计算出的hashCode值赋给h, 然后将h与h右移(符号为>>>)16位之后的结果进行异或运算(符号为^).
右移16位的作用: 数字在计算机中的存储是二进制的, int总共有32位二进制(这也是int取值范围的由来). 当h与h右移16位的结果进行异或运算时, 就会使h的高16位(从左数起的16个二进制数字)和h的低16位(从右数起的16个二进制数字)进行异或运算(两个数相同为0, 不同为1). 而由于int总共是32位, 右移16位的异或运算可以充分利用全部32位的每位数字, 从而提高这个哈希方法的性能.
在put()方法调用putVal()时, 后面还传了一个false和一个true的值, 它们的意思已经在putVal()方法的上方说明了.
第一个boolean值为true时, 不改变已存在的key-value键值对. put方法传入时默认为false.
第二个boolean值在这里可以忽略, put方法传入时默认为true. 在方法体中虽然有传给afterNodeInsertion()方法使用, 但并没有起实质作用. HashMap的子类LinkedHashMap类重写了afterNodeInsertion()方法, 对evict这个值有作真正的运用.
现在进入putVal()方法的方法体, 先看第627行定义的四个局部变量.
tab代表一个Node数组. p代表节点(可能是链表节点, 也可能是红黑树节点). n代表数组的长度(代表n个数字). i代表数组的下标(代表index).
此时, 有必要看一下Node类. 它在HashMap类里面有作定义, 下面是put方法会用到的内容.
可以看到, Node类的定义中只有next而没有prev(代表previous), 也就是说HashMap中的链表都是单向链表, 而非LinkedList中的双向链表.
那么HashMap究竟为什么要使用数组+链表呢? 可以结合ArrayList与LinkedList的优缺点进行思考: ArrayList的底层是由数组实现的, 它的特点是查询效率高, 但是插入删除效率低; LinkedList的底层是链表实现的(虽然它底层是双向链表, 与HashMap的单向链表有所不同, 但原理都是近似的), 它的特点是插入删除效率高, 但是查询效率低. 那么, 将数组与链表相结合使用, 就能实现查询的效率与插入删除的效率都有所保障, 从而保障了运行效率.
具体的结合是这样一个模型:
在JDK1.8之后, HashMap改为了由数组+链表+红黑树实现, 我们先不讨论红黑树的效果, 仅谈论树的效率: 当链表的节点数量过大时, 树结构可以提供更好的效率, 尤其是二叉查找树(Binary Search Tree). 但如果是普通的二叉查找树, 当插入的数字大小倾斜得很厉害时, 效率会很差(比如以9为根节点, 依次插入1,2,3,4,5,6,7,8后, 其实和链表的效率毫无区别, 就是一长条链表的线性查找, 见下图). 为了优化这样的极端情况, 需要更合适的树结构.
红黑树属于二叉查找树, 是一种特殊的二叉查找树. 那么为什么要用红黑树呢? 不能用平衡二叉树(AVL树)吗? 这个问题的答案, 与结合ArrayList和LinkedList的原理类似: 需要均衡查询的效率和插入删除的效率. 虽然AVL树的查询效率非常高, 但是插入删除的效率却很低, 需要的代价太大了, 而红黑树很好地平衡了查询的效率与插入删除的效率. 因此, 红黑树被加入到了这个体系中.
由于HashMap的查询和插入删除操作都很频繁, 并不能像ArrayList和LinkedList那样有所倾向性地选择, 故均衡查询与插入删除的效率是更优的选择.
回到代码. 第一次put操作时, 一定会运行第628和629行, 因为全局变量table在第一次put之前从来没有被初始化过.
看一下全局变量table的介绍, 它指代HashMap的底层数组. 看注释, 它要求table的长度总是为2的次方(很重要, 后面会解释).
628行调用了resize()方法, 那么看一下resize()方法的结构.
先分析方法体里面的这一段代码.
看注释, 言下之意是: 第一次的调用相当于初始化.
第678行和679行与之前putVal中table==null同理, 故oldCap为0.
第680行会看到一个新的变量threshold, 那么找到threshold(意思是阈值)的相关代码. 看到它一开始不会被初始化, 即为0.
此时, oldCap和oldThr都为0, 会进入第693~696行的else语句.
这里有两个final的全局变量, 找到它们.
DEFAULT_INITIAL_CAPACITY 指的是数组的初始容量, 这里又用到了移位运算符: 将1左移4位, 即16. 为什么要用右移而不是直接用十进制数字16表示呢? 依旧是计算机使用二进制的原因, 十进制的16最终还是要转换成二进制来进行运算的, 所以直接这么写可以提高性能.
DEFAULT_LOAD_FACTOR 指的是负载因子. 说白了就是当数组使用的个数达到某个临界值(也就是扩容阈值, 由初始容量负载因子得到), 要对数组进行扩容, 而不是等达到数组容量的上限值再扩容, 那样存储效率就太低了, 可能还会影响使用.
这两个值都可以在构造器中定义, 但默认就是160.75, 即默认阈值是12, 也就是当数组内的位置有12个被占用时, 进行扩容.
现在得到newCap=16, newThr=12了, 看接下来怎么使用.
702行将newThr的值赋给threshold, 即阈值.
703行是一个注释, @suppressWarnings代表抑制编译器的警告. 这行可以忽略.
704行新建了一个Node数组, 长度为newCap, 即16.
705行将这个数组赋给全局变量table, 此时putVal()方法中用到的全局变量都已经初始化完成.
resize()方法剩下的部分在第一次调用时并不会进行, 因为不满足oldTab != null(oldTab在第678行有做赋值)的条件, 方法会直接到748行, 返回这个新创建的数组.
回到putVal()方法, 此时n已经被赋值了(第629行), 即16.
接下来, 630行将判断数组的某个位置(即新加入的Node要放入的数组位)是否已经有节点.
那么如何算出放哪个位置上呢? 注意语句中的这个运算.
将n-1与传入的key的hash值进行与运算. 与运算: 只有当两个都是1是才为1, 否则为0.
这是一个很精妙的设计. 为什么要n-1呢? 因为此时n已经被赋值了16, 二进制为0001 0000, 它与hash值的任何操作都很难散列化, 说白了就是难以保证数组每个位置上传入的key-value键值对数量尽可能接近. 而n-1=15, 15的二进制是0000 1111, 它与hash值进行与操作, 就有了更多可能的结果, 进一步提高了散列程度, 哈希的效率更高了(因为之前已经对key进行了散列化, 现在又进行了一次散列化). 那么为什么要用与操作而不是其他的呢? 因为与操作下, 0000 1111与任何数的最大结果还是0000 1111, 不会超过这个值, 也就是不会超过十进制的15, 而其他的如异或运算等, 都有可能得到超过15这个值的结果. 那么15有什么用呢? 15是一个长度为16数组的最后一个元素下标值, 超过15就数组越界了! 所以这一行设计得非常精妙, 兼顾了性能与实际情况!
而第一次的put操作, 得到的任意一个数组位置一定是null, 故会运行第631行, new一个新的Node, 将hash, key, value值赋给它, 并且标记它的next节点为null. 此时, 插入操作完成. 对于数组上每个位置的第一个节点, 都是经过第631行进行插入操作.
那么如果数组对应位置已经存在节点了呢? 看紧跟着的else代码
e代表p的next节点, 它们处在同一条链表上(即它们的hash经过与运算后, 数组的下标值是一样的). k代表key值, 用泛型表示, 指代当前或者下一个检索的节点的key值, 用于判断是否已经存在一个key值相同的节点.
多说一句, 为什么HashMap里要用泛型(K)而不是Object类呢? Object类不是所有类的父类吗? 有两个原因, 一是省去了做一次强制类型转换, 方便且提高效率; 二是提高了程序的安全性, 使用Object类的话, 无法保证传入的类都是同一个类, 也许是不同类型, 这时会在运行时得到一个类型转换异常(ClassCastException), 这是运行时异常, 在编译的时候不会被编译器警告的, 大大影响了程序的可用性. 而使用泛型的话, 编译器不会允许不同的类进行操作, 也就是说编译器会保证类型的正确性, 能通过编译就能使用. (注: 泛型是JDK1.5之后加入的)
第634行和635行判断数组对应位置的第一个节点(即这条链表的头部)是否与将要传入的key值重复. 如果是重复了, 则将这个节点赋给e, 运行653行(后面会分析653行之后的代码).
如果没有与链表的头节点重复, 往下走637行, 用instanceof判断p目前是不是红黑树的节点类(即TreeNode类). 如果是, 先强转成TreeNode类(如果不进行强转, 无法调用里面的方法), 再调用该类的putTreeVal()方法进行插入. 这是红黑树TreeNode类的参数信息.
由于红黑树的原理和方法实现写进来有点复杂, 篇幅也会很长, 以后有时间再单独开一篇进行介绍. 可以暂时理解为: 第638行这里调用了红黑树的插入方法, 将新节点插入到已存在红黑树中.
那么如果p节点不是TreeNode类, 会走第639行的else语句. 这里有一个for循环, 注意它没有限定条件, 只有里面的if-break能结束循环. 即: 我没法提前知道链表有多长, 但是我可以通过一定的条件来遍历整个链表, 直到在尾部插入新的节点, 或是在中间捕获已存在对应key值并进行记录, 才结束遍历.
第641行, 先给e赋值为p的next节点, 再判断p是否为null, 即判断链表是否走到尽头. 如果是, 运行第642行, 在当前节点p的next插入一个newNode, 信息为hash, key, balue, next为null(这与第631行一样). 接着, 判断binCount是否达到转成红黑树的阈值(注意640行的++binCount, 它不同于binCount++, 它会在新的一次循环开始前就时binCount+1, 而不是新的循环结束后+1. 也就是说, 如果真的插入了新节点, 不需要再为binCount+1之后与阈值比较, 本次循环开始前已经+1了).
找到这个final的全局变量, 为8.
对应的, 移除节点时也有一个将树转回链表的阈值, 为6.
如果需要转成树, 则调用这个treeifyBin()方法, 走759~773行的else if语句, 将链表的每个节点逐个转为红黑树的节点. (注: hd代表head, 头部; tl代表tail, 尾部; 通过replacementTreeNode()方法进行节点类型的转换. treeify()方法用于确立树的根节点. 篇幅有限, 不作深入讨论)
注意传入的hash参数, 它在这里用了同样的方式去定位数组下标, 所以key的hash值在HashMap中是非常重要的. 其实get()和remove()方法也用到了key的hash值.
回到putVal()方法对链表进行遍历的for循环语句.
如果遍历到的不是尾节点, 会走到647~648行. 这里是对e节点(在641行被赋值为p的next节点)进行判断, 判断key是否与节点e重复.
如果重复, 则跳出循环, 运行第653行.
如果上面两个if语句均不满足, 能运行到第650行, 说明当前循环下的节点p只是链表中的一个中间节点, 且它不与要插入的key值重复, 也就是说本次循环没有有价值的信息. 那么就将e赋值给p, 进行下一次循环, 在链表中推进到下一个节点.
接下来是653行的if语句.
它可能来自636行, 也可能来自638行, 也可能来自641行. 根据它给的注释就可以明白, 是为了处理key值已存在的情况. 这里, 默认传入为false的boolean值onlyIfAbsent发挥了作用, 重新读一下它的变量介绍.
此外, HashMap是允许null作为key或者value的. 所以如果||右边的oldValue==null判断如果为真, 也会直接被新值替代.
第657行的方法可以忽略, 在HashMap中并没有实际的方法体进行操作.
然后就是658行了, 返回oldValue值, 结束方法. 所以其实put()和putVal()方法都是有返回值的, 如果key值存在就会返回旧value值, 不存在则返回null(马上介绍).
最后, 看一下putVal()方法的最后几句话. 能运行到这里, 说明key值是没有重复过的, 也就是插入了一个新的节点(无论是链表的节点还是红黑树的节点).
找到661行的全局变量modCount, 读一下它的注释. 大体意思是说, 修改次数+1.
put()带来的插入/修改, remove()带来的删除操作都会使修改次数+1, 而查询并不会.
这是remove()调用的removeNode()方法中第845行的”++modCount”.
然后就是++size与扩容阈值进行比较. 注意这里也是”++变量”(先加再比较), 而不是”变量++”(先比较再加). 扩容阈值已经计算过了, 它的默认值是160.75=12.
如果需要扩容, 再次调用resize()方法. 先看前半段代码.
此时oldCap为16, oldThr为12, 会进入681~689行的if语句中. 正常情况下都不会超过定义的最大容量值(被定义为1<<30), 故进入686行和683行的else if判断语句, 注意左移运算符, 左移一位即2. 当数组长度*2之后不大于最大容量, 且旧数组长度大于等于初始数组容量时(即已经被初始化扩容过)时, 新阈值为旧阈值的2倍. 而新数组的长度在判断语句中已经作赋值, 也是旧数组的2倍, 这也符合了table数组的注释: 必须为2的次方.
为什么要扩容为2倍呢? 回顾一下刚才讲到的数组默认长度为16, 并将它减去1之后与hash值进行与运算的精巧设计, 也就很简单可以联想到了: 提升散列度.
然后, 会运行第701行(因为此时696行的if条件不满足), 将新阈值赋给全局变量threshold. 然后703行重建了一个长度为旧数组两倍的Node数组, 并在704行赋给全局变量table.
接着往下看, 剩下的代码是将旧数组中的节点们重新计算存储的数组位置, 以均衡新数组不同位置的节点个数.
第707行一个for循环遍历整个旧数组, 708行声明一个Node变量为后面的操作做准备, 709行判断数组当前位置是否有节点, 如果没有则直接进入数组的下一个位置.
如果当前位置有节点, 可以看到在判断语句中e已经被这个节点赋值了. 所以710行先将数组的当前位置设为null, 为了后续重新计算存储位置做准备. 将当前位置设置为null并不会出现节点丢失, 因为e已经被赋值了, 通过next可以找到每一个节点.
随后, 第711行先判断e的next节点是否为空, 如果为空则简单, 说明数组的这个位置只有这一个节点. 那么直接将e的hash值与新数组长度-1进行与操作计算新的存储位置, 并直接放进去即可, 因为此时原位置已经被设置为null了, 与运算后可能的新位置也不会有节点已经存在. 可以尝试一下任意数字和15(16-1)进行与运算, 以及这个数字和31(32-1)进行与运算的结果, 这个结果类似于6和16取余为6, 22和6取余也为6, 但是6和32取余还是6, 22和32取余就变成了22. 即新数组的各个位置中, 有些旧位置上还是旧数组相应位置上的那些元素, 有些旧位置会移走一些元素到新的位置空间中.
第二种情况, e是红黑树的节点, 那么先转换为TreeNode类, 调用它自己的划分方法split()进行重新排列. (仅介绍, 本次不作深入讨论).
第三种情况, e有next节点, 即链表结构. 首先会声明四个为null的Node变量. 它们分别代表旧链表头部(loHead), 旧链表尾部(loTail), 新链表头部(hiHead), 新链表尾部(hiTail). 还有一个叫作next的Node类, 顾名思义, 用来表示当前节点的下一个节点.
然后就是这个do while语句. 为什么是do while而不是while呢? 因为do while至少会运行一次, 如果把735行while语句提出来作while循环, 那么第一次就根本不会运行进去, 因为next还没有被赋值, 仍为null.(当然, 也可以用其他逻辑去写while形式的循环条件, 那就另谈了)
第720行将next赋值为e节点的next节点. 然后721行判断e节点在旧数组的下标是否为0(即第一个位置).
如果是, 将e节点赋值给旧链表节点. 722行判断尾部是否为null, 如果是, 则代表第一次插入, 将e赋值给头部. 如果不是, 说明这是链表的一个中间节点, 将e插入尾部的下一个. 接着, 726行将e赋值给尾部, 进入下一次循环.
如果不是, 就将e节点赋值给新链表节点. 729行~733行与上面那段是一样的逻辑, 就不赘述了.
结束do while循环之后, 来到下面的代码, 也是最后的代码了.
先判断旧链表尾部是否为空, 如果不为空, 将它的next值设为null. 然后, 将旧链表头部放进扩容新数组的第j个位置. j在外面嵌套的for循环已经有赋值, 即这条旧链表在旧数组中对应的下标位置.
如果旧链表尾部为空, 判断新链表尾部是否为空, 如果不为空则将它的next值设置为null, 和上面一样. 然后, 将新链表头部放进扩容数组的第j+16个位置, 这个位置也就是hash值与新数组长度-1的与运算得出的位置, 结果是一样的, 只不过省去了运算步骤, 提升效率.
最后, 返回这个扩容后的新数组.
不过putVal()方法并没有接收这个数组, 这个数组已经给全局变量table赋值过了.
此时回到putVal()方法, 它也可以结束了. (664行的方法体也为空, 可以忽略)
至此, 整个HashMap的put()方法(插入/修改)全部结束.