如何高效阅读源码:类的阅读

938 阅读8分钟

之前回答了「如何快速阅读源码」,总结了四个步骤:

  • 先「跑起来」
  • 自顶向下拆解
  • 深入细节
  • 延伸改进

并以Mybatis的源码阅读为例进行讲解。实际上这个步骤不仅仅适用于框架源码的阅读,同样也适用于业务代码的阅读以及单个类源码的阅读

本文以HashMap为例,来实践阅读源码的步骤。

先“跑起来”

对于单个类来说,「跑起来」相对简单一点,但是也并没有想象中那么简单。就以HashMap来说,写完下面的代码,运行看到输出就算「跑起来」了吗?

public class Test {
 public static void main(String[] args) {
 Map<String,String> map = new HashMap<>();
 map.put("key","111");
 System.out.println(map.get("key"));
 }
}

当然不是。前面已经说了,跑起来是在你的脑子里「跑起来」

从上面的代码,我们可以理出最粗略的流程:

  • 创建一个HashMap实例
  • 通过put方法,存入数据
  • 通过get方法,获取数据

理出这个流程了,能看代码了吗?别急,这只是个很粗略的流程。还不够细致,流程越细致,后面看代码越容易,所以耐住性子。基于上面的流程,先问自己几个问题:

  • 那HashMap是如何put数据的呢?
  • 又是如何get数据的呢?
  • 如何存储这些数据的?

这时候,你就可以借助万能的谷歌,来查找答案。你要相信,当你想做一件事情的时候,早就有人已经做过了。(注意,你现在不需要完整的理解网上的文章,带着你的问题,去看这些文章,先给出一个粗略的答案即可):

  • HashMap对key取hash,根据hash将值存入到对应的槽
  • get则是反过来,对key取hash,根据hash从对应的槽中将值取出来
  • 通过数组+链表的方式来存储数据

现在又有了新问题了:「为什么要用数组+链表的方式来存储数据呢?」

继续谷歌,就可以找到答案:「因为可能不同key的hash会落到同一个槽中,也就是说,一个槽中可能会有多个值,所以这些值会通过链表的方式存储起来」

你在搜索的过程中,可能还会注意到有的文章会提到,jdk1.8以后,HashMap使用「数组+链表/红黑树」的方式来存储数据!

问题又来了:

  • 为什么jdk1.8里面既要用链表,又要用红黑树呢?
  • 什么时候用链表?什么时候用红黑树呢?
  • 继续找答案:
  • 因为在查找数据的时候,会从过个值里面去找,数据量大了以后,链表的查询效率没有红黑树高
  • 数量>=8时,链表转红黑树,数量<=6时,红黑树转链表!原因是,这是一个临界值,数据量>8后红黑树效率比链表效率高,反之亦然

现在我们再来看HashMap存入数据和获取数据的流程:

  • 创建一个HashMap实例
  • 通过put方法,存入数据
  • 对key进行hash,找到对应的槽
  • 如果槽是空的,直接存入数据
  • 如果槽中有数据,则将数据加到链表/红黑树中
  • 通过get方法,获取数据
  • 对key进行hash,找到对应的槽
  • 如果槽里只有一个数据,则直接返回
  • 否则从链表/红黑树中查找对应的值

HashMap删除元素的流程,我们也可以猜出来了:

  • 对key进行hash,找到对应的槽
  • 如果槽里只有一个数据,则直接删除这个数据
  • 否则从链表/红黑树中找到对应的值,进行删除

现在,我们就可以来看代码了。

自顶向下拆解

我们以put流程为例,来看一下HashMap的put流程!

 public V put(K key, V value) {
 return putVal(hash(key), key, value, false, true);
}
  • 直接调用了方法putVal

  • 看到hash方法了吗?验证了我们的第一个流程

  • 后面的参数我们先不考虑,目前还是以流程为主,细节下面再考虑

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) ...... // p1 if ((p = tab[i = (n - 1) & hash]) == null) ...... // p2 else { ...... // p3 } ++modCount; // p4 if (++size > threshold) resize(); // p5 afterNodeInsertion(evict); // p6 return null; }

putVal逻辑比较复杂,我们忽略细节,先一行行的看看流程:

  • 首先定义了一个Node数组tab和一个Node对象p,两个int类型的变量n和i。Node是什么呢?初步猜测应该是链表或红黑树里的节点
  • 接着将table赋值给tab,并判断是否为空,空的话就执行p1流程。这里的table是什么?看定义,是个Node数组。初步猜测就是存放数据的数组!那数组为空,要干嘛?创建一个呗。
  • 接着判定tab[i = (n - 1) & hash])是否为空,如果为空执行p2,否则执行p3。这里确定槽位的方式好像和我们一般的想法不太一样。一般我们是取余数,这里是通过位与的方式来定位,为什么呢?一般位操作都是因为性能或者节省空间,这里猜测应该是性能问题。按照我们「跑起来」的流程,p2应该是直接设值;而p3是将值设置到链表或者树里面
  • 然后执行p4,p5,p6

我们依次来看p1,p2,p3,p4,p5,p6分别做了什么。

p1:

n = (tab = resize()).length;
  • 如果数组为空,那么就新建(resize())一个数组
  • 看resize方法的注释:Initializes or doubles table size。初始化或者扩容数组。耐住性子,不要急着去看resize的细节

p2:

