开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

数据结构与算法

发表于 2022-11-07

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭将基于 Java / Kotlin 语言,为你分享常见的数据结构与算法问题,及其解题框架思路。

本文是数据结构与算法系列的第 15 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在前几篇文章里,我们聊到了 Java 中的几种线性表结构,包括 ArrayList、LinkedList、ArrayDeque 等。今天,我们来讨论另一种常用的基础数据结构,同时也是 “面试八股文” 的标准题库之一 —— 散列表(Hash Table)。

同时,在后续的文章里,我们将以 Java 语言为例,分析标准库中实现的散列表实现,包括 HashMap、ThreadLocalMap、LinkedHashMap 和 ConcurrentHashMap。请关注。


思维导图:


  1. 什么是散列表?

散列表是基于散列思想实现的 Map 数据结构,散列思想是散列表的核心特性,也就做哈希算法或 Hash 算法。散列算法是一种将 “任意长度的输入数据” 映射为 “固定长度的特征值” 的算法,输出的特征值就是散列值。

用一个表格总结散列算法的主要性质:

性质 描述
1、单向性(基本性质) 支持从输入生成散列值,不支持从散列值反推输入
2、高效性(基本性质) 单次散列运算计算量低
3、一致性(基本性质) 相同输入重复计算,总是得到相同散列值
4、随机性(高效性质) 散列值在输出值域的分布尽量随机
5、输入敏感性(高效性质) 相似的数据,计算后的散列值差别很大

将散列思想应用到散列表数据结构上时,就是通过 hash 函数提取键(Key)的特征值(散列值),再将键值对映射到固定的数组下标中,利用数组支持随机访问的特性,实现 O(1) 时间的存储和查询操作。

事实上,一般不会直接使用 hash 函数计算后的散列值作为数组下标。例如 Java Object#hashCode() 散列值是 int 类型,值域足足有 2^32 的容量,我们不可能创建这么大的数组。

最简单的做法是将散列值对数组长度取余后再取绝对值:|hash % length|。如果数组长度 length 是 2 的整数幂,还可以等价替换成位运算:hash & (length - 1) ,不管被除数是正负结果都是正数。 不仅将取余运算替换为位运算,而且减少了一次取绝对值运算,提高了索引的计算效率。

1
2
3
4
java复制代码10  % 4 = 2
-10 % 4 = -2 // 负数
10 & (4 - 1) = 2
-10 & (4 - 1) = 2 // 正数

散列表示意图

提示: 虽然我们将取余运算优化为位运算,但是为了便于理解,我们在后文中依然描述为逻辑上的 “取余” 运算。


  1. 散列表无法避免的冲突问题

因为 Hash 算法会将非常大甚至无穷大的输入值域映射到 “固定长度的特征值”,所以 Hash 算法一定是压缩映射。例如,MD5 的输出散列值为 128 位,SHA256 的输出散列值为 256 位,这就存在 2 个不同的输入产生相同输出的可能性。这就是散列冲突或哈希冲突(Hash Collision)问题。

事实上,在散列表的设计中存在 2 次散列冲突:

  • 第 1 次 - hash 函数的散列冲突: 这是一般意义上的散列冲突;
  • 第 2 次 - 散列值取余转数组下标: 本质上,将散列值转数组下标也是一次 Hash 算法,也会存在散列冲突。同时,这也说明 HashMap 中同一个桶中节点的散列值不一定是相同的。

其实,散列冲突只要用鸽巢原理(又称:抽屉原理)就很好理解了,假设有 10 个鸽巢,现有 11 只鸽子,无论分配多么平均,也肯定有一个鸽巢里有两只甚至多只鸽子。举一个直接的例子,Java 中的字符串 "Aa" 与 "BB" 的就存在散列冲突。

散列冲突举例

1
2
3
4
java复制代码String str1 = "Aa";
String str2 = "BB";
System.out.println(str1.hashCode()); // 2112
System.out.println(str2.hashCode()); // 2112 散列冲突

由于我们无法避免散列冲突,所以只能保证散列表不会因为散列冲突而失去正确性。常用的散列冲突解决方法有 2 类:

  • 开放寻址法: 例如 ThreadLocalMap;
  • 分离链表法: 例如 HashMap。

  1. 开放寻址法

开放寻址(Open Addressing)的核心思想是: 在出现散列冲突时,在数组上重新探测出一个空闲位置。 经典的探测方法有线性探测(Linear Probing)、平方探测(Quadratic Probing)和双散列探测(Double Hashing Probing)。

3.1 线性探测

线性探测是最基本的探测方法,在 Java 实现线程局部存储的 ThreadLocal 类中的散列表,就是基于线性探测的散列表。ThreadLocal 我们会在后续专栏文章会讨论,请关注。

  • 添加键值对: 先将散列值取余映射到数组下标,然后从数组下标位置开始探测与目标 Key 相等的节点。如果找到,则将旧 Value 替换为新 Value,否则沿着数组顺序线性探测。直到线性探测遇到空闲位置,则说明节点不存在,需要添加新节点。如果在添加键值对后数组没有空闲位置,就触发扩容;
  • 查找键值对: 查找类似。也是先将散列值映射到数组下标,然后从数组下标位置开始线性探测。直到线性探测遇到空闲位置,则说明节点不存在;
  • 删除键值对: 删除类似。由于查找操作在遇到空闲位置时,会认为键值对不存在于散列表中,如果删除操作时 “真删除”,就会使得一组连续段产生断层,导致查找操作失效。因此,删除操作要做 “假删除”,删除操作只是将节点标记为 “Deleted”,查找操作在遇到 “Deleted” 标记的节点时会继续向下探测。

开放寻址法示意图

线性探测的缺点是 “一次聚集” 问题: 不仅会让散列冲突的键值对聚集,还会让原本没有散列冲突但位置被占用的节点被迫聚集在一起,降低了添加和查找效率。最坏情况下,有可能需要线性探测整张散列表才能找到目标位置。

3.2 平方探测法

平方探测与线性探测类似,区别在于: 线性探测的探测指针是一个线性序列,而平方探测的探测指针是一个平方序列。使用平方探测并不能完全解决 “聚集” 问题,但相比于线性探测聚集现象有所减弱。

需要特别注意, 平方探测法必须要求数组的长度必须是 4k+3 型素数, 才能保证能够探测完整个数组空间,否则会出现数组有空闲位置,但平方探测找不到的情况,此时扩容显得没有必要。

3.3 双散列探测

双散列探测的核心思想是: 提供一组散列函数,在遇到计算得到的数组下标位置被占用,则使用下一个散列函数重新计算,直到找到空闲位置。

对比下 3 种方法的探测步骤:

  • 线性探测: hash(key) + 0,hash(key) + 1,hash(key) + 2,hash(key) + 3…
  • 平方探测: hash(key) + 0^2,hash(key) + 1^2,hash(key) + 2^2,hash(key) + 3^2…
  • 双散列探测: hash(key),hash1(key),hash2(key),hash3(key)…

  1. 分离链表法

分离链表法(Separate Chaining)的核心思想是: 在出现散列冲突时,将冲突的元素添加到同一个桶(Bucket / Slot)中,桶中的元素会组成一个链表,或者跳表、红黑树等动态数据结构。

相较之下,链表法是更常用且更稳定的冲突解决方法,我们熟悉的 Java HashMap 就是基于分离链表法的实现。HashMap 我们会在后续专栏文章会讨论,请关注。

  • 添加键值对: 先通过散列函数将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找到,则将旧 Value 替换为新 Value,如果找不到,则创建在链表上新建节点;
  • 查找键值对: 查找与添加的步骤类似,也是先将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找不到,则说明键值对不存在于散列表中;
  • 删除键值对: 删除键值对不需要 “假删除”,与添加和查找类似,也是先将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找到,则将节点从链表上移除。

分离链表法示意图


  1. 影响散列表性能的因素

从上面的内容我们逐渐明白, 散列表操作的时间复杂度并不是绝对的 O(1)。 它与地址堆积的个数 K 或链表的长度 K 有关,也就是 O(K)。虽然 O(K) 也是常数时间复杂度,但并不是固定的常数。在极端情况下,当所有的数据都堆积在一起,或者所有数据都映射到相同的链表中时,时间复杂度就会从 O(1) 退化到 O(n)。

换句话说,影响散列表性能的关键在于 “散列冲突的发生概率”,冲突概率越低,时间复杂度越接近于 O(1)。 那么,哪些因素会影响冲突概率呢?主要有 3 个:装载因子、冲突解决方法、散列函数。

5.1 因素 1 - 装载因子和扩容

理解了开放地址法和分离链表法两种冲突解决方法后,我们会发现: 无论使用哪种方法,随着散列表中元素越来越多,空闲位置越来越少,就会导致散列冲突的发生概率越来越大,使得散列表操作的平均时间会越来越大。为了描述散列表的装满程度,我们定义 装载因子 (Load Factor) = 散列表中键值对数目 / 散列表的长度。

  • 在基于开放寻址法的散列表中: 装载因子的最大值是 1(数组装满),装载因子为 1 时无法添加新元素,必须扩容;
  • 在基于分离链表法的散列表中: 允许装载因子超过 1(拉长出很长的链表),装载因子为 1 时,扩容并不是必须的。
1
bash复制代码装载因子 = 散列表中键值对数目 / 散列表的长度

扩容本质上是扩大了散列算法的输出值域,扩大输出值域可以直接降低冲突概率。事实上,一般不会等到装载因子接近 1 时再扩容,而是设置一个处于 (0, 1) 之间的 装载因子上限(扩容阈值)。 例如,在 HashMap 中设置的默认装载因子上限是 0.75。

当散列表的装载因子大于扩容阈值时,就会触发扩容操作,并将原有的数据搬运到新的数组上。与普通数组相比,散列表的动态扩容不再是简单的数据搬运,因为数组的长度变化了,公式 hash & (length - 1) 的计算的下标位置也变了,所以这一扩容过程也叫 “再散列”(不要和双散列探测混淆)。

散列表的扩容过程

当添加操作触发扩容时,需要花费 O(n) 时间再散列和搬运数据,那么散列表的时间复杂度还是 O(1) 常数时间吗?对于这种大部分操作时间复杂度很低,只有个别情况下时间复杂度会退化,而且这些操作之间存在很强烈的顺序关系的情况,就很适合用 “均摊时间复杂度分析” 了。我们将花费 O(n) 时间的那一次插入操作的时间均摊到随后的多次 O(1) 时间插入操作上,那我们从整体看,添加数据的均摊时间复杂度就是 O(1)。

以上是从算法分析的角度,从工程分析的角度看,事情还没这么简单。 在大数据场景下,如果旧散列表中有 1 GB 数据,那么扩容操作就是对 1 GB 的数据量做再散列。无论算法分析把时间复杂度摊还到多低,对 1 GB 数据量的再散列就是实打实的耗时操作,也是无法忍受的。此时,为避免一次性扩容过多数据的情况,有一种 “懒扩容” 方案:在创新一个新散列表的同时,保留旧的散列表。每次插入新的数据都插入到新散列表中,并从旧散列表中取一个数据再散列到新的散列表中。经过多次操作后,旧散列表中的数据就逐渐搬运到新散列表中。

5.2 因素 2 - 采用的冲突解决方法

开放寻址法和分离链表法的优缺点和适用场景不同:

  • 1、访问效率不同: 开放寻址法中数据都存储在数组中,是一个连续的内存区域,基于局部性原理,开放寻址法能够更好地命中 CPU 缓存行。而分离链表法中的数据主要位于链表中,是离散的内存区域,对 CPU 缓存行不优友好;
  • 2、冲突概率不同: 开放寻址法的冲突概率天然比分离链表法高,这是因为开放寻址法在发生冲突后,会在临近的位置寻找空闲位置填充数据,这使得原本并没有 “冲突” 的键值对也会因为没有空闲位置而被迫堆积。而分离链表法只有确实发生冲突的键值对才会堆积到同一个桶中;
  • 3、内存利用率不同: 由于开放寻址法的冲突概率更高,所以装载因子上限不能设置很高,存储相同的数据量,开放寻址法也需要预先申请更大的数组空间,内存利用率不会高。当然,分离链表法在链表指针上也有额外内存消耗,如果存储的元素的内存量远远大于一个指针的内存量,则可以忽略不及。

综上所述,它们各自的适用场景是什么呢?

  • 开放寻址法 - 对装载因子敏感,适合于小数据量且装载因子较小的场景: 例如 Java 的 ThreadlLocalMap,因为项目中不会大量使用 ThreadLocal 线程局部存储,所以它是一个小规模数据场景,这里使用开发地址法是没问题的;
  • 分离链表法 - 对装载因子的容忍度更高,适合于大数据量且大对象(相对于一个指针)的场景: 例如,Java 中更通用的 HashMap 散列表就是采用分离链表法。而且,分离链表法还能够使用更多灵活的优化策略,例如将链表树化为红黑树,避免极端情况下时间复杂度退化为 O(n)。

5.3 因素 3 - 散列函数设计

散列算法随机性和高效性也会影响散列表的性能。如果散列值不够随机,即使散列表整体的装载因子不高,也会使得数据聚集在某一个区域或桶内,依然会影响散列表的性能。如果散列算法不够高效,也会直接消耗计算性能。


  1. 总结

  • 1、散列表是基于散列思想实现的 Map 数据结构,就是通过 hash 函数提取键(Key)的特征值(散列值),再将键值对映射到固定的数组下标中,利用数组支持随机访问的特性,实现 O(1) 时间的存储和查询操作;
  • 2、当数组的长度为 2 的整数幂时,可以将取余运算转换为位运算 hash & (length - 1),提高索引的计算效率;
  • 3、由于散列值算法是压缩映射,所以散列表永远无法避免散列冲突,常用的散列冲突解决方法有开放寻址法和分离链表法;
  • 4、开放寻址(Open Addressing)的核心思想是在出现散列冲突时,在数组上重新探测出一个空闲位置。 经典的探测方法有线性探测、平方探测和双散列探测;
  • 5、分离链表法(Separate Chaining)的核心思想是在出现散列冲突时,将冲突的元素添加到同一个桶(Bucket / Slot)中,桶中的元素会组成一个链表,或者跳表、红黑树等动态数据结构;
  • 6、开放寻址法对装载因子敏感,适合于小数据量且装载因子较小的场景。分离链表法对装载因子的容忍度更高,适合于大数据量且大对象(相对于一个指针)的场景;
  • 7、采用的散列冲突解决方法、装载因子和散列函数设计都会影响散列表性能。

今天,我们聊了散列表的整体设计思想。在后续几篇文章里,我们将讨论散列表的具体实现 —— # Java & Android 集合框架 #5 万字 HashMap 详解,基础(优雅)永不过时


版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

  • 数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
  • 算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
  • 散列算法 —— 维基百科
  • 数据结构与算法之美(第 18~22 讲) —— 王争 著,极客时间 出品

推荐阅读

数据结构与算法系列完整目录如下(2023/07/11 更新):

  • #1 链表问题总结
  • #2 链表相交 & 成环问题总结
  • #3 计算器与逆波兰表达式总结
  • #4 高楼丢鸡蛋问题总结
  • #5 为什么你学不会递归?谈谈我的经验
  • #6 回溯算法解题框架总结
  • #7 下次面试遇到二分查找,别再写错了
  • #8 什么是二叉树?
  • #9 什么是二叉堆 & Top K 问题
  • #10 使用前缀和数组解决 “区间和查询” 问题
  • #11 面试遇到线段树,已经这么卷了吗?
  • #12 使用单调队列解决 “滑动窗口最大值” 问题
  • #13 使用单调栈解决 “下一个更大元素” 问题
  • #14 使用并查集解决 “朋友圈” 问题
  • #15 如何实现一个优秀的 HashTable 散列表
  • #16 简答一波 HashMap 常见面试题
  • #17 二叉树高频题型汇总
  • #18 下跳棋,极富想象力的同向双指针模拟

Java & Android 集合框架系列文章: 跳转阅读

LeetCode 上分之旅系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java & Android 集合框架

发表于 2022-11-06

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。

本文是 Java & Android 集合框架系列的第 4 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了基于链表的 Queue 和 Stack 实现 —— LinkedList。那么 Java 中有没有基于数组的 Queue 和 Stack 实现呢?今天我们就来聊聊这个话题。


思维导图:


  1. 回顾 LinkedList

在数据结构上,LinkedList 不仅实现了与 ArrayList 相同的 List 接口,还实现了 Deque 接口,而我们今天要讲的 ArrayDeque 就是实现于 Deque 接口的动态数组。

Deque 接口表示一个双端队列(Double Ended Queue),允许在队列的首尾两端操作,所以既能实现队列行为,也能实现栈行为。

1.1 Queue 接口

Queue 的 API 可以分为 2 类,区别在于方法的拒绝策略上:

  • 抛异常:
    • 向空队列取数据,会抛出 NoSuchElementException 异常;
    • 向容量满的队列加数据,会抛出 IllegalStateException 异常。
  • 返回特殊值:
    • 向空队列取数据,会返回 null;
    • 向容量满的队列加数据,会返回 false。
拒绝策略 抛异常 返回特殊值
入队(队尾) add(e) offer(e)
出队(队头) remove() poll()
观察(队头) element() peek()

1.2 Deque 接口(继承于 Queue 接口)

Java 没有提供标准的栈接口(很好奇为什么不提供),而是放在 Deque 接口中:

拒绝策略 抛异常 等价于
入栈 push(e) addFirst(e)
出栈 pop() removeFirst()
观察(栈顶) peek() peekFirst()

除了标准的队列和栈行为,Deque 接口还提供了 12 个在两端操作的方法:

拒绝策略 抛异常 返回值
增加 addFirst(e)/ addLast(e) offerFirst(e)/ offerLast(e)
删除 removeFirst()/ removeLast() pollFirst()/ pollLast()
观察 getFirst()/ getLast() peekFirst()/ peekLast()

  1. ArrayDeque 的特点

2.1 说一下 ArrayDeque 的特点

  • 1、ArrayDeque 是基于动态数组实现的 Deque 双端队列,内部封装了扩容和数据搬运的逻辑;
  • 2、ArrayDeque 的数组容量保证是 2 的整数幂;
  • 3、ArrayDeque 不是线程安全的;
  • 4、ArrayDeque 不支持 null 元素;
  • 5、ArrayDeque 虽然入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;

2.2 说一下 ArrayDeque 和 LinkedList 的区别?

  • 1、数据结构: 在数据结构上,ArrayDeque 和 LinkedList 都实现了 Java Deque 双端队列接口。但 ArrayDeque 没有实现了 Java List 列表接口,所以不具备根据索引位置操作的行为;
  • 2、线程安全: ArrayDeque 和 LinkedList 都不考虑线程同步,不保证线程安全;
  • 3、底层实现: 在底层实现上,ArrayDeque 是基于动态数组的,而 LinkedList 是基于双向链表的。
+ **在遍历速度上:** ArrayDeque 是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而 LinkedList 是离散的内存空间对缓存行不友好;
+ **在操作速度上:** ArrayDeque 和 LinkedList 的栈和队列行为都是 O(1) 时间复杂度,ArrayDeque 的入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;
+ **额外内存消耗上:** ArrayDeque 在数组的头指针和尾指针外部有闲置空间,而 LinkedList 在节点上增加了前驱和后继指针。

  1. 如何使用数组实现栈和队列?

我们知道栈和队列都是 “操作受限” 的线性表:栈是 LIFO,限制在表的一端入栈和出栈。而队列是 FIFO,限制在表的一端入队,在另一端出队。栈和队列既可以用数组实现,也可以用链表实现:

  • 数组:用数组实现时叫作顺序栈和顺序队列;
  • 链表:用链表实现时叫作链式栈和链式队列。

3.1 为什么 ArrayList 不适合实现队列?

在上一篇文章里,我们提到了 LinkedList 的多面人生,它即作为 List 的链式表,又作为 Queue 的链式队列,又作为 “Stack” 的链式栈,功能很全面。相较之下,ArrayList 却只作为实现了 List 的顺序表,为什么呢?

这是因为在数组上同时实现 List 和 Queue 时,无法平衡这两个行为的性能矛盾。具体来说:ArrayList 不允许底层数据有空洞,所有的有效数据都会 “压缩” 到底层数组的首部。所以,使用 ArrayList 开发栈的结构或许合适,可以在数组的尾部操作数据。但使用 ArrayList 开发队列就不合适,因为在数组的首部入队或出队需要搬运数据。

3.2 使用数组实现栈结构

使用数组实现栈相对容易,因为栈结构的操作被限制在数组的一端,所以我们可以选择数组的尾部作为栈顶,并且使用一个 top 指针记录栈顶位置:

  • 栈空: top == 0;
  • 栈满: top == n;
  • 入栈: 将数据添加到栈顶位置,均摊时间复杂度是 O(1);
  • 出栈: 将栈顶位置移除,时间复杂度是 O(1);

对于出栈而言,时间复杂度总是 O(1),但是对于入栈而言,却不一定。因为当数组的空间不足(top == n)时,就需要扩容和搬运数据来容纳新的数据。此时,时间复杂度就从 O(1) 退化到 O(n)。

对于这种大部分操作时间复杂度很低,只有个别情况下时间复杂度会退化,而且这些操作之间存在很强烈的顺序关系的情况,就很适合用 “均摊时间复杂度分析” 了:

假设我们的扩容策略是将数组扩大到旧数组的 2 倍,用均摊分析法:

  • 1、对于一个大小为 K 的空数组,在前 K - 1 次入栈操作中,时间复杂度都是 O(1);
  • 2、在第 K 次入栈中,由于数组容量不足,所以我们将数组扩大为 2K,并且搬运 K 个数据,时间复杂度退化为 O(K);
  • 3、对于一个大小为 2K 的数组,在接下来的 K - 1 次入栈操作中,时间复杂度都是 O(1);
  • 4、在第 2K 次入栈中,由于数组容量不足,所以我们将数组扩大为 4K,并且搬运 2K 个数据,时间复杂度再次退化为 O(K);
  • 5、依此类推。

可以看到,在每次搬运 K 个次数后,随后的 K - 1 次入栈操作就只是简单的 O(1) 操作,K 次入栈操作涉及到 K 个数据搬运和 K 次赋值操作。那我们从整体看,如果把复杂度较高的 1 次入栈操作的耗时,均摊到其他复杂度较低的操作上,就等于说 1 次入栈操作只需要搬运 1 个数据和 1 次赋值操作,所以入栈的均摊时间复杂度就是 O(1)。

入栈的均摊时间复杂度分析

3.3 使用数组实现队列结构

使用数组实现队列相对复杂,我们需要一个队头指针和一个队尾指针:

  • 队空: head == tail;
  • 队满: tail == n(并不是真的满,只是无法填充新数据);
  • 入队: 将数据添加到队尾位置,均摊时间复杂度是 O(1);
  • 出队: 将队头位置移除,时间复杂度是 O(1)。

对于出队而言,时间复杂度总是 O(1)。对于入队而言,当 tail == n 时,就需要扩容和搬运数据来容纳新的数据,我们用均摊分析法得出均摊时间复杂度依然是 O(1),就不重复了。

但是我们发现,栈的 top == n 表示栈空间不足,扩容没有问题,而队列的 tail == n 却不一定表示队列空间不足。因为入队和出队发生在不同方向,有可能出现 tail == n 但队头依然有非常多剩余空间的情况。此时,扩容显得没有必要。

扩容显得没有必要

那么,怎么避免没有必要的扩容和数据搬移呢?—— 循环数组。

我们在逻辑上将数组的首尾相连,当 tail == n 时,如果数组头部还有空闲位置,我们就把 tail 指针调整到数组头部,在数组头部添加数据。我们下面要分析的 ArrayDeque 数据结构,就是采用了循环数组的实现。

使用循环数组后,队列空和队列满的判断条件会发生变化:

  • 队列空: head == tail;
  • 队列满: (tail + 1)%size == head,如果 size 是 2 的整数幂,还可以用位运算判断:(tail + 1) & (size - 1) == head。

理解了使用数组实现栈和队列的思路后,下面再来分析 ArrayDeque 的实现原理,就显得游刃有余了。


  1. ArrayDeque 源码分析

这一节,我们来分析 ArrayDeque 中主要流程的源码。

4.1 ArrayDeque 的属性

  • ArrayDeque 底层是一个 Object 数组;
  • ArrayDeque 用 head 和 tail 指针指向数组的 “队头位置” 和 “队尾位置”,需要注意 tail 队尾指针实际上是指向队尾最后一个有效元素的下一位。

ArrayDeque 的属性很好理解的,不出意外的话又有小朋友出来举手提问了:

  • 🙋🏻‍♀️疑问 1: 为什么字段都不声明 private 关键字?(回答过多少次了,把手给我放下)
  • 🙋🏻‍♀️疑问 2: 为什么字段都声明 transient 关键字?(回答过多少次了,把手给我放下)
  • 🙋🏻‍♀️疑问 3: 为什么 elements 字段不声明为泛型类型 E?(回答过多少次了,把手给我放下)
  • 🙋🏻‍♀️疑问 4:为什么没有看到 ArrayList 类似的 MAX_ARRAY_SIZE 最大容量限制?

这个问题我们在分析源码的过程中回答。有了 ArrayList 的分析基础,问题少了很多,ArrayDeque 真香。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码public class ArrayDeque<E> extends AbstractCollection<E>
implements Deque<E>, Cloneable, Serializable
{
// 疑问 1:为什么字段都声明 private 关键字?
// 疑问 2:为什么字段都声明 transient 关键字?
// 疑问 3:为什么 elements 字段不声明为泛型类型 E?
// 疑问 4:为什么没有看到 ArrayList 类似的 MAX_ARRAY_SIZE 最大容量限制?

// 底层数组
transient Object[] elements; // non-private to simplify nested class access

// 队头指针
transient int head;

// 队尾指针
transient int tail;

// new ArrayDeque(0) 最小初始容量
private static final int MIN_INITIAL_CAPACITY = 8;

// 尾指针 - 头指针
public int size() {
return (tail - head) & (elements.length - 1);
}
}

4.2 ArrayDeque 的构造方法

ArrayDeque 有 3 个构造函数:

  • 1、无参构造方法: 初始化容量为 16 的数组;
  • 2、带初始容量的构造方法: 如果初始容量小于 8 (MIN_INITIAL_CAPACITY),则初始化数组大小为 8。如果初始容量大于 8,则计算 “最近的 2 的整数幂” 作为初始大小。例如 numElements 为 8,则初始化容量为 16。numElements 为 19,则初始化容量为 32;
  • 3、带集合的构造方法: 用相同的方法创建初始容量为 2 的整数幂的数组,并调用 addAll 逐个添加元素。

ArrayDeque.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
java复制代码// 无参构造方法
public ArrayDeque() {
elements = new Object[16];
}

// 带初始容量的构造方法
public ArrayDeque(int numElements) {
allocateElements(numElements);
}

// 带集合的构造方法
public ArrayDeque(Collection<? extends E> c) {
allocateElements(c.size());
// 疑问 5:为什么带集合的构造方法不使用 Arrays 工具整体复制,而是逐个添加?
addAll(c);
}

private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}

// 求离 numElements 最近的 2 的整数幂,即 nextPow2 问题
// 疑问 6:为什么 ArrayDeque 要求容量是 2 的整数幂?
// 疑问 7:calculateSize() 的函数体解释一下?
private static int calculateSize(int numElements) {
// 如果 numElements 小于 8(MIN_INITIAL_CAPACITY),则返回 8
int initialCapacity = MIN_INITIAL_CAPACITY;
if (numElements >= initialCapacity) {
initialCapacity = numElements;
// 五轮无符号右移和或运算后,变成最高有效位开始后面都是 1
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
// 加 1 进位,得到最近 2 的整数幂
initialCapacity++;

// 变成负数(高位是 1000,低位都是 0000)
if (initialCapacity < 0)
// 右移 1 位,取 2^30 为初始容量
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
}
return initialCapacity;
}

AbstractCollection.java

1
2
3
4
5
6
7
8
java复制代码// 逐个添加元素
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}

小朋友总有太多的问号,举手提问 🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 5:为什么带集合的构造方法不使用 Arrays 工具整体复制,而是逐个添加?

因为 ArrayDeque 禁止存储 null 元素,所以需要逐个判断元素是否为 null 值后才添加。

  • 🙋🏻‍♀️疑问 6:为什么 ArrayDeque 要求数组容量是 2 的整数幂?

在循环数组中需要使用取余运算计算游标指针循环后的位置,例如 (tail + 1) % size,而如果数组的尺寸 size 是 2 的整数幂,那么就可以将取余运算替换为位运算,例如 (tail + 1) & (size - 1) ,不管被除数是正负结果都是正数。 不仅将取余运算替换为位运算,而且减少了一次取绝对值运算,提高了索引的计算效率。

1
2
3
4
5
6
7
8
9
10
11
java复制代码size     = 0 0 0 1 0 0 0 0 0 0     // 2^n 的补码
size - 1 = 0 0 0 0 1 1 1 1 1 1 // 2^n - 1 的补码
-1 = 1 1 1 1 1 1 1 1 1 1 // -1 的补码
0 = 0 0 0 0 0 0 0 0 0 0 // 0 的补码

// 尾指针的循环:
1、如果 tail + 1 <= size - 1,则 (tail + 1) & (size - 1) 后保持不变
2、如果 tail + 1 == size,则 size & (size - 1) 为 0
// 头指针的循环
1、如果 head - 1 >= 0,则(head - 1) & (size - 1) 后保持不变
2、如果 head - 1 == -1,则 -1 & (size - 1) 后为 size - 1
  • 🙋🏻‍♀️疑问 7:calculateSize() 的函数体解释一下?

calculateSize() 是求离 numElements 最近的 2 的整数幂,即 nextPow2 问题。

  • 1、首先,如果 numElements 小于 8(MIN_INITIAL_CAPACITY),则直接返回 8;
  • 2、否则执行 nextPow2 运算,经过五轮无符号右移和或运算,将 numElements 转换为从最高位开始后面都是 1 的数。再执行 +1 运算,就求出了最近的 2 的整数幂(最高有效位是 1,低位都是 0);
  • 3、当 numElements 在 2^30 到 2^ 31-1 之间(即最高位是 0100 的数),计算后得到的 nextPow2 就是负数(最高位是 1000,低位都是 0),此时就需要右移一位,取 2^30 为初始容量。
1
2
3
4
5
6
7
java复制代码n = 0 0 0 0 1 x x x x x     // n
n = 0 0 0 0 1 1 x x x x // n |= n >>> 1;
n = 0 0 0 0 1 1 1 1 x x // n |= n >>> 2;
n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 4;
n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 8;(这一步对 n 没有影响了)
n = 0 0 0 0 1 1 1 1 1 1 // n |= n >>> 16;(这一步对 n 没有影响了)
n = 0 0 0 1 0 0 0 0 0 0 // n ++(进位,得到最近 2 的整数幂)

4.3 ArrayDeque 的添加和扩容方法

ArrayDeque 可以在数组的两端添加元素,不支持在数组的中间添加元素:

  • 在队头添加: 在 head 指针的上一个位置赋值,如果数组越界则循环到数组尾部;
  • 在队尾添加: 在 tail 指针的位置赋值,并将 tail 指针指向下一个位置,如果数组越界则循环到数组头部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public void addLast(E e) {
// 疑问:为什么 ArrayDeque 不支持添加 null 元素
if (e == null)
throw new NullPointerException();
// tail 指针本身就是指向下一个位置,所以直接填充
elements[tail] = e;
// 修改 tail 指针到下一个位置,并通过取余操作循环到数组头部
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}

public void addFirst(E e) {
// 疑问 8:为什么 ArrayDeque 禁止存储 null 元素?
if (e == null)
throw new NullPointerException();
// 修改 head 指针到前一个位置,并通过取余操作循环到数组尾部
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
doubleCapacity();
}

不出意外小朋友又要出来举手提问了🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 8:为什么 ArrayDeque 禁止存储 null 元素?

其实在 Deque 接口上并不严格禁止存储 null 元素,但是会强烈建议 Deque 的实现不提供存储 null 值的能力。因为 null 通常会作为一个特殊值来判断队列是否为空。

Deque javadoc

1
bash复制代码While Deque implementations are not strictly required to prohibit the insertion of null elements, they are strongly encouraged to do so. Users of any Deque implementations that do allow null elements are strongly encouraged not to take advantage of the ability to insert nulls. This is so because null is used as a special return value by various methods to indicated that the deque is empty.
1
2
3
4
5
6
7
8
9
10
java复制代码public E pollFirst() {
int h = head;
E result = (E) elements[h];
// 队列为空
if (result == null)
return null;
elements[h] = null; // Must null out slot
head = (h + 1) & (elements.length - 1);
return result;
}

在每次添加元素后,如果队头指针和队尾指针相遇,说明数组空间已满,此时就需要扩容操作。ArrayDeque 会将新数组的容量扩大到旧数组的 2 倍 ,由于旧数组的容量也是 2 的整数幂,因此乘以 2 之后依然是 2 的整数幂。

搬运数据的过程就是把 head 头指针到数组末尾的元素拷贝到数组头部,而剩下的从数组头部到尾指针的元素则衔接到后面,使得所有元素规整地排列在数组的头部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码// 扩容操作
private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
// 队头指针到数组末尾的元素个数
int r = n - p;
// 容量翻倍
int newCapacity = n << 1;
// 容量变成负数
if (newCapacity < 0)
// ArrayList 扩容整型溢出时会抛出 OutOfMemoryError
// ArrayDeque 扩容整型溢出时会抛出 IllegalStateException
// 看了一下,发现这两个容器出自不同作者,不能统一下吗哈哈
throw new IllegalStateException("Sorry, deque too big");
// 创建新数组
Object[] a = new Object[newCapacity];
// 将队头指针到数组末尾的元素拷贝到数组头部
System.arraycopy(elements, p, a, 0, r);
// 拷贝剩下的从数组头部到尾指针的元素
System.arraycopy(elements, 0, a, r, p);
// 指向新数组
elements = a;
// 重置头尾指针
head = 0;
tail = n;
}

扩容操作

  • 🙋🏻‍♀️疑问 4:为什么没有看到 ArrayList 类似的 MAX_ARRAY_SIZE 最大容量限制?

现在我们可以回答这个疑问了, 网上也有资料说 ArrayDeque 没有容量限制,最坑的是代码注释也这么说:“Array deques have no capacity restrictions”。 显然不是这么一回事。 第一,数组的容量显示是被虚拟机固化的,不可能无限容量。第二,从 doubleCapacity() 函数可以看出, 最大容量值是 2^30(高位 0100,低位都是0), 如果超过这个数,在 doubleCapacity() 扩容的时候就会抛出异常了。

4.4 ArrayDeque 的获取和移除方法

ArrayDeque 可以在数组的两端移除元素,不支持在数组的中间移除元素:

  • 在队头移除: 在 head 指针的位置获取,再将 head 指向上一个位置,如果数组越界则循环到数组尾部;
  • 在队尾移除: 在 tail 指针的下一个位置获取,如果数组越界则循环到数组头部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public E pollFirst() {
// head 指针本身就是指向队头元素,所以直接获取
int h = head;
E result = (E) elements[h];
if (result == null)
return null;
elements[h] = null;
// 修改 head 指针
head = (h + 1) & (elements.length - 1);
return result;
}

public E pollLast() {
// tail 指针本身是指向队尾元素的下一个位置,所以需要返回上一个位置
int t = (tail - 1) & (elements.length - 1);
E result = (E) elements[t];
if (result == null)
return null;
elements[t] = null;
tail = t;
return result;
}

4.5 ArrayDeque 的迭代器

Java 的 foreach 是语法糖,本质上也是采用 iterator 的方式。ArrayDeque 提供了 2 个迭代器:

  • iterator():DeqIterator(): 正向迭代器
  • ListIterator DescendingIterator(): 反向迭代器

ArrayDeque 迭代器同样有 fail-fast 机制,不给过ArrayDeque 并没有使用类似 ArrayList 类似的 modCount 检查并发修改,而是通过头尾指针的位置和元素检查并发修改。这个方法不一定能保证检测到所有的并发修改情况,例如无法检查先移除了尾部元素,又马上添加了一个尾部元素的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码private class DeqIterator implements Iterator<E> {

private int cursor = head;

private int fence = tail;

private int lastRet = -1;

public boolean hasNext() {
return cursor != fence;
}

public E next() {
if (cursor == fence) throw new NoSuchElementException();
E result = (E) elements[cursor];
// 无法检测到所有并发修改的情况
if (tail != fence || result == null)
throw new ConcurrentModificationException();
lastRet = cursor;
cursor = (cursor + 1) & (elements.length - 1);
return result;
}
...
}

4.6 ArrayDeque 的序列化过程

ArrayDeque 重写了 JDK 序列化的逻辑,只把 elements 数组中有效元素的部分序列化,而不会序列化整个数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码// 序列化过程
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
s.defaultWriteObject();
// 写入数组长度
s.writeInt(size());
// 写入从 head 指针到 tail 指针之间的有效元素
int mask = elements.length - 1;
for (int i = head; i != tail; i = (i + 1) & mask)
s.writeObject(elements[i]);
}

// 反序列化过程
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
// 读取数组长度
int size = s.readInt();
// 计算最近的 2 的整数幂
int capacity = calculateSize(size);
// 分配数组
SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
allocateElements(size);
// 设置头尾指针
head = 0;
tail = size;
// 将数据规整到数组下标 0 位置
for (int i = 0; i < size; i++)
elements[i] = s.readObject();
}

4.7 ArrayDeque 的 clone() 过程

ArrayDeque 中的 elements 数组是引用类型,因此在 clone() 中需要实现深拷贝,否则原对象与克隆对象会相互影响:

1
2
3
4
5
6
7
8
9
10
java复制代码public ArrayDeque<E> clone() {
try {
@SuppressWarnings("unchecked")
ArrayDeque<E> result = (ArrayDeque<E>) super.clone();
result.elements = Arrays.copyOf(elements, elements.length);
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}

  1. 总结

  • 1、ArrayDeque 是基于动态数组的线性表,具备 Queue 和 Stack 的行为,但不具备 List 的行为;
  • 2、ArrayDeque 的数组容量是 2 的整数幂,在扩容时容量会翻倍,且不支持 null 元素;
  • 3、ArrayDeque 和 LinkedList 的栈和队列行为都是 O(1) 时间复杂度,ArrayDeque 的入栈和入队有可能会触发扩容,但从均摊分析上看依然是 O(1) 时间复杂度;
  • 4、ArrayDeque 是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而 LinkedList 是离散的内存空间对缓存行不友好;
  • 5、ArrayDeque 和 LinkedList 都不考虑线程同步,不保证线程安全。

版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

  • 数据结构与算法之美(第 4、8、9 讲) —— 王争 著,极客时间 出品
  • 17 张图带你深度剖析 ArrayDeque(JDK双端队列)源码 —— 一无是处的研究僧
  • Java 容器源码分析之 Deque 与 ArrayDeque —— jrthe42 著
  • Why null values are not allowed in ArrayDeque? —— stack overflow

推荐阅读

Java & Android 集合框架系列文章目录(2023/07/08 更新):

  • #1 ArrayList 可以完全替代数组吗?
  • #2 说一下 ArrayList 和 LinkedList 的区别?
  • #3 CopyOnWriteArrayList 是如何保证线程安全的?
  • #4 ArrayDeque:如何用数组实现栈和队列?
  • #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
  • #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
  • #7 如何使用 LinkedHashMap 实现 LRU 缓存?
  • #8 说一下 WeakHashMap 如何清理无效数据的?
  • #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
  • #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇

数据结构与算法系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java & Android 集合框架

发表于 2022-11-05

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。

本文是 Java & Android 集合框架系列的第 2 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了基于动态数组 ArrayList 线性表,今天我们来讨论一个基于链表的线性表 —— LinkedList。


思维导图:


  1. LinkedList 的特点

1.1 说一下 ArrayList 和 LinkedList 的区别?

  • 1、数据结构: 在数据结构上,ArrayList 和 LinkedList 都是 “线性表”,都继承于 Java 的 List 接口。另外 LinkedList 还实现了 Java 的 Deque 接口,是基于链表的栈或队列,与之对应的是 ArrayDeque 基于数组的栈或队列;
  • 2、线程安全: ArrayList 和 LinkedList 都不考虑线程同步,不保证线程安全;
  • 3、底层实现: 在底层实现上,ArrayList 是基于动态数组的,而 LinkedList 是基于双向链表的。事实上,它们很多特性的区别都是因为底层实现不同引起的。比如说:
    • 在遍历速度上: 数组是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而链表是离散的内存空间对缓存行不友好;
    • 在访问速度上: 数组是一块连续内存空间,支持 O(1) 时间复杂度随机访问,而链表需要 O(n) 时间复杂度查找元素;
    • 在添加和删除操作上: 如果是在数组的末尾操作只需要 O(1) 时间复杂度,但在数组中间操作需要搬运元素,所以需要 O(n)时间复杂度,而链表的删除操作本身只是修改引用指向,只需要 O(1) 时间复杂度(如果考虑查询被删除节点的时间,复杂度分析上依然是 O(n),在工程分析上还是比数组快);
    • 额外内存消耗上: ArrayList 在数组的尾部增加了闲置位置,而 LinkedList 在节点上增加了前驱和后继指针。

1.2 LinkedList 的多面人生

在数据结构上,LinkedList 不仅实现了与 ArrayList 相同的 List 接口,还实现了 Deque 接口(继承于 Queue 接口)。

Deque 接口表示一个双端队列(Double Ended Queue),允许在队列的首尾两端操作,所以既能实现队列行为,也能实现栈行为。

Queue 接口:

拒绝策略 抛异常 返回特殊值
入队(队尾) add(e) offer(e)
出队(队头) remove() poll()
观察(队头) element() peek()

Queue 的 API 可以分为 2 类,区别在于方法的拒绝策略上:

  • 抛异常:
+ 向空队列取数据,会抛出 NoSuchElementException 异常;
+ 向容量满的队列加数据,会抛出 IllegalStateException 异常。
  • 返回特殊值:
+ 向空队列取数据,会返回 null;
+ 向容量满的队列加数据,会返回 false。

Deque 接口:

Java 没有提供标准的栈接口(很好奇为什么不提供),而是放在 Deque 接口中:

拒绝策略 抛异常 等价于
入栈 push(e) addFirst(e)
出栈 pop() removeFirst()
观察(栈顶) peek() peekFirst()

除了标准的队列和栈行为,Deque 接口还提供了 12 个在两端操作的方法:

拒绝策略 抛异常 返回值
增加 addFirst(e)/ addLast(e) offerFirst(e)/ offerLast(e)
删除 removeFirst()/ removeLast() pollFirst()/ pollLast()
观察 getFirst()/ getLast() peekFirst()/ peekLast()

  1. LinkedList 源码分析

这一节,我们来分析 LinkedList 中主要流程的源码。

2.1 LinkedList 的属性

  • LinkedList 底层是一个 Node 双向链表,Node 节点中会持有数据 E 以及 prev 与next 两个指针;
  • LinkedList 用 first 和 last 指针指向链表的头尾指针。

LinkedList 的属性很好理解的,不出意外的话又有小朋友出来举手提问了:

  • 🙋🏻‍♀️ 疑问 1:为什么字段都不声明 private 关键字?

这个问题直接回答吧。我的理解是:因为内部类在编译后会生成独立的 Class 文件,如果外部类的字段是 private 类型,那么编译器就需要通过方法调用,而 non-private 字段就可以直接访问字段。

  • 🙋🏻‍♀️ 疑问 2:为什么字段都声明 transient 关键字?

这个问题我们在分析源码的过程中回答。

疑问比 ArrayList 少很多,LinkedList 真香(还是别高兴得太早吧)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {

// 疑问 1:为什么字段都不声明 private 关键字?
// 疑问 2:为什么字段都声明 transient 关键字?
// 元素个数
transient int size = 0;

// 头指针
transient Node<E> first;

// 尾指针
transient Node<E> last;

// 链表节点
private static class Node<E> {
// 节点数据
// (类型擦除后:Object item;)
E item;
// 前驱指针
Node<E> next;
// 后继指针
Node<E> prev;

Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
}

2.2 LinkedList 的构造方法

LinkedList 有 2 个构造方法:

  • 1、无参构造方法: no-op;
  • 2、带集合的构造: 在链表末尾添加整个集合,内部调用了 addAll 方法将整个集合添加到数组的末尾。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// 无参构造方法
public LinkedList() {
}

// 带集合的构造方法
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}

// 在链表尾部添加集合
public boolean addAll(Collection<? extends E> c) {
// 索引为 size,等于在链表尾部添加
return addAll(size, c);
}

2.3 LinkedList 的添加方法

LinkedList 提供了非常多的 addXXX 方法,内部都是调用一系列 linkFirst、linkLast 或 linkBefore 完成的。如果在链表中间添加节点时,会用到 node(index) 方法查询指定位置的节点。

其实,我们会发现所有添加的逻辑都可以用 6 个步骤概括:

  • 步骤 1: 找到插入位置的后继节点(在头部插入就是 first,在尾部插入就是 null);
  • 步骤 2: 构造新节点;
  • 步骤 3: 将新节点的 prev 指针指向前驱节点(在头部插入就是 null,在尾部插入就是 last);
  • 步骤 4: 将新节点的 next 指针指向后继节点(在头部插入就是 first,在尾部插入就是 null);
  • 步骤 5: 将前驱节点的 next 指针指向新节点(在头部插入没有这个步骤);
  • 步骤 6: 将后继节点的 prev 指针指向新节点(在尾部插入没有这个步骤)。

分析一下添加方法的时间复杂度,区分在链表两端或中间添加元素的情况共:

  • 如果是在链表首尾两端添加: 只需要 O(1) 时间复杂度;
  • 如果在链表中间添加: 由于需要定位到添加位置的前驱和后继节点,所以需要 O(n) 时间复杂度。如果事先已经获得了添加位置的节点,就只需要 O(1) 时间复杂度。

添加方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
java复制代码public void addFirst(E e) {
linkFirst(e);
}

public void addLast(E e) {
linkLast(e);
}

public boolean add(E e) {
linkLast(e);
return true;
}

public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
// 在尾部添加
linkLast(element);
else
// 在指定位置添加
linkBefore(element, node(index));
}