tab[i] = newNode(hash, key, value, null);
  • 代码意图很明显,如果没有数据,就创建一个新节点

p3,代码流程比较复杂,我们再次先理流程:

Node<K,V> e; K k;
if (p.hash == hash &&
 ((k = p.key) == key || (key != null && key.equals(k))))
 ... // p31else if (p instanceof TreeNode)
 ... // p32else {
 ... // p33
}
if (e != null) { // existing mapping for key
 ... // p34
}
  • p2里可以看到,p现在是hash对应的槽里的Node
  • 如果槽里的Node的hash与key的hash相同且key也相同,则执行p31。key也相同,hash也相同,p31应该是执行了替换操作
  • 否则如果p的类型是个TreeNode,则执行p32。这里应该就是执行树的插入操作
  • 否则执行p33。按照流程,这里应该是执行链表的插入操作了
  • 最后,如果e不为空,则执行p34。目前还没有理出来e是什么,先不考虑。

来看具体的代码:

p31:

e = p;
  • 如果key相等,则将p赋值给e。这是什么操作?不是应该替换掉吗?不急,继续往下看

p32:

e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  • 如果是TreeNode,则执行putTreeVal,和我们预想的一样。putTreeVal就是树的操作了,暂时就不管了。

p33:

for (int binCount = 0; ; ++binCount) {
 if ((e = p.next) == null) {
 p.next = newNode(hash, key, value, null);
 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
 treeifyBin(tab, hash);
 break;
 }
 if (e.hash == hash &&
 ((k = e.key) == key || (key != null && key.equals(k))))
 break;
 p = e;
}
  • 否则就是链表操作了
  • 如果当前Node没有后续节点,则将数据作为p的后续节点
  • 如果binCount>=TREEIFY_THRESHOLD - 1,也就是链表长度>=8(binCount从0开始的),则转为红黑树
  • 如果链表里,有Node的key与插入的key相同,且hash相同,则p=e

p34:

V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
 e.value = value;
afterNodeAccess(e);
return oldValue;
  • 将新的value设置为e节点的value
  • 结合p31,就是值替换了
  • afterNodeAccess是LinkedHashMap使用的,那说明了LinkedHashMap继承了HashMap

回到主流程!

p4:

++modCount;
  • modCount加1,modCount是map结构调整次数,用于遍历

p5:

if (++size > threshold)
 resize();
  • 如果大小大于threshold,就扩容(capacity * load factor)

p6:

afterNodeInsertion(evict);
  • LinkedHashMap使用

现在,HashMap的put流程就变成了:

  • 对key进行hash
  • 如果数组为空,则初始化数组
  • 通过数组长度与hash的位与操作,获取到hash对应的槽
  • 如果槽为null,则构建Node,将Node设置到这个槽中
  • 如果槽不为null
  • 则判定key是否与槽中的key相同,如果相同则覆盖
  • 否则判定是否为TreeNode,如果是,则执行树的节点插入
  • 否则执行链表的插入操作
  • 找到链表的最后一个节点,将插入的值作为后续节点添加
  • 同时判断链表的长度,如果>=8(binCount是从0开始的),则链表转为树
  • 如果链表里有key与插入的key相同,则覆盖
  • 如果数组长度超过了设定的长度阈值(threshold),则扩容

其它的方法也是类似的方式整理,这里不再赘述。整理完流程,我们就开始深入细节。

深入细节

细节问题可以从上面的流程梳理中整理。比如:

  • putVal方法后面的两个参数是干嘛用的?
  • 为什么使用hash与数组长度进行位与操作来获取对应的槽?
  • 为什么HashMap的默认初始长度是16?
  • 树的节点具体是怎么插入的?
  • 为什么在长度>=8的时候转为树?有没有什么影响?

也可以从上面搜索的文章中搜集。比如:

  • HashMap是线程不安全的,哪里会导致线程不安全?
  • 为什么jdk8以前HashMap会出现死链?哪里的代码会引起死链?JDk8又是如何解决多线程下的死链问题的?
  • 为什么多线程下会出现数据丢失的问题?
  • HashMap的初始长度为什么要设置为2的n次方?

这里的很多问题,需要去理解,有些则需要模拟场景去慢慢的推敲具体的原因。比如多线程引起的死链问题、数据丢失问题,就需要模拟多线程的情况去模拟具体的流程。

而对于「HashMap的初始长度为什么要设置为2的n次方」这样的问题,则需要结合源码去思考!上面源码可以看到,获取槽位的代码是 (n - 1) & hash,2的倍数是多少呢?就以初始长度16为例!转换为2进制就是10000,10000 - 1 = 1111,与hash进行位与操作,得到的就是最后四位。而如果不是2的倍数,那么可能有某位或某几位上出现0,位与出现相同位置的hash就变多了。

其它问题大家自行思考。这里不再赘述。

延伸改进

从上面的源码阅读中,会涉及到其它的知识点,比如:

  • 链表的查询、插入、删除
  • 什么是红黑树?为什么需要红黑树?红黑树的查询、插入、删除
  • 泛型
  • HashMap和LinkedHashMap是什么关系?
  • ConcurrentHashMap又是如何保证线程安全的?
  • 多线程

这就使得HashMap的相关知识与其它知识点产生了联系,使得你的知识点不再是孤立存在的。能逐渐建立你的知识体系。同时在你看有联系的知识点时也能有复习的效果,加强知识的巩固。