public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}

// 在链表头部添加
private void linkFirst(E e) {
// 1. 找到插入位置的后继节点(first)
final Node<E> f = first;
// 2. 构造新节点
// 3. 将新节点的 prev 指针指向前驱节点(null)
// 4. 将新节点的 next 指针指向后继节点(f)
// 5. 将前驱节点的 next 指针指向新节点(前驱节点是 null,所以没有这个步骤)
final Node<E> newNode = new Node<>(null, e, f);
// 修改 first 指针
first = newNode;
if (f == null)
// f 为 null 说明首个添加的元素,需要修改 last 指针
last = newNode;
else
// 6. 将后继节点的 prev 指针指向新节点
f.prev = newNode;
size++;
modCount++;
}

// 在链表尾部添加
void linkLast(E e) {
final Node<E> l = last;
// 1. 找到插入位置的后继节点(null)
// 2. 构造新节点
// 3. 将新节点的 prev 指针指向前驱节点(l)
// 4. 将新节点的 next 指针指向后继节点(null)
final Node<E> newNode = new Node<>(l, e, null);
// 修改 last 指针
last = newNode;
if (l == null)
// l 为 null 说明首个添加的元素,需要修改 first 指针
first = newNode;
else
// 5. 将前驱节点的 next 指针指向新节点
l.next = newNode;
// 6. 将后继节点的 prev 指针指向新节点(后继节点是 null,所以没有这个步骤)
size++;
modCount++;
}

// 在指定节点前添加
// 1. 找到插入位置的后继节点
void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev;
// 2. 构造新节点
// 3. 将新节点的 prev 指针指向前驱节点(pred)
// 4. 将新节点的 next 指针指向后继节点(succ)
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
// 5. 将前驱节点的 next 指针指向新节点
pred.next = newNode;
size++;
modCount++;
}

// 在指定位置添加整个集合元素
// index 为 0:在链表头部添加
// index 为 size:在链表尾部添加
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
// 事实上,c.toArray() 的实际类型不一定是 Object[],有可能是 String[] 等
// 不过,我们是通过 Node中的item 承接的,所以不用担心 ArrayList 中的 ArrayStoreException 问题
Object[] a = c.toArray();
// 添加的数组为空,跳过
int numNew = a.length;
if (numNew == 0)
return false;

// 1. 找到插入位置的后继节点
// pred:插入位置的前驱节点
// succ:插入位置的后继节点
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
// 找到 index 位置原本的节点,插入后变成后继节点
succ = node(index);
pred = succ.prev;
}
// 插入集合元素
for (Object o : a) {
E e = (E) o;
// 2. 构造新节点
// 3. 将新节点的 prev 指针指向前驱节点
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
// pred 为 null 说明是在头部插入,需要修改 first 指针
first = newNode;
else
// 5. 将前驱节点的 next 指针指向新节点
pred.next = newNode;
// 修改前驱指针
pred = newNode;
}

if (succ == null) {
// succ 为 null 说明是在尾部插入,需要修改 last 指针
last = pred;
} else {
// 4. 将新节点的 next 指针指向后继节点
pred.next = succ;
// 6. 将后继节点的 prev 指针指向新节点
succ.prev = pred;
}
// 数量增加 numNew
size += numNew;
modCount++;
return true;
}

// 将 LinkedList 转化为 Object 数组
public Object[] toArray() {
Object[] result = new Object[size];
int i = 0;
for (Node<E> x = first; x != null; x = x.next)
result[i++] = x.item;
return result;
}

在链表中间添加节点时,会用到 node(index) 方法查询指定位置的节点。可以看到维持 first 和 last 头尾节点的作用又发挥出来了:

  • 如果索引位置小于 size/2,则从头节点开始找;
  • 如果索引位置大于 size/2,则从尾节点开始找。

虽然,我们从复杂度分析的角度看,从哪个方向查询是没有区别的,时间复杂度都是 O(n)。但从工程分析的角度看还是有区别的,从更靠近目标节点的位置开始查询,实际执行的时间会更短。

查询指定位置节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码// 寻找指定位置的节点,时间复杂度:O(n)
Node<E> node(int index) {
if (index < (size >> 1)) {
// 如果索引位置小于 size/2,则从头节点开始找
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 如果索引位置大于 size/2,则从尾节点开始找
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

LinkedList 的删除方法其实就是添加方法的逆运算,我们就不重复分析了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码// 删除头部元素
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}

// 删除尾部元素
public E removeLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return unlinkLast(l);
}

// 删除指定元素
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}

2.4 LinkedList 的迭代器

Java 的 foreach 是语法糖,本质上也是采用 iterator 的方式。由于 LinkedList 本身就是双向的,所以 LinkedList 只提供了 1 个迭代器:

  • ListIterator listIterator(): 双向迭代器

与其他容器类一样,LinkedList 的迭代器中都有 fail-fast 机制。如果在迭代的过程中发现 expectedModCount 变化,说明数据被修改,此时就会提前抛出 ConcurrentModificationException 异常(当然也不一定是被其他线程修改)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码public ListIterator<E> listIterator(int index) {
checkPositionIndex(index);
return new ListItr(index);
}

// 非静态内部类
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned;
private Node<E> next;
private int nextIndex;
// 创建迭代器时会记录外部类的 modCount
private int expectedModCount = modCount;

ListItr(int index) {
next = (index == size) ? null : node(index);
nextIndex = index;
}

public E next() {
// 更新 expectedModCount
checkForComodification();
...
}
...
}

2.5 LinkedList 的序列化过程

  • 🙋🏻‍♀️ 疑问 2:为什么字段都声明 transient 关键字?

LinkedList 重写了 JDK 序列化的逻辑,不序列化链表节点,而只是序列化链表节点中的有效数据,这样序列化产物的大小就有所降低。在反序列时,只需要按照对象顺序依次添加到链表的末尾,就能恢复链表的顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码// 序列化和反序列化只考虑有效数据

// 序列化过程
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out any hidden serialization magic
s.defaultWriteObject();

// 写入链表长度
s.writeInt(size);

// 写入节点上的有效数据
for (Node<E> x = first; x != null; x = x.next)
s.writeObject(x.item);
}

// 反序列化过程
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in any hidden serialization magic
s.defaultReadObject();

// 读取链表长度
int size = s.readInt();

// 读取有效元素并用 linkLast 添加到链表尾部
for (int i = 0; i < size; i++)
linkLast((E)s.readObject());
}

2.6 LinkedList 的 clone() 过程

LinkedList 中的 first 和 last 指针是引用类型,因此在 clone() 中需要实现深拷贝。否则,克隆后两个 LinkedList 对象会相互影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
php复制代码private LinkedList<E> superClone() {
try {
return (LinkedList<E>) super.clone();
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}

public Object clone() {
LinkedList<E> clone = superClone();

// Put clone into "virgin" state
clone.first = clone.last = null;
clone.size = 0;
clone.modCount = 0;

// 将原链表中的数据依次添加到新立案表中
for (Node<E> x = first; x != null; x = x.next)
clone.add(x.item);

return clone;
}

2.7 LinkedList 如何实现线程安全?

有 5 种方式:

  • 方法 1 - 使用 Collections.synchronizedList 包装类: 原理也是在所有方法上增加 synchronized 关键字;
  • 方法 2 - 使用 ConcurrentLinkedQueue 容器类: 基于 CAS 无锁实现的线程安全队列;
  • 方法 3 - 使用 LinkedBlockingQueue 容器: 基于加锁的阻塞队列,适合于带阻塞操作的生产者消费者模型;
  • 方法 4 - 使用 LinkedBlockingDeque 容器: 基于加锁的阻塞双端队列,适合于带阻塞操作的生产者消费者模型;
  • 方法 5 - 使用 ConcurrentLinkedDeque 容器类: 基于 CAS 无锁实现的线程安全双端队列。

  1. 总结

  • 1、LinkedList 是基于链表的线性表,同时具备 List、Queue 和 Stack 的行为;
  • 2、在查询指定位置的节点时,如果索引位置小于 size/2,则从头节点开始找,否则从尾节点开始找;
  • 3、LinkedList 重写了序列化过程,只处理链表节点中有效的元素;
  • 4、LinkedList 和 ArrayList 都不考虑线程同步,不保证线程安全。

在上一篇文章里,我们提到了 List 的数组实现 ArrayList,而 LinkedList 不仅是 List 的链表实现,同时还是 Queue 和 Stack 的链表实现。那么,在 Java 中的 Queue 和 Stack 的数组实现是什么呢,这个我们在下篇文章讨论,请关注。


版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

推荐阅读

Java & Android 集合框架系列文章目录(2023/07/08 更新):

  • #1 ArrayList 可以完全替代数组吗?
  • #2 说一下 ArrayList 和 LinkedList 的区别?
  • #3 CopyOnWriteArrayList 是如何保证线程安全的?
  • #4 ArrayDeque:如何用数组实现栈和队列?
  • #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
  • #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
  • #7 如何使用 LinkedHashMap 实现 LRU 缓存?
  • #8 说一下 WeakHashMap 如何清理无效数据的?
  • #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
  • #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇

数据结构与算法系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java & Android 集合框架

发表于 2022-11-04

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。

本文是 Java & Android 集合框架系列的第 3 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了ArrayList 的线程安全问题,其中提到了 CopyOnWriteArrayList 的解决方法。那么 CopyOnWriteArrayList 是如何解决线程安全问题的,背后的设计思想是什么,今天我们就围绕这些问题展开。

本文源码基于 Java 8 CopyOnWriteArrayList。


思维导图:


  1. 回顾 ArrayList

ArrayList 是基于数组实现的动态数据,是线程不安全的。例如,我们在遍历 ArrayList 的时候,如果其他线程并发修改数组(当然也不一定是被其他线程修改),在迭代器中就会触发 fail-fast 机制,抛出 ConcurrentModificationException 异常。

示例程序

1
2
3
4
5
6
7
8
9
10
java复制代码List<String> list = new ArrayList();
list.add("xiao");
list.add("peng");
list.add(".");

Iterator iterator = list.iterator();
while (iterator.hasNext()) {
// 可能抛出 ConcurrentModificationException 异常
iterator.next();
}

要实现线程安全有 3 种方式:

  • 方法 1 - 使用 Vector 容器: Vector 是线程安全版本的数组容器,它会在所有方法上增加 synchronized 关键字(过时,了解即可);
  • 方法 2 - 使用 Collections.synchronizedList 包装类
  • 方法 3 - 使用 CopyOnWriteArrayList 容器

Collections.synchronizedList 包装类的原理很简单,就是使用 synchronized 加锁,源码摘要如下:

Collections.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}

// 使用 synchronized 实现线程安全
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
final List<E> list;

public boolean equals(Object o) {
if (this == o) return true;
synchronized (mutex) {return list.equals(o);}
}
public int hashCode() {
synchronized (mutex) {return list.hashCode();}
}

public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
...
}

如果我们将 ArrayList 替换为 CopyOnWriteArrayList,即使其他线程并发修改数组,也不会抛出 ConcurrentModificationException 异常,这是为什么呢?


  1. CopyOnWriteArrayList 的特点

CopyOnWriteArrayList 和 ArrayList 都是基于数组的动态数组,封装了操作数组时的搬运和扩容等逻辑。除此之外,CopyOnWriteArrayList 还是用了基于加锁的 “读写分离” 和 “写时复制” 的方案解决线程安全问题:

  • 思想 1 - 读写分离(Read/Write Splitting): 将对资源的读取和写入操作分离,使得读取和写入没有依赖,在 “读多写少” 的场景中能有效减少资源竞争;
  • 思想 2 - 写时复制(CopyOnWrite,COW): 在写入数据时,不直接在原数据上修改,而是复制一份新数据后写入到新数据,最后再替换到原数据的引用上。这个特性各有优缺点:
    • 优点 1 - 延迟处理: 在没有写入操作时不会复制 / 分配资源,能够避免瞬时的资源消耗。例如操作系统的 fork 操作也是一种写时复制的思想;
    • 优点 2 - 降低锁颗粒度: 在写的过程中,读操作不会被影响,读操作也不需要加锁,锁的颗粒度从整个列表降低为写操作;
    • 缺点 1 - 弱数据一致性: 在读的过程中,如果数据被其他线程修改,是无法实时感知到最新的数据变化的;
    • 缺点 2 - 有内存压力: 在写操作中需要复制原数组,在复制的过程中内存会同时存在两个数组对象(只是引用,数组元素的对象还是只有一份),会带来内存占用和垃圾回收的压力。如果是 “写多读少” 的场景,就不适合。

所以,使用 CopyOnWriteArrayList 的场景一定要保证是 “读多写少” 且数据量不大的场景,而且在写入数据的时候,要做到批量操作。否则每个写入操作都会触发一次复制,想想就可怕。举 2 个例子:

  • 例如批量写入一组数据,要使用 addAll 方法 批量写入;
  • 例如在做排序时,要先输出为 ArrayList,在 ArrayList 上完成排序后再写回 CopyOnWriteArrayList。

  1. CopyOnWriteArrayList 源码分析

这一节,我们来分析 CopyOnWriteArrayList 中主要流程的源码。

3.1 CopyOnWriteArrayList 的属性

ArrayList 的属性很好理解,底层是一个 Object 数组,我要举手提问 🙋🏻‍♀️:

  • 疑问 1: 为什么 array 字段要使用 volatile 关键字?
1
2
3
4
5
6
7
8
9
10
java复制代码// 锁
final transient ReentrantLock lock = new ReentrantLock();

// 在 Java 11 中,ReentrantLock 被替换为 synchronized 锁
// The lock protecting all mutators. (We have a mild preference for builtin monitors over ReentrantLock when either will do.)
final transient Object lock = new Object();

// 底层数组
// 疑问 1:为什么 array 要使用 volatile 关键字?
private transient volatile Object[] array;

这个问题我们在分析源码的过程中回答。有了 ArrayList 的分析基础,疑问也变少了,CopyOnWriteArrayList 真香。

3.2 CopyOnWriteArrayList 的构造方法

构造器的源码不难,但小朋友总有太多的问号,举手提问 🙋🏻‍♀️:

  • 疑问 2:为什么 CopyOnWriteArrayList 不提供初始化容量的构造器?

这是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。如果提供了带初始化容量的构造器,意味着开发者预期会一个个地写入数据,这不符合 CopyOnWriteArrayList 的正确使用方法。所以,不提供这个构造器才是合理的。

  • 疑问 3:为什么要把 E[] 类型的入参转化为 Object[] 类型?

如果不转化数组类型,那么在 toArray() 方法返回的数组中插入 Object 类型对象时,会抛出 ArrayStoreException。

提示: 这个问题与 “奇怪” 分支的原因相同,具体分析可以看讲 《Java 面试题:ArrayList 可以完全替代数组吗?》 的文章中,这里不重复讲了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码// 疑问 2:为什么 CopyOnWriteArrayList 不提供预初始化容量的构造器?

// 无参构造方法
public CopyOnWriteArrayList() {
// 创建空数组
setArray(new Object[0]);
}

// 带集合的构造方法
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else {
elements = c.toArray();
// 这个“奇怪”的分支在 ArrayList 文章中分析过,去看看
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
setArray(elements);
}

// 带数组的构造方法
public CopyOnWriteArrayList(E[] toCopyIn) {
// 疑问 3:为什么要把 E[] 类型的入参转化为 Object[] 类型
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, *Object[]*.class));
}

final Object[] getArray() {
return array;
}

final void setArray(Object[] a) {
array = a;
}

public Object[] toArray() {
Object[] elements = getArray();
return Arrays.copyOf(elements, elements.length);
}

3.3 CopyOnWriteArrayList 的写方法

我们将 CopyOnWriteArrayList 的添加、删除和修改方法统一为 “写方法”,三种写方法的模板其实是一样的:

  • 1、在写入之前先获取对象的锁;
  • 2、复制新数组;
  • 3、在新数组上完成写入操作;
  • 4、将新数组设置为底层数组;
  • 5、释放对象的锁。

小朋友总是有太多问号,举手提问 🙋🏻‍♀️:

  • 疑问 4:在添加方法中,为什么扩容只增大 1 容量,而 ArrayList 会增大 1.5 倍?

这还是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。ArrayList 额外扩容 1.5 倍是为了避免每次 add 都扩容,而 CopyOnWriteArrayList 并不建议一个个地添加数据,而是建议批量操作写入数据,例如 addAll 方法。所以,CopyOnWriteArrayList 不额外扩容才是合理的。

另外,网上有观点看到 CopyOnWriteArrayList 没有限制数组最大容量,就说 CopyOnWriteArrayList 是无界的,没有容量限制。这显然太表面了。数组的长度限制是被虚拟机固化的,CopyOnWriteArrayList 没有限制的原因是:它没有做额外扩容,而且不适合大数据的场景,所以没有限制的必要。

最后还剩下 1 个问题:

  • 疑问 1:为什么 array 字段要使用 volatile 关键字?

volatile 变量是 Java 轻量级的线程同步原语,volatile 变量的读取和写入操作中会加入内存屏障,能够保证变量写入的内存可见性,保证一个线程的写入能够被另一个线程观察到。

添加方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码// 在数组尾部添加元素
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
// 复制新数组
Object[] elements = getArray();
int len = elements.length;
// 疑问 4:在添加方法中,为什么扩容只增大 1 容量,而 ArrayList 会增大 1.5 倍?
Object[] newElements = Arrays.copyOf(elements, len + 1 /* 容量 + 1*/);
// 在新数组上添加元素
newElements[len] = e;
// 设置新数组
setArray(newElements);
// 释放锁
lock.unlock();
return true;
}

// 在数组尾部添加元素
public void add(int index, E element) {
// 原理相同,省略
...
}

// 批量在数组尾部添加元素
public boolean addAll(Collection<? extends E> c) {
// 原理相同,省略
...
}

修改方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码// 修改数组元素
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
// 旧元素
Object[] elements = getArray();
E oldValue = get(elements, index);

if (oldValue != element) {
// 复制新数组
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
// 在新数组上添加元素
newElements[index] = element;
// 设置新数组
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
// 释放锁
lock.unlock();
// 返回旧数据
return oldValue;
}

删除方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码// 删除数组元素
public E remove(int index) {
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
Object[] elements = getArray();
int len = elements.length;
// 旧元素
E oldValue = get(elements, index);
int numMoved = len - index - 1;
if (numMoved == 0)
// 删除首位元素
setArray(Arrays.copyOf(elements, len - 1));
else {
// 删除中间元素
// 复制新数组
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index, numMoved);
// 设置新数组
setArray(newElements);
}
// 释放锁
lock.unlock();
// 返回旧数据
return oldValue;
}

3.4 CopyOnWriteArrayList 的读取方法

可以看到读取方法并没有加锁。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码private E get(Object[] a, int index) {
return (E) a[index];
}

public E get(int index) {
return get(getArray(), index);
}

public boolean contains(Object o) {
Object[] elements = getArray();
return indexOf(o, elements, 0, elements.length) >= 0;
}

3.5 CopyOnWriteArrayList 的迭代器

CopyOnWriteArrayList 的迭代器 COWIterator 是 “弱数据一致性的” ,所谓数据一致性问题讨论的是同一份数据在多个副本之间的一致性问题,你也可以理解为多个副本的状态一致性问题。例如内存与多核心 Cache 副本之间的一致性,或者数据在主从数据库之间的一致性。

提示: 关于 “数据一致性和顺序一致性” 的区别,在小彭的计算机组成原理专栏讨论过 《已经有 MESI 协议,为什么还需要 volatile 关键字?》,去看看。

为什么是 “弱” 的呢?这是因为 COWIterator 迭代器会持有 CopyOnWriteArrayList “底层数组” 的引用,而 CopyOnWriteArrayList 的写入操作是写入到新数组,因此 COWIterator 是无法感知到的,除非重新创建迭代器。

相较之下,ArrayList 的迭代器是通过持有 “外部类引用” 的方式访问 ArrayList 的底层数组,因此在 ArrayList 上的写入操作会实时被迭代器观察到。

CopyOnWriteArrayList.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 注意看:有 static 关键字,直接引用底层数组
static final class COWIterator<E> implements ListIterator<E> {
// 底层数组
private final Object[] snapshot;
private int cursor;

private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
}

ArrayList.java

1
2
3
4
5
6
7
8
9
java复制代码// 注意看:没有 static 关键字,通过外部类引用来访问底层数组
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;

Itr() {}
...
}

3.6 CopyOnWriteArraySet 的序列化过程

与 ArrayList 类似,CopyOnWriteArraySet 也重写了 JDK 序列化的逻辑,只把 elements 数组中有效元素的部分序列化,而不会序列化整个数组。

同时,ReentrantLock 对象是锁对象,序列化没有意义。在反序列化时,会通过 resetLock() 设置一个新的 ReentrantLock 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
scss复制代码// 序列化过程
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
s.defaultWriteObject();
Object[] elements = getArray();
// 写入数组长度
s.writeInt(elements.length);
// 写入有效元素
for (Object element : elements)
s.writeObject(element);
}

// 反序列化过程
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
// 设置 ReentrantLock 对象
resetLock();
// 读取数组长度
int len = s.readInt();
SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, len);
// 创建底层数组
Object[] elements = new Object[len];
// 读取数组对象
for (int i = 0; i < len; i++)
elements[i] = s.readObject();
// 设置新数组
setArray(elements);
}

// 疑问 5:resetLock() 方法不好理解,解释一下?
private void resetLock() {
// 等价于带 Volatile 语义的 this.lock = new ReentrantLock()
UNSAFE.putObjectVolatile(this, lockOffset, new ReentrantLock());
}

// Unsafe API
private static final sun.misc.Unsafe UNSAFE;
// lock 字段在对象实例数据中的偏移量
private static final long lockOffset;

static {
// 这三行的作用:lock 字段在对象实例数据中的偏移量
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = CopyOnWriteArrayList.class;
lockOffset = UNSAFE.objectFieldOffset(k.getDeclaredField("lock"));
}

小朋友又是有太多问号,举手提问 🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 5:resetLock() 方法不好理解,解释一下?

在 static 代码块中,会使用 Unsafe API 获取 CopyOnWriteArrayList 的 “lock 字段在对象实例数据中的偏移量” 。由于字段的偏移是全局固定的,所以这个偏移量可以记录在 static 字段 lockOffset 中。

在 resetLock() 中,通过 UnSafe API putObjectVolatile 将新建的 ReentrantLock 对象设置到 CopyOnWriteArrayList 的 lock 字段中,等价于带 volatile 语义的 this.lock = new ReentrantLock(),保证这个字段的写入具备内存可见性。

字段的偏移量是什么意思呢?简单来说,普通对象和 Class 对象的实例数据区域是不同的:

  • 1、普通对象: 包括当前类声明的实例字段以及父类声明的实例字段,不包括类的静态字段。UnSafe API objectFieldOffset(Filed) 就是获取了参数 Filed 在实例数据中的偏移量,后续就可以通过这个偏移量为字段赋值;
  • 2、Class 对象: 包括当前类声明的静态字段和方法表等。

对象内存布局

提示: 关于字段的偏移量,我们在 《对象的内存分为哪几个部分?》 这篇文章里讨论过,去看看。

3.7 CopyOnWriteArraySet 的 clone() 过程

CopyOnWriteArraySet 的 clone() 很巧妙。按照正常的思维,CopyOnWriteArraySet 中的 array 数组是引用类型,因此在 clone() 中需要实现深拷贝,否则原对象与克隆对象就会相互影响。但事实上,array 数组并没有被深拷贝,哇点解啊?

  • 🙋🏻‍♀️疑问 6:为什么 array 数组没有深拷贝?

这就是因为 CopyOnWrite 啊!没有 Write 为什么要 Copy 呢?(我觉得已经提醒到位了,只要你仔细阅读前文对 CopyOnWrite 的论证,你一定会懂的。要是是在不懂,私信我吧~)

1
2
3
4
5
6
7
8
9
10
11
12
13
typescript复制代码public Object clone() {
try {
@SuppressWarnings("unchecked")
// 疑问 6:为什么 array 数组没有深拷贝?
CopyOnWriteArrayList<E> clone = (CopyOnWriteArrayList<E>) super.clone();
// 设置 ReentrantLock 对象(相当于 lock 字段的深拷贝)
clone.resetLock();
return clone;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError();
}
}

  1. CopyOnWriteArraySet 源码分析

在 Java 标准库中,还提供了一个使用 COW 思想的 Set 集合 —— CopyOnWriteArraySet。

CopyOnWriteArraySet 和 HashSet 都是继承于 AbstractSet 的,但 CopyOnWriteArraySet 是基于 CopyOnWriteArrayList 动态数组的,并没有使用哈希思想。而 HashSet 是基于 HashMap 散列表的,能够实现 O(1) 查询。

4.1 CopyOnWriteArraySet 的构造方法

看一下 CopyOnWriteArraySet 的构造方法,底层就是有一个 CopyOnWriteArrayList 动态数组。

CopyOnWriteArraySet.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码public class CopyOnWriteArraySet<E> extends AbstractSet<E> implements java.io.Serializable {
// 底层就是 OnWriteArrayList
private final CopyOnWriteArrayList<E> al;

// 无参构造方法
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}

// 带集合的构造方法
public CopyOnWriteArraySet(Collection<? extends E> c) {
if (c.getClass() == CopyOnWriteArraySet.class) {
// 入参是 CopyOnWriteArraySet,说明是不重复的,直接添加
CopyOnWriteArraySet<E> cc = (CopyOnWriteArraySet<E>)c;
al = new CopyOnWriteArrayList<E>(cc.al);
}
else {
// 使用 addAllAbsent 添加不重复的元素
al = new CopyOnWriteArrayList<E>();
al.addAllAbsent(c);
}
}

public int size() {
return al.size();
}
}

4.2 CopyOnWriteArraySet 的操作方法

CopyOnWriteArraySet 的方法基本上都是交给 CopyOnWriteArraySet 代理的,由于没有使用哈希思想,所以操作的时间复杂度是 O(n)。

CopyOnWriteArraySet.java

1
2
3
4
5
6
7
java复制代码public boolean add(E e) {
return al.addIfAbsent(e);
}

public boolean contains(Object o) {
return al.contains(o);
}

CopyOnWriteArrayList.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码public boolean addIfAbsent(E e) {
Object[] snapshot = getArray();
return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false : addIfAbsent(e, snapshot);
}

public boolean contains(Object o) {
Object[] elements = getArray();
return indexOf(o, elements, 0, elements.length) >= 0;
}

// 通过线性扫描匹配元素位置,而不是计算哈希匹配,时间复杂度是 O(n)
private static int indexOf(Object o, Object[] elements, int index, int fence) {
if (o == null) {
for (int i = index; i < fence; i++)
if (elements[i] == null) return i;
} else {
for (int i = index; i < fence; i++)
if (o.equals(elements[i])) return i;
}
return -1;
}

  1. 总结

  • 1、CopyOnWriteArrayList 和 ArrayList 都是基于数组的动态数组,封装了操作数组时的搬运和扩容等逻辑;
  • 2、CopyOnWriteArrayList 还是 “读写分离” 和 “写时复制” 的方案解决线程安全问题;
  • 3、使用 CopyOnWriteArrayList 的场景一定要保证是 “读多写少” 且数据量不大的场景,而且在写入数据的时候,要做到批量操作;
  • 4、CopyOnWriteArrayList 的迭代器是 “弱数据一致性的” 的,迭代器会持有 “底层数组” 的引用,而 CopyOnWriteArrayList 的写入操作是写入到新数组,因此迭代器是无法感知到的;
  • 5、CopyOnWriteArraySet 是基于 CopyOnWriteArrayList 动态数组的,并没有使用哈希思想。

版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

推荐阅读

Java & Android 集合框架系列文章目录(2023/07/08 更新):

  • #1 ArrayList 可以完全替代数组吗?
  • #2 说一下 ArrayList 和 LinkedList 的区别?
  • #3 CopyOnWriteArrayList 是如何保证线程安全的?
  • #4 ArrayDeque:如何用数组实现栈和队列?
  • #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
  • #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
  • #7 如何使用 LinkedHashMap 实现 LRU 缓存?
  • #8 说一下 WeakHashMap 如何清理无效数据的?
  • #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
  • #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇

数据结构与算法系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java & Android 集合框架

发表于 2022-11-03

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。

本文是 Java & Android 集合框架系列的第 1 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在前面的文章里,我们学习了很多数据结构与算法思想。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。

在后续的文章里,我们将以 Java 语言为例,分析从 ArrayList 到 LinkedHashMap 等一系列标准库容器类,最后再有一篇总结回顾,请关注。


思维导图:


  1. 说一下 ArrayList 和 LinkedList 的区别?

  • 1、数据结构: 在数据结构上,ArrayList 和 LinkedList 都是 “线性表”,都继承于 Java 的 List 接口。另外 LinkedList 还实现了 Java 的 Deque 接口,是基于链表的栈或队列,与之对应的是 ArrayDeque 基于数组的栈或队列;
  • 2、线程安全: ArrayList 和 LinkedList 都不考虑线程同步,不保证线程安全;
  • 3、底层实现: 在底层实现上,ArrayList 是基于动态数组的,而 LinkedList 是基于双向链表的。事实上,它们很多特性的区别都是因为底层实现不同引起的。比如说:
+ **在遍历速度上:** 数组是一块连续内存空间,基于局部性原理能够更好地命中 CPU 缓存行,而链表是离散的内存空间对缓存行不友好;
+ **在访问速度上:** 数组是一块连续内存空间,支持 O(1) 时间复杂度随机访问,而链表需要 O(n) 时间复杂度查找元素;
+ **在添加和删除操作上:** 如果是在数组的末尾操作只需要 O(1) 时间复杂度,但在数组中间操作需要搬运元素,所以需要 O(n)时间复杂度,而链表的删除操作本身只是修改引用指向,只需要 O(1) 时间复杂度(如果考虑查询被删除节点的时间,复杂度分析上依然是 O(n),在工程分析上还是比数组快);
+ **额外内存消耗上:** ArrayList 在数组的尾部增加了闲置位置,而 LinkedList 在节点上增加了前驱和后继指针。

  1. ArrayList 源码分析

这一节,我们来分析 ArrayList 中主要流程的源码。

2.1 ArrayList 的属性

ArrayList 的属性很好理解,底层是一个 Object 数组,我要举手提问:

  • 🙋🏻‍♀️疑问 1: 为什么 elementData 字段不声明 private 关键字?
  • 🙋🏻‍♀️疑问 2: 为什么 elementData 字段声明 transient 关键字?
  • 🙋🏻‍♀️疑问 3: 为什么elementData 字段不声明为泛型类型 E?
  • 🙋🏻‍♀️疑问 4: 为什么 ArrayList 的最大容量是 Integer.MAX_VALUE,Long.MAX_VALUE 不行吗?
  • 🙋🏻‍♀️疑问 5: 为什么 ArrayList 的最大容量是 MAX_VALUE - 8,一定会减 8 吗?

这些问题我们在分析源码的过程中回答。疑问这么多,ArrayList 瞬间不香了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// new ArrayList() 默认初始容量
private static final int DEFAULT_CAPACITY = 10;

// new ArrayList(0) 的全局空数组
private static final Object[] EMPTY_ELEMENTDATA = {};

// new ArrayList() 的全局空数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// 修改次数记录
protected transient int modCount = 0;

// 数组的最大长度
// 疑问 4:为什么 ArrayList 的最大容量是 Integer.MAX_VALUE,Long.MAX_VALUE 不行吗?
// 疑问 5:为什么 ArrayList 的最大容量是 MAX_VALUE - 8,一定会减 8 吗?
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8

// 疑问 1:为什么不声明 private(后文回答)
// 疑问 2:为什么声明 transient(后文回答)
// 疑问 3:为什么不声明为泛型类型 E
// 底层数组
transient Object[] elementData;

// 数组的有效长度(不是 elementData.length)
private int size;

// size() 返回的是数组的有效长度(合理,底层数组我们不关心)
public int size() {
return size;
}
}

2.2 ArrayList 的构造方法

ArrayList 有三个构造函数:

  • 1、带初始容量的构造方法: 如果初始容量大于 0,则创建指定长度的数组。如果初始容量是 0,则指向第 1 个全局空数组 ;EMPTY_ELEMENTDATA;
  • 2、无参构造方法: 指向第 2 个全局空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
  • 3、带集合的构造方法: 将集合转为数组,如果数组为空,则指向第 1 个全局空数组 EMPTY_ELEMENTDATA;

可以看到,除了指定大于 0 的初始容量外,ArrayList 在构造时不会创建数组,而是指向全局空数组,这是懒初始化的策略。

构造器的源码不难,但小朋友总有太多的问号,举手提问 🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 6:既然都是容量为 0 ,为什么 ArrayList 要区分出 2 个空数组?

这个问题直接回答吧:ArrayList 认为无参构造函数应该使用默认行为,在首次添加数据时会创建长度为 10(DEFAULT_CAPACITY) 的默认初始数组;而显示设置初始容量为 0 是开发者的显式意图,所以不使用默认初始数组,在首次添加数据时只会创建长度为 1 (size + 1)的数组(可以结合后文源码理解下)。

  • 🙋🏻‍♀️疑问 7: 在带集合的构造方法中,为什么会存在集合转化后的数组类型不是 Object[].class 的情况?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码// 带初始容量的构造方法
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
// 创建 initialCapacity 长度的数组
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
// 指向第 1 个全局空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
// 不合法的初始容量
throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
}
}

// 无参构造方法
public ArrayList() {
// 指向第 1 个全局空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

// 带集合的构造方法
public ArrayList(Collection<? extends E> c) {
// 将集合转为数组
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// 疑问 7:这一个条件语句好奇怪,toArray() 的返回值类型就是 Object[] 啊?
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
this.elementData = EMPTY_ELEMENTDATA;
}
}

public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}

2.3 ArrayList 的添加与扩容方法

ArrayList 可以在数组末尾或数组中间添加元素:

  • 如果是在数组末尾添加,均摊时间只需要 O(1) 时间复杂度;
  • 如果在数组中间添加,由于需要搬运数据,所以需要 O(n) 时间复杂度。

添加前会先检查数据容量,不足会先扩容:

  • 在使用无参构造器初始化时,首次添加元素时会直接扩容到 10 的容量;
  • 在其他情况,会直接扩容到旧容量的 1.5 倍,而不是扩容到最小容量要求。

不管是扩容到 10 还是扩容到 1.5 倍,都是为了防止频繁扩容,避免每次 add 添加数据都要扩容一次。

现在,我们可以回到一些小朋友的疑问:

  • 🙋🏻‍♀️疑问 4:为什么 ArrayList 的最大容量是 Integer.MAX_VALUE,Long.MAX_VALUE 不行吗?

本质上看,应该说是数组长度的限制,而不是 ArrayList 长度的限制。在 《对象的内存分为哪几个部分?》 这篇文章里,我们讨论过对象的内存布局。数组对象的长度是记录在对象头中的 “数组长度” 字段中,这个字段是 4 字节,正好就是 Integer 也是 4 个字节,所以限制为 Integer.MAX_VALUE,而不能使用 Long.MAX_VALUE。

不对啊,Java Integer 是有符号整数,所以 Integer.MAX_VALUE 只有 31 位有效位,还少了 1 位呢。没错,是少了一位。如果要榨干这 1 位容量,当然可以用 long 类型并且限制到 32 位能够表示的最大正整数上,并且在源码中到处加上数组越界判断,想想就不稳定的。相比之下,限制数组长度为 int 类型且最大长度为 Integer.MAX_VALUE,如果有超过 Integer.MAX_VALUE 存储容量的需求,那就创建两个数组呀:)你觉得哪种更好。

Java 对象内存布局

  • 🙋🏻‍♀️疑问 5:为什么 ArrayList 的最大容量是 MAX_VALUE - 8,一定会减 8 吗?

依然与对象的内存布局有关。在 Java 虚拟机垃圾回收算法中,需要计算对象的内存大小,计算结果是存储在 jint 类型变量(Java int 类型在 JNI 中的映射)中的。如果数组的长度是 MAX_VALUE,那么加上对象头之后就整型溢出了,所以 ArrayList 会预先减掉对象头可能占用的 8 个字节。对象头的具体大小取决于虚拟机实现,减 8 是相对保守的。

其实,ArrayList 的最大容量也不一定会减 8,如果最小容量要求是超过 MAX_ARRAY_SIZE 的,那么还是会扩容到 MAX_VALUE 。这有点摆烂的意思,会不会溢出运行时再说。

数组长度溢出

1
java复制代码OutOfMemoryError: Requested array size exceeds VM limit
  • 🙋🏻‍♀️疑问 8:不应该是 elementData.length - minCapacity > 0 吗? 这是考虑到整型溢出的情况,minCapacity 溢出就变成负数了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
java复制代码// 在数组末尾添加元素
public boolean add(E e) {
// 先确保底层数组容量足够容纳 size + 1,不足会扩容
ensureCapacityInternal(size + 1); // Increments modCount!!
// 在 size + 1 的位置赋值
elementData[size++] = e;
return true;
}

// 在数组中间插入元素
public void add(int index, E element) {
// 范围检查
rangeCheckForAdd(index);
// 先确保容量足够容纳 size + 1,不足会扩容
ensureCapacityInternal(size + 1); // Increments modCount!!
// 先搬运数据腾出空位
System.arraycopy(elementData, index, elementData, index + 1, size - index);
// 在 index 的位置赋值
elementData[index] = element;
// 长度加一
size++;
}

// 在数组末尾添加集合
public boolean addAll(Collection<? extends E> c) {
// 集合转数组
Object[] a = c.toArray();
// 先确保底层数组容量足够容纳 size + numNew,不足会扩容
int numNew = a.length;
ensureCapacityInternal(size + numNew); // Increments modCount
// 搬运原数据
System.arraycopy(a, 0, elementData, size, numNew);
// 长度加 numNew
size += numNew;
return numNew != 0;
}

// 在数组中间插入集合
public boolean addAll(int index, Collection<? extends E> c) {
// 略,原理类似
}

// 尝试扩容
// (提示:源码调用了 calculateCapacity() 函数,这里做内联简化)
private void ensureCapacityInternal(int minCapacity) {
// 使用无参构造器初始化时,指定扩容为 10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 疑问 8:不应该是 elementData.length - minCapacity > 0 吗?
// 如果底层数组长度不够 minCapacity 最小容量要求,则需要扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}

private void grow(int minCapacity) {
// 旧容量
int oldCapacity = elementData.length;
// 新容量 = 旧容量 * 1.5 倍(有可能整型溢出)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果新容量小于最小容量要求,则使用最小容量(addAll 大集合的情况)
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
// (提示:上一个 if 的 newCapacity 有可能是溢出的)
// 如果新容量超出最大数组长度限制,说明无法扩容 1.5 倍,回归到 minCapacity 上
// (提示:源码调用了 hugeCapacity() 函数,这里做内联简化)
if (newCapacity - MAX_ARRAY_SIZE > 0) {
// 最小容量要求发生整型溢出,无法满足要求,只能直接抛出 OOM
if (minCapacity < 0) throw new OutOfMemoryError();
// 如果最小容量要求超出最大数组长度限制,则扩容到 MAX_VALUE(说明不一定会减 8)
// 否则,扩容到最大数组长度限制(MAX_VALUE - 8)
newCapacity = (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
// 扩容到 newCapacity 长度
elementData = Arrays.copyOf(elementData, newCapacity);
}

private static int hugeCapacity(int minCapacity) {
// 已经内联简化到 grow 方法中
}

除了扩容之外,ArrayList 还支持缩容,将底层数组的容量缩小到实际元素的数量:

1
2
3
4
5
6
7
java复制代码// 缩容
public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size);
}
}

另外,因为扩容涉及到数据搬运操作,所以如果能事先知道数据的容量,最好在创建 ArrayList 时就显式指定数据量大小。

2.4 ArrayList 的迭代器

Java 的 foreach 是语法糖,本质上也是采用 iterator 的方式。ArrayList 提供了 2 个迭代器:

  • iterator():Iterator(): 单向迭代器
  • ListIterator listIterator(): 双向迭代器

在迭代器遍历数组的过程中,有可能出现多个线程并发修改数组的情况,造成数据不一致甚至数组越界,所以 Java 很多容器类的迭代器中都有 fail-fast 机制。

如果在迭代的过程中发现 expectedModCount 变化,说明数据被修改,此时就会提前抛出 ConcurrentModificationException 异常(当然也不一定是被其他线程修改)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
// 创建迭代器是会记录外部类的 modCount
int expectedModCount = modCount;
...

Itr() {}

public boolean hasNext() {
return cursor != size;
}

@SuppressWarnings("unchecked")
public E next() {
// 检查
checkForComodification();
...
}

public void remove() {
...
// 更新
expectedModCount = modCount;
}
}
  • 🙋🏻‍♀️疑问 1:为什么 elementData 字段不声明 private 关键字?

在注释中的解释是:“non-private to simplify nested class access”。但我们知道在 Java 中,内部类是可以访问外部类的 private 变量的,所以这就说不通的。我的理解是:因为内部类在编译后会生成独立的 Class 文件,如果外部类的 elementData 字段是 private 类型,那么编译器就需要在 ArrayList 中插入 getter / setter,并通过方法调用,而 non-private 字段就可以直接访问字段。

2.5 ArrayList 的序列化过程

  • 🙋🏻‍♀️疑问 2:为什么 elementData 字段声明 transient 关键字?

ArrayList 重写了 JDK 序列化的逻辑,只把 elementData 数组中有效元素的部分序列化,而不会序列化整个数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码// 序列化和反序列化只考虑有效元素
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();

// 写入数组长度
s.writeInt(size);

// 写入有效元素
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}

2.6 ArrayList 的 clone() 过程

ArrayList 中的 elementData 数组是引用类型,因此在 clone() 中需要实现深拷贝,否则原对象与克隆对象会相互影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
typescript复制代码public Object clone() {
try {
ArrayList<?> v = (ArrayList<?>) super.clone();
// 拷贝数组对象
v.elementData = Arrays.copyOf(elementData, size);
// 修改计数归零
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
}

2.7 为什么阿里巴巴要求谨慎使用 subList API?

在 《阿里巴巴 Java 开发手册》中,有关于 ArrayList#subList API 的规定。为什么阿里巴巴要做这样的限制呢?

  • 【强制】ArrayList 的 subList 结果不可强转成 ArrayList,否则会抛出 ClassCastException 异常;
  • 【强制】在 subList 场景中,高度注意对原集合元素的增加或删除,均会导致子列表的遍历、增加、删除产生 ConcurrentModificationException 异常。

这是因为 subList API 只是提供通过起始索引 fromIndex 和终止索引 toIndex 包装了一个原 ArrayList 的 “视图窗口” ,并不是真的截取并创建了一个新的 ArrayList,所以强制类型转换就会抛出 ClassCastException 异常。

此时,在 ArrayList 或 SubList 上做修改,要注意相互之间的影响:

  • 在 ArrayList 或 SubList 上修改元素,都会同步更新到对方(因为底层都是 ArrayList 本身);
  • 在 SubList 上增加或删除元素,会影响到 ArrayList;
  • 在 ArrayList 上增加或删除元素,会导致 SubList 抛出 ConcurrentModificationException 异常。

ArrayList.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, fromIndex, toIndex);
}

private class SubList extends AbstractList<E> implements RandomAccess {
// 原 ArrayList
private final AbstractList<E> parent;
private final int parentOffset;
private final int offset;
int size;

SubList(AbstractList<E> parent, int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
// modCount 记录
this.modCount = ArrayList.this.modCount;
}

public E set(int index, E e) {
rangeCheck(index);
// 在 ArrayList 上增加或删除元素,会导致 SubList 抛出 ConcurrentModificationException 异常
checkForComodification();
// 在 SubList 上增加或删除元素,会影响到 ArrayList;
E oldValue = ArrayList.this.elementData(offset + index);
ArrayList.this.elementData[offset + index] = e;
return oldValue;
}
}

2.8 ArrayList 如何实现线程安全?

有 4 种方式:

  • 方法 1 - 使用 Vector 容器: Vector 是线程安全版本的数组容器,它会在所有方法上增加 synchronized 关键字;
  • 方法 2 - 使用 Collections.synchronizedList 包装类: 原理也是在所有方法上增加 synchronized 关键字;
  • 方法 3 - 使用 CopyOnWriteArrayList 容器: 基于加锁的 “读写分离” 和 “写时复制” 实现的动态数组,适合于读多写少,数据量不大的场景。
  • 方法 4 - 使用 ArrayBlockingQueue 容器: 基于加锁的阻塞队列,适合于带阻塞操作的生产者消费者模型。

提示: 关于 CopyOnWriteArrayList 的更多内容,去看看专栏文章 《CopyOnWriteArrayList 是如何保证线程安全的?》


  1. Arrays#ArrayList:世界上的另一个我

事实上,在 Java 环境中有两个 ArrayList,这或许是一个隐藏的彩蛋(坑):

  • ArrayList: 一般认为的 ArrayList,是一个顶级类;
  • Arrays#ArrayList: Arrays 的静态内部类,和上面这个 ArrayList 没有任何关系。

其实,Arrays#ArrayList 的定位就是在数组和和 List 直接切换而已。Arrays 提供了数组转 List 的 API,而 Arrays#ArrayList 也提供了 List 转数组的 API(这些 API 第一个 ArrayList 中也都有…)

回过头看剩下的 2 个问题:

  • 🙋🏻‍♀️疑问 3:为什么 elementData 字段不声明为泛型类型 E?

泛型擦除后等于 Object[] elementData,没有区别。

  • 🙋🏻‍♀️疑问 7:在带集合的构造方法中,为什么会存在集合转化后的数组类型不是 Object[].class 的情况?

这是因为有些 List 集合的底层数组不是 Object[] 类型,有可能是 String[] 类型。而在 ArrayList#toArray() 方法中,返回值的类型是 Object[] 类型,有类型错误风险。

例如:在这段代码中,ArrayList 接收一个由 String 数组转化的 List,最后在 ArrayList#toArray() 返回的 Object 数组中添加一个 Object 对象,就出现异常了:

示例代码

1
2
3
4
5
6
7
8
9
10
java复制代码// 假设没有特殊处理
List<String> list = new ArrayList<>(Arrays.asList("list"));
// class java.util.ArrayList
System.out.println(list.getClass());

Object[] listArray = list.toArray();
// 如果过没有特殊处理,实际类型是 [Ljava.lang
System.out.println(listArray.getClass());
// 如果过没有特殊处理,将抛出 ArrayStoreException 异常
listArray[0] = new Object();

源码摘要:

Arrays.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable {
// 泛型擦除后:Object[] a;
private final E[] a;

// 泛型擦除后:Object[] array;
// Java 数组是协变的,能够接收 String[] 类型的数组
ArrayList(E[] array) {
// 赋值
a = Objects.requireNonNull(array);
}

// 实际返回的数组可能是 Object[] 类型,也可能是 String[] 类型
@Override
public Object[] toArray() {
return a.clone();
}
}

ArrayList.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
transient Object[] elementData;

// 带集合的构造方法
public ArrayList(Collection<? extends E> c) {
// 将集合转为数组
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// 疑问 7:这一个条件语句好奇怪,toArray() 的返回值类型就是 Object[] 啊?
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
this.elementData = EMPTY_ELEMENTDATA;
}
}

public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
}

  1. ArrayList 这么好用,可以完全替代数组吗?

大多数场景可以,但不能完全替代。

ArrayList 是基于 Object 数组封装的动态数组,我们不需要关心底层数组的数据搬运和扩容等逻辑,因此在大多数业务开发场景中,除非是为了最求极致的性能,否则直接使用 ArrayList 代替数组是更好的选择。

那么,ArrayList 有哪些地方上比数组差呢?

  • 举例 1 - ArrayList 等容器类不支持 int 等基本数据类型,所以必然存在装箱拆箱操作;
  • 举例 2 - ArrayList 默认的扩容逻辑是会扩大到原容量的 1.5 倍,在大数据量的场景中,这样的扩容逻辑是否多余,需要打上问题;
  • 举例 3 - ArrayList 的灵活性不够。ArrayList 不允许底层数据有空洞,所有的有效数据都会 “压缩” 到底层数组的首部。因此,当需要基于数组二次开发容器时,ArrayList 并不是一个好选择。
+ 例如,使用 ArrayList 开发栈的结构或许合适,可以在数组的尾部操作数据。但使用 ArrayList 开发队列就不合适,因为在数组的首部入队或出队需要搬运数据;
+ 而数组没有这些约束,我们可以将数组设计为 “环形数组”,就可以避免入队和出队时搬运数据。例如 Java 的 `ArrayBlockingQueue` 和 [ArrayDeque](https://dev.newban.cn/7162819765361672199) 就是基于数组的队列。

  1. 总结

  • 1、ArrayList 是基于数组封装的动态数组,封装了操作数组时的搬运和扩容等逻辑;
  • 2、在构造 ArrayList 时,除了指定大于 0 的初始容量外,ArrayList 在构造时不会创建数组,而是指向全局空数组,这是懒初始化的策略;
  • 3、在添加数据时会先检查数据容量,不足会先扩容。首次添加默认会扩容到 10 容量,后续会扩容到旧容量的 1.5 倍,这是为了避免反复扩容;
  • 4、因为扩容涉及到数据搬运操作,所以如果能事先知道数据的容量,最好在创建 ArrayList 时就显式指定数据量大小;
  • 5、ArrayList 重写了序列化过程,只处理数组中有效的元素;
  • 6、ArrayList 的 subList API 只是提供视图窗口,并不是创建新列表;
  • 7、ArrayList 在大多数场景中可以代替数组,但在高性能和二次封装的场景中,ArrayList 无法替代数组。

在下一篇文章里,我们来讨论 ArrayList 的孪生兄弟 —— LinkedList。请关注。

版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

  • 为什么阿里巴巴要求谨慎使用 ArrayList 中的 subList 方法 —— HollisChuang 著
  • ArrayList 源码解析,老哥,来一起复习一哈? —— iisheng 著
  • Arrays.asList(x).toArray().getClass() should be Object[].class —— OpenJDK
  • Why I can’t create an array with large size? —— stack overflow

推荐阅读

Java & Android 集合框架系列文章目录(2023/07/08 更新):

  • #1 ArrayList 可以完全替代数组吗?
  • #2 说一下 ArrayList 和 LinkedList 的区别?
  • #3 CopyOnWriteArrayList 是如何保证线程安全的?
  • #4 ArrayDeque:如何用数组实现栈和队列?
  • #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
  • #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
  • #7 如何使用 LinkedHashMap 实现 LRU 缓存?
  • #8 说一下 WeakHashMap 如何清理无效数据的?
  • #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
  • #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇

数据结构与算法系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

重新认识下JVM级别的本地缓存框架Guava Cache(2

发表于 2022-11-02

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!


大家好,又见面了。

通过《重新认识下JVM级别的本地缓存框架Guava Cache——优秀从何而来》一文,我们知道了Guava Cache作为JVM级别的本地缓存组件的诸多暖心特性,也一步步地学习了在项目中集成并使用Guava Cache进行缓存相关操作。Guava Cache作为一款优秀的本地缓存组件,其内部很多实现机制与设计策略,同样值得开发人员深入的掌握与借鉴。

作为系列专栏,本篇文章我们将在上一文的基础上,继续探讨下Guava Cache对于缓存容量限制与数据清理相关的使用与设计机制,进而让我们在项目中使用起来可以更加的游刃有余,解锁更多使用技巧。

容量限制时的Size与Weight区别

弄清Size与Weight

Guava Cache提供了对缓存总量的限制,并且支持从两个维度进行限制,这里我们首先要厘清size与weight两个概念的区别与联系。

  • 限制缓存条数size
1
2
3
java复制代码public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder().maximumSize(10000L).build();
}
  • 限制缓存权重weight
1
2
3
4
5
6
java复制代码public Cache<String, String> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(50000)
.weigher((key, value) -> (int) Math.ceil(value.length() / 1000))
.build();
}

一般而言,我们限制容器的容量的初衷,是为了防止内存占用过大导致内存溢出,所以本质上是限制内存的占用量。从实现层面,往往会根据总内存占用量与预估每条记录字节数进行估算,将其转换为对缓存记录条数的限制。这种做法相对简单易懂,但是对于单条缓存记录占用字节数差异较大的情况下,会导致基于条数控制的结果不够精准。

比如:

需要限制缓存最大占用500M总量,缓存记录可能大小范围是1k~100k,按照每条50k进行估算,设定缓存容器最大容量为限制最大容量1w条。如果存储的都是1k大小的记录,则内存总占用量才10M(内存没有被有效利用起来);若都存储的是100k大小的记录,又会导致内存占用为1000M,远大于预期的内存占用量(容易造成内存溢出)。

为了解决这个问题,Guava Cache中提供了一种相对精准的控制策略,即基于权重的总量控制,根据一定的规则,计算出每条value记录所占的权重值,然后以权重值进行总量的计算。

还是上面的例子,我们按照权重进行设定,假定1k对应基础权重1,则100k可转换为权重100。这样一来:

限制缓存最大占用500M,1k对应权重1,Nk代表权重N,则我们可以限制总权重为50w。这样假如存储的都是1k的记录,则最多可以缓存5w条记录;而如果都是100k大小的记录,则最多仅可以缓存5000条记录。根据存储数据的大小不同,最大存储的记录条数也不相同,但是最终占用的总体量可以实现基本吻合。

所以,基于weight权重的控制方式,比较适用于这种对容器体量控制精度有严格诉求的场景,可以在创建容器的时候指定每条记录的权重计算策略(比如基于字符串长度或者基于bytes数组长度进行计算权重)。

使用约束说明

在实际使用中,这几个参数之间有一定的使用约束,需要特别注意一下:

  • 如果没有指定weight实现逻辑,则使用maximumSize来限制最大容量,按照容器中缓存记录的条数进行限制;这种情况下,即使设定了maximumWeight也不会生效。
  • 如果指定了weight实现逻辑,则必须使用 maximumWeight 来限制最大容量,按照容器中每条缓存记录的weight值累加后的总weight值进行限制。

看下面的一个反面示例,指定了weighter和maximumSize,却没有指定 maximumWeight属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public static void main(String[] args) {
try {
Cache<String, String> cache = CacheBuilder.newBuilder()
.weigher((key, value) -> 2)
.maximumSize(2)
.build();
cache.put("key1", "value1");
cache.put("key2", "value2");
System.out.println(cache.size());
} catch (Exception e) {
e.printStackTrace();
}
}

执行的时候,会报错,提示weighter和maximumSize不可以混合使用:

1
2
3
4
css复制代码java.lang.IllegalStateException: maximum size can not be combined with weigher
at com.google.common.base.Preconditions.checkState(Preconditions.java:502)
at com.google.common.cache.CacheBuilder.maximumSize(CacheBuilder.java:484)
at com.veezean.skills.cache.guava.CacheService.main(CacheService.java:205)

Guava Cache淘汰策略

为了简单描述,我们将数据从缓存容器中移除的操作统称数据淘汰。按照触发形态不同,我们可以将数据的清理与淘汰策略分为被动淘汰与主动淘汰两种。

被动淘汰

  • 基于数据量(size或者weight)

当容器内的缓存数量接近(注意是接近、而非达到)设定的最大阈值的时候,会触发guava cache的数据清理机制,会基于LRU或FIFO删除一些不常用的key-value键值对。这种方式需要在创建容器的时候指定其maximumSize或者maximumWeight,然后才会基于size或者weight进行判断并执行上述的清理操作。

看下面的实验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public static void main(String[] args) {
try {
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(2)
.removalListener(notification -> {
System.out.println("---监听到缓存移除事件:" + notification);
})
.build();
System.out.println("put放入key1");
cache.put("key1", "value1");
System.out.println("put放入key2");
cache.put("key2", "value1");
System.out.println("put放入key3");
cache.put("key3", "value1");
System.out.println("put操作后,当前缓存记录数:" + cache.size());
System.out.println("查询key1对应值:" + cache.getIfPresent("key1"));
} catch (Exception e) {
e.printStackTrace();
}
}

上面代码中,没有设置数据的过期时间,理论上数据是长期有效、不会被过期删除。为了便于测试,我们设定缓存最大容量为2条记录,然后往缓存容器中插入3条记录,观察下输出结果如下:

1
2
3
4
5
6
arduino复制代码put放入key1
put放入key2
put放入key3
---监听到缓存移除事件:key1=value1
put操作后,当前缓存记录数:2
查询key1对应值:null

从输出结果可以看到,即使数据并没有过期,但在插入第3条记录的时候,缓存容器还是自动将最初写入的key1记录给移除了,挪出了空间用于新的数据的插入。这个就是因为触发了Guava Cache的被动淘汰机制,以确保缓存容器中的数据量始终是在可控范围内。

  • 基于过期时间

Guava Cache支持根据创建时间或者根据访问时间来设定数据过期处理,实际使用的时候可以根据具体需要来选择对应的方式。

过期策略 具体说明
创建过期 基于缓存记录的插入时间判断。比如设定10分钟过期,则记录加入缓存之后,不管有没有访问,10分钟时间到则
访问过期 基于最后一次的访问时间来判断是否过期。比如设定10分钟过期,如果缓存记录被访问到,则以最后一次访问时间重新计时;只有连续10分钟没有被访问的时候才会过期,否则将一直存在缓存中不会被过期。

看下面的实验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public static void main(String[] args) {
try {
Cache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(1L, TimeUnit.SECONDS)
.recordStats()
.build();
cache.put("key1", "value1");
cache.put("key2", "value2");
cache.put("key3", "value3");
System.out.println("put操作后,当前缓存记录数:" + cache.size());
System.out.println("查询key1对应值:" + cache.getIfPresent("key1"));
System.out.println("统计信息:" + cache.stats());
System.out.println("-------sleep 等待超过过期时间-------");
Thread.sleep(1100L);
System.out.println("执行key1查询操作:" + cache.getIfPresent("key1"));
System.out.println("当前缓存记录数:" + cache.size());
System.out.println("当前统计信息:" + cache.stats());
System.out.println("剩余数据信息:" + cache.asMap());
} catch (Exception e) {
e.printStackTrace();
}
}

在实验代码中,我们设置了缓存记录1s有效期,然后等待其过期之后查看其缓存中数据情况,代码执行结果如下:

1
2
3
4
5
6
7
8
ini复制代码put操作后,当前缓存记录数:3
查询key1对应值:value1
统计信息:CacheStats{hitCount=1, missCount=0, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}
-------sleep 等待超过过期时间-------
执行key1查询操作:null
当前缓存记录数:1
当前统计信息:CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=2}
剩余数据信息:{}

从结果中可以看出,超过过期时间之后,再次执行get操作已经获取不到已过期的记录,相关记录也被从缓存容器中移除了。请注意,上述代码中我们特地是在过期之后执行了一次get请求然后才去查看缓存容器中存留记录数量与统计信息的,主要是因为Guava Cache的过期数据淘汰是一种被动触发技能。

当然,细心的小伙伴可能会发现上面的执行结果有一个“问题”,就是前面一起put写入了3条记录,等到超过过期时间之后,只移除了2条过期数据,还剩了一条记录在里面?但是去获取剩余缓存里面的数据的时候又显示缓存里面是空的?

Guava Cache作为一款优秀的本地缓存工具包,是不可能有这么个大的bug遗留在里面的,那是什么原因呢?

这个现象其实与Guava Cache的缓存淘汰实现机制有关系,前面说过Guava Cache的过期数据清理是一种被动触发技能,我们看下getIfPresent方法对应的实现源码,可以很明显的看出每次get请求的时候都会触发一次cleanUp操作:

为了实现高效的多线程并发控制,Guava Cache采用了类似ConcurrentHashMap一样的分段锁机制,数据被分为了不同分片,每个分片同一时间只允许有一个线程执行写操作,这样降低并发锁争夺的竞争压力。而上面代码中也可以看出,执行清理的时候,仅针对当前查询的记录所在的Segment分片执行清理操作,而其余的分片的过期数据并不会触发清理逻辑 —— 这个也就是为什么前面例子中,明明3条数据都过期了,却只清理掉了其中的2条的原因。

为了验证上述的原因说明,我们可以在创建缓存容器的时候将concurrencyLevel设置为允许并发数为1,强制所有的数据都存放在同一个分片中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public static void main(String[] args) {
try {
Cache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(1L, TimeUnit.SECONDS)
.concurrencyLevel(1) // 添加这一约束,强制所有数据放在一个分片中
.recordStats()
.build();

// ...省略其余逻辑,与上一段代码相同

} catch (Exception e) {
e.printStackTrace();
}
}

重新运行后,从结果可以看出,这一次3条过期记录全部被清除了。

1
2
3
4
5
6
7
8
ini复制代码put操作后,当前缓存记录数:3
查询key1对应值:value1
统计信息:CacheStats{hitCount=1, missCount=0, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}
-------sleep 等待超过过期时间-------
执行key1查询操作:null
当前缓存记录数:0
当前统计信息:CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=3}
剩余数据信息:{}

在实际的使用中,我们倒也无需过于关注数据过期是否有被从内存中真实移除这一点,因为Guava Cache会在保证业务数据准确的情况下,尽可能的兼顾处理性能,在该清理的时候,自会去执行对应的清理操作,所以也无需过于担心。

  • 基于引用

基于引用回收的策略,核心是利用JVM虚拟机的GC机制来达到数据清理的目的。按照JVM的GC原理,当一个对象不再被引用之后,便会执行一系列的标记清除逻辑,并最终将其回收释放。这种实际使用的较少,此处不多展开。

主动淘汰

上述通过总体容量限制或者通过过期时间约束来执行的缓存数据清理操作,是属于一种被动触发的机制。

实际使用的时候也会有很多情况,我们需要从缓存中立即将指定的记录给删除掉。比如执行删除或者更新操作的时候我们就需要删除已有的历史缓存记录,这种情况下我们就需要主动调用 Guava Cache提供的相关删除操作接口,来达到对应诉求。

接口名称 含义描述
invalidate(key) 删除指定的记录
invalidateAll(keys) 批量删除给定的记录
invalidateAll() 清空整个缓存容器

小结回顾

好啦,关于Guava Cache中的容量限制与数据淘汰策略,就介绍到这里了。关于本章的内容,你是否有自己的一些想法与见解呢?欢迎评论区一起交流下,期待和各位小伙伴们一起切磋、共同成长。

📣 补充说明1 :

本文属于《深入理解缓存原理与实战设计》系列专栏的内容之一。该专栏围绕缓存这个宏大命题进行展开阐述,全方位、系统性地深度剖析各种缓存实现策略与原理、以及缓存的各种用法、各种问题应对策略,并一起探讨下缓存设计的哲学。

如果有兴趣,也欢迎关注此专栏。

📣 补充说明2 :

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

React 之 createElement 源码解读

发表于 2022-11-01

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

React 与 Babel

元素标签转译

用过 React 的同学都知道,当我们这样写时:

1
jsx复制代码<div id="foo">bar</div>

Babel 会将其转译为:

1
javascript复制代码React.createElement("div", {id: "foo"}, "bar");

我们会发现,createElement 的第一个参数是元素类型,第二个参数是元素属性,第三个参数是子元素

组件转译

如果我们用的是一个组件呢?

1
2
3
4
5
6
7
javascript复制代码function Foo({id}) {
return <div id={id}>foo</div>
}

<Foo id="foo">
<div id="bar">bar</div>
</Foo>

Babel 则会将其转译为:

1
2
3
4
5
6
javascript复制代码function Foo({id}) {
return React.createElement("div", {id: id}, "foo")}

React.createElement(Foo, {id: "foo"},
React.createElement("div", {id: "bar"}, "bar")
);

我们会发现,createElement 的第一个参数传入的是变量 Foo

子元素转译

如果我们有多个子元素呢?

1
2
3
4
5
javascript复制代码<div id="foo">
<div id="bar">bar</div>
<div id="baz">baz</div>
<div id="qux">qux</div>
</div>

Babel 则会将其转译为:

1
2
3
4
5
javascript复制代码React.createElement("div", { id: "foo"}, 
React.createElement("div", {id: "bar"}, "bar"),
React.createElement("div", {id: "baz"}, "baz"),
React.createElement("div", {id: "qux"}, "qux")
);

我们会发现,子元素其实是作为参数不断追加传入到函数中

createElement

那 React.createElement 到底做了什么呢?

源码

我们查看 React 的 GitHub 仓库:github.com/facebook/re…,查看 pacakges/react/index.js文件,可以看到 createElement 的定义在 ./src/React文件:

1
2
javascript复制代码// 简化后
export {createElement} from './src/React';

我们打开 ./src/React.js文件:

1
2
3
4
5
6
7
8
9
javascript复制代码import {
createElement as createElementProd
} from './ReactElement';

const createElement = __DEV__
? createElementWithValidation
: createElementProd;

export { createElement };

继续查看 ./ReactElement.js文件,在这里终于找到最终的定义,鉴于这里代码较长,我们将代码极度简化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
javascript复制代码const RESERVED_PROPS = {
key: true,
ref: true,
__self: true,
__source: true,
};

export function createElement(type, config, ...children) {
let propName;

// Reserved names are extracted
const props = {};

// 第一段
let key = '' + config.key;
let ref = config.ref;
let self = config.__self;
let source = config.__source;

// 第二段
for (propName in config) {
if (config.hasOwnProperty(propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}

// 第三段
props.children = children;

// 第四段
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}

// 第五段
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}

这里可以看出,createElement 函数主要是做了一个预处理,然后将处理好的数据传入 ReactElement 函数中,我们先分析下 createElement 做了什么。

函数入参

我们以最一开始的例子为例:

1
2
3
javascript复制代码<div id="foo">bar</div>
// 转译为
React.createElement("div", {id: "foo"}, "bar");

对于createElement 的三个形参,其中type 表示类型,既可以是标签名字符串(如 div 或 span),也可以是 React 组件(如 Foo)

config 表示传入的属性,children 表示子元素

第一段代码 __self 和 __source

现在我们开始看第一段代码:

1
2
3
4
5
javascript复制代码  // 第一段
let key = '' + config.key;
let ref = config.ref;
let self = config.__self;
let source = config.__source;

可以看到在 createElement 函数内部,key、ref、__self、__source 这四个参数是单独获取并处理的,key 和 ref 很好理解,__self 和 __source 是做什么用的呢?

通过这个 issue,我们了解到,__self 和 __source 是 babel-preset-react注入的调试信息,可以提供更有用的错误信息。

我们查看 babel-preset-react 的文档,可以看到:

development

boolean 类型,默认值为 false.
这可以用于开启特定于开发环境的某些行为,例如添加 __source 和 __self。
在与 env 参数 配置或 js 配置文件 配合使用时,此功能很有用。

如果我们试着开启 development 参数,就会看到 __self 和 __source 参数,依然以 <div id="foo">bar</div> 为例,会被 Babel 转译成:

1
2
3
4
5
6
7
8
9
10
javascript复制代码var _jsxFileName = "/Users/kevin/Desktop/react-app/src/index.js";
React.createElement("div", {
id: "foo",
__self: this,
__source: {
fileName: _jsxFileName,
lineNumber: 5,
columnNumber: 13
}
}, "bar");

第二段代码 props 对象

现在我们看第二段代码:

1
2
3
4
5
6
javascript复制代码// 第二段
for (propName in config) {
if (config.hasOwnProperty(propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}

这段代码实现的功能很简单,就是构建一个 props 对象,去除传入的 key、ref、__self、__source属性,这就是为什么在组件中,我们明明传入了 key 和ref,但我们无法通过 this.props.key 或者 this.props.ref 来获取传入的值,就是因为在这里被去除掉了。

而之所以去除,React 给出的解释是,key 和 ref 是用于 React 内部处理的,如果你想用比如 key 值,你可以再传一个其他属性,用跟 key 相同的值即可。

第三段代码 children

现在我们看第三段代码,这段代码被精简的很简单:

1
2
javascript复制代码// 第三段
props.children = children;

这是其实是因为我们为了简化代码,用了 ES6 的扩展运算法,实际的源码里会复杂且有一些差别:

1
2
3
4
5
6
7
8
9
10
javascript复制代码const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}

我们也可以发现,当只有一个子元素的时候,children 其实会直接赋值给 props.children,也就是说,当只有一个子元素时,children 是一个对象(React 元素),当有多个子元素时,children 是一个包含对象(React 元素)的数组。

第四段代码 defaultProps

现在我们看第四段代码:

1
2
3
4
5
6
7
8
9
javascript复制代码  // 第四段
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}

这段其实是处理组件的defaultProps,无论是函数组件还是类组件都支持 defaultProps,举个使用例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码// 函数组件
function Foo({id}) {
return <div id={id}>foo</div>
}

Foo.defaultProps = {
id: 'foo'
}

// 类组件
class Header extends Component {
static defaultProps = {
id: 'foo'
}
render () {
const { id } = this.props
return <div id={id}>foo</div>
}
}

第五段代码 owner

现在我们看第五段代码:

1
2
3
4
5
6
7
8
9
10
javascript复制代码  // 第五段
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);

这段就是把前面处理好的 type、key 等值传入 ReactElement 函数中,那 ReactCurrentOwner.current是个什么鬼?

我们根据引用地址查看 ReactCurrentOwner定义的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码/**
* Keeps track of the current owner.
*
* The current owner is the component who should own any components that are
* currently being constructed.
*/
const ReactCurrentOwner = {
/**
* @internal
* @type {ReactComponent}
*/
current: null,
};

export default ReactCurrentOwner;

其初始的定义非常简单,根据注释,我们可以了解到 ReactCurrentOwner.current 就是指向处于构建过程中的组件的 owner,具体作用在以后的文章中还有介绍,现在可以简单的理解为,它就是用于记录临时变量。

ReactElement

源码

现在我们开始看 ReactElement 函数,其实这个函数的内容更简单,代码精简后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,

// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,

// Record the component responsible for creating this element.
_owner: owner,
};

return element;
};

如你所见,它就是返回一个对象,这个对象包括 $$typeof、type、key 等属性,这个对象就被称为 “React 元素”。它描述了我们在屏幕上看到的内容。React 会通过读取这些对象,使用它们构建和更新 DOM

REACT_ELEMENT_TYPE

而 REACT_ELEMENT_TYPE 查看引用的 packages/shared/ReactSymbols 文件,可以发现它就是一个唯一常量值,用于标示是 React 元素节点

1
javascript复制代码export const REACT_ELEMENT_TYPE = Symbol.for('react.element');

那还有其他类型的节点吗? 查看这个定义 REACT_ELEMENT_TYPE 的文件,我们发现还有:

1
2
3
4
5
6
7
javascript复制代码export const REACT_PORTAL_TYPE: symbol = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE: symbol = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE: symbol = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE: symbol = Symbol.for('react.profiler');
export const REACT_PROVIDER_TYPE: symbol = Symbol.for('react.provider');
export const REACT_CONTEXT_TYPE: symbol = Symbol.for('react.context');
// ...

你可能会自然的理解为 $$typeof 还可以设置为 REACT_FRAGMENT_TYPE等值。

我们可以写代码实验一下,比如使用 Portal,打印一下返回的对象:

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码import ReactDOM from 'react-dom/client';
import {createPortal} from 'react-dom'

const root = ReactDOM.createRoot(document.getElementById('root'));

function Modal() {
const portalObject = createPortal(<div id="foo">foo</div>, document.getElementById("root2"));
console.log(portalObject)
return portalObject
}

root.render(<Modal />);

打印的对象为:
image.png
它的 $$typeof 确实是 REACT_PORTAL_TYPE

而如果我们使用 Fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码import ReactDOM from 'react-dom/client';
import React from 'react';

const root = ReactDOM.createRoot(document.getElementById('root'));

function Modal() {
const fragmentObject = (
<React.Fragment>
<div id="foo">foo</div>
</React.Fragment>
);
console.log(fragmentObject)
return fragmentObject
}

root.render(<Modal />);

打印的对象为:
image.png
我们会发现,当我们使用 fragment 的时候,返回的对象的 $$typeof 却依然是 REACT_ELEMENT_TYPE 这是为什么呢?

其实细想一下我们使用 portals 的时候,我们用的是 React.createPortal 的方式,但 fragments 走的依然是普通的 React.createElement 方法,createElement 的代码我们也看到了,并无特殊处理 $$typeof 的地方,所以自然是 REACT_ELEMENT_TYPE

那么 $$typeof 到底是为什么存在呢?其实它主要是为了处理 web 安全问题,试想这样一段代码:

1
2
3
4
5
6
javascript复制代码let message = { text: expectedTextButGotJSON };

// React 0.13 中有风险
<p>
{message.text}
</p>

如果 expectedTextButGotJSON是来自于服务器的值,比如:

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码// 服务端允许用户存储 JSON
let expectedTextButGotJSON = {
type: 'div',
props: {
dangerouslySetInnerHTML: {
__html: '/* something bad */'
},
},
// ...
};
let message = { text: expectedTextButGotJSON };

这就很容易受到 XSS 攻击,虽然这个攻击是来自服务器端的漏洞,但使用 React 我们可以处理的更好。如果我们用 Symbol 标记每个 React 元素,因为服务端的数据不会有 Symbol.for('react.element'),React 就可以检测 element.$$typeof,如果元素丢失或者无效,则可以拒绝处理该元素,这样就保证了安全性。

回顾

至此,我们完整的看完了 React.createElement 的源码,现在我们再看 React 官方文档的这段:

以下两种示例代码完全等效:

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码const element = (
<h1 className="greeting">
Hello, world!
</h1>
);

const element = React.createElement(
'h1',
{className: 'greeting'},
'Hello, world!'
);

React.createElement() 会预先执行一些检查,以帮助你编写无错代码,但实际上它创建了一个这样的对象:

1
2
3
4
5
6
7
8
javascript复制代码// 注意:这是简化过的结构
const element = {
type: 'h1',
props: {
className: 'greeting',
children: 'Hello, world!'
}
};

这些对象被称为 “React 元素”。它们描述了你希望在屏幕上看到的内容。React 通过读取这些对象,然后使用它们来构建 DOM 以及保持随时更新。

现在你对这段是不是有了更加深入的认识?

React 系列

React 系列的预热系列,带大家从源码的角度深入理解 React 的各个 API 和执行过程,全目录不知道多少篇,预计写个 50 篇吧。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【项目实战】MNIST 手写数字识别(下) 前言 构建网络

发表于 2022-10-31

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第32天,点击查看活动详情

前言

本文将介绍如何在 PyTorch 中构建一个简单的卷积神经网络,并训练它使用 MNIST 数据集识别手写数字,这将可以被看做是图像识别的 “Hello, World!”;

在 【项目实战】MNIST 手写数字识别(上) 中,我已经介绍过了如何配置环境,准备数据集以及使用数据集,接下来将要进行构建网络、训练模型、评估模型、优化模型等;

构建网络

现在让我们继续构建我们的网络。我们将使用两个二维卷积层,然后是两个全连接(或线性)层。作为激活函数,我们将选择校正线性单元(简称 ReLU),作为正则化的手段,我们将使用两个 dropout 层。在 PyTorch 中,构建网络的一种好方法是为我们希望构建的网络创建一个新类。让我们在这里导入一些子模块以获得更易读的代码。

1
2
3
py复制代码import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
py复制代码class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
self.conv2_drop = nn.Dropout2d()
self.fc1 = nn.Linear(320, 50)
self.fc2 = nn.Linear(50, 10)

def forward(self, x):
x = F.relu(F.max_pool2d(self.conv1(x), 2))
x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
x = x.view(-1, 320)
x = F.relu(self.fc1(x))
x = F.dropout(x, training=self.training)
x = self.fc2(x)
return F.log_softmax(x)

从广义上讲,我们可以认为 torch.nn 层包含可训练的参数,而 torch.nn.functional 是纯函数式的。 forward() 传递定义了我们使用给定层和函数计算输出的方式。在前向传递中的某处打印张量以方便调试是非常好的。这在尝试更复杂的模型时会派上用场。请注意,前向传递可以利用例如一个成员变量甚至数据本身来确定执行路径——它也可以使用多个参数!

现在让我们初始化网络和优化器。

1
2
3
py复制代码network = Net()
optimizer = optim.SGD(network.parameters(), lr=learning_rate,
momentum=momentum)

注意:如果我们使用 GPU 进行训练,我们还应该将网络参数发送到 GPU,例如 network.cuda()。在将网络参数传递给优化器之前,将它们传输到适当的设备非常重要,否则优化器将无法以正确的方式跟踪它们。

训练模型

是时候建立我们的训练循环了。

首先,我们要确保我们的网络处于训练模式。

然后我们每个 epoch 对所有训练数据进行一次迭代。

再由 DataLoader 加载单个批次。我们需要使用 optimizer.zero_grad() 手动将梯度设置为零,因为 PyTorch 默认会累积梯度。然后产生网络的输出(前向传递)并计算输出和地面实况标签之间的负对数似然损失。

现在,backward() 调用收集了一组新的梯度,我们使用 optimizer.step() 将其传播回每个网络参数。

我们还将通过一些打印输出跟踪进度。为了稍后创建一个漂亮的训练曲线,我们还创建了两个列表来保存训练和测试损失。在 x 轴上,我们希望显示网络在训练期间看到的训练示例的数量。

1
2
3
4
py复制代码train_losses = []
train_counter = []
test_losses = []
test_counter = [i*len(train_loader.dataset) for i in range(n_epochs + 1)]

我们将在开始训练之前运行一次测试循环,看看我们仅使用随机初始化的网络参数实现了什么样的准确度损失。你能猜出我们在这种情况下的准确性如何吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
py复制代码def train(epoch):
network.train()
for batch_idx, (data, target) in enumerate(train_loader):
optimizer.zero_grad()
output = network(data)
loss = F.nll_loss(output, target)
loss.backward()
optimizer.step()
if batch_idx % log_interval == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, batch_idx * len(data), len(train_loader.dataset),
100. * batch_idx / len(train_loader), loss.item()))
train_losses.append(loss.item())
train_counter.append(
(batch_idx*64) + ((epoch-1)*len(train_loader.dataset)))
torch.save(network.state_dict(), f'{BASEPATH}/results/model.pth')
torch.save(optimizer.state_dict(), f'{BASEPATH}/results/optimizer.pth')

神经网络模块和优化器能够使用 .state_dict() 保存和加载它们的内部状态。有了这个,我们可以通过调用 .load_state_dict(state_dict),继续从以前保存的状态字典中训练。

现在为我们的测试循环。在这里,我们总结了测试损失并跟踪正确分类的数字以计算网络的准确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
py复制代码def test():
network.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in test_loader:
output = network(data)
test_loss += F.nll_loss(output, target, size_average=False).item()
pred = output.data.max(1, keepdim=True)[1]
correct += pred.eq(target.data.view_as(pred)).sum()
test_loss /= len(test_loader.dataset)
test_losses.append(test_loss)
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format(
test_loss, correct, len(test_loader.dataset),
100. * correct / len(test_loader.dataset)))

使用上下文管理器 no_grad() 我们可以避免在计算图中存储生成网络输出的计算。

是时候进行培训了,在循环 n_epochs 之前,我们将手动添加一个 test() 调用,以使用随机初始化的参数评估我们的模型。

1
2
3
4
py复制代码test()
for epoch in range(1, n_epochs + 1):
train(epoch)
test()

image.png

评估模型

仅通过 3 个 epoch 的训练,我们就已经成功地在测试集上达到了 97% 的准确率!我们从随机初始化的参数开始,正如预期的那样,在开始训练之前,测试集的准确率只有大约 10%。

绘制一下我们的训练曲线:

1
2
3
4
5
6
py复制代码fig = plt.figure()
plt.plot(train_counter, train_losses, color='blue')
plt.scatter(test_counter, test_losses, color='red')
plt.legend(['Train Loss', 'Test Loss'], loc='upper right')
plt.xlabel('number of training examples seen')
plt.ylabel('negative log likelihood loss')

image.png

看起来我们甚至可以继续训练几个 epoch!

但在此之前,让我们再看几个例子,就像我们之前所做的一样,并比较模型的输出。

1
2
py复制代码with torch.no_grad():
output = network(example_data)
1
2
3
4
5
6
7
8
9
py复制代码fig = plt.figure()
for i in range(6):
plt.subplot(2,3,i+1)
plt.tight_layout()
plt.imshow(example_data[i][0], cmap='gray', interpolation='none')
plt.title("Prediction: {}".format(
output.data.max(1, keepdim=True)[1][i].item()))
plt.xticks([])
plt.yticks([])

image.png

我们模型的预测似乎与这些示例相符!

继续训练

现在让我们继续训练网络,或者更确切地说,看看我们如何从我们在第一次训练运行期间保存的 state_dicts 继续训练。我们将初始化一组新的网络和优化器。

1
2
3
py复制代码continued_network = Net()
continued_optimizer = optim.SGD(network.parameters(), lr=learning_rate,
momentum=momentum)

使用 .load_state_dict() 可以加载上次保存时网络和优化器的内部状态。

1
2
3
4
5
py复制代码network_state_dict = torch.load(f'{BASEPATH}/results/model.pth')
continued_network.load_state_dict(network_state_dict)

optimizer_state_dict = torch.load(f'{BASEPATH}/results/optimizer.pth')
continued_optimizer.load_state_dict(optimizer_state_dict)

再次运行一个训练循环,应该从我们离开的地方继续训练。

要检查这一点,让我们简单地使用与以前相同的列表来跟踪损失值

由于我们为看到的训练示例数量构建测试计数器,因此我们必须在此处手动追加。

1
2
3
4
py复制代码for i in range(4,9):
test_counter.append(i*len(train_loader.dataset))
train(i)
test()

image.png

我们再次看到测试集准确性从一个时期到另一个时期的增加(慢得多)。让我们将其可视化以进一步检查训练进度。

1
2
3
4
5
6
py复制代码fig = plt.figure()
plt.plot(train_counter, train_losses, color='blue')
plt.scatter(test_counter, test_losses, color='red')
plt.legend(['Train Loss', 'Test Loss'], loc='upper right')
plt.xlabel('number of training examples seen')
plt.ylabel('negative log likelihood loss')

image.png

这看起来仍然是一条相当平滑的学习曲线,就像我们最初会训练 8 个 epoch 一样!请记住,我们只是从第 5 个红点开始将值附加到相同的列表中。

由此我们可以得出两个结论:

  1. 从检查点内部状态继续按预期工作。
  2. 我们似乎仍然没有遇到过拟合问题!看起来我们的 dropout 层在规范化模型方面做得很好。

后记

MNIST 手写数字识别的内容到这里就结束了;

PyTorch 和 TorchVision 构建了一个新环境,用它来分类 MNIST 数据集中的手写数字,并希望使用 PyTorch 开发出良好的直觉。

📝 上篇精讲:【项目实战】MNIST 手写数字识别(上)

💖 我是 𝓼𝓲𝓭𝓲𝓸𝓽,期待你的关注;

👍 创作不易,请多多支持;

🔥 系列专栏: 项目实战 AI

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

重新认识下JVM级别的本地缓存框架Guava Cache——

发表于 2022-10-31

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!


大家好,又见面了。

不知不觉,这已经是《深入理解缓存原理与实战设计》系列专栏的第6篇文章了。经过前面5篇文章的铺垫,我们系统且全面的介绍了缓存相关的概念与典型问题,也手动实操了如何构建一个本地最简版本的通用缓存框架,还对JAVA主流的本地缓存规范进行了解读。

秉持着不重复造轮子的理念,本篇文章中,我们就来一起深入剖析JAVA本地缓存的优秀“轮子” —— 来自Google家族的Guava Cache。聊一聊其实现机制、看一看如何使用。

Guava Cache初识

Guava是Google提供的一套JAVA的工具包,而Guava Cache则是该工具包中提供的一套完善的JVM级别的高并发缓存框架。其实现机制类似ConcurrentHashMap,但是进行了众多的封装与能力扩展。作为JVM级别的本地缓存框架,Guava Cache具备缓存框架该有的众多基础特性。当然,Guava Cache能从众多本地缓存类产品中脱颖而出,除了具备上述基础缓存特性外,还有众多贴心的能力增强,绝对算得上是工具包届的超级暖男!为什么这么说呢?我们一起看下Guava Cache的能力介绍,应该可以有所体会。

支持缓存记录的过期设定

作为一个合格的缓存容器,支持缓存记录过期是一个基础能力。Guava Cache不但支持设定过期时间,还支持选择是根据插入时间进行过期处理(创建过期)、或者是根据最后访问时间进行过期处理(访问过期)。

过期策略 具体说明
创建过期 基于缓存记录的插入时间判断。比如设定10分钟过期,则记录加入缓存之后,不管有没有访问,10分钟时间到则
访问过期 基于最后一次的访问时间来判断是否过期。比如设定10分钟过期,如果缓存记录被访问到,则以最后一次访问时间重新计时;只有连续10分钟没有被访问的时候才会过期,否则将一直存在缓存中不会被过期。

实际使用的时候,可以在创建缓存容器的时候指定过期策略即可:

  • 基于创建时间过期
1
2
3
4
5
java复制代码public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterWrite(30L, TimeUnit.MINUTES)
.build();
}
  • 基于访问时间过期
1
2
3
4
5
java复制代码public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.expireAfterAccess(30L, TimeUnit.MINUTES)
.build();
}

是不是很方便?

支持缓存容量限制与不同淘汰策略

作为内存型缓存,必须要防止出现内存溢出的风险。Guava Cache支持设定缓存容器的最大存储上限,并支持根据缓存记录条数或者基于每条缓存记录的权重(后面会具体介绍)进行判断是否达到容量阈值。

当容量触达阈值后,支持根据FIFO + LRU策略实施具体淘汰处理以腾出位置给新的记录使用。

淘汰策略 具体说明
FIFO 根据缓存记录写入的顺序,先写入的先淘汰
LRU 根据访问顺序,淘汰最久没有访问的记录

实际使用的时候,同样是在创建缓存容器的时候指定容量上限与淘汰策略,这样就可以放心大胆的使用而不用担心内存溢出问题咯。

  • 限制缓存记录条数
1
2
3
4
5
java复制代码public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumSize(10000L)
.build();
}
  • 限制缓存记录权重
1
2
3
4
5
6
java复制代码public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(10000L)
.weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
.build();
}

这里需要注意:按照权重进行限制缓存容量的时候必须要指定weighter属性才可以生效。上面代码中我们通过计算value对象的字节数(byte)来计算其权重信息,每1kb的字节数作为1个权重,整个缓存容器的总权重限制为1w,这样就可以实现将缓存内存占用控制在10000*1k≈10M左右。

有没有很省心?

支持集成数据源能力

在前面文章中,我们有介绍过缓存的三种模型,分别是旁路型、穿透型、异步型。Guava Cache作为一个封装好的缓存框架,是一个典型的穿透型缓存。正常业务使用缓存时通常会使用旁路型缓存,即先去缓存中尝试查询获取数据,如果获取不到则会从数据库中进行查询并加入到缓存中;而为了简化业务端使用复杂度,Guava Cache支持集成数据源,业务层面调用接口查询缓存数据的时候,如果缓存数据不存在,则会自动去数据源中进行数据获取并加入缓存中。

1
2
3
4
5
6
7
8
9
10
11
java复制代码public User findUser(Cache<String, User> cache, String userId) {
try {
return cache.get(userId, () -> {
System.out.println(userId + "用户缓存不存在,尝试回源查找并回填...");
return userDao.getUser(userId);
});
} catch (ExecutionException e) {
e.printStackTrace();
}
return null;
}

实际使用的时候如果查询的用户不存在,则会自动去回源查找并写入缓存里,再次获取的时候便可以从缓存直接获取:

上面的方法里,是通过在get方法里传入Callable实现的方式指定回源获取数据的方式,来实现缓存不存在情况的自动数据拉取与回填到缓存中的。实际使用的时候,除了Callable方式,还有一种CacheLoader的模式,也可以实现这一效果。

需要我们在创建缓存容器的时候声明容器为LoadingCache类型(下面的章节中有介绍),并且指定CacheLoader处理逻辑:

1
2
3
4
5
6
7
8
9
10
java复制代码public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
return userDao.getUser(key);
}
});
}

这样,获取不到数据的时候,也会自动回源查询并填充。比如我们执行如下调用逻辑:

1
2
3
4
5
6
7
8
9
10
11
java复制代码    public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123"));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}

执行结果如下:

1
2
3
4
5
ini复制代码123用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)

两种方式都可以实现这一效果,实际可以根据需要与场景选择合适的方式。

当然,有些时候,可能也会涉及到CacheLoader与Callable两种方式结合使用的场景,这种情况下优先会执行Callable提供的逻辑,Callable缺失的场景会使用CacheLoader提供的逻辑。

1
2
3
4
5
6
7
8
9
10
11
java复制代码public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache();
try {
System.out.println(cache.get("123", () -> new User("xxx")));
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
} catch (Exception e) {
e.printStackTrace();
}
}

执行后,可以看出Callable逻辑被优先执行,而CacheLoader作为兜底策略存在:

1
2
3
4
sql复制代码User(userId=xxx, userName=null, department=null)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=xxx, userName=null, department=null)

支持更新锁定能力

这个是与上面数据源集成一起的辅助增强能力。在高并发场景下,如果某个key值没有命中缓存,大量的请求同步打到下游模块处理的时候,很容易造成缓存击穿问题。

为了防止缓存击穿问题,可以通过加锁的方式来规避。当缓存不可用时,仅持锁的线程负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。

作为穿透型缓存的保护策略之一,Guava Cache自带了并发锁定机制,同一时刻仅允许一个请求去回源获取数据并回填到缓存中,而其余请求则阻塞等待,不会造成数据源的压力过大。

有没有被暖心到?

提供了缓存相关的一些监控统计

引入缓存的一个初衷是希望缓存能够提升系统的处理性能,而有限缓存容量中仅存储部分数据的时候,我们会希望存储的有限数据可以尽可能的覆盖并抗住大部分的请求流量,所以对缓存的命中率会非常关注。

Guava Cache深知这一点,所以提供了stat统计日志,支持查看缓存数据的加载或者命中情况统计。我们可以基于命中情况,不断的去优化代码中缓存的数据策略,以发挥出缓存的最大价值。

Guava Cache的统计信息封装为CacheStats对象进行承载,主要包含一下几个关键指标项:

指标 含义说明
hitCount 命中缓存次数
missCount 没有命中缓存次数(查询的时候内存中没有)
loadSuccessCount 回源加载的时候加载成功次数
loadExceptionCount 回源加载但是加载失败的次数
totalLoadTime 回源加载操作总耗时
evictionCount 删除记录的次数

缓存容器创建的时候,可以通过recordStats()开启缓存行为的统计记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码    public static void main(String[] args) {
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.recordStats()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
User user = userDao.getUser(key);
if (user == null) {
System.out.println(key + "用户不存在");
}
return user;
}
});

try {
System.out.println(cache.get("123");
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
System.out.println(cache.get("126"));

} catch (Exception e) {
} finally {
CacheStats stats = cache.stats();
System.out.println(stats);
}
}

上述代码执行之后结果输出如下:

1
2
3
4
5
6
7
8
ini复制代码123用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)
126用户缓存不存在,尝试CacheLoader回源查找并回填...
126用户不存在
CacheStats{hitCount=1, missCount=3, loadSuccessCount=2, loadExceptionCount=1, totalLoadTime=1972799, evictionCount=0}

可以看出,一共执行了4次请求,其中1次命中,3次回源处理,2次回源加载成功,1次回源没找到数据,与打印出来的CacheStats统计结果完全吻合。

有着上述能力的加持,前面将Guava Cache称作“暖男”不过分吧?

Guava Cache适用场景

在本系列专栏的第一篇文章《聊一聊作为高并发系统基石之一的缓存,会用很简单,用好才是技术活》中,我们在缓存的一步步演进介绍中提过本地缓存与集中式缓存的区别,也聊了各自的优缺点。

作为一款纯粹的本地缓存框架,Guava Cache具备本地缓存该有的优势,也无可避免的存在着本地缓存的弊端。

维度 简要概述
优势 基于空间换时间的策略,利用内存的高速处理效率,提升机器的处理性能,减少大量对外的IO请求交互,比如读取DB、请求外部网络、读取本地磁盘数据等等操作。
弊端 整体容量受限,可能对本机内存造成压力。此外,对于分布式多节点集群部署的场景,缓存更新场景会出现缓存漂移问题,导致各个节点之间的缓存数据不一致。

鉴于上述优劣综合判断,可以大致圈定Guava Cache的实际适用场合:

  • 数据读多写少且对一致性要求不高的场景

这类场景中,会将数据缓存到本地内存中,采用定时触发(或者事件推送)的策略重新加载到内存中。这样业务处理逻辑直接从内存读取需要的数据,修改系统配置项之后,需要等待一定的时间后方可生效。

很多的配置中心采用的都是这个缓存策略。统一配置中心中管理配置数据,然后各个业务节点会从统一配置中心拉取配置并存储在自己本地的内存中然后使用本地内存中的数据。这样可以有效规避配置中心的单点故障问题,降低了配置中心的请求压力,也提升了业务节点自身的业务处理性能(减少了与配置中心之间的网络交互请求)。

  • 对性能要求极其严苛的场景

对于分布式系统而言,集中式缓存是一个常规场景中很好的选项。但是对于一些超大并发量且读性能要求严苛的系统而言,一个请求流程中需要频繁的去与Redis交互,其网络开销也是不可忍受的。所以可以采用将数据本机内存缓存的方式,分散redis的压力,降低对外请求交互的次数,提升接口响应速度。

  • 简单的本地数据缓存,作为HashMap/ConcurrentHashMap的替代品

这种场景也很常见,我们在项目中经常会遇到一些数据的需要临时缓存一下,为了方便很多时候直接使用的HashMap或者ConcurrentHashMap来实现。而Guava Cache聚焦缓存场景做了很多额外的功能增强(比如数据过期能力支持、容量上限约束等),可以完美替换掉HashMap/ConcurrentHashMap,更适合缓存场景使用。

Guava Cache使用

引入依赖

使用Guava Cache,首先需要引入对应的依赖包。对于Maven项目,可以在pom.xml中添加对应的依赖声明即可:

1
2
3
4
5
xml复制代码<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>

这样,就完成了依赖引入。

容器创建 —— CacheBuilder

具体使用前首先面临的就是如何创建Guava Cache实例。可以借助CacheBuilder以一种优雅的方式来构建出合乎我们诉求的Cache实例。

对CacheBuilder中常见的属性方法,归纳说明如下:

方法 含义说明
newBuilder 构造出一个Builder实例类
initialCapacity 待创建的缓存容器的初始容量大小(记录条数)
maximumSize 指定此缓存容器的最大容量(最大缓存记录条数)
maximumWeight 指定此缓存容器的最大容量(最大比重值),需结合weighter方可体现出效果
expireAfterWrite 设定过期策略,按照数据写入时间进行计算
expireAfterAccess 设定过期策略,按照数据最后访问时间来计算
weighter 入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比情况。这个需要与maximumWeight结合使用
refreshAfterWrite 缓存写入到缓存之后
concurrencyLevel 用于控制缓存的并发处理能力,同时支持多少个线程并发写入操作
recordStats 设定开启此容器的数据加载与缓存命中情况统计

基于CacheBuilder及其提供的各种方法,我们可以轻松的进行缓存容器的构建、并指定容器的各种约束条件。

比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.initialCapacity(1000) // 初始容量
.maximumSize(10000L) // 设定最大容量
.expireAfterWrite(30L, TimeUnit.MINUTES) // 设定写入过期时间
.concurrencyLevel(8) // 设置最大并发写操作线程数
.refreshAfterWrite(1L, TimeUnit.MINUTES) // 设定自动刷新数据时间
.recordStats() // 开启缓存执行情况统计
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return userDao.getUser(key);
}
});
}

业务层使用

Guava Cache容器对象创建完成后,可以基于其提供的对外接口完成相关缓存的具体操作。首先可以了解下Cache提供的对外操作接口:

对关键接口的含义梳理归纳如下:

接口名称 具体说明
get 查询指定key对应的value值,如果缓存中没匹配,则基于给定的Callable逻辑去获取数据回填缓存中并返回
getIfPresent 如果缓存中存在指定的key值,则返回对应的value值,否则返回null(此方法不会触发自动回源与回填操作)
getAllPresent 针对传入的key列表,返回缓存中存在的对应value值列表(不会触发自动回源与回填操作)
put 往缓存中添加key-value键值对
putAll 批量往缓存中添加key-value键值对
invalidate 从缓存中删除指定的记录
invalidateAll 从缓存中批量删除指定记录,如果无参数,则清空所有缓存
size 获取缓存容器中的总记录数
stats 获取缓存容器当前的统计数据
asMap 将缓存中的数据转换为ConcurrentHashMap格式返回
cleanUp 清理所有的已过期的数据

在项目中,可以基于上述接口,实现各种缓存操作功能。

1
2
3
4
5
6
7
8
9
10
java复制代码public static void main(String[] args) {
CacheService cacheService = new CacheService();
LoadingCache<String, User> cache = cacheService.createUserCache6();
cache.put("122", new User("122"));
cache.put("122", new User("122"));
System.out.println("put操作后查询:" + cache.getIfPresent("122"));
cache.invalidate("122");
System.out.println("invalidate操作后查询:" + cache.getIfPresent("122"));
System.out.println(cache.stats());
}

执行后,结果如下:

1
2
3
ini复制代码put操作后查询:User(userId=122, userName=null, department=null)
invalidate操作后查询:null
CacheStats{hitCount=1, missCount=1, loadSuccessCount=0, loadExceptionCount=0, totalLoadTime=0, evictionCount=0}

当然,上述示例代码中这种使用方式有个明显的弊端就是业务层面对Guava Cache的私有API依赖过深,后续如果需要替换Cache组件的时候会比较痛苦,需要对业务调用的地方进行大改。所以真正项目里面,最好还是对其适当封装,以实现业务层面的解耦。如果你的项目是使用Spring框架,也可以基于Spring Cache统一规范来集成并使用Guava Cache,降低对业务逻辑的侵入。

小结回顾

好啦,关于Guava Cache的功能与关键特性介绍,以及项目中具体的集成与使用方法,就介绍到这里了。总结一下,Guava Cache其实就是一个增强版的大号ConcurrentHashMap,在保证线程安全的情况下,增加了缓存必备的数据过期、容量限制、回源策略等能力,既保证了本身的精简,又使得整体能力足以满足大部分本地缓存场景的使用诉求。也正是由于这些原因,Guava Cache在JAVA领域广受好评,使用范围非常的广泛。

下一篇文章中,我们将继续对Guava Cache展开讨论,跳出使用层面,剖析其内部核心实现逻辑。如果有兴趣,欢迎关注后续文章的更新。

那么,关于本文中提及的内容,你是否有自己的一些想法与见解呢?欢迎评论区一起交流下,期待和各位小伙伴们一起切磋、共同成长。

📣 补充说明1 :

本文属于《深入理解缓存原理与实战设计》系列专栏的内容之一。该专栏围绕缓存这个宏大命题进行展开阐述,全方位、系统性地深度剖析各种缓存实现策略与原理、以及缓存的各种用法、各种问题应对策略,并一起探讨下缓存设计的哲学。

如果有兴趣,也欢迎关注此专栏。

📣 补充说明2 :

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 开源库

发表于 2022-10-31

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。

本文是 Android 开源库系列的第 5 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

Gson 是 Google 推出的 Java Json 解析库,具有接入成本低、使用便捷、功能扩展性良好等优点,想必大家都很熟悉了。在这篇文章里,我们将讨论 Gson 的基本用法和以及主要流程的源码分析。


学习路线图:


  1. Gson 的基本使用

Gradle 依赖

1
2
3
groovy复制代码dependencies {
implementation 'com.google.code.gson:gson:2.10'
}

1.1 GsonBuilder 配置项

Gson 类是整个库的核心 API,在进行任何序列化或反序列化之前,我们都需要获得一个 Gson 对象。可以直接 new 创建默认配置的 Gson 对象,也可以使用 GsonBuilder 构造者配置 Gson 对象。

事实上,一个 Gson 对象代表一个 Gson 工作环境,不同 Gson 对象之间的配置和缓存都不会复用。 因此,在项目中有必要在 common 层提供一个全局的 Gson 对象,既有利于统一序列化配置,也是 Gson 性能优化的基本保障。

GsonBuilder 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
groovy复制代码Gson gson = new GsonBuilder()
// 设置自定义解析(不支持协变)
.registerTypeAdapter(Id.class, new IdTypeAdapter())
// 设置自定义解析(支持协变)
registerTypeHierarchyAdapter(List.class, new MyListTypeAdapter())
// 设置自定义解析(以工厂方式)
.registerTypeAdapterFactory(new IdTypeAdapterFactory())
// 设置日期格式
.setDateFormat("yyyy-MM-dd HH:mm:ss:SSS")
// 设置自动切换命名风格规则(默认不切换命名风格)
.setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
// 设置过滤指定字段标识符(默认只过滤 transient 和 static 字段)
.excludeFieldsWithModifiers(Modifier.TRANSIENT | Modifier.STATIC)
// 设置类或字段过滤规则
.setExclusionStrategies(new MyExclusionStrategy1())
// 设置过滤规则(只适用于序列化)
.addSerializationExclusionStrategy(new MyExclusionStrategy2())
// 设置过滤规则(只适用于反序列化)
.addDeserializationExclusionStrategy(new MyExclusionStrategy3())
// 设置序列化版本号
.setVersion(1.0)
// 启用非基础类型 Map Key
.enableComplexMapKeySerialization()
// 启用不过滤空值(默认会过滤空值)
.serializeNulls()
// 启用 Json 格式化
.setPrettyPrinting()
.create();

1.2 注解配置

Gson 没有编译时处理,所以注解均是运行时注解。

  • @SerializedName 字段别名: 支持设置多个别名,value 变量在序列化和反序列化时都有效,而 alternate 变量只是在反序列化时做兼容而已;
  • @Expose 字段暴露:
    • 默认情况下,一个类中所有字段都会暴露,但使用 @Expose 注解后,只有声明注解的字段才会暴露;
    • 注解的 serialize 变量或 deserialize 变量可以声明字段只参与序列化或反序列化,默认都参与。
  • @JsonAdapter 注解: 声明在具体类或字段上,用于更细粒度地设置 TypeAdapter,优先级比 registerTypeAdapter 高;
  • @Since 注解: 声明在具体类或字段上,声明字段的起始序列化版本;
  • @Until 注解: 声明在具体类或字段上,声明字段的终止序列化版本。当字段的序列化版本满足 since ≥ GsonBuilder#setVersion 且 GsonBuilder#setVersion ≤ until 时,才会参与序列化;

1.3 JsonSerializer 和 JsonDeserializer 自定义解析

JsonSerializer 和 JsonDeserializer 是 Gson 1.x 版本提供的自定义解析 API,是基于树型结构的解析 API。在解析数据时,它们会将 Json 数据一次性解析为 JsonElement 树型结构。 JsonElement 代表 Json 树上的一个节点,有 4 种具体类型:

JsonElement 描述
JsonObject {} 对象
JsonArray [] 数组
JsonPrimitive 基本类型
JsonNull null 值

1.4 TypeAdapter 自定义解析

TypeAdapter 是 Gson 2.0 新增的自定义解析 API,是基于流式结构的 API。事实上, JsonSerializer 和 JsonDeserializer 最终也会被构造为 TreeTypeAdapter;

相较之下,JsonSerializer & JsonDeserializer 相对方便,但更费内存。而 TypeAdapter 更节省内存,但不方便。不过,如果需要用到完整数据结构(例如根据 type 字段按照不同类型解析 data),也可以手动解析为树型结构。因此 TypeAdapter 这个 API 的优先级更高。

TypeAdapter JsonSerializer、JsonDeserializer
引入版本 2.0 1.x
Stream API 支持 不支持
Tree API 支持,可以手动转换 支持
内存占用 小 比 TypeAdapter 大
效率 高 比 TypeAdapter 低
作用范围 序列化 + 反序列化 序列化 / 反序列化

1.5 registerTypeAdapter 和 registerTypeHierarchyAdapter 的区别

  • registerTypeAdapter 是不变型的: 只会对注册的类型生效。例如注册 <List.class,TypeAdapter> ,则只会影响 List 类型的字段,但不会影响 ArrayList 类型的字段;
  • registerTypeHierarchyAdapter 是协变型的: 会对注册的类型及其子类生效。例如注册 <List.class,TypeAdapter>,则只会影响 List、ArrayList 类型的字段;
registerTypeAdapter registerTypeHierarchyAdapter
支持泛型 是 否
支持继承 否 是

  1. Gson 源码分析

这一节,我们来分析 Gson 核心流程的工作原理和源码。

2.1 说一下 Gson 解析的工作过程

“TypeAdapter” 是 Gson 解析的重要角色,Gson 每次解析一种对象类型,首先需要创建一个 TypeAdapter 对象,之后所有的解析工作都会交给其中的 TypeAdapter#write 和 TypeAdapter#read 方法;

Java Bean 类型的 TypeAdapter 对象是交给 “ReflectiveTypeAdapterFactory” 创建的。每创建一种类型的 TypeAdapter,都需要递归地使用 “反射” 遍历所有字段,并解析字段上的注解,生成一个 <serializeName - BoundFiled> 的映射表。

  • 在序列化时,首先使用反射获取字段值,再使用字段的 BoundFiled 序列化;
  • 在反序列化时,首先创建对象实例(下文会讨论如何创建),再使用依次使用字段的 BoundField 反序列为字段类型的值,再通过反射为字段赋值。

由于字段值的写入和读取是通过 Field 元数据反射操作的,所以 private 字段也可以操作。

在构造 Gson 对象时,已经初始化了一系列 TypeAdapter 创建工厂,开发者可以注册自定义的 TypeAdapter:

Gson.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码Gson(final Excluder excluder, ...) {
List<TypeAdapterFactory> factories = new ArrayList<TypeAdapterFactory>();

// built-in type adapters that cannot be overridden
factories.add(TypeAdapters.JSON_ELEMENT_FACTORY);
factories.add(ObjectTypeAdapter.FACTORY);

// 过滤规则
factories.add(excluder);

// 自定义 TypeAdapter
factories.addAll(factoriesToBeAdded);

// 1. 基础类型
factories.add(TypeAdapters.STRING_FACTORY);
factories.add(TypeAdapters.INTEGER_FACTORY);
...
// 2. 复合类型
// 2.1 列表类型
factories.add(new CollectionTypeAdapterFactory(constructorConstructor));
// 2.2 集合类型
factories.add(new MapTypeAdapterFactory(constructorConstructor, complexMapKeySerialization));
this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor);
factories.add(jsonAdapterFactory);
// 2.3 枚举类型
factories.add(TypeAdapters.ENUM_FACTORY);
// 2.4 Java Bean 类型
factories.add(new ReflectiveTypeAdapterFactory(constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory));
}

通过 Gson#getAdapter 查找匹配 TypeAdapter 的方法:

Gson.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码// TypeAdapter 缓存映射表 <Type - TypeAdapter>
private final Map<TypeToken<?>, TypeAdapter<?>> typeTokenCache = new ConcurrentHashMap<TypeToken<?>, TypeAdapter<?>>();

public <T> TypeAdapter<T> getAdapter(TypeToken<T> type) {
// 先从映射表缓存中查找
TypeAdapter<?> cached = typeTokenCache.get(type);
if (cached != null) {
return (TypeAdapter<T>) cached;
}
// 再通过 TypeAdapter 创建工厂创建,并加入映射表缓存中
for (TypeAdapterFactory factory : factories) {
// 从前到后线性扫描创建工厂,找到合适的 TypeAdapter
TypeAdapter<T> candidate = factory.create(this, type);
if (candidate != null) {
// 加入缓存中
typeTokenCache.put(type, candidate);
return candidate;
}
}
}

使用 ReflectiveTypeAdapterFactory 工厂为每种类型创建 TypeAdapter 对象:

ReflectiveTypeAdapterFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
java复制代码// 1. 创建 TypeAdapter 对象
@Override
public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
Class<? super T> raw = type.getRawType();
// 检查是否为 Object 类型
if (!Object.class.isAssignableFrom(raw)) {
return null; // it's a primitive!
}
// 1.1 获取对象构造器(下文分析)
ObjectConstructor<T> constructor = constructorConstructor.get(type);
// 1.2 getBoundFields:解析每个字段的适配器
// 1.3 FieldReflectionAdapter:TypeAdapter 对象(Adapter 类型)
return new FieldReflectionAdapter<T>(constructor, getBoundFields(gson, type, raw));
}

public static abstract class Adapter<T, A> extends TypeAdapter<T> {
final Map<String, BoundField> boundFields;

// 2. 反序列化过程
@Override
public T read(JsonReader in) {
// 2.1 创建对象
T instance = constructor.construct();
// 2.2 消费 {
in.beginObject();
// 2.3 递归反序列化每个字段
while (in.hasNext()) {
String name = in.nextName();
BoundField field = boundFields.get(name);
if (field == null || !field.deserialized) {
in.skipValue();
} else {
// 读取流并设置到 instance 对象中
readIntoField(in, instance);
}
}
// 2.4 消费 }
in.endObject();
return instance;
}

// 3. 序列化过程
@Override
public void write(JsonWriter out, T value) {
// 3.1 写入 {
out.beginObject();
// 3.2 递归序列化每个字段
for (BoundField boundField : boundFields.values()) {
// 将对象的每个字段写入流中
boundField.write(out, value);
}
// 3.3 写入 }
out.endObject();
}
}

// -> 1.2 getBoundFields:解析每个字段的适配器
private Map<String, BoundField> getBoundFields(Gson context, TypeToken<?> type, Class<?> raw, boolean blockInaccessible, boolean isRecord) {
// 1.2.1 映射表
Map<String, BoundField> result = new LinkedHashMap<>();
if (raw.isInterface()) {
return result;
}
...
// 1.2.2 遍历所有 Field
Field[] fields = raw.getDeclaredFields();
for (Field field : fields) {
// 1.2.2.1 字段过滤
boolean serialize = includeField(field, true);
boolean deserialize = includeField(field, false);
if (!serialize && !deserialize) {
continue;
}
// 1.2.2.2 获取字段的所有别名,第 0 位是主名称
List<String> fieldNames = getFieldNames(field);
// 1.2.2.3 为所有字段别名创建 BoundField 对象
for (int i = 0, size = fieldNames.size(); i < size; ++i) {
String name = fieldNames.get(i);
// serialize = false 这一行说明:序列化时是采用字段的主名称
if (i != 0) serialize = false;
BoundField boundField = createBoundField(context, field, accessor, name, TypeToken.get(fieldType), serialize, deserialize, blockInaccessible);
BoundField replaced = result.put(name, boundField);
if (previous == null) previous = replaced;
}
// 1.2.2.4 存在两个的字段使用相同 serializeName 的冲突
if (previous != null) {
throw new IllegalArgumentException(declaredType + " declares multiple JSON fields named " + previous.name);
}
}
// 1.2.3 返回映射表
return result;
}

// -> 1.2.2.3 为所有字段别名创建 BoundField 对象
private ReflectiveTypeAdapterFactory.BoundField createBoundField(
final Gson context, final Field field, final String name,
final TypeToken<?> fieldType, boolean serialize, boolean deserialize) {
// 基本类型
final boolean isPrimitive = Primitives.isPrimitive(fieldType.getRawType());
// @JsonAdapter 注解
JsonAdapter annotation = field.getAnnotation(JsonAdapter.class);
TypeAdapter<?> mapped = null;
if (annotation != null) {
mapped = jsonAdapterFactory.getTypeAdapter(constructorConstructor, context, fieldType, annotation);
}
final boolean jsonAdapterPresent = mapped != null;
if (mapped == null) mapped = context.getAdapter(fieldType);
final TypeAdapter<?> typeAdapter = mapped;
return new ReflectiveTypeAdapterFactory.BoundField(name, serialize, deserialize) {

@Override
void write(JsonWriter writer, Object value) {
if (!serialized) return;
// 通过反射读取字段值
Object fieldValue = field.get(value);
TypeAdapter t = jsonAdapterPresent ? typeAdapter : new TypeAdapterRuntimeTypeWrapper(context, typeAdapter, fieldType.getType());
// 写出到流
t.write(writer, fieldValue);
}

@Override
void readIntoField(JsonReader reader, Object target) {
// 从流读取
Object fieldValue = typeAdapter.read(reader);
// 通过反射写入字段值
field.set(target, fieldValue);
}
};
}

2.2 List & Set & Map 等容器类型是如何解析的?

  • 1、在预置的容器 TypAdapter 中,会先通过容器类型的 RawType 获取容器构造器,再根据泛型实参 elementType 获取元素类型的 TypeAdapter;
  • 2、在序列化时,先写入 [ 左中括号,再用元素类型的 TypeAdapter 依次序列化元素对象,再写入 ] 右中括号;
  • 3、在反序列化时,先创建集合对象,再用元素类型的 TypeAdapter 依次反序列化元素对象;
  • 4、Map 类型需要维护 Key 和 Value 两个 TypeAdapter。

CollectionTypeAdapterFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
java复制代码// 1. 创建 TypeAdapter
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
Type type = typeToken.getType();
// 检查是否为列表类型
Class<? super T> rawType = typeToken.getRawType();
if (!Collection.class.isAssignableFrom(rawType)) {
return null;
}
// 1.1 解析元素类型
Type elementType = $Gson$Types.getCollectionElementType(type, rawType);
// 1.2 查找元素类型映射的 TypeAdapter
TypeAdapter<?> elementTypeAdapter = gson.getAdapter(TypeToken.get(elementType));
// 1.3 解析容器对象的构造器
ObjectConstructor<T> constructor = constructorConstructor.get(typeToken);
// 1.4 包装新的 TypeAdapter
TypeAdapter<T> result = new Adapter(gson, elementType, elementTypeAdapter, constructor);
}

private static final class Adapter<E> extends TypeAdapter<Collection<E>> {
// 2. 反序列化过程
@Override
public Collection<E> read(JsonReader in) {
// 2.1 创建容器对象
Collection<E> collection = constructor.construct();
// 2.2 消费 [
in.beginArray();
// 2.3 使用 1.2 步骤的 TypeAdapter 反序列化每个元素
while (in.hasNext()) {
E instance = elementTypeAdapter.read(in);
collection.add(instance);
}
// 2.4 消费 ]
in.endArray();
return collection;
}

// 3. 序列化过程
@Override
public void write(JsonWriter out, Collection<E> collection) {
// 3.1 写入 [
out.beginArray();
// 3.2 使用 1.2 步骤的 TypeAdapter 序列化每个元素
for (E element : collection) {
elementTypeAdapter.write(out, element);
}
// 3.3 写入 ]
out.endArray();
}
}

2.3 枚举类型是如何解析的?

  • 1、在预置的 EnumTypeAdapter 适配器中,会先获取枚举类型的整个枚举列表,并生成 2 个映射表。
    • <name - 枚举> 映射表
    • <枚举 - name> 映射表
  • 2、在序列化时,会写入枚举的 name。在反序列化时,会根据 name 查询枚举对象。

TypeAdapters.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码private static final class EnumTypeAdapter<T extends Enum<T>> extends TypeAdapter<T> {
// <name - 枚举> 映射表
private final Map<String, T> nameToConstant = new HashMap<String, T>();
// <枚举 - name> 映射表
private final Map<T, String> constantToName = new HashMap<T, String>();

public EnumTypeAdapter(Class<T> classOfT) {
for (T constant : classOfT.getEnumConstants()) {
String name = constant.name()
nameToConstant.put(name, constant);
constantToName.put(constant, name);
}
}

@Override
public T read(JsonReader in) {
return nameToConstant.get(in.nextString());
}

@Override
public void write(JsonWriter out, T value) {
out.value(constantToName.get(value));
}
}

2.4 同类型嵌套会不会无限递归?

ReflectiveTypeAdapterFactory 在创建对象的 TypeAdapter 适配器时,需要递归的创建每个字段的 TypeAdapter。如果字段的类型正好与类的类型相同,那么又会触发创建一个相同的 TypeAdapter,造成无限递归。例如:

无限递归的例子

1
2
3
java复制代码public class Article {
public Article linkedArticle;
}

分析发现,无限递归只发生在第一次创建某个 Java Bean 类型的 TypeAdapter 时,而下一次会从缓存获取,不会发生无限递归。因此,Gson 的做法是:

  • 1、在每次新创建 TypeAdapter 前,先在临时映射表中创建一个 FutureTypeAdapter 代理对象。在创建真实的 TypeAdapter 后,将其注入到代理对象中。这样在递归获取字段的 TypeAdapter 时,就会拿到代理对象,而不是重新创建 TypeAdapter,因此解决递归问题;
  • 2、另外,考虑到多线程环境下,临时映射表的新增和移除会有并发问题,因此 Gson 的策略是使用 ThreadLocal 隔离各个线程的临时映射表。

Gson.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
java复制代码// 线程隔离的映射表
private final ThreadLocal<Map<TypeToken<?>, FutureTypeAdapter<?>>> calls = new ThreadLocal<Map<TypeToken<?>, FutureTypeAdapter<?>>>();
// 线程共享的映射表(基于 ConrurrentHashMap)
private final Map<TypeToken<?>, TypeAdapter<?>> typeTokenCache = new ConcurrentHashMap<TypeToken<?>, TypeAdapter<?>>();

public <T> TypeAdapter<T> getAdapter(TypeToken<T> type) {
// 1. 尝试从缓存获取
TypeAdapter<?> cached = typeTokenCache.get(type);
if (cached != null) {
return (TypeAdapter<T>) cached;
}

// 2. 初始化当前线程的临时映射表
Map<TypeToken<?>, FutureTypeAdapter<?>> threadCalls = calls.get();
boolean requiresThreadLocalCleanup = false;
if (threadCalls == null) {
threadCalls = new HashMap<TypeToken<?>, FutureTypeAdapter<?>>();
calls.set(threadCalls);
requiresThreadLocalCleanup = true;
}

// 3. 尝试从临时映射表获取(递归调用时,会从这里获取到代理 TypeAdapter,而不会走到下面的 factory.create
FutureTypeAdapter<T> ongoingCall = (FutureTypeAdapter<T>) threadCalls.get(type);
if (ongoingCall != null) {
return ongoingCall;
}

try {
// 4.1 创建代理 TypeAdapter
FutureTypeAdapter<T> call = new FutureTypeAdapter<T>();
threadCalls.put(type, call);

for (TypeAdapterFactory factory : factories) {
// 4.2 创建 TypeAdapter
TypeAdapter<T> candidate = factory.create(this, type);
// 4.3 将真实的 TypeAdapter 注入到代理 TypeAdapter 中
if (candidate != null) {
call.setDelegate(candidate);
// 4.4 将 TypeAdapter 写入缓存
typeTokenCache.put(type, candidate);
return candidate;
}
}
} finally {
// 5. 清除临时映射表
threadCalls.remove(type);

if (requiresThreadLocalCleanup) {
calls.remove();
}
}
}

2.5 Gson 是如何创建对象的?

  • 1、基础类型:Integer、Calendar 等基础类型由固定的 TypeAdapter,会通过 new 关键字创建对象;
  • 2、枚举:枚举的序列化和反序列化只是在枚举名 name 和枚举对象之间切换,不会创建新的枚举对象;
  • 3、List & Set & Map:容器类型会通过预置的对象创建工厂,调用 new 关键字创建对象;
  • 4、Java Bean:Java Bean 的创建分为多种可能:
    • 情况 1:自定义了对象创建工厂 InstanceCreator,则优先通过自定义工厂创建;
    • 情况 2:存在默认的无参构造函数,则通过反射构造函数创建;
    • 情况 3:使用 Unsafe API 兜底创建对象。

ConstructorConstructor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
java复制代码public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
final Type type = typeToken.getType();
final Class<? super T> rawType = typeToken.getRawType();

// InstanceCreator API
final InstanceCreator<T> typeCreator = (InstanceCreator<T>) instanceCreators.get(type);
if (typeCreator != null) {
return new ObjectConstructor<T>() {
@Override
public T construct() {
return typeCreator.createInstance(type);
}
};
}
final InstanceCreator<T> rawTypeCreator = (InstanceCreator<T>) instanceCreators.get(rawType);
if (rawTypeCreator != null) {
return new ObjectConstructor<T>() {
@Override
public T construct() {
return rawTypeCreator.createInstance(type);
}
};
}
// 无参构造函数
ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);
if (defaultConstructor != null) {
return defaultConstructor;
}

// 容器类型
ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);
if (defaultImplementation != null) {
return defaultImplementation;
}

// Unsafe API
return newUnsafeAllocator(type, rawType);
}

2.6 Gson 隐藏的坑

当 Class 未提供默认的无参构造函数时,Gson 会使用 Unsafe API 兜底来创建对象。Unsafe API 主要提供一些用于执行低级别、不安全操作的方法,也提供了一个非常规实例化对象的 allocateInstance 方法。

这个 API 不会调用构造函数 ,因此相关的构造初始化操作会丢失;

  • 1、构造函数参数的默认值丢失;
  • 2、字段的默认值丢失;
  • 3、Kotlin 非空类型失效;
  • 4、初始化块未执行;
  • 5、by 属性代理(没有创建代理对象)

  1. Gson 如何解析泛型类型?

由于 Java 有泛型擦除,无法直接在 .class 语法上声明泛型信息,Gson 的方法是要求程序员创建匿名内部类,由 Gson 在运行时通过反射获取类声明上的泛型信息。

示例代码

1
2
3
4
5
java复制代码// 非法:
Response<User> obj = Gson().fromJson<Response<User>>(jsonStr, Response<User>.class)
// 合法;
TypeToken token = object : TypeToken<Response<User>>() {}
Response<User> obj = Gson().fromJson<Response<User>>(jsonStr, token.type)

为什么反序列化泛型类要使用匿名内部类呢?

原理是 Class 文件中的 Signature 属性会保持类签名信息,而 TypeToken 只是一个工具类,内部通过反射获取类签名中泛型信息并返回 Type 类型。

TypeToken.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码protected TypeToken() {
this.type = getSuperclassTypeParameter(getClass());
this.rawType = (Class<? super T>) $Gson$Types.getRawType(type);
this.hashCode = type.hashCode();
}

// 返回 Response<User>
static Type getSuperclassTypeParameter(Class<?> subclass) {
Type superclass = subclass.getGenericSuperclass();
if (superclass instanceof Class) {
throw new RuntimeException("Missing type parameter.");
}
ParameterizedType parameterized = (ParameterizedType) superclass;
return $Gson$Types.canonicalize(parameterized.getActualTypeArguments()[0]);
}

public final Type getType() {
return type;
}

既然 TypeToken 只是一个获取 Type 类型的工具类,我们也可以跳过它直接提供 Type,方法是定义 ParameterizedType 参数化类型的子类:

ParameterizedTypeAdapter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码private static class ParameterizedTypeAdapter implements ParameterizedType {

private final Class<?> rawType;
private final Type[] types;

private ParameterizedTypeAdapter(Class<?> rawType, Type... types) {
this.rawType = rawType;
this.types = types;
}

@Override
public Type[] getActualTypeArguments() {
return types;
}

@Override
public Type getRawType() {
return rawType;
}

@Override
public Type getOwnerType() {
return null;
}
}

示例代码

1
java复制代码Response<User> obj = new Gson().fromJson<Response<User>>(jsonStr, ParameterizedTypeAdapter(Response.class, User.class))

在 Kotlin 中,还可以使用 reified 实化类型参数简化:

Utils.kt

1
2
3
4
5
kotlin复制代码inline fun <reified T> toList(jsonStr: String): List<T> =
Gson().fromJson(content, ParameterizedTypeAdapter(List::class.java, T::class.java))

inline fun <reified T> toObject(jsonStr: String): List<T> =
Gson().fromJson(content, T::class.java))

示例代码

1
kotlin复制代码List<User> obj = toList<User>(jsonStr)

  1. 总结

今天,我们讨论了 Gson 的基本用法和以及主要流程的源码分析。

在 Gson 的反序列化中,首次反序列化一个类型的对象时,Gson 需要使用大量反射调用解析一个 TypeAdapter 适配器对象。随着 Model 的复杂程度增加,首次解析的耗时会不断膨胀。

这个问题在抖音的技术博客中提到一个解决方案,这个问题我们在下篇文章讨论,请关注。


版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

  • Java Google Json (Gson) Introduction —— Mithil Shah 著
  • Gson — Getting Started with Java-JSON —— Norman Peitek 著
  • Javadoc · Gson —— Gson 官方文档
  • Gson 源码解析和它的设计模式 —— 拉丁吴 著
  • 还在被数据类的序列化折磨?是时候丢弃 Gson 了—— bennyhuo 著
  • 抖音 Android 性能优化系列:启动优化实践(反序列化优化) —— 字节跳动技术团队 著
  • JSON —— Wikipedia

推荐阅读

Android 开源库系列完整目录如下(2023/07/12 更新):

  • #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
  • #3 IO 框架 Okio 的实现原理,到底哪里 OK?
  • #4 IO 框架 Okio 的实现原理,如何检测超时?
  • #5 序列化框架 Gson 原理分析,可以优化吗?
  • #6 适可而止!看 Glide 如何把生命周期安排得明明白白
  • #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
  • #8 内存缓存框架 LruCache 的实现原理,手写试试?
  • #9 这是一份详细的 EventBus 使用教程

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…848586…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%