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

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


  • 首页

  • 归档

  • 搜索

计算机系统

发表于 2022-11-25

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

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 3 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在前面的文章里,我们聊到了计算机的冯·诺依曼架构的 3 个基本原则。其中第 1 个原则是计算机中所有信息都是采用二进制格式的编码。也就是说,在计算机中程序的数据和指令,以及用户输入的所有数据,计算机都需要把它们转换为二进制的格式,才能进行识别和运算。

然而,我们日常生活接触到的大部分数字却是十进制编码,例如手机号码、工牌号、学号。那为什么计算机要使用二进制数制?二进制数据如何进行运算,以及计算机做了哪些优化来如何提高运算的效率?今天我们就围绕这些问题展开。


思维导图:


  1. 为什么计算机要使用二进制数制?

所谓数制其实就是一种 “计数的进位方式”。

常见的数制有十进制、二进制、八进制和十六进制:

  • 十进制是我们日常生活中最熟悉的进位方式,它一共有 0、1、2、3、4、5、6、7、8 和 9 十个符号。在计数的过程中,当某一位满 10 时,就需要向它临近的高位进一,即逢十进一;
  • 二进制是程序员更熟悉的进位方式,也是随着计算机的诞生而发展起来的,它只有 0 和 1 两个符号。在计数的过程中,当某一位满 2 时,就需要向它临近的高位进一,即逢二进一;
  • 八进制和十六进制同理。

那么,为什么计算机要使用二进制数制,而不是人类更熟悉的十进制呢?其原因在于二进制只有两种状态,制造只有 2 个稳定状态的电子元器件可以使用高低电位或有无脉冲区分,而相比于具备多个状态的电子元器件会更加稳定可靠。


2.有符号数与无符号数

在计算机中会区分有符号数和无符号数,无符号数不需要考虑符号,可以将数字编码中的每一位都用来存放数值。有符号数需要考虑正负性,然而计算机是无法识别符号的 “正+” 或 “负-” 标志的,那怎么办呢?

好在我们发现 “正 / 负” 是两种截然不同的状态,正好可以映射到计算机能够理解的 “0 / 1” 上。因此,我们可以直接 “将符号数字化”,将 “正+” 数字化为 “0”,将 “负-” 数字化为 “1”,并将数字化后的符号和数值共同组成数字编码。

另外,为了计算方便,我们额外再规定将 “符号位” 放在数字编码的 “最高位”。例如,+1110 和 -1110 用 8 位二进制表示就是:

  • 0000, 1110(符号作为编码的一部分,最高位 0 表示正数)
  • 1000, 1110(符号作为编码的一部分,最高位 1 表示负数)

从中我们也可以看出无符号数和有符号数的区别:

  • 1、最高位功能不同: 无符号数的编码中的每一位都可以用来存放数值信息,而有符号数需要在编码的最高位留出一位符号位;
  • 2、数值范围不同: 相同位数下有符号数和无符号数表示的数值范围不同。以 16 位数为例,无符号数可以表示 065535,而有符号数可以表示 -3276832767。

提示: 无符号数和有符号数表示的数值范围大小是一样大的,n 位二进制最多只能表示 2n2^n2n 个信息量,这是无法被突破的。


  1. 机器数的运算效率问题

在计算机中,我们会把带 “正 / 负” 符号的数称为真值(True Value),而把符号化后的数称为机器数(Computer Number)。

机器数才是数字在计算机中的二进制表示。 例如在前面的数字中, +1110 是真值,而 0000, 1110 是机器数。新的问题来了:将符号数字化后的机器数,在运算的过程中符号位是否与数值参与运算,又应该如何运算呢?

我们先举几个加法运算的例子:

  • 两个正数相加:
1
2
3
java复制代码0000, 1110 + 0000, 0001 = 0000, 1111 // 14 + 1 = 15 正确
^ ^ ^
符号位 符号位 符号位
  • 两个负数相加:
1
2
3
java复制代码1000, 1110 + 1000, 0001 = 0000, 1111 // (-14) + (-1) = 15 错误
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)
  • 正负数相加:
1
2
3
java复制代码0000, 1110 + 1000, 0001 = 1001, 1111 // 14 + (-1) = -15 错误
^ ^ ^
符号位 符号位 符号位

可以看到,在对机器数进行 “按位加法” 运算时,只有两个正数的加法运算的结果是正确的,而包含负数的加法运算的结果却是错误的,会出现 -14 - 1 = 15 和 14 - 1 = -15 这种错误结果。

所以,带负数的加法运算就不能使用常规的按位加法运算了,需要做特殊处理:

  • 两个正数相加:
+ 直接做按位加法。
  • 两个负数相加:
+ 1、用较大的绝对值 + 较小的绝对值(加法运算);
+ 2、最终结果的符号为负。
  • 正负数相加:
+ 1、判断两个数的绝对值大小(数值部分);
+ 2、用较大的绝对值 - 较小的绝对值(减法运算);
+ 3、最终结果的符号取绝对值较大数的符号。

哇🤩?好好的加法运算给整成减法运算? 运算器的电路设计不仅要多设置一个减法器,而且运算步骤还特别复杂。那么,有没有不需要设置减法器,而且步骤简单的方案呢?


  1. 原码、反码、补码

为了解决有符号机器数运算效率问题,计算机科学家们提出多种机器数的表示法:

机器数 正数 负数
原码 符号位表示符号数值位表示真值的绝对值 符号位表示数字的符号数值位表示真值的绝对值
反码 无(或者认为是原码本身) 符号位为 1数值位是对原码数值位的 “按位取反”
补码 无(或者认为是原码本身) 在负数反码的基础上 + 1
  • 1、原码: 原码是最简单的机器数,例如前文提到从 +1110 和 -1110 转换得到的 0000, 1110 和 1000, 1110 就是原码表示法,所以原码在进行数字运算时会存在前文提到的效率问题;
  • 2、反码: 反码一般认为是原码和补码转换的中间过渡;
  • 3、补码: 补码才是解决机器数的运算效率的关键, 在计算机中所有 “整型类型” 的负数都会使用补码表示法;
+ ~~正数的补码是原码本身;~~
+ 零的补码是零;
+ 负数的补码是在反码的基础上再加 1。

很多教材和网上的资料会认为正数的原码、反码和补码是相同的,这么说倒也不影响什么。 但结合补码的设计原理,小彭的观点是正数是没有反码和补码的,负数使用补码是为了找到一个 “等价” 的正补数代替负数参与计算,将加减法运算统一为两个正数加法运算,而正数自然是不需要替换的,所以也就没有补码的形式。

提示: 为了便于你理解,小彭后文会继续用 “正数的补码是原码本身” 这个观点阐述。


  1. 使用补码消除减法运算

理解补码表示法后,似乎还是不清楚补码有什么用❓

我们重新计算上一节的加法运算试试:

举例 真值 原码 反码 补码
+14 +1110 0000, 1110 0000, 1110 0000, 1110
+13 +1101 0000, 1101 0000, 1101 0000, 1101
-14 +1110 1000, 1110 1111, 0001 1111, 0010
-15 -1110 1000, 1111 1111, 0000 1111, 0001
+1 +0001 0000, 0001 0000, 0001 0000, 0001
-1 -0001 1000, 0001 1111, 1110 1111, 1111
  • 两个正数相加:
1
2
3
4
java复制代码// 补码表示法
0000, 1110 + 0000, 0001 = 0000, 1111 // 14 + 1 = 15 正确
^ ^ ^
符号位 符号位 符号位
  • 两个负数相加:
1
2
3
4
java复制代码// 补码表示法
1111, 0010 + 1111, 1111 = 1111, 0001 // (-14) + (-1) = -15 正确
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)
  • 正负数相加:
1
2
3
4
java复制代码// 补码表示法
0000, 1110 + 1111, 1111 = 0000, 1101 // 14 + (-1) = 13 正确
^ ^ ^
符号位 符号位 符号位(最高位的 1 溢出)

可以看到,使用补码表示法后,有符号机器数加法运算就只是纯粹的加法运算,不会因为符号的正负性而采用不同的计算方法,也不需要减法运算。因此电路设计中只需要设置加法器和补数器,就可以完成有符号数的加法和减法运算,能够简化电路设计。

除了消除减法运算外,补码表示法还实现了 “0” 的机器数的唯一性:

在原码表示法中,“+0” 和 “-0” 都是合法的,而在补码表示法中 “0” 只有唯一的机器数表示,即 0000, 0000 。换言之补码能够比原码多表示一个最小的负数 1000, 0000。

最后提供按照不同表示法解释二进制机器数后得到的真值对比:

二进制数 无符号真值 原码真值 反码真值 补码真值
0000, 0000 0 +0 +0 +0
0000, 0001 1 +1 +1 +1
… … … … …
1000, 0000 128 -0(负零,无意义) -127 -128(多表示一个数)
1000, 0001 129 -1 -126 -127
… … … … …
1111, 1110 254 -126 -1 -2
1111, 1111 255 -127 -0(负零) -1

  1. 补码我懂了,但是为什么?

理解原码和补码的定义不难,理解补码作用也不难,难的是理解补码是怎么设计出来的,总不可能是被树上的苹果砸到后想到的吧?

这就要提到数学中的 “补数” 概念:

  • 1、当一个正数和一个负数互为补数时,它们的绝对值之和就是模;
  • 2、一个负数可以用它的正补数代替。

6.1 时钟里的补数

听起来很抽象对吧❓其实生活中,就有一个更加形象的例子 —— 时钟,时钟里就蕴含着补数的概念!

比如说,现在时钟的时针刻度指向 6 点,我们想让它指向 3 点,应该怎么做:

  • 方法 1 : 逆时针地拨动 3 个点数,让时针指向 3 点,这相当于做减法运算 -3;
  • 方法 2: 顺时针地拨动 9 个点数,让时针指向 3 点,这相当于做加法运算 +9。

可以看到,对于时钟来说 -3 和 +9 竟然是等价的! 这是因为时钟只能 12 个小时,当时间点数超过 12 时就会自动丢失,所以 15 点和 3 点在时钟看来是都是 3 点。如果我们要在时钟上进行 6 - 3 减法运算,我们可以将 -3 等价替换为它的正补数 +9 后参与计算,从而将减法运算替换为 6 + 9 加法运算,结果都是 3。

6.2 十进制的例子

理解了补数的概念后,我们再多看一个十进制的例子:我们要计算十进制 354365 - 95937 = 的结果,怎么做呢?

  • 方法 1 - 借位做减法: 常规的做法是利用连续向前借位做减法的方式计算,这没有问题;
  • 方法 2 - 减模加补: 使用补数的概念后,我们就可以将减法运算消除为加法运算。

具体来说,如果我们限制十进制数的位长最多只有 6 位,那么模就是 1000000,-95937 对应的正补数就是 1000000 - 95937 = 904063 。此时,我们可以直接用正补数代替负数参与计算,则有:

1
2
3
4
5
6
7
java复制代码354365 - 95937 // = 258428

= 354365 - (1000000 - 904063)

= 354365 - 1000000 + 904063 【减整加补】

= 258428

可以看到,把 -95937 等价替换为 +904063 后,就把减法运算替换为加法运算。细心的你可能要举手提问了,还是需要减去 1000000 呀?🙋🏻‍♀️

其实并不用,因为 1000000 是超过位数限制的,所以减去 1000000 这一步就像时针逆时针拨动一整圈一样是无效的。所以实际上需要计算的是:

1
2
3
4
5
java复制代码// 实际需要计算的是:
354365 + 904063
= 1258428 = 258428
^
最高位 1 超出位数限制,直接丢弃

6.3 为什么要使用补码?

继续使用前文提到的 14 + (-1) 正负数相加的例子:

1
2
3
4
5
6
7
8
9
java复制代码// 原码表示法
0000, 1110 + 1000, 0001 = 1001, 1111 // 14 + (-1) = -15 错误
^ ^ ^
符号位 符号位 符号位

// 补码表示法
0000, 1110 + 1111, 1111 = 1, 0000, 1101 // 14 + (-1) = 13 正确
^ ^ ^
符号位 符号位 最高位 1 超出位数限制,直接丢弃

如果我们限制二进制数字的位长最多只有 8 位,那么模就是 1, 0000, 0000 ,此时,-1 的二进制数 1000, 0001 的正补数就是 1111, 1111。

我们使用正补数 1111, 1111 代替负数 1000, 0001 参与运算,加法运算后的结果是 1, 0000, 1101。其中最高位 1 超出位数限制,直接丢弃,所以最终结果是 0000, 1101,也就是 13,计算正确。

补码示意图

到这里,相信补码的设计原理已经很清楚了。

补码的关键在于:找到一个与负数等价的正补数,使用该正补数代替负数,从而将减法运算替换为两个正数加法运算。 补码的出现与运算器的电路设计有关,从设计者的角度看,希望尽可能简化电路设计和计算复杂度。而使用正补数代替负数就可以消除减法器,实现简化电路的目的。

所以,小彭认为只有负数才存在补码,正数本身就是正数,根本就没必要使用补数,更不需要转为补码。而且正数使用补码的话,还不能把负数转补码的算法用在正数上,还得强行加一条 “正数的补码是原码本身” 的规则,就离谱好吧。


  1. 总结

  • 1、无符号数的编码中的每一位都可以用来存放数值信息,而有符号数需要在最高位留出一位符号位;
  • 2、在有符号数的机器数运算中,需要对正数和负数采用不同的计算方法,而且需要引入减法器;
  • 3、为了解决有符号机器数运算效率问题,计算机科学家们提出多种机器数的表示法:原码、反码、补码和移码;
  • 4、使用补码表示法后,运算器可以消除减法运算,而且实现了 “0” 的机器数的唯一性;
  • 5、补码的关键是找到一个与负数等价的正补数,使用该正补数代替负数参与计算,从而将减法运算替换为加法运算。

在前文讲补码的地方,我们提到计算机所有 “整型类型” 的负数都会使用补码表示法,刻意强调 “整数类型” 是什么原因呢,难道浮点数和整数在计算机中的表示方法不同吗?这个问题我们在 下一篇文章 里讨论,请关注。


参考资料

  • 计算机组成原理教程(第 2、6 章) —— 尹艳辉 王海文 邢军 著
  • 深入浅出计算机组成原理(第 11 ~ 16 讲) —— 徐文浩 著,极客时间 出品
  • 10分钟速成课 计算机科学 —— Carrie Anne 著
  • Binary number —— Wikipedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

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

本文正在参加「金石计划 . 瓜分6万现金大奖」

本文转载自: 掘金

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

【AI】浅谈使用正则化防止过拟合(上) 前言 什么是欠拟合与

发表于 2022-11-24

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

对于机器学习问题,我们最常遇到的一个问题便是过拟合。在对已知的数据集合进行学习的时候,我们选择适应度最好的模型最为最终的结果。虽然我们选择的模型能够很好的解释训练数据集合,但却不一定能够很好的解释测试数据或者其他数据,也就是说这个模型过于精细的刻画了训练数据,对于测试数据或者其他新的数据泛化能力不强。

因此,我们需要通过正则化的方法来防止过拟合,接下来跟博主一起来了解一下吧。

本篇将重点介绍什么是欠拟合与过拟合,是什么原因造成的,该如何解决?

什么是欠拟合与过拟合

先来看一组图片,这三张图片是线性回归模型,拟合的函数和训练集的关系

  1. 第一张图片拟合的函数和训练集误差较大,我们称这种情况为 欠拟合;
  2. 第二张图片拟合的函数和训练集误差较小,我们称这种情况为 合适拟合;
  3. 第三张图片拟合的函数完美的匹配训练集数据,我们称这种情况为 过拟合;

总结得出:

  • 过拟合 (overfitting):为训练集而非测试集提供准确的结果,存在较高的方差;
  • 欠拟合 (underfitting):提供的训练集和测试集结果都不太准确,存在较高的偏差;

如下图所示:

假设红色的靶心区域是学习算法完美的正确预测值,蓝色点为训练数据集所训练出的模型对样本的预测值,当我们从靶心逐渐往外移动时,预测效果逐渐变差。

从上面的图片中很容易可以看到,左边一列的蓝色点比较集中,右边一列的蓝色点比较分散,它们描述的是方差的两种情况。比较集中的属于方差比较小,比较分散的属于方差比较大的情况。

我们再从蓝色点与红色靶心区域的位置关系来看,靠近红色靶心的属于偏差较小的情况,远离靶心的属于偏差较大的情况。

image.png

通俗地讲,过拟合就是应试能力很强,实际应用能力很差,擅长背诵知识,却不懂得灵活利用知识;而欠拟合就是啥啥都不行;

如何解决欠拟合问题

欠拟合问题,根本的原因是特征维度过少,导致拟合的函数无法满足训练集,误差较大。

欠拟合基本上都会发生在训练刚开始的时候,经过不断训练之后欠拟合应该不怎么考虑了。但是如果真的还是存在的话,可以 增加网络复杂度 或者在模型中 增加特征,这些都是很好解决欠拟合的方法。

如何解决过拟合问题

过拟合问题,根本的原因则是特征维度过多,导致拟合的函数完美的经过训练集,但是对新数据的预测结果则较差。

具体来说造成原因的话,有以下几种:

  1. 训练数据集样本单一,样本不足。如果训练样本只有负样本,然后拿生成的模型去预测正样本,这肯定预测不准,所以训练样本要尽可能的全面,覆盖所有的数据类型。
  2. 训练数据中噪声干扰过大。噪声指训练数据中的干扰数据。过多的干扰会导致记录了很多噪声特征,忽略了真实输入和输出之间的关系。
  3. 模型过于复杂。 模型太复杂,已经能够 “死记硬背” 记下了训练数据的信息,但是遇到没有见过的数据的时候不能够变通,泛化能力太差。我们希望模型对不同的模型都有稳定的输出。模型太复杂是过拟合的重要因素 。

要想解决过拟合问题,就要显著减少测试误差而不过度增加训练误差,从而提高模型的泛化能力。我们可以使用正则化(Regularization)方法。那什么是正则化呢?正则化是指修改学习算法,使其降低泛化误差而非训练误差。

常用的正则化方法根据具体的使用策略不同可分为:

(1) 直接提供正则化约束的参数正则化方法,如 L1/L2 正则化;

(2) 通过工程上的技巧来实现更低泛化误差的方法,如提前终止 (Early stopping) 和 Dropout;

(3) 不直接提供约束的隐式正则化方法,如数据增强等。

那接下来讲讲什么是正则化;

正则化

让我们先回顾一下上述过拟合的例子:h(x)=θ0+θ1x+θ2x2+θ3x3+θ4x4h(x) = \theta_0 + \theta_1x + \theta_2x^2 + \theta_3x^3 + \theta_4x^4h(x)=θ0+θ1x+θ2x2+θ3x3+θ4x4 ;

image.png

从图中可以看出,解决这个过拟合问题可以通过消除特征 x3x^3x3 和 x4x^4x4 的影响, 我们称为对参数的惩罚, 也就是使得参数 θ3θ_3θ3,θ4θ_4θ4 接近于0。

最简单的方法是对损失函数进行改造,例如:

min⁡θ12m∑i=1m(hθ(x(i))−y(i))2+100θ32+100θ42\min_\theta \frac{1}{2m} \sum^m_{i=1} (h_\theta(x^{(i)})-y^{(i)})^2 + 100\theta_3^2 + 100\theta_4^2θmin2m1i=1∑m(hθ(x(i))−y(i))2+100θ32+100θ42
这样在求解最小化损失函数的时候使得参数 θ3θ_3θ3,θ4θ_4θ4 接近于0。

番外:这块如果不懂的话,可以看看博主之前写的文章:【AI】浅谈梯度下降算法(理论篇),里面有详细的算法推导过程;

正则化其实就是通过对参数 θθθ 的惩罚来影响整个模型,在损失函数上加上正则项达到目的;

正则化 具体将在下一篇 【AI】浅谈使用正则化防止过拟合(下) 中进行介绍;

后记

以上就是 浅谈使用正则化防止过拟合(上) 的全部内容了,介绍了什么是欠拟合与过拟合,是什么原因造成的,该如何解决,通过图文结合,细致地讲述了要点,希望大家有所收获!

📝 上篇精讲:【AI】浅谈损失函数

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

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

🔥 系列专栏:AI

本文转载自: 掘金

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

Android 开源库

发表于 2022-11-24

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

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

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

前言

我们继续上一篇文章的分析:

  • Android 开源库 #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • Android 开源库 #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)

  1. 两种写回策略

在获得事务对象后,我们继续分析 Editor 接口中的 commit 同步写回策略和 apply 异步写回策略。

6.1 commit 同步写回策略

Editor#commit 同步写回相对简单,核心步骤分为 4 步:

  • 1、调用 commitToMemory() 创建 MemoryCommitResult 事务对象;
  • 2、调用 enqueueDiskWrite(mrc, null) 提交磁盘写回任务(在当前线程执行);
  • 3、调用 CountDownLatch#await() 阻塞等待磁盘写回完成;
  • 4、调用 notifyListeners() 触发回调监听。

commit 同步写回示意图

其实严格来说,commit 同步写回也不绝对是在当前线程同步写回,也有可能在后台 HandlerThread 线程写回。但不管怎么样,对于 commit 同步写回来说,都会调用 CountDownLatch#await() 阻塞等待磁盘写回完成,所以在逻辑上也等价于在当前线程同步写回。

SharedPreferencesImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public final class EditorImpl implements Editor {

@Override
public boolean commit() {
// 1、获取事务对象(前文已分析)
MemoryCommitResult mcr = commitToMemory();
// 2、提交磁盘写回任务
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* 写回成功回调 */);
// 3、阻塞等待写回完成
mcr.writtenToDiskLatch.await();
// 4、触发回调监听器
notifyListeners(mcr);
return mcr.writeToDiskResult;
}
}

6.2 apply 异步写回策略

Editor#apply 异步写回相对复杂,核心步骤分为 5 步:

  • 1、调用 commitToMemory() 创建 MemoryCommitResult 事务对象;
  • 2、创建 awaitCommit Ruunnable 并提交到 QueuedWork 中。awaitCommit 中会调用 CountDownLatch#await() 阻塞等待磁盘写回完成;
  • 3、创建 postWriteRunnable Runnable,在 run() 中会执行 awaitCommit 任务并将其从 QueuedWork 中移除;
  • 4、调用 enqueueDiskWrite(mcr, postWriteRunnable) 提交磁盘写回任务(在子线程执行);
  • 5、调用 notifyListeners() 触发回调监听。

可以看到不管是调用 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交磁盘写回任务。

区别在于:

  • 在 commit 中 enqueueDiskWrite() 的第 2 个参数是 null;
  • 在 apply 中 enqueueDiskWrite() 的第 2 个参数是一个 postWriteRunnable 写回结束的回调对象,enqueueDiskWrite() 内部就是根据第 2 个参数来区分 commit 和 apply 策略。

apply 异步写回示意图

SharedPreferencesImpl.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复制代码@Override
public void apply() {
// 1、获取事务对象(前文已分析)
final MemoryCommitResult mcr = commitToMemory();
// 2、提交 aWait 任务
// 疑问:postWriteRunnable 可以理解,awaitCommit 是什么?
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
// 阻塞线程直到磁盘任务执行完毕
mcr.writtenToDiskLatch.await();
}
};
QueuedWork.addFinisher(awaitCommit);
// 3、创建写回成功回调
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
// 执行 aWait 任务
awaitCommit.run();
// 移除 aWait 任务
QueuedWork.removeFinisher(awaitCommit);
}
};

// 4、提交磁盘写回任务,并绑定写回成功回调
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable /* 写回成功回调 */);

// 5、触发回调监听器
notifyListeners(mcr);
}

QueuedWork.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码// 提交 aWait 任务(后文详细分析)
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();

public static void addFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.add(finisher);
}
}

public static void removeFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.remove(finisher);
}
}

这里有一个疑问:

在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?

我们稍微再回答,先继续往下走。

6.3 enqueueDiskWrite() 提交磁盘写回事务

可以看到,不管是 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交写回磁盘任务。虽然 enqueueDiskWrite() 还没到真正调用磁盘写回操作的地方,但确实创建了与磁盘 IO 相关的 Runnable 任务,核心步骤分为 4 步:

  • 步骤 1:根据是否有 postWriteRunnable 回调区分是 commit 和 apply;
  • 步骤 2:创建磁盘写回任务(真正执行磁盘 IO 的地方):
    • 2.1 调用 writeToFile() 执行写回磁盘 IO 操作;
    • 2.2 在写回结束后对前文提到的 mDiskWritesInFlight 计数自减 1;
    • 2.3 执行 postWriteRunnable 写回成功回调;
  • 步骤 3:如果是异步写回,则提交到 QueuedWork 任务队列;
  • 步骤 4:如果是同步写回,则检查 mDiskWritesInFlight 变量。如果存在并发写回的事务,则也要提交到 QueuedWork 任务队列,否则就直接在当前线程执行。

其中步骤 2 是真正执行磁盘 IO 的地方,逻辑也很好理解。不好理解的是,我们发现除了 “同步写回而且不存在并发写回事务” 这种特殊情况,其他情况都会交给 QueuedWork 再调度一次。

在通过 QueuedWork#queue 提交任务时,会将 writeToDiskRunnable 任务追加到 sWork 任务队列中。如果是首次提交任务,QueuedWork 内部还会创建一个 HandlerThread 线程,通过这个子线程实现异步的写回任务。这说明 SharedPreference 的异步写回相当于使用了一个单线程的线程池,事实上在 Android 8.0 以前的版本中就是使用一个 singleThreadExecutor 线程池实现的。

提交任务示意图

SharedPreferencesImpl.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复制代码private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
// 1、根据是否有 postWriteRunnable 回调区分是 commit 和 apply
final boolean isFromSyncCommit = (postWriteRunnable == null);
// 2、创建磁盘写回任务
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 2.1 写入磁盘文件
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 2.2 mDiskWritesInFlight:进行中事务自减 1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
// 2.3 触发写回成功回调
postWriteRunnable.run();
}
}
};

// 3、同步写回且不存在并发写回,则直接在当前线程
// 这就是前文提到 “commit 也不是绝对在当前线程同步写回” 的源码出处
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
// 如果存在并发写回的事务,则此处 wasEmpty = false
wasEmpty = mDiskWritesInFlight == 1;
}
// wasEmpty 为 true 说明当前只有一个线程在执行提交操作,那么就直接在此线程上完成任务
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}

// 4、交给 QueuedWork 调度(同步任务不可以延迟)
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit /*是否可以延迟*/ );
}

@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
// 稍后分析
}

QueuedWork 调度:

QueuedWork.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
java复制代码@GuardedBy("sLock")
private static LinkedList<Runnable> sWork = new LinkedList<>();

// 提交任务
// shouldDelay:是否延迟
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();

synchronized (sLock) {
// 入队
sWork.add(work);
// 发送 Handler 消息,触发 HandlerThread 执行任务
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY /* 100ms */);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}

private static Handler getHandler() {
synchronized (sLock) {
if (sHandler == null) {
// 创建 HandlerThread 后台线程
HandlerThread handlerThread = new HandlerThread("queued-work-looper", Process.THREAD_PRIORITY_FOREGROUND);
handlerThread.start();

sHandler = new QueuedWorkHandler(handlerThread.getLooper());
}
return sHandler;
}
}

private static class QueuedWorkHandler extends Handler {
static final int MSG_RUN = 1;

QueuedWorkHandler(Looper looper) {
super(looper);
}

public void handleMessage(Message msg) {
if (msg.what == MSG_RUN) {
// 执行任务
processPendingWork();
}
}
}

private static void processPendingWork() {
synchronized (sProcessingWork) {
LinkedList<Runnable> work;

synchronized (sLock) {
// 创建新的任务队列
// 这一步是必须的,否则会与 enqueueDiskWrite 冲突
work = sWork;
sWork = new LinkedList<>();

// Remove all msg-s as all work will be processed now
getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
}

// 遍历 ,按顺序执行 sWork 任务队列
if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}
}
}
}

比较不理解的是:

同一个文件的多次写回串行化可以理解,对于多个文件的写回串行化意义是什么,是不是可以用多线程来写回多个不同的文件?或许这也是 SharedPreferences 是轻量级框架的原因之一,你觉得呢?

6.4 主动等待写回任务结束

现在我们可以回答 6.1 中遗留的问题:

在 apply() 方法中,在执行 enqueueDiskWrite() 前创建了 awaitCommit 任务并加入到 QueudWork 等待队列,直到磁盘写回结束才将 awaitCommit 移除。这个 awaitCommit 任务是做什么的呢?

要理解这个问题需要管理分析到 ActivityThread 中的主线程消息循环:

可以看到,在主线程的 Activity#onPause、Activity#onStop、Service#onStop、Service#onStartCommand 等生命周期状态变更时,会调用 QueudeWork.waitToFinish():

ActivityThread.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Override
public void handlePauseActivity(...) {
performPauseActivity(r, finished, reason, pendingActions);
// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
...
}

private void handleStopService(IBinder token) {
...
QueuedWork.waitToFinish();
ActivityManager.getService().serviceDoneExecuting(token, SERVICE_DONE_EXECUTING_STOP, 0, 0);
...
}

waitToFinish() 会执行所有 sFinishers 等待队列中的 aWaitCommit 任务,主动等待所有磁盘写回任务结束。在写回任务结束之前,主线程会阻塞在等待锁上,这里也有可能发生 ANR。

主动等待示意图

至于为什么 Google 要在 ActivityThread 中部分生命周期中主动等待所有磁盘写回任务结束呢?官方并没有明确表示,结合头条和抖音技术团队的文章,我比较倾向于这 2 点解释:

  • 解释 1 - 跨进程同步(主要): 为了保证跨进程的数据同步,要求在组件跳转前,确保当前组件的写回任务必须在当前生命周期内完成;
  • 解释 2 - 数据完整性: 为了防止在组件跳转的过程中可能产生的 Crash 造成未写回的数据丢失,要求当前组件的写回任务必须在当前生命周期内完成。

当然这两个解释并不全面,因为就算要求主动等待,也不能保证跨进程实时同步,也不能保证不产生 Crash。

抖音技术团队观点

QueuedWork.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
java复制代码@GuardedBy("sLock")
private static Handler sHandler = null;

public static void waitToFinish() {
boolean hadMessages = false;

Handler handler = getHandler();

synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}

// Android 8.0 优化:帮助子线程执行磁盘写回
// 作用有限,因为 QueuedWork 使用了 sProcessingWork 锁保证同一时间最多只有一个线程在执行磁盘写回
// 所以这里应该是尝试在主线程执行,可以提升线程优先级
processPendingWork();

// 执行 sFinshers 等待队列,等待所有写回任务结束
try {
while (true) {
Runnable finisher;

synchronized (sLock) {
finisher = sFinishers.poll();
}

if (finisher == null) {
break;
}
// 执行 mcr.writtenToDiskLatch.await();
// 阻塞线程直到磁盘任务执行完毕
finisher.run();
}
} finally {
sCanDelay = true;
}
}

Android 7.1 QueuedWork 源码对比:

1
2
3
java复制代码public static boolean hasPendingWork() {
return !sPendingWorkFinishers.isEmpty();
}

  1. writeToFile() 姗姗来迟

最终走到具体调用磁盘 IO 操作的地方了!

7.1 写回步骤

writeToFile() 的逻辑相对复杂一些了。经过简化后,剩下的核心步骤只有 4 大步骤:

  • 步骤 1:过滤无效写回事务:
+ 1.1 事务的 memoryStateGeneration 内存版本小于 mDiskStateGeneration 磁盘版本,跳过;
+ 1.2 同步写回必须写回;
+ 1.3 异步写回事务的 memoryStateGeneration 内存版本版本小于 mCurrentMemoryStateGeneration 最新内存版本,跳过。
  • 步骤 2:文件备份:
+ 2.1 如果不存在备份文件,则将旧文件重命名为备份文件;
+ 2.2 如果存在备份文件,则删除无效的旧文件(上一次写回出并且后处理没有成功删除的情况)。
  • 步骤 3:全量覆盖写回磁盘:
+ 3.1 打开文件输出流;
+ 3.2 将 mapToWriteToDisk 映射表全量写出;
+ 3.3 调用 FileUtils.sync() 强制操作系统页缓存写回磁盘;
+ 3.4 写入成功,则删除备份文件(如果没有走到这一步,在将来读取文件时,会重新恢复备份文件);
+ 3.5 将磁盘版本记录为当前内存版本;
+ 3.6 写回结束(成功)。
  • 步骤 4:后处理: 删除写至半途的无效文件。

7.2 写回优化

继续分析发现,SharedPreference 的写回操作并不是简单的调用磁盘 IO,在保证 “可用性” 方面也做了一些优化设计:

  • 优化 1 - 过滤无效的写回事务:

如前文所述,commit 和 apply 都可能出现并发修改同一个文件的情况,此时在连续修改同一个文件的事务序列中,旧的事务是没有意义的。为了过滤这些无意义的事务,在创建 MemoryCommitResult 事务对象时会记录当时的 memoryStateGeneration 内存版本,而在 writeToFile() 中就会根据这个字段过滤无效事务,避免了无效的 I/O 操作。

  • 优化 2 - 备份旧文件:

由于写回文件的过程存在不确定的异常(比如内核崩溃或者机器断电),为了保证文件的完整性,SharedPreferences 采用了文件备份机制。在执行写回操作之前,会先将旧文件重命名为 .bak 备份文件,在全量覆盖写入新文件后再删除备份文件。

如果写回文件失败,那么在后处理过程中会删除写至半途的无效文件。此时磁盘中只有一个备份文件,而真实文件需要等到下次触发写回事务时再写回。

如果直到应用退出都没有触发下次写回,或者写回的过程中 Crash,那么在前文提到的创建 SharedPreferencesImpl 对象的构造方法中调用 loadFromDisk() 读取并解析文件数据时,会从备份文件恢复数据。

  • 优化 3 - 强制页缓存写回:

在写回文件成功后,SharedPreference 会调用 FileUtils.sync() 强制操作系统将页缓存写回磁盘。

写回示意图

SharedPreferencesImpl.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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
java复制代码// 内存版本
@GuardedBy("this")
private long mCurrentMemoryStateGeneration;

// 磁盘版本
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;

// 写回事务
private static class MemoryCommitResult {
// 内存版本
final long memoryStateGeneration;
// 需要全量覆盖写回磁盘的数据
final Map<String, Object> mapToWriteToDisk;
// 同步计数器
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);

// 后文写回结束后调用
// wasWritten:是否有执行写回
// result:是否成功
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
writeToDiskResult = result;
// 唤醒等待锁
writtenToDiskLatch.countDown();
}
}

// 提交写回事务
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) {
...
// 创建磁盘写回任务
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 2.1 写入磁盘文件
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 2.2 mDiskWritesInFlight:进行中事务自减 1
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
// 2.3 触发写回成功回调
postWriteRunnable.run();
}
}
};
...
}

// 写回文件
// isFromSyncCommit:是否同步写回
@GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
boolean fileExists = mFile.exists();
// 如果旧文件存在
if (fileExists) {
// 1. 过滤无效写回事务
// 是否需要执行写回
boolean needsWrite = false;

// 1.1 磁盘版本小于内存版本,才有可能需要写回
// (只有旧文件存在才会走到这个分支,但是旧文件不存在的时候也可能存在无意义的写回,
// 猜测官方是希望首次创建文件的写回能够及时尽快执行,毕竟只有一个后台线程)
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
// 1.2 同步写回必须写回
needsWrite = true;
} else {
// 1.3 异步写回需要判断事务对象的内存版本,只有最新的内存版本才有必要执行写回
synchronized (mLock) {
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}

if (!needsWrite) {
// 1.4 无效的异步写回,直接结束
mcr.setDiskWriteResult(false, true);
return;
}

// 2. 文件备份
boolean backupFileExists = mBackupFile.exists();
if (!backupFileExists) {
// 2.1 如果不存在备份文件,则将旧文件重命名为备份文件
if (!mFile.renameTo(mBackupFile)) {
// 备份失败
mcr.setDiskWriteResult(false, false);
return;
}
} else {
// 2.2 如果存在备份文件,则删除无效的旧文件(上一次写回出并且后处理没有成功删除的情况)
mFile.delete();
}
}

try {
// 3、全量覆盖写回磁盘
// 3.1 打开文件输出流
FileOutputStream str = createFileOutputStream(mFile);
if (str == null) {
// 打开输出流失败
mcr.setDiskWriteResult(false, false);
return;
}
// 3.2 将 mapToWriteToDisk 映射表全量写出
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
// 3.3 FileUtils.sync:强制操作系统将页缓存写回磁盘
FileUtils.sync(str);
// 关闭输出流
str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

// 3.4 写入成功,则删除备份文件(如果没有走到这一步,在将来读取文件时,会重新恢复备份文件)
mBackupFile.delete();
// 3.5 将磁盘版本记录为当前内存版本
mDiskStateGeneration = mcr.memoryStateGeneration;
// 3.6 写回结束(成功)
mcr.setDiskWriteResult(true, true);

return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}

// 在 try 块中抛出异常,会走到这里
// 4、后处理:删除写至半途的无效文件
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
// 写回结束(失败)
mcr.setDiskWriteResult(false, false);
}

// -> 读取并解析文件数据
private void loadFromDisk() {
synchronized (mLock) {
if (mLoaded) {
return;
}
// 1、如果存在备份文件,则恢复备份数据(后文详细分析)
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
...
}

至此,SharedPreferences 核心源码分析结束。


  1. SharedPreferences 的其他细节

SharedPreferences 还有其他细节值得学习。

8.1 SharedPreferences 锁总结

SharedPreferences 是线程安全的,但它的线程安全并不是直接使用一个全局的锁对象,而是采用多种颗粒度的锁对象实现 “锁细化” ,而且还贴心地使用了 @GuardedBy 注解标记字段或方法所述的锁级别。

使用 @GuardedBy 注解标记锁级别

1
2
java复制代码@GuardedBy("mLock")
private Map<String, Object> mMap;
对象锁 功能 描述
1、SharedPreferenceImpl#mLock SharedPreferenceImpl 对象的全局锁 全局使用
2、EditorImpl#mEditorLock EditorImpl 修改器的写锁 确保多线程访问 Editor 的竞争安全
3、SharedPreferenceImpl#mWritingToDiskLock SharedPreferenceImpl#writeToFile() 的互斥锁 writeToFile() 中会修改内存状态,需要保证多线程竞争安全
4、QueuedWork.sLock QueuedWork 的互斥锁 确保 sFinishers 和 sWork 的多线程资源竞争安全
5、QueuedWork.sProcessingWork QueuedWork#processPendingWork() 的互斥锁 确保同一时间最多只有一个线程执行磁盘写回任务

8.2 使用 WeakHashMap 存储监听器

SharedPreference 提供了 OnSharedPreferenceChangeListener 回调监听器,可以在主线程监听键值对的变更(包含修改、新增和移除)。

SharedPreferencesImpl.java

1
2
3
java复制代码@GuardedBy("mLock")
private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners =
new WeakHashMap<OnSharedPreferenceChangeListener, Object>();

SharedPreferences.java

1
2
3
4
5
6
java复制代码public interface SharedPreferences {

public interface OnSharedPreferenceChangeListener {
void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
}
}

比较意外的是: SharedPreference 使用了一个 WeakHashMap 弱键散列表存储监听器,并且将监听器对象作为 Key 对象。这是为什么呢?

这是一种防止内存泄漏的考虑,因为 SharedPreferencesImpl 的生命周期是全局的(位于 ContextImpl 的内存缓存),所以有必要使用弱引用防止内存泄漏。想想也对,Java 标准库没有提供类似 WeakArrayList 或 WeakLinkedList 的容器,所以这里将监听器对象作为 WeakHashMap 的 Key,就很巧妙的复用了 WeakHashMap 自动清理无效数据的能力。

提示: 关于 WeakHashMap 的详细分析,请阅读小彭说 · 数据结构与算法 专栏文章 《WeakHashMap 和 HashMap 的区别是什么,何时使用?》

8.3 如何检查文件被其他进程修改?

在读取和写入文件后记录 mStatTimestamp 时间戳和 mStatSize 文件大小,在检查时检查这两个字段是否发生变化

SharedPreferencesImpl.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
java复制代码// 文件时间戳
@GuardedBy("mLock")
private StructTimespec mStatTimestamp;
// 文件大小
@GuardedBy("mLock")
private long mStatSize;

// 读取文件
private void loadFromDisk() {
...
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
...
}

// 写入文件
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
...
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
...
}

// 检查文件
private boolean hasFileChangedUnexpectedly() {
synchronized (mLock) {
if (mDiskWritesInFlight > 0) {
// If we know we caused it, it's not unexpected.
if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
return false;
}
}

// 读取文件 Stat 信息
final StructStat stat = Os.stat(mFile.getPath());

synchronized (mLock) {
// 检查修改时间和文件大小
return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size;
}
}

至此,SharedPreferences 全部源码分析结束。


  1. 总结

可以看到,虽然 SharedPreferences 是一个轻量级的 K-V 存储框架,但的确是一个完整的存储方案。从源码分析中,我们可以看到 SharedPreferences 在读写性能、可用性方面都有做一些优化,例如:锁细化、事务化、事务过滤、文件备份等,值得细细品味。

在下篇文章里,我们来盘点 SharedPreferences 中存在的 “缺点”,为什么 SharedPreferences 没有乘上新时代的船只。请关注。


版权声明

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

参考资料

  • Android SharedPreferences 的理解与使用 —— ghroosk 著
  • 一文读懂 SharedPreferences 的缺陷及一点点思考 —— 业志陈 著
  • 反思|官方也无力回天?Android SharedPreferences 的设计与实现 —— 却把青梅嗅 著
  • 剖析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技术团队
  • 今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待 —— 字节跳动技术团队

推荐阅读

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 交流社群~

本文转载自: 掘金

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

Android 开源库

发表于 2022-11-24

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

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

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

前言

大家好,我是小彭。

SharedPreferences 是 Android 平台上轻量级的 K-V 存储框架,亦是初代 K-V 存储框架,至今被很多应用沿用。

有的小伙伴会说,SharedPreferences 是旧时代的产物,现在已经有 DataStore 或 MMKV 等新时代的 K-V 框架,没有学习意义。但我认为,虽然 SharedPreference 这个方案已经过时,但是并不意味着 SharedPreference 中使用的技术过时。做技术要知其然,更要知其所以然,而不是人云亦云,如果要你解释为什么 SharedPreferences 会过时,你能说到什么程度?

不知道你最近有没有读到一本在技术圈非常火爆的一本新书 《安卓传奇 · Android 缔造团队回忆录》,其中就讲了很多 Android 架构演进中设计者的思考。如果你平时也有从设计者的角度思考过 “为什么”,那么很多内容会觉得想到一块去了,反之就会觉得无感。

—— 图片引用自电商平台

今天,我们就来分析 SharedPreference 源码,在过程中依然可以学习到非常丰富的设计技巧。在后续的文章中,我们会继续分析其他 K-V 存储框架,请关注。

本文源码分析基于 Android 10(API 31),并关联分析部分 Android 7.1(API 25)。

  • Android 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • Android 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)

思维导图:


  1. 实现 K-V 框架应该思考什么问题?

在阅读 SharedPreference 的源码之前,我们先思考一个 K-V 框架应该考虑哪些问题?

  • 问题 1 - 线程安全: 由于程序一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;
  • 问题 2 - 内存缓存: 由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;
  • 问题 3 - 事务: 由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;
  • 问题 4 - 事务串行化: 由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;
  • 问题 5 - 异步写回: 由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;
  • 问题 6 - 增量更新: 由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;
  • 问题 7 - 变更回调: 由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;
  • 问题 8 - 多进程: 由于程序可能有多进程需求,那么框架如何保证多进程数据同步?
  • 问题 9 - 可用性: 由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;
  • 问题 10 - 高效性: 性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;
  • 问题 11 - 安全性: 如果程序需要存储敏感数据,如何保证数据完整性和保密性;
  • 问题 12 - 数据迁移: 如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;
  • 问题 13 - 研发体验: 是否模板代码冗长,是否容易出错。

提出这么多问题后:

你觉得学习 SharedPreferences 有没有价值呢?

如果让你自己写一个 K-V 框架,你会如何解决这些问题呢?

新时代的 MMKV 和 DataStore 框架是否良好处理了这些问题?


  1. 从 Sample 开始

SharedPreferences 采用 XML 文件格式持久化键值对数据,文件的存储位置位于应用沙盒的内部存储 /data/data/<packageName>/shared_prefs/ 位置,每个 XML 文件对应于一个 SharedPreferences 对象。

在 Activity、Context 和 PreferenceManager 中都存在获取 SharedPreferences 对象的 API,它们最终都会走到 ContextImpl 中:

ContextImpl.java

1
2
3
4
5
6
7
8
java复制代码class ContextImpl extends Context {

// 获取 SharedPreferences 对象
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// 后文详细分析...
}
}

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码SharedPreferences sp = getSharedPreferences("prefs", Context.MODE_PRIVATE);

// 创建事务
Editor editor = sp.edit();
editor.putString("name", "XIAO PENG");
// 同步提交事务
boolean result = editor.commit();
// 异步提交事务
// editor.apply()

// 读取数据
String blog = sp.getString("name", "PENG");

prefs.xml 文件内容

1
2
3
4
xml复制代码<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="name">XIAO PENG</string>
</map>

  1. SharedPreferences 的内存缓存

由于磁盘 IO 操作是耗时操作,如果每一次访问 SharedPreferences 都执行一次 IO 操作就显得没有必要,所以 SharedPreferences 会在业务层和磁盘之间增加一层内存缓存。在 ContextImpl 类中,不仅支持获取 SharedPreferencesImpl 对象,还负责支持 SharedPreferencesImpl 对象的内存缓存。

ContextImpl 中的内存缓存逻辑是相对简单的:

  • 步骤1:通过文件名 name 映射文件对应的 File 对象;
  • 步骤 2:通过 File 对象映射文件对应的 SharedPreferencesImpl 对象。

两个映射表:

  • mSharedPrefsPaths: 缓存 “文件名 to 文件对象” 的映射;
  • sSharedPrefsCache: 这是一个二级映射表,第一级是包名到 Map 的映射,第二级是缓存 “文件对象 to SP 对象” 的映射。每个 XML 文件在内存中只会关联一个全局唯一的 SharedPreferencesImpl 对象

继续分析发现: 虽然 ContextImpl 实现了 SharedPreferencesImpl 对象的缓存复用,但没有实现缓存淘汰,也没有提供主动移除缓存的 API。因此,在 APP 运行过程中,随着访问的业务范围越来越多,这部分 SharedPreferences 内存缓存的空间也会逐渐膨胀。这是一个需要注意的问题。

在 getSharedPreferences() 中还有 MODE_MULTI_PROCESS 标记位的处理:

如果是首次获取 SharedPreferencesImpl 对象会直接读取磁盘文件,如果是二次获取 SharedPreferences 对象会复用内存缓存。但如果使用了 MODE_MULTI_PROCESS 多进程模式,则在返回前会检查磁盘文件相对于最后一次内存修改是否变化,如果变化则说明被其他进程修改,需要重新读取磁盘文件,以实现多进程下的 “数据同步”。

但是这种同步是非常弱的,因为每个进程本身对磁盘文件的写回是非实时的,再加上如果业务层缓存了 getSharedPreferences(…) 返回的对象,更感知不到最新的变化。所以严格来说,SharedPreferences 是不支持多进程的,官方也明确表示不要将 SharedPreferences 用于多进程环境。

SharedPreferences 内存缓存示意图

流程图

ContextImpl.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
java复制代码class ContextImpl extends Context {

// SharedPreferences 文件根目录
private File mPreferencesDir;

// <文件名 - 文件>
@GuardedBy("ContextImpl.class")
private ArrayMap<String, File> mSharedPrefsPaths;

// 获取 SharedPreferences 对象
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// 1、文件名转文件对象
File file;
synchronized (ContextImpl.class) {
// 1.1 查询映射表
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
// 1.2 缓存未命中,创建 File 对象
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
// 2、获取 SharedPreferences 对象
return getSharedPreferences(file, mode);
}

// -> 1.2 缓存未命中,创建 File 对象
@Override
public File getSharedPreferencesPath(String name) {
return makeFilename(getPreferencesDir(), name + ".xml");
}

private File getPreferencesDir() {
synchronized (mSync) {
// 文件目录:data/data/[package_name]/shared_prefs/
if (mPreferencesDir == null) {
mPreferencesDir = new File(getDataDir(), "shared_prefs");
}
return ensurePrivateDirExists(mPreferencesDir);
}
}
}

文件对象 to SP 对象:

ContextImpl.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
java复制代码class ContextImpl extends Context {

// <包名 - Map>
// <文件 - SharedPreferencesImpl>
@GuardedBy("ContextImpl.class")
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

// -> 2、获取 SharedPreferences 对象
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
// 2.1 查询缓存
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
// 2.2 未命中缓存(首次获取)
if (sp == null) {
// 2.2.1 检查 mode 标记
checkMode(mode);
// 2.2.2 创建 SharedPreferencesImpl 对象
sp = new SharedPreferencesImpl(file, mode);
// 2.2.3 缓存
cache.put(file, sp);
return sp;
}
}
// 3、命中缓存(二次获取)
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
// 判断当前磁盘文件相对于最后一次内存修改是否变化,如果时则重新加载文件
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}

// 根据包名获取 <文件 - SharedPreferencesImpl> 映射表
@GuardedBy("ContextImpl.class")
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
sSharedPrefsCache = new ArrayMap<>();
}

final String packageName = getPackageName();
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}

return packagePrefs;
}
...
}

  1. 读取和解析磁盘文件

在创建 SharedPreferencesImpl 对象时,构造函数会启动一个子线程去读取本地磁盘文件,一次性将文件中所有的 XML 数据转化为 Map 散列表。

需要注意的是: 如果在执行 loadFromDisk() 解析文件数据的过程中,其他线程调用 getValue 查询数据,那么就必须等待 mLock 锁直到解析结束。

如果单个 SharedPreferences 的 .xml 文件很大的话,就有可能导致查询数据的线程被长时间被阻塞,甚至导致主线程查询时产生 ANR。这也辅证了 SharedPreferences 只适合保存少量数据,文件过大在解析时会有性能问题。

读取示意图

SharedPreferencesImpl.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
java复制代码// 目标文件
private final File mFile;
// 备份文件(后文详细分析)
private final File mBackupFile;
// 模式
private final int mMode;
// 锁
private final Object mLock = new Object();
// 读取文件标记位
@GuardedBy("mLock")
private boolean mLoaded = false;

SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
// 读取并解析文件数据
startLoadFromDisk();
}

private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
// 子线程
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}

// -> 读取并解析文件数据(子线程)
private void loadFromDisk() {
synchronized (mLock) {
if (mLoaded) {
return;
}
// 1、如果存在备份文件,则恢复备份数据(后文详细分析)
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}

Map<String, Object> map = null;
if (mFile.canRead()) {
// 2、读取文件
BufferedInputStream str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);
// 3、将 XML 数据解析为 Map 映射表
map = (Map<String, Object>) XmlUtils.readMapXml(str);
IoUtils.closeQuietly(str);
}

synchronized (mLock) {
mLoaded = true;

if (map != null) {
// 使用解析的映射表
mMap = map;
} else {
// 创建空的映射表
mMap = new HashMap<>();
}
// 4、唤醒等待 mLock 锁的线程
mLock.notifyAll();
}
}

static File makeBackupFile(File prefsFile) {
return new File(prefsFile.getPath() + ".bak");
}

查询数据可能会阻塞等待:

SharedPreferencesImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
// 等待 mLoaded 标记位
awaitLoadedLocked();
// 查询数据
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}

private void awaitLoadedLocked() {
// “检查 - 等待” 模式
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
}

  1. SharedPreferences 的事务机制

是的,SharedPreferences 也有事务操作。

虽然 ContextImpl 中使用了内存缓存,但是最终数据还是需要执行磁盘 IO 持久化到磁盘文件中。如果每一次 “变更操作” 都对应一次磁盘 “写回操作” 的话,不仅效率低下,而且没有必要。

所以 SharedPreferences 会使用 “事务” 机制,将多次变更操作聚合为一个 “事务”,一次事务最多只会执行一次磁盘写回操作。虽然 SharedPreferences 源码中并没有直接体现出 “Transaction” 之类的命名,但是这就是一种 “事务” 设计,与命名无关。

5.1 MemoryCommitResult 事务对象

SharedPreferences 的事务操作由 Editor 接口实现。

SharedPreferences 对象本身只保留获取数据的 API,而变更数据的 API 全部集成在 Editor 接口中。Editor 中会将所有的 putValue 变更操作记录在 mModified 映射表中,但不会触发任何磁盘写回操作,直到调用 Editor#commit 或 Editor#apply 方法时,才会一次性以事务的方式发起磁盘写回任务。

比较特殊的是:

  • 在 remove 方法中:会将 this 指针作为特殊的移除标记位,后续将通过这个 Value 来判断是移除键值对还是修改 / 新增键值对;
  • 在 clear 方法中:只是将 mClear 标记位置位。

可以看到: 在 Editor#commit 和 Editor#apply 方法中,首先都会调用 Editor#commitToMemery() 收集需要写回磁盘的数据,并封装为一个 MemoryCommitResult 事务对象,随后就是根据这个事务对象的信息写回磁盘。

SharedPreferencesImpl.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
java复制代码final class SharedPreferencesImpl implements SharedPreferences {

// 创建修改器对象
@Override
public Editor edit() {
// 等待磁盘文件加载完成
synchronized (mLock) {
awaitLoadedLocked();
}
// 创建修改器对象
return new EditorImpl();
}

// 修改器
// 非静态内部类(会持有外部类 SharedPreferencesImpl 的引用)
public final class EditorImpl implements Editor {

// 锁对象
private final Object mEditorLock = new Object();

// 修改记录(将以事务方式写回磁盘)
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();

// 清除全部数据的标记位
@GuardedBy("mEditorLock")
private boolean mClear = false;

// 修改 String 类型键值对
@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}

// 修改 int 类型键值对
@Override
public Editor putInt(String key, int value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}

// 移除键值对
@Override
public Editor remove(String key) {
synchronized (mEditorLock) {
// 将 this 指针作为特殊的移除标记位
mModified.put(key, this);
return this;
}
}

// 清空键值对
@Override
public Editor clear() {
synchronized (mEditorLock) {
// 清除全部数据的标记位
mClear = true;
return this;
}
}

...

@Override
public void apply() {
// commitToMemory():写回磁盘的数据并封装事务对象
MemoryCommitResult mcr = commitToMemory();
// 同步写回,下文详细分析
}

@Override
public boolean commit() {
// commitToMemory():写回磁盘的数据并封装事务对象
final MemoryCommitResult mcr = commitToMemory();
// 异步写回,下文详细分析
}
}
}

MemoryCommitResult 事务对象核心的字段只有 2 个:

  • memoryStateGeneration: 当前的内存版本(在 writeToFile() 中会过滤低于最新的内存版本的无效事务);
  • mapToWriteToDisk: 最终全量覆盖写回磁盘的数据。

SharedPreferencesImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码private static class MemoryCommitResult {
// 内存版本
final long memoryStateGeneration;
// 需要全量覆盖写回磁盘的数据
final Map<String, Object> mapToWriteToDisk;
// 同步计数器
final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);

@GuardedBy("mWritingToDiskLock")
volatile boolean writeToDiskResult = false;
boolean wasWritten = false;

// 后文写回结束后调用
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
// writeToDiskResult 会作为 commit 同步写回的返回值
writeToDiskResult = result;
// 唤醒等待锁
writtenToDiskLatch.countDown();
}
}

5.2 创建 MemoryCommitResult 事务对象

下面,我们先来分析创建 Editor#commitToMemery() 中 MemoryCommitResult 事务对象的步骤,核心步骤分为 3 步:

  • 步骤 1 - 准备映射表

首先,检查 SharedPreferencesImpl#mDiskWritesInFlight 变量,如果 mDiskWritesInFlight == 0 则说明不存在并发写回的事务,那么 mapToWriteToDisk 就只会直接指向 SharedPreferencesImpl 中的 mMap 映射表。如果存在并发写回,则会深拷贝一个新的映射表。

mDiskWritesInFlight 变量是记录进行中的写回事务数量记录,每执行一次 commitToMemory() 创建事务对象时,就会将 mDiskWritesInFlight 变量会自增 1,并在写回事务结束后 mDiskWritesInFlight 变量会自减 1。

  • 步骤 2 - 合并变更记录

其次,遍历 mModified 映射表将所有的变更记录(新增、修改或删除)合并到 mapToWriteToDisk 中(此时,Editor 中的数据已经同步到内存缓存中)。

这一步中的关键点是:如果发生有效修改,则会将 SharedPreferencesImpl 对象中的 mCurrentMemoryStateGeneration 最新内存版本自增 1,比最新内存版本小的事务会被视为无效事务。

  • 步骤 3 - 创建事务对象

最后,使用 mapToWriteToDisk 和 mCurrentMemoryStateGeneration 创建 MemoryCommitResult 事务对象。

事务示意图

SharedPreferencesImpl.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
java复制代码final class SharedPreferencesImpl implements SharedPreferences {

// 进行中事务计数(在提交事务是自增 1,在写回结束时自减 1)
@GuardedBy("mLock")
private int mDiskWritesInFlight = 0;

// 内存版本
@GuardedBy("this")
private long mCurrentMemoryStateGeneration;

// 磁盘版本
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;

// 修改器
public final class EditorImpl implements Editor {

// 锁对象
private final Object mEditorLock = new Object();

// 修改记录(将以事务方式写回磁盘)
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();

// 清除全部数据的标记位
@GuardedBy("mEditorLock")
private boolean mClear = false;

// 获取需要写回磁盘的事务
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;

synchronized (SharedPreferencesImpl.this.mLock) {
// 如果同时存在多个写回事务,则使用深拷贝
if (mDiskWritesInFlight > 0) {
mMap = new HashMap<String, Object>(mMap);
}
// mapToWriteToDisk:需要写回的数据
mapToWriteToDisk = mMap;
// mDiskWritesInFlight:进行中事务自增 1
mDiskWritesInFlight++;

synchronized (mEditorLock) {
// changesMade:标记是否发生有效修改
boolean changesMade = false;

// 清除全部键值对
if (mClear) {
// 清除 mapToWriteToDisk 映射表(下面的 mModified 有可能重新增加键值对)
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
mClear = false;
}

// 将 Editor 中的 mModified 修改记录合并到 mapToWriteToDisk
// mapToWriteToDisk 指向 SharedPreferencesImpl 中的 mMap,所以内存缓存越会被修改
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
if (v == this /*使用 this 指针作为魔数*/|| v == null) {
// 移除键值对
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
// 新增或更新键值对
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
// 标记发生有效修改
changesMade = true;
// 记录变更的键值对
if (hasListeners) {
keysModified.add(k);
}
}
// 重置修改记录
mModified.clear();
// 如果发生有效修改,内存版本自增 1
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
// 记录当前的内存版本
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified, listeners, mapToWriteToDisk);
}
}
}

步骤 2 - 合并变更记录中,存在一种 “反直觉” 的 clear() 操作:

如果在 Editor 中存在 clear() 操作,并且 clear 前后都有 putValue 操作,就会出现反常的效果:如以下示例程序,按照直观的预期效果,最终写回磁盘的键值对应该只有 ,但事实上最终 和 两个键值对都会被写回磁盘。

出现这个 “现象” 的原因是:SharedPreferences 事务中没有保持 clear 变更记录和 putValue 变更记录的顺序,所以 clear 操作之前的 putValue 操作依然会生效。

示例程序

1
2
3
4
5
6
java复制代码getSharedPreferences("user", Context.MODE_PRIVATE).let {
it.edit().putString("name", "XIAOP PENG")
.clear()
.putString("age", "18")
.apply()
}

小结一下 3 个映射表的区别:

  • 1、mMap 是 SharedPreferencesImpl 对象中记录的键值对数据,代表 SharedPreferences 的内存缓存;
  • 2、mModified 是 Editor 修改器中记录的键值对变更记录;
  • 3、mapToWriteToDisk 是 mMap 与 mModified 合并后,需要全量覆盖写回磁盘的数据。

后续源码分析,见下一篇文章:Android 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)


版权声明

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

参考资料

  • Android SharedPreferences 的理解与使用 —— ghroosk 著
  • 一文读懂 SharedPreferences 的缺陷及一点点思考 —— 业志陈 著
  • 反思|官方也无力回天?Android SharedPreferences 的设计与实现 —— 却把青梅嗅 著
  • 剖析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技术团队
  • 今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待 —— 字节跳动技术团队

推荐阅读

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 交流社群~

本文转载自: 掘金

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

Android 开源库

发表于 2022-11-20

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

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

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

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了 Square 开源的 I/O 框架 Okio 的三个优势:精简且全面的 API、基于共享的缓冲区设计以及超时机制。前两个优势已经分析过了,今天我们来分析 Okio 的超时检测机制。

本文源码基于 Okio v3.2.0。


学习路线图:


  1. 认识 Okio 的超时机制

超时机制是一项通用的系统设计,能够避免系统长时间阻塞在某些任务上。例如网络请求在超时时间内没有响应,客户端就会提前中断请求,并提示用户某些功能不可用。

1.1 说一下 Okio 超时机制的优势

先思考一个问题,相比于传统 IO 的超时有什么优势呢?我认为主要体现在 2 个方面:

  • 优势 1 - Okio 弥补了部分 IO 操作不支持超时检测的缺陷:

Java 原生 IO 操作是否支持超时,完全取决于底层的系统调用是否支持。例如,网络 Socket 支持通过 setSoTimeout API 设置单次 IO 操作的超时时间,而文件 IO 操作就不支持,使用原生文件 IO 就无法实现超时。

而 Okio 是统一在应用层实现超时检测,不管系统调用是否支持超时,都能提供统一的超时检测机制。

  • 优势 2 - Okio 不仅支持单次 IO 操作的超时检测,还支持包含多次 IO 操作的复合任务超时检测:

Java 原生 IO 操作只能实现对单次 IO 操作的超时检测,无法实现对包含多次 IO 操作的复合任务超时检测。例如,OkHttp 支持配置单次 connect、read 或 write 操作的超时检测,还支持对一次完整 Call 请求的超时检测,有时候单个操作没有超时,但串联起来的完整 call 却超时了。

而 Okio 超时机制和 IO 操作没有强耦合,不仅支持对 IO 操作的超时检测,还支持非 IO 操作的超时检测,所以这种复合任务的超时检测也是可以实现的。

1.2 Timeout 类的作用

Timeout 类是 Okio 超时机制的核心类,Okio 对 Source 输入流和 Sink 输出流都提供了超时机制,我们在构造 InputStreamSource 和 OutputStreamSink 这些流的实现类时,都需要携带 Timeout 对象:

Source.kt

1
2
3
4
5
6
7
kotlin复制代码interface Source : Closeable {

// 返回超时控制对象
fun timeout(): Timeout

...
}

Sink.kt

1
2
3
4
5
6
7
kotlin复制代码actual interface Sink : Closeable, Flushable {

// 返回超时控制对象
actual fun timeout(): Timeout

...
}

Timeout 类提供了两种配置超时时间的方式(如果两种方式同时存在的话,Timeout 会优先采用更早的截止时间):

  • 1、timeoutNanos 任务处理时间: 设置处理单次任务的超时时间,

最终触发超时的截止时间是任务的 startTime + timeoutNanos;

  • 2、deadlineNanoTime 截止时间: 直接设置未来的某个时间点,多个任务整体的超时时间点。

Timeout.kt

1
2
3
4
kotlin复制代码// hasDeadline 这个属性显得没必要
private var hasDeadline = false // 是否设置了截止时间点
private var deadlineNanoTime = 0L // 截止时间点(单位纳秒)
private var timeoutNanos = 0L // 处理单次任务的超时时间(单位纳秒)

创建 Source 和 Sink 对象时,都需要携带 Timeout 对象:

JvmOkio.kt

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
kotlin复制代码// ----------------------------------------------------------------------------
// 输入流
// ----------------------------------------------------------------------------

fun InputStream.source(): Source = InputStreamSource(this, Timeout() /*Timeout 对象*/)

// 文件输入流
fun File.source(): Source = InputStreamSource(inputStream(), Timeout.NONE)

// Socket 输入流
fun Socket.source(): Source {
val timeout = SocketAsyncTimeout(this)
val source = InputStreamSource(getInputStream(), timeout /*携带 Timeout 对象*/)
// 包装为异步超时
return timeout.source(source)
}

// ----------------------------------------------------------------------------
// 输出流
// ----------------------------------------------------------------------------

fun OutputStream.sink(): Sink = OutputStreamSink(this, Timeout() /*Timeout 对象*/)

// 文件输出流
fun File.sink(append: Boolean = false): Sink = FileOutputStream(this, append).sink()

// Socket 输出流
fun Socket.sink(): Sink {
val timeout = SocketAsyncTimeout(this)
val sink = OutputStreamSink(getOutputStream(), timeout /*携带 Timeout 对象*/)
// 包装为异步超时
return timeout.sink(sink)
}

在 Timeout 类的基础上,Okio 提供了 2 种超时机制:

  • Timeout 是同步超时
  • AsyncTimeout 是异步超时

Okio 框架


  1. Timeout 同步超时

Timeout 同步超时依赖于 Timeout#throwIfReached() 方法。

同步超时在每次执行任务之前,都需要先调用 Timeout#throwIfReached() 检查当前时间是否到达超时截止时间。如果超时则会直接抛出超时异常,不会再执行任务。

JvmOkio.kt

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
kotlin复制代码private class InputStreamSource(
// 输入流
private val input: InputStream,
// 超时控制
private val timeout: Timeout
) : Source {

override fun read(sink: Buffer, byteCount: Long): Long {
// 1、参数校验
if (byteCount == 0L) return 0
require(byteCount >= 0) { "byteCount < 0: $byteCount" }
// 2、检查超时时间
timeout.throwIfReached()
// 3、执行输入任务(已简化)
val bytesRead = input.read(...)
return bytesRead.toLong()
}
...
}

private class OutputStreamSink(
// 输出流
private val out: OutputStream,
// 超时控制
private val timeout: Timeout
) : Sink {

override fun write(source: Buffer, byteCount: Long) {
// 1、参数校验
checkOffsetAndCount(source.size, 0, byteCount)
// 2、检查超时时间
timeout.throwIfReached()
// 3、执行输入任务(已简化)
out.write(...)
...
}
...
}

看一眼 Timeout#throwIfReached 的源码。 可以看到,同步超时只考虑 “deadlineNanoTime 截止时间”,如果只设置 “timeoutNanos 任务处理时间” 是无效的,我觉得这个设计容易让开发者出错。

Timeout.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码@Throws(IOException::class)
open fun throwIfReached() {
if (Thread.interrupted()) {
// 传递中断状态
Thread.currentThread().interrupt() // Retain interrupted status.
throw InterruptedIOException("interrupted")
}

if (hasDeadline && deadlineNanoTime - System.nanoTime() <= 0) {
// 抛出超时异常
throw InterruptedIOException("deadline reached")
}
}

有必要解释所谓 “同步” 的意思:

同步超时就是指任务的 “执行” 和 “超时检查” 是同步的。当任务超时时,Okio 同步超时不会直接中断任务执行,而是需要检主动查超时时间(Timeout#throwIfReached)来判断是否发生超时,再决定是否中断任务执行。

这其实与 Java 的中断机制是非常相似的:

当 Java 线程的中断标记位置位时,并不是真的会直接中断线程执行,而是主动需要检查中断标记位(Thread.interrupted)来判断是否发生中断,再决定是否中断线程任务。所以说 Java 的线程中断机制是一种 “同步中断”。

可以看出,同步超时存在 “滞后性”:

因为同步超时需要主动检查,所以即使在任务执行过程中发生超时,也必须等到检查时才会发现超时,无法及时触发超时异常。因此,就需要异步超时机制。

同步超时示意图


  1. AsyncTimeout 异步超时

  • 异步超时监控进入: 异步超时在每次执行任务之前,都需要先调用 AsyncTimeout#enter() 方法将 AsyncTimeout 挂载到超时队列中,并根据超时截止时间的先后顺序排序,队列头部的节点就是会最先超时的任务;
  • 异步超时监控退出: 在每次任务执行结束之后,都需要再调用 AsyncTimeout#exit() 方法将 AsyncTimeout 从超时队列中移除。

注意: enter() 方法和 eixt() 方法必须成对存在。

AsyncTimeout.kt

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
kotlin复制代码open class AsyncTimeout : Timeout() {

// 是否在等待队列中
private var inQueue = false

// 后续指针
private var next: AsyncTimeout? = null

// 超时截止时间
private var timeoutAt = 0L

// 异步超时监控进入
fun enter() {
check(!inQueue) { "Unbalanced enter/exit" }
val timeoutNanos = timeoutNanos()
val hasDeadline = hasDeadline()
if (timeoutNanos == 0L && !hasDeadline) {
return
}
inQueue = true
scheduleTimeout(this, timeoutNanos, hasDeadline)
}

// 异步超时监控退出
// 返回值:是否发生超时(如果节点不存在,说明被 WatchDog 线程移除,即发生超时)
fun exit(): Boolean {
if (!inQueue) return false
inQueue = false
return cancelScheduledTimeout(this)
}

// 在 WatchDog 线程调用
protected open fun timedOut() {}

companion object {
// 超时队列头节点(哨兵节点)
private var head: AsyncTimeout? = null

// 分发超时监控任务
private fun scheduleTimeout(node: AsyncTimeout, timeoutNanos: Long, hasDeadline: Boolean) {
synchronized(AsyncTimeout::class.java) {
// 首次添加监控时,需要启动 Watchdog 线程
if (head == null) {
// 哨兵节点
head = AsyncTimeout()
Watchdog().start()
}

// now:当前时间
val now = System.nanoTime()
// timeoutAt 超时截止时间:计算 now + timeoutNanos 和 deadlineNanoTime 的较小值
if (timeoutNanos != 0L && hasDeadline) {
node.timeoutAt = now + minOf(timeoutNanos, node.deadlineNanoTime() - now)
} else if (timeoutNanos != 0L) {
node.timeoutAt = now + timeoutNanos
} else if (hasDeadline) {
node.timeoutAt = node.deadlineNanoTime()
} else {
throw AssertionError()
}

// remainingNanos 超时剩余时间:当前时间距离超时发生的时间
val remainingNanos = node.remainingNanos(now)
var prev = head!!
// 线性遍历超时队列,按照超时截止时间将 node 节点插入超时队列
while (true) {
if (prev.next == null || remainingNanos < prev.next!!.remainingNanos(now)) {
node.next = prev.next
prev.next = node
// 如果插入到队列头部,需要唤醒 WatchDog 线程
if (prev === head) {
(AsyncTimeout::class.java as Object).notify()
}
break
}
prev = prev.next!!
}
}
}

// 取消超时监控任务
// 返回值:是否超时
private fun cancelScheduledTimeout(node: AsyncTimeout): Boolean {
synchronized(AsyncTimeout::class.java) {
// 线性遍历超时队列,将 node 节点移除
var prev = head
while (prev != null) {
if (prev.next === node) {
prev.next = node.next
node.next = null
return false
}
prev = prev.next
}
// 如果节点不存在,说明被 WatchDog 线程移除,即发生超时
return true
}
}
}
}

同时,在首次添加异步超时监控时,AsyncTimeout 内部会开启一个 WatchDog 守护线程,按照 “检测 - 等待” 模型观察超时队列的头节点:

  • 如果发生超时,则将头节点移除,并回调 AsyncTimeout#timeOut() 方法。这是一个空方法,需要由子类实现来主动取消任务;
  • 如果未发生超时,则 WatchDog 线程会计算距离超时发生的时间间隔,调用 Object#wait(时间间隔) 进入限时等待。

需要注意的是: AsyncTimeout#timeOut() 回调中不能执行耗时操作,否则会影响后续检测的及时性。

有意思的是:我们会发现 Okio 的超时检测机制和 Android ANR 的超时检测机制非常类似,所以我们可以说 ANR 也是一种异步超时机制。

AsyncTimeout.kt

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
kotlin复制代码private class Watchdog internal constructor() : Thread("Okio Watchdog") {
init {
// 守护线程
isDaemon = true
}

override fun run() {
// 死循环
while (true) {
try {
var timedOut: AsyncTimeout? = null
synchronized(AsyncTimeout::class.java) {
// 取头节点(Maybe wait)
timedOut = awaitTimeout()
// 超时队列为空,退出线程
if (timedOut === head) {
head = null
return
}
}
// 超时发生,触发 AsyncTimeout#timedOut 回调
timedOut?.timedOut()
} catch (ignored: InterruptedException) {
}
}
}
}

companion object {
// 超时队列为空时,再等待一轮的时间
private val IDLE_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(60)
private val IDLE_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(IDLE_TIMEOUT_MILLIS)

@Throws(InterruptedException::class)
internal fun awaitTimeout(): AsyncTimeout? {
// Get the next eligible node.
val node = head!!.next

// 如果超时队列为空
if (node == null) {
// 需要再等待 60s 后再判断(例如在首次添加监控时)
val startNanos = System.nanoTime()
(AsyncTimeout::class.java as Object).wait(IDLE_TIMEOUT_MILLIS)
return if (head!!.next == null && System.nanoTime() - startNanos >= IDLE_TIMEOUT_NANOS) {
// 退出 WatchDog 线程
head
} else {
// WatchDog 线程重新取一次
null
}
}
// 计算当前时间距离超时发生的时间
var waitNanos = node.remainingNanos(System.nanoTime())

// 未超时,进入限时等待
if (waitNanos > 0) {
// Waiting is made complicated by the fact that we work in nanoseconds,
// but the API wants (millis, nanos) in two arguments.
val waitMillis = waitNanos / 1000000L
waitNanos -= waitMillis * 1000000L
(AsyncTimeout::class.java as Object).wait(waitMillis, waitNanos.toInt())
return null
}

// 超时,将头节点移除
head!!.next = node.next
node.next = null
return node
}
}

异步超时示意图

直接看代码不好理解,我们来举个例子:


  1. 举例:OkHttp Call 的异步超时监控

在 OkHttp 中,支持配置一次完整的 Call 请求上的操作时间 callTimeout。一次 Call 请求包含多个 IO 操作的复合任务,使用传统 IO 是不可能监控超时的,所以需要使用 AsyncTimeout 异步超时。

在 OkHttp 的 RealCall 请求类中,就使用了 AsyncTimeout 异步超时:

  • 1、开始任务: 在 execute() 方法中,调用 AsyncTimeout#enter() 进入异步超时监控,再执行请求;
  • 2、结束任务: 在 callDone() 方法中,调用 AsyncTimeout#exit() 退出异步超时监控。分析源码发现:callDone() 不仅在请求正常时会调用,在取消请求时也会回调,保证了 enter() 和 exit() 成对存在;
  • 3、超时回调: 在 AsyncTimeout#timeOut 超时回调中,调用了 Call#cancel() 提前取消请求。Call#cancel() 会调用到 Socket#close(),让阻塞中的 IO 操作抛出 SocketException 异常,以达到提前中断的目的,最终也会走到 callDone() 执行 exit() 退出异步监控。

Call 超时监控示意图

RealCall

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
kotlin复制代码class RealCall(
val client: OkHttpClient,
/** The application's original request unadulterated by redirects or auth headers. */
val originalRequest: Request,
val forWebSocket: Boolean
) : Call {

// 3、AsyncTimeout 超时监控
private val timeout = object : AsyncTimeout() {
override fun timedOut() {
// 取消请求
cancel()
}
}.apply {
timeout(client.callTimeoutMillis.toLong(), MILLISECONDS)
}

// 取消请求
override fun cancel() {
if (canceled) return // Already canceled.

canceled = true
exchange?.cancel()
// 最终会调用 Socket#close()
connectionToCancel?.cancel()

eventListener.canceled(this)
}

// 1、请求开始(由业务层调用)
override fun execute(): Response {
// 1.1 异步超时监控进入
timeout.enter()
// 1.2 执行请求
client.dispatcher.executed(this)
return getResponseWithInterceptorChain()
}

// 2、请求结束(由 OkHttp 引擎层调用,包含正常和异常情况)
// 除了 IO 操作在抛出异常后会走到 callDone(),在取消请求时也会走到 callDone()
internal fun <E : IOException?> messageDone(
exchange: Exchange,
requestDone: Boolean, // 请求正常结束
responseDone: Boolean, // 响应正常结束
e: E
): E {
...
if (callDone) {
return callDone(e)
}
return e
}

private fun <E : IOException?> callDone(e: E): E {
...
// 检查是否超时
val result = timeoutExit(e)
if (e != null) {
// 请求异常(包含超时异常)
eventListener.callFailed(this, result!!)
} else {
// 请求正常结束
eventListener.callEnd(this)
}
return result
}

private fun <E : IOException?> timeoutExit(cause: E): E {
if (timeoutEarlyExit) return cause
// 2.1 异步超时监控退出
if (!timeout.exit()) return cause
// 2.2 包装超时异常
val e = InterruptedIOException("timeout")
if (cause != null) e.initCause(cause)
return e as E
}
}

调用 Socket#close() 会让阻塞中的 IO 操作抛出 SocketException 异常:

Socket.java

1
2
3
4
5
6
7
8
9
10
java复制代码// Any thread currently blocked in an I/O operation upon this socket will throw a {@link SocketException}.
public synchronized void close() throws IOException {
synchronized(closeLock) {
if (isClosed())
return;
if (created)
impl.close();
closed = true;
}
}

Exchange 中会捕获 Socket#close() 抛出的 SocketException 异常:

Exchange.kt

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
kotlin复制代码private inner class RequestBodySink(
delegate: Sink,
/** The exact number of bytes to be written, or -1L if that is unknown. */
private val contentLength: Long
) : ForwardingSink(delegate) {

@Throws(IOException::class)
override fun write(source: Buffer, byteCount: Long) {
...
try {
super.write(source, byteCount)
this.bytesReceived += byteCount
} catch (e: IOException) {
// Socket#close() 会抛出异常,被这里拦截
throw complete(e)
}
}

private fun <E : IOException?> complete(e: E): E {
if (completed) return e
completed = true
return bodyComplete(bytesReceived, responseDone = false, requestDone = true, e = e)
}
}

fun <E : IOException?> bodyComplete(
bytesRead: Long,
responseDone: Boolean,
requestDone: Boolean,
e: E
): E {
...
// 回调到上面的 RealCall#messageDone
return call.messageDone(this, requestDone, responseDone, e)
}

  1. OkHttp 超时检测总结

先说一下 Okhttp 定义的 2 种颗粒度的超时:

  • 第 1 种是在单次 connect、read 或 write 操作上的超时;
  • 第 2 种是在一次完整的 call 请求上的超时,有时候单个操作没有超时,但连接起来的完整 call 却超时。

其实 Socket 支持通过 setSoTimeout API 设置单次操作的超时时间,但这个 API 无法满足需求,比如说 Call 超时是包含多个 IO 操作的复合任务,而且不管是 HTTP/1 并行请求还是 HTTP/2 多路复用,都会存在一个 Socket 连接上同时承载多个请求的情况,无法区分是哪个请求超时。

因此,OkHttp 采用了两种超时监测:

  • 对于 connect 操作,OkHttp 继续使用 Socket 级别的超时,没有问题;
  • 对于 call、read 和 write 的超时,OkHttp 使用一个 Okio 的异步超时机制来监测超时。

版权声明

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

推荐阅读

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 交流社群~

本文转载自: 掘金

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

Android 开源库

发表于 2022-11-19

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

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

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

前言

大家好,我是小彭。

今天,我们来讨论一个 Square 开源的 I/O 框架 Okio,我们最开始接触到 Okio 框架还是源于 Square 家的 OkHttp 网络框架。那么,OkHttp 为什么要使用 Okio,它相比于 Java 原生 IO 有什么区别和优势?今天我们就围绕这些问题展开。

本文源码基于 Okio v3.2.0。


思维导图


  1. 说一下 Okio 的优势?

相比于 Java 原生 IO 框架,我认为 Okio 的优势主要体现在 3 个方面:

  • 1、精简且全面的 API: 原生 IO 使用装饰模式,例如使用 BufferedInputStream 装饰 FileInputStream 文件输入流,可以增强流的缓冲功能。但是原生 IO 的装饰器过于庞大,需要区分字节、字符流、字节数组、字符数组、缓冲等多种装饰器,而这些恰恰又是最常用的基础装饰器。相较之下,Okio 直接在 BufferedSource 和 BufferedSink 中聚合了原生 IO 中所有基础的装饰器,使得框架更加精简;
  • 2、基于共享的缓冲区设计: 由于 IO 系统调用存在上下文切换的性能损耗,为了减少系统调用次数,应用层往往会采用缓冲区策略。但是缓冲区又会存在副作用,当数据从一个缓冲区转移到另一个缓冲区时需要拷贝数据,这种内存中的拷贝显得没有必要。而 Okio 采用了基于共享的缓冲区设计,在缓冲区间转移数据只是共享 Segment 的引用,而减少了内存拷贝。同时 Segment 也采用了对象池设计,减少了内存分配和回收的开销;
  • 3、超时机制: Okio 弥补了部分 IO 操作不支持超时检测的缺陷,而且 Okio 不仅支持单次 IO 操作的超时检测,还支持包含多次 IO 操作的复合任务超时检测。

下面,我们将从这三个优势展开分析:


  1. 精简的 Okio 框架

先用一个表格总结 Okio 框架中主要的类型:

类型 描述
Source 输入流
Sink 输出流
BufferedSource 缓存输入流接口,实现类是 RealBufferedSource
BufferedSink 缓冲输出流接口,实现类是 RealBufferedSink
Buffer 缓冲区,由 Segment 链表组成
Segment 数据片段,多个片段组成逻辑上连续数据
ByteString String 类
Timeout 超时控制

2.1 Source 输入流 与 Sink 输出流

在 Java 原生 IO 中有四个基础接口,分别是:

  • 字节流: InputStream 输入流和 OutputStream 输出流;
  • 字符流: Reader 输入流和 Writer 输出流。

而在 Okio 更加精简,只有两个基础接口,分别是:

  • 流: Source 输入流和 Sink 输出流。

Source.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码interface Source : Closeable {

// 从输入流读取数据到 Buffer 中(Buffer 等价于 byte[] 字节数组)
// 返回值:-1:输入内容结束
@Throws(IOException::class)
fun read(sink: Buffer, byteCount: Long): Long

// 超时控制(详细分析见后续文章)
fun timeout(): Timeout

// 关闭流
@Throws(IOException::class)
override fun close()
}

Sink.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码actual interface Sink : Closeable, Flushable {

// 将 Buffer 的数据写入到输出流中(Buffer 等价于 byte[] 字节数组)
@Throws(IOException::class)
actual fun write(source: Buffer, byteCount: Long)

// 清空输出缓冲区
@Throws(IOException::class)
actual override fun flush()

// 超时控制(详细分析见后续文章)
actual fun timeout(): Timeout

// 关闭流
@Throws(IOException::class)
actual override fun close()
}

2.2 InputStream / OutputStream 与 Source / Sink 互转

在功能上,InputStream - Source 和 OutputStream - Sink 分别是等价的,而且是相互兼容的。结合 Kotlin 扩展函数,两种接口之间的转换会非常方便:

  • source(): InputStream 转 Source,实现类是 InputStreamSource;
  • sink(): OutputStream 转 Sink,实现类是 OutputStreamSink;

比较不理解的是: Okio 没有提供 InputStreamSource 和 OutputStreamSink 转回 InputStream 和 OutputStream 的方法,而是需要先转换为 BufferSource 与 BufferSink,再转回 InputStream 和 OutputStream。

  • buffer(): Source 转 BufferedSource,Sink 转 BufferedSink,实现类分别是 RealBufferedSource 和 RealBufferedSink。

示例代码

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 原生 IO -> Okio
val source = FileInputStream(File("")).source()
val bufferSource = FileInputStream(File("")).source().buffer()

val sink = FileOutputStream(File("")).sink()
val bufferSink = FileOutputStream(File("")).sink().buffer()

// Okio -> 原生 IO
val inputStream = bufferSource.inputStream()
val outputStream = bufferSink.outputStream()

JvmOkio.kt

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
kotlin复制代码// InputStream -> Source
fun InputStream.source(): Source = InputStreamSource(this, Timeout())

// OutputStream -> Sink
fun OutputStream.sink(): Sink = OutputStreamSink(this, Timeout())

private class InputStreamSource(
private val input: InputStream,
private val timeout: Timeout
) : Source {

override fun read(sink: Buffer, byteCount: Long): Long {
if (byteCount == 0L) return 0
require(byteCount >= 0) { "byteCount < 0: $byteCount" }
try {
// 同步超时监控(详细分析见后续文章)
timeout.throwIfReached()
// 读入 Buffer
val tail = sink.writableSegment(1)
val maxToCopy = minOf(byteCount, Segment.SIZE - tail.limit).toInt()
val bytesRead = input.read(tail.data, tail.limit, maxToCopy)
if (bytesRead == -1) {
if (tail.pos == tail.limit) {
// We allocated a tail segment, but didn't end up needing it. Recycle!
sink.head = tail.pop()
SegmentPool.recycle(tail)
}
return -1
}
tail.limit += bytesRead
sink.size += bytesRead
return bytesRead.toLong()
} catch (e: AssertionError) {
if (e.isAndroidGetsocknameError) throw IOException(e)
throw e
}
}

override fun close() = input.close()

override fun timeout() = timeout

override fun toString() = "source($input)"
}

private class OutputStreamSink(
private val out: OutputStream,
private val timeout: Timeout
) : Sink {

override fun write(source: Buffer, byteCount: Long) {
checkOffsetAndCount(source.size, 0, byteCount)
var remaining = byteCount
// 写出 Buffer
while (remaining > 0) {
// 同步超时监控(详细分析见后续文章)
timeout.throwIfReached()
// 取有效数据量和剩余输出量的较小值
val head = source.head!!
val toCopy = minOf(remaining, head.limit - head.pos).toInt()
out.write(head.data, head.pos, toCopy)

head.pos += toCopy
remaining -= toCopy
source.size -= toCopy

// 指向下一个 Segment
if (head.pos == head.limit) {
source.head = head.pop()
SegmentPool.recycle(head)
}
}
}

override fun flush() = out.flush()

override fun close() = out.close()

override fun timeout() = timeout

override fun toString() = "sink($out)"
}

Okio.kt

1
2
3
4
5
kotlin复制代码// Source -> BufferedSource
fun Source.buffer(): BufferedSource = RealBufferedSource(this)

// Sink -> BufferedSink
fun Sink.buffer(): BufferedSink = RealBufferedSink(this)

2.3 BufferSource 与 BufferSink

在 Java 原生 IO 中,为了减少系统调用次数,我们一般不会直接调用 InputStream 和 OutputStream,而是会使用 BufferedInputStream 和 BufferedOutputStream 包装类增加缓冲功能。

例如,我们希望采用带缓冲的方式读取字符格式的文件,则需要先将文件输入流包装为字符流,再包装为缓冲流:

Java 原生 IO 示例

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 第一层包装
FileInputStream fis = new FileInputStream(file);
// 第二层包装
InputStreamReader isr = new InputStreamReader(new FileInputStream(file), "UTF-8");
// 第三层包装
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
...
}
// 省略 close

同理,我们在 Okio 中一般也不会直接调用 Source 和 Sink,而是会使用 BufferedSource 和 BufferedSink 包装类增加缓冲功能:

Okio 示例

1
2
3
4
5
6
kotlin复制代码val bufferedSource = file.source()/*第一层包装*/.buffer()/*第二层包装*/
while (!bufferedSource.exhausted()) {
val line = bufferedSource.readUtf8Line();
...
}
// 省略 close

网上有资料说 Okio 没有使用装饰器模式,所以类结构更简单。 这么说其实不太准确,装饰器模式本身并不是缺点,而且从 BufferedSource 和 BufferSink 可以看出 Okio 也使用了装饰器模式。 严格来说是原生 IO 的装饰器过于庞大,而 Okio 的装饰器更加精简。

比如原生 IO 常用的流就有这么多:

  • 原始流: FileInputStream / FileOutputStream 与 SocketInputStream / SocketOutputStream;
  • 基础接口(区分字节流和字符流): InputStream / OutputStream 与 Reader / Writer;
  • 缓存流: BufferedInputStream / BufferedOutputStream 与 BufferedReader / BufferedWriter;
  • 基本类型: DataInputStream / DataOutputStream;
  • 字节数组和字符数组: ByteArrayInputStream / ByteArrayOutputStream 与 CharArrayReader / CharArrayWriter;
  • 此处省略一万个字。

原生 IO 框架

而这么多种流在 Okio 里还剩下多少呢?

  • 原始流: FileInputStream / FileOutputStream 与 SocketInputStream / SocketOutputStream;
  • 基础接口: Source / Sink;
  • 缓存流: BufferedSource / BufferedSink。

Okio 框架

就问你服不服?

而且你看哈,这些都是平时业务开发中最常见的基本类型,原生 IO 把它们都拆分开了,让问题复杂化了。反观 Okio 直接在 BufferedSource 和 BufferedSink 中聚合了原生 IO 中基本的功能,而不再需要区分字节、字符、字节数组、字符数组、基础类型等等装饰器,确实让框架更加精简。

BufferedSource.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码actual interface BufferedSource : Source, ReadableByteChannel {

actual val buffer: Buffer

// 读取 Int
@Throws(IOException::class)
actual fun readInt(): Int

// 读取 String
@Throws(IOException::class)
fun readString(charset: Charset): String

...

fun inputStream(): InputStream
}

BufferedSink.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码actual interface BufferedSink : Sink, WritableByteChannel {

actual val buffer: Buffer

// 写入 Int
@Throws(IOException::class)
actual fun writeInt(i: Int): BufferedSink

// 写入 String
@Throws(IOException::class)
fun writeString(string: String, charset: Charset): BufferedSink

...

fun outputStream(): OutputStream
}

2.4 RealBufferedSink 与 RealBufferedSource

BufferedSource 和 BufferedSink 还是接口,它们的真正的实现类是 RealBufferedSource 和 RealBufferedSink。可以看到,在实现类中会创建一个 Buffer 缓冲区,在输入和输出的时候,都会借助 “Buffer 缓冲区” 减少系统调用次数。

RealBufferedSource.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码internal actual class RealBufferedSource actual constructor(
// 装饰器模式
@JvmField actual val source: Source
) : BufferedSource {

// 创建输入缓冲区
@JvmField val bufferField = Buffer()

// 带缓冲地读取(全部数据)
override fun readString(charset: Charset): String {
buffer.writeAll(source)
return buffer.readString(charset)
}

// 带缓冲地读取(byteCount)
override fun readString(byteCount: Long, charset: Charset): String {
require(byteCount)
return buffer.readString(byteCount, charset)
}
}

RealBufferedSink.kt

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
kotlin复制代码internal actual class RealBufferedSink actual constructor(
// 装饰器模式
@JvmField actual val sink: Sink
) : BufferedSink {

// 创建输出缓冲区
@JvmField val bufferField = Buffer()

// 带缓冲地写入(全部数据)
override fun writeString(string: String, charset: Charset): BufferedSink {
buffer.writeString(string, charset)
return emitCompleteSegments()
}

// 带缓冲地写入(beginIndex - endIndex)
override fun writeString(
string: String,
beginIndex: Int,
endIndex: Int,
charset: Charset
): BufferedSink {
buffer.writeString(string, beginIndex, endIndex, charset)
return emitCompleteSegments()
}
}

至此,Okio 基本框架分析结束,用一张图总结:

Okio 框架


  1. Okio 的缓冲区设计

3.1 使用缓冲区减少系统调用次数

在操作系统中,访问磁盘和网卡等 IO 操作需要通过系统调用来执行。系统调用本质上是一种软中断,进程会从用户态陷入内核态执行中断处理程序,完成 IO 操作后再从内核态切换回用户态。

可以看到,系统调用存在上下文切换的性能损耗。为了减少系统调用次数,应用层往往会采用缓冲区策略:

以 Java 原生 IO BufferedInputStream 为例,会通过一个 byte[] 数组作为数据源的输入缓冲,每次读取数据时会读取更多数据到缓冲区中:

  • 如果缓冲区中存在有效数据,则直接从缓冲区数据读取;
  • 如果缓冲区不存在有效数据,则先执行系统调用填充缓冲区(fill),再从缓冲区读取数据;
  • 如果要读取的数据量大于缓冲区容量,就会跳过缓冲区直接执行系统调用。

输出流 BufferedOutputStream 也类似,输出数据时会优先写到缓冲区,当缓冲区满或者手动调用 flush() 时,再执行系统调用写出数据。

伪代码

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
java复制代码// 1. 输入
fun read(byte[] dst, int len) : Int {
// 缓冲区有效数据量
int avail = count - pos
if(avail <= 0) {
if(len >= 缓冲区容量) {
// 直接从输入流读取
read(输入流 in, dst, len)
}
// 填充缓冲区
fill(数据源 in, 缓冲区)
}
// 本次读取数据量,不超过可用容量
int cnt = (avail < len) ? avail : len?
read(缓冲区, dst, cnt)
// 更新缓冲区索引
pos += cnt
return cnt
}

// 2. 输出
fun write(byte[] src, len) {
if(len > 缓冲区容量) {
// 先将缓冲区写出
flush(缓冲区)
// 直接写出数据
write(输出流 out, src, len)
}
// 缓冲区剩余容量
int left = 缓冲区容量 - count
if(len > 缓冲区剩余容量) {
// 先将缓冲区写出
flush(缓冲区)
}
// 将数据写入缓冲区
write(缓冲区, src, len)
// 更新缓冲区已添加数据容量
count += len
}

3.2 缓冲区的副作用

的确,缓冲区策略能有效地减少系统调用次数,不至于读取一个字节都需要执行一次系统调用,大多数情况下表现良好。 但考虑一种 “双流操作” 场景,即从一个输入流读取,再写入到一个输出流。回顾刚才讲的缓存策略,此时的数据转移过程为:

  • 1、从输入流读取到缓冲区;
  • 2、从输入流缓冲区拷贝到 byte[](拷贝)
  • 3、将 byte[] copy 到输出流缓冲区(拷贝);
  • 4、将输出流缓冲区写入到输出流。

如果这两个流都使用了缓冲区设计,那么数据在这两个内存缓冲区之间相互拷贝,就显得没有必要。

3.3 Okio 的 Buffer 缓冲区

Okio 当然也有缓冲区策略,如果没有就会存在频繁系统调用的问题。

Buffer 是 RealBufferedSource 和 RealBufferedSink 的数据缓冲区。虽然在实现上与原生 BufferedInputStream 和 BufferedOutputStream 不一样,但在功能上是一样的。区别在于:

  • 1、BufferedInputStream 中的缓冲区是 “一个固定长度的字节数组” ,数据从一个缓冲区转移到另一个缓冲区需要拷贝;
  • 2、Buffer 中的缓冲区是 “一个 Segment 双向循环链表” ,每个 Segment 对象是一小段字节数组,依靠 Segment 链表的顺序组成逻辑上的连续数据。这个 Segment 片段是 Okio 高效的关键。

Buffer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码actual class Buffer : BufferedSource, BufferedSink, Cloneable, ByteChannel {

// 缓冲区(Segment 双向链表)
@JvmField internal actual var head: Segment? = null

// 缓冲区数据量
@get:JvmName("size")
actual var size: Long = 0L
internal set

override fun buffer() = this

actual override val buffer get() = this
}

对比 BufferedInputStream:

BufferedInputStream.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public class BufferedInputStream extends FilterInputStream {

// 缓冲区的默认大小(8KB)
private static int DEFAULT_BUFFER_SIZE = 8192;

// 输入缓冲区(固定长度的数组)
protected volatile byte buf[];

// 有效数据起始位,也是读数据的起始位
protected int pos;

// 有效数据量,pos + count 是写数据的起始位
protected int count;

...
}

3.4 Segment 片段与 SegmentPool 对象池

Segment 中的字节数组是可以 “共享” 的,当数据从一个缓冲区转移到另一个缓冲区时,可以共享数据引用,而不一定需要拷贝数据。

Segment.kt

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
kotlin复制代码internal class Segment {

companion object {
// 片段的默认大小(8KB)
const val SIZE = 8192
// 最小共享阈值,超过 1KB 的数据才会共享
const val SHARE_MINIMUM = 1024
}

// 底层数组
@JvmField val data: ByteArra
// 有效数据的起始位,也是读数据的起始位
@JvmField var pos: Int = 0
// 有效数据的结束位,也是写数据的起始位
@JvmField var limit: Int = 0
// 共享标记位
@JvmField var shared: Boolean = false
// 宿主标记位
@JvmField var owner: Boolean = false
// 后续指针
@JvmField var next: Segment? = null
// 前驱指针
@JvmField var prev: Segment? = null

constructor() {
// 默认构造 8KB 数组(为什么默认长度是 8KB)
this.data = ByteArray(SIZE)
// 宿主标记位
this.owner = true
// 共享标记位
this.shared = false
}
}

另外,Segment 还使用了对象池设计,被回收的 Segment 对象会缓存在 SegmentPool 中。SegmentPool 内部维护了一个被回收的 Segment 对象单链表,缓存容量的最大值是 MAX_SIZE = 64 * 1024,也就相当于 8 个默认 Segment 的长度:

SegmentPool.kt

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码// object:全局单例
internal actual object SegmentPool {

// 缓存容量
actual val MAX_SIZE = 64 * 1024

// 头节点
private val LOCK = Segment(ByteArray(0), pos = 0, limit = 0, shared = false, owner = false)

...
}

Segment 示意图


  1. 总结

  • 1、Okio 将原生 IO 多种基础装饰器聚合在 BufferedSource 和 BufferedSink,使得框架更加精简;
  • 2、为了减少系统调用次数的同时,应用层 IO 框架会使用缓存区设计。而 Okio 使用了基于共享 Segment 的缓冲区设计,减少了在缓冲区间转移数据的内存拷贝;
  • 3、Okio 弥补了部分 IO 操作不支持超时检测的缺陷,而且 Okio 不仅支持单次 IO 操作的超时检测,还支持包含多次 IO 操作的复合任务超时检测。

关于 Okio 超时机制的详细分析,我们在 下一篇文章 里讨论。请关注。


版权声明

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

参考资料

  • Github · Okio
  • Okio 官网
  • Okio 源码学习分析 —— 川峰 著
  • Okio 好在哪?—— MxsQ 著

推荐阅读

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 交流社群~

本文转载自: 掘金

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

【AI】浅谈损失函数 前言 介绍 分类损失 回归损失 实战

发表于 2022-11-17

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言

在任何深度学习项目中,配置损失函数都是确保模型以预期方式工作的最重要步骤之一。 损失函数可以为神经网络提供很多实用的灵活性,它将定义网络输出与网络其余部分的连接方式。

神经网络可以执行多种任务,从预测连续值(如每月支出)到对离散类别(如猫和狗)进行分类。 每个不同的任务将需要不同的损失类型,因为输出格式将不同。 具体任务将定义不同的损失函数。

接下来博主会细致讲解常见的损失函数,并结合代码使之更容易理解;

介绍

损失函数(loss function)是用来估量你模型的预测值 f(x)f(x)f(x) 与真实值 YYY 的不一致程度,它是一个非负实值函数,通常使用 L(Y,f(x))L(Y, f(x))L(Y,f(x)) 来表示,损失函数越小,模型的鲁棒性就越好。损失函数是经验风险函数的核心部分,也是结构风险函数重要组成部分。模型的结构风险函数包括了经验风险项和正则项,通常可以表示成如下式子:

image.png

其中,前面的均值函数表示的是经验风险函数,LLL 代表的是损失函数,后面的 ΦΦΦ 是正则化项(regularizer)或者叫惩罚项(penalty term),它可以是 L1L1L1,也可以是 L2L2L2,或者其他的正则函数。整个式子表示的意思是找到使目标函数最小时的 θθθ 值。

从非常简化的角度来看,损失函数(J)可以定义为具有两个参数的函数:

  1. 预测输出;
  2. 实际输出。

image.png

如何使用损失函数呢?具体步骤:

  1. 用随机值初始化前向计算公式的参数;
  2. 代入样本,计算输出的预测值;
  3. 用损失函数计算预测值和标签值(真实值)的误差;
  4. 根据损失函数的导数,沿梯度最小方向将误差回传,修正前向计算公式中的各个权重值;
  5. 进入第2步重复,直到损失函数值达到一个满意的值就停止迭代。

分类损失

当神经网络试图预测离散值时,我们可以将其视为分类模型。 这可能是网络试图预测图像中存在哪种动物,或者电子邮件是否为垃圾邮件。 首先,让我们看看分类神经网络的输出表示方式。

image.png

输出层的节点数将取决于数据中存在的类数。 每个节点将代表一个类。 每个输出节点的值本质上表示该类别为正确类别的概率。

1
vbnet复制代码Pr(Class 1) = Probability of Class 1 being the correct class

一旦获得所有不同类别的概率,我们就将具有最高概率的类别视为该实例的预测类别。 首先,让我们探讨如何进行二进制分类。

二进制分类

在二进制分类中,即使我们将在两个类之间进行预测,在输出层中也将只有一个节点。 为了获得概率格式的输出,我们需要应用一个激活函数。 由于概率要求取0到1之间的值,因此我们将使用S型函数,该函数可以将任何实际值压缩为0到1之间的值。

image.png

Sigmoid(x)=11+e−x=ex1+exSigmoid(x) = \frac{1}{1+e^{-x}} = \frac{e^x}{1+e^x}Sigmoid(x)=1+e−x1=1+exex
随着 SigmoidSigmoidSigmoid 的输入变大并趋向于无穷大,SigmoidSigmoidSigmoid 的输出趋向于1。随着 SigmoidSigmoidSigmoid 的输入变小而趋向于负无穷大,输出将趋于0。现在我们保证总会得到 一个介于0到1之间的值,这正是我们需要的值,因为我们需要概率。

根据公式编写 SigmoidSigmoidSigmoid 函数:

1
2
3
py复制代码def sigmoid(x):
s = 1 / (1 + np.exp(-x))
return s

我们用于二进制分类的损失函数称为二进制交叉熵(BCE)。 该功能有效地惩罚了用于二进制分类任务的神经网络。

image.png

我们可以在数学上将整个损失函数表示为一个方程式,如下所示:

Loss=(Y)(−log⁡(Ypred))+(1−Y)(−log⁡(1−Ypred))Loss = (Y)(-\log(Y_{pred}))+(1-Y)(-\log(1-Y_{pred}))Loss=(Y)(−log(Ypred))+(1−Y)(−log(1−Ypred))
此损失函数也称为对数损失。 这就是为二进制分类神经网络设计损失函数的方式。 现在,让我们继续来看如何为多类别分类网络定义损失。

多类别分类

当我们需要我们的模型每次预测一个可能的类输出时,多类分类是合适的。 现在,由于我们仍在处理概率,因此仅将 sigmoidsigmoidsigmoid 应用于所有输出节点可能有意义,以便我们为所有输出获得介于0–1之间的值,但这是有问题的。 在考虑多个类别的概率时,我们需要确保所有单个概率的总和等于1,因为这是定义概率的方式。 应用 SSS 形不能确保总和始终等于1,因此我们需要使用另一个激活函数。

未命名文件.png

Softmax(yi)=eyi∑i=0neyiSoftmax(y_i) = \frac{e^{y_i}}{\sum^n_{i=0}e^{y_i}}Softmax(yi)=∑i=0neyieyi
根据公式编写 SoftmaxSoftmaxSoftmax 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
py复制代码import numpy as np
def softmax(x):
# 计算每行的最大值
row_max = np.max(x)
# 每行元素都需要减去对应的最大值,否则求 exp(x) 会溢出,导致 inf 情况
x = x - row_max
# 计算 e 的指数次幂
x_exp = np.exp(x)
x_sum = np.sum(x_exp)
s = x_exp / x_sum
return s

softmax(np.array([[3, 0.8, 0.96]]))
# array([[0.80591096, 0.08929748, 0.10479156]])

如图所示,我们只是将所有值传递给指数函数。 之后,要确保它们都在0–1的范围内,并确保所有输出值的总和等于1,我们只需将每个指数除以所有指数的总和即可。

那么,为什么在归一化每个值之前必须将它们传递给指数呢? 为什么我们不能仅将值本身标准化? 这是因为 softmaxsoftmaxsoftmax 的目标是确保一个值非常高(接近1),而所有其他值都非常低(接近0)。 Softmax使用指数来确保发生这种情况。 然后我们在归一化,因为我们需要概率。

这种损失称为分类交叉熵。 现在,让我们进入一种称为多标签分类的特殊分类情况。

多标签分类

当模型需要预测多个类别作为输出时,便完成了多标签分类。 例如,假设您正在训练神经网络,以预测某些食物图片中的成分。 我们需要预测多种成分,因此 YYY 中会有多个1。

为此,我们不能使用 softmaxsoftmaxsoftmax,因为 softmaxsoftmaxsoftmax 始终只会迫使一个类别变为1,而其他类别变为0。因此,由于我们试图预测每个类别的个体概率,因此可以简单地在所有输出节点值上保持 sigmoidsigmoidsigmoid。

至于损失,我们可以直接在每个节点上使用对数损失进行求和,类似于在多类分类中所做的。

既然我们已经介绍了分类,现在让我们介绍回归损失函数。

回归损失

在回归中,我们的模型正在尝试预测连续值。 回归模型的一些示例是:

  • 房价预测
  • 年龄预测

在回归模型中,我们的神经网络将为每个我们试图预测的连续值提供一个输出节点。 通过在输出值和真实值之间进行直接比较来计算回归损失。

我们用于回归模型的最流行的损失函数是均方误差损失函数。 在此,我们仅计算 YYY 和 YpredY_{pred}Ypred 之差的平方,并对所有数据求平均值。 假设有 nnn 个数据点:

Loss=1n∗∑i=0n(Yi−Ypredi)2Loss = \frac{1}{n}*\sum^n_{i=0}(Y_i-Y_{pred_i})^2Loss=n1∗i=0∑n(Yi−Ypredi)2
在这里,YiY_iYi 和 YprediY_{pred_i}Ypredi 指的是数据集中第 iii 个 YYY 值,以及来自神经网络的相同数据的相应 YpredY_{pred}Ypred。

根据公式编写 MSEMSEMSE 函数:

1
2
3
4
5
6
7
8
9
10
py复制代码def mean_squared_error(y_true, y_pred):
sq_error = (y_pred - y_true) ** 2
sum_sq_error = np.sum(sq_error)
mse = sum_sq_error / y_true.size
return mse

y_true = [[0, 0, 1], [0, 1, 0], [1, 0, 0]]
y_pred = [[0.3, 0.3, 0.4], [0.3, 0.4, 0.3], [0.1, 0.2, 0.7]]
mean_squared_error(np.array(y_pred), np.array(y_true))
# 0.2688888888888889

实战

我们希望根据图片动物的轮廓、颜色等特征,来预测动物的类别,有三种可预测类别:猫、狗、猪。假设我们当前有两个模型(参数不同),这两个模型都是通过 sigmoidsigmoidsigmoid / softmaxsoftmaxsoftmax 的方式得到对于每个预测结果的概率值:

模型1:

预测 真实 是否正确
0.3 0.3 0.4 0 0 1 (猪) 正确
0.3 0.4 0.3 0 1 0 (狗) 正确
0.1 0.2 0.7 1 0 0 (猫) 错误

模型2:

预测 真实 是否正确
0.1 0.2 0.7 0 0 1 (猪) 正确
0.1 0.7 0.2 0 1 0 (狗) 正确
0.3 0.4 0.3 1 0 0 (猫) 错误
  • 模型1对于样本1和样本2以非常微弱的优势判断正确,对于样本3的判断则彻底错误。
  • 模型2对于样本1和样本2判断非常准确,对于样本3判断错误,但是相对来说没有错得太离谱。

有了模型之后,我们需要通过定义损失函数来判断模型在样本上的表现:


分类损失

模型1:

sample1Loss=−(0×log⁡0.3+0×log⁡0.3+1×log⁡0.4)=0.91sample2Loss=−(0×log⁡0.3+1×log⁡0.4+0×log⁡0.3)=0.91sample3Loss=−(1×log⁡0.1+0×log⁡0.2+0×log⁡0.7)=2.30L=0.91+0.91+2.33=1.37sample1 \quad Loss = -(0\times\log0.3+0\times\log0.3+1\times\log0.4) = 0.91\
sample2 \quad Loss = -(0\times\log0.3+1\times\log0.4+0\times\log0.3) = 0.91\
sample3 \quad Loss = -(1\times\log0.1+0\times\log0.2+0\times\log0.7) = 2.30\
\quad
\
L = \frac{0.91+0.91+2.3}{3}=1.37sample1Loss=−(0×log0.3+0×log0.3+1×log0.4)=0.91sample2Loss=−(0×log0.3+1×log0.4+0×log0.3)=0.91sample3Loss=−(1×log0.1+0×log0.2+0×log0.7)=2.30L=30.91+0.91+2.3=1.37
模型2:

sample1Loss=−(0×log⁡0.1+0×log⁡0.2+1×log⁡0.7)=0.35sample2Loss=−(0×log⁡0.1+1×log⁡0.7+0×log⁡0.2)=0.35sample3Loss=−(1×log⁡0.3+0×log⁡0.4+0×log⁡0.4)=1.20L=0.35+0.35+1.23=0.63sample1 \quad Loss = -(0\times\log0.1+0\times\log0.2+1\times\log0.7) = 0.35\
sample2 \quad Loss = -(0\times\log0.1+1\times\log0.7+0\times\log0.2) = 0.35\
sample3 \quad Loss = -(1\times\log0.3+0\times\log0.4+0\times\log0.4) = 1.20\
\quad
\
L = \frac{0.35+0.35+1.2}{3}=0.63sample1Loss=−(0×log0.1+0×log0.2+1×log0.7)=0.35sample2Loss=−(0×log0.1+1×log0.7+0×log0.2)=0.35sample3Loss=−(1×log0.3+0×log0.4+0×log0.4)=1.20L=30.35+0.35+1.2=0.63

1
2
3
4
5
6
7
py复制代码from sklearn.metrics import log_loss 

y_true = [[0, 0, 1], [0, 1, 0], [1, 0, 0]]
y_pred_1 = [[0.3, 0.3, 0.4], [0.3, 0.4, 0.3], [0.1, 0.2, 0.7]]
y_pred_2 = [[0.1, 0.2, 0.7], [0.1, 0.7, 0.2], [0.3, 0.4, 0.3]]
print(log_loss(y_true, y_pred_1))
print(log_loss(y_true, y_pred_2))

image.png


回归损失

模型1:

sample1Loss=(0.3−0)2+(0.3−0)2+(0.4−1)23=0.18sample2Loss=(0.3−0)2+(0.4−1)2+(0.3−0)23=0.18sample3Loss=(0.1−1)2+(0.2−0)2+(0.7−0)23=0.45L=0.18+0.18+0.453=0.27sample1 \quad Loss = \frac{(0.3-0)^2 + (0.3-0)^2 + (0.4-1)^2}{3} = 0.18\
sample2 \quad Loss = \frac{(0.3-0)^2 + (0.4-1)^2 + (0.3-0)^2}{3} = 0.18\
sample3 \quad Loss = \frac{(0.1-1)^2 + (0.2-0)^2 + (0.7-0)^2}{3} = 0.45\
\quad
\
L = \frac{0.18+0.18+0.45}{3}=0.27sample1Loss=3(0.3−0)2+(0.3−0)2+(0.4−1)2=0.18sample2Loss=3(0.3−0)2+(0.4−1)2+(0.3−0)2=0.18sample3Loss=3(0.1−1)2+(0.2−0)2+(0.7−0)2=0.45L=30.18+0.18+0.45=0.27
模型2:

sample1Loss=(0.1−0)2+(0.2−0)2+(0.7−1)23=0.05sample2Loss=(0.1−0)2+(0.7−1)2+(0.2−0)23=0.05sample3Loss=(0.3−1)2+(0.4−0)2+(0.3−0)23=0.25L=0.05+0.05+0.253=0.11sample1 \quad Loss = \frac{(0.1-0)^2 + (0.2-0)^2 + (0.7-1)^2}{3} = 0.05\
sample2 \quad Loss = \frac{(0.1-0)^2 + (0.7-1)^2 + (0.2-0)^2}{3} = 0.05\
sample3 \quad Loss = \frac{(0.3-1)^2 + (0.4-0)^2 + (0.3-0)^2}{3} = 0.25\
\quad
\
L = \frac{0.05+0.05+0.25}{3}=0.11sample1Loss=3(0.1−0)2+(0.2−0)2+(0.7−1)2=0.05sample2Loss=3(0.1−0)2+(0.7−1)2+(0.2−0)2=0.05sample3Loss=3(0.3−1)2+(0.4−0)2+(0.3−0)2=0.25L=30.05+0.05+0.25=0.11

1
2
3
4
5
6
7
py复制代码from sklearn.metrics import mean_squared_error

y_true = [[0, 0, 1], [0, 1, 0], [1, 0, 0]]
y_pred_1 = [[0.3, 0.3, 0.4], [0.3, 0.4, 0.3], [0.1, 0.2, 0.7]]
y_pred_2 = [[0.1, 0.2, 0.7], [0.1, 0.7, 0.2], [0.3, 0.4, 0.3]]
print("模型1:", mean_squared_error(y_true, y_pred_1))
print("模型2:", mean_squared_error(y_true, y_pred_2))

image.png


不难发现,不同的损失函数对模型的表现反馈是不同的,因此,在实际场景中,要根据切实需要选择损失函数!

后记

以上就是 浅谈损失函数 的全部内容了,介绍了损失函数的概念以及常用的损失函数,通过图文与代码结合,细致地讲述了损失函数的要点,希望大家有所收获!

📝 上篇精讲:【AI】浅析恶意文件静态检测及部分问题解决思路

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

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

🔥 系列专栏:AI

本文转载自: 掘金

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

计算机系统

发表于 2022-11-16

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

计算机系统是程序员的知识体系中最基础的理论知识,你越早掌握这些知识,你就能越早享受知识带来的 “复利效应”。

本文是计算机系统系列的第 7 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了计算机的冯·诺依曼架构,以及计算机的五大部件:控制器、运算器、存储器、输入设备和输出设备。在现在计算机体系中,CPU 是整个计算机的核心,主要包含控制器和运算器两大部件。

在后续文章中,我们将从 CPU 的基本认识开始,逐步将 CPU 与执行系统、存储系统 和 I/O 系统串联起来,请关注。


思维导图:


  1. 认识 CPU 中央处理器

1.1 什么是 CPU?

中央处理单元(Central Processing Unit,CPU)也叫中央处理器或主处理器,是整个计算机的核心,也是整台计算机中造价最昂贵的部件之一。

从硬件的角度: CPU 由超大规模的晶体管组成;

从功能的角度: CPU 内部由时钟、寄存器、控制器和运算器 4 大部分组成。

  • 1、时钟(Clock): 负责发出时钟信号,也可以位于 CPU 外部;
  • 2、寄存器(Register): 负责暂存指令或数据,位于存储器系统金字塔的顶端。使用寄存器能够弥补 CPU 和内存的速度差,减少 CPU 的访存次数,提高 CPU 的吞吐量;
  • 3、控制器(Control Unit): 负责控制程序指令执行,包括从主内存读取指令和数据发送到寄存器,再将运算器计算后的结果写回主内存;
  • 4、运算器(Arithmetic Logic Unit,ALU): 负责执行控制器取出的指令,包括算术运算和逻辑运算。

冯·诺依曼架构

—— 图片引用自 Wikipedia

1.2 为什么要学习 CPU?

对于大部分程序员,日常所处理的工作都是在跟 Java 和 C++ 等高级语言打交道,并不会直接地与 CPU 打交道。那么,为什么我们还要花这么多时间去学习 CPU 呢?我认为有以下原因:

  • 原因 1 - 掌握 CPU 原理能够开发更高性能的程序: 理解 CPU 的工作原理有助于设计出更高性能的算法或代码,例如通过避免伪共享、提高缓存命中率等方式提高程序运行效率,就需要对 CPU 的缓存机制有一定的理解;
  • 原因 2 - 扩展方案积累: CPU 是整个计算机系统中最复杂的模块,也是当代计算机科学的制高点。积累 CPU 内部的解决方案,能够为将来的遇到类似问题提供思路,达到触类旁通的作用。例如 CPU 缓存淘汰策略与应用内存的缓存淘汰策略有相似之处;
  • 原因 3 - CPU 是知识体系最底层的知识: 当我们在思考或解决某一个问题时,就需要利用到更深层次的知识积累来解释,而 CPU 就是位于知识体系中最底层知识。例在内存系统的可见性、执行系统的 IO_WAIT 和线程池设计等问题中,都需要对 CPU 的执行机制有一定理解。

CPU

—— 图片引用自 图片来源

1.3 通用处理器和专用处理器

在早期的计算机系统中,只有 1 个通用处理器,使用 1 个处理器就能够完成所有计算任务。后来人们发现可以把一些计算任务分离出来,单独设计专门的芯片微架构,在执行效率上会远远高于通用处理器,最典型的专用处理器就是 GPU 图形处理器。

这种用来专门处理某种计算任务的处理器就是专用处理器,那为什么专用处理器在处理某些特定问题时更快呢,我认为有 3 点解释:

  • 1、最优架构: 专用处理器只处理少量类型的工作,可以为特定工作设计最优芯片架构,而通用处理器只能设计全局最优架构,但不一定是执行特定工作的最优机构;
  • 2、硬件加速: 可以把多条指令的计算工作直接用硬件实现,相比于 CPU 一条条地执行指令,能够节省大量指令周期;
  • 3、成本更低: 专用处理器执行的计算流程是固定的,不需要 CPU 的流水线控制、乱序执行等功能,实现相同计算性能的造价更低。

现代计算机架构都是 1 个通用处理器加上多个专用处理器,这种将不同类型的计算任务采用不同的计算单元完成的设计,也叫 异构计算(Heterogeneous Computing)。

多处理器架构


  1. 指令集架构 ISA

2.1 什么是指令集架构?

CPU 所能理解的机器语言就是 指令(Instruction Code), 一个 CPU 所能理解的所有指令就是 指令集(Instruction Set)。

为了保证芯片间的兼容性,芯片厂商并不为每款新芯片设计一个新的指令集,而是将指令集推广为标准规范,这个规范就是 指令集架构(Instruction Set Architecture,ISA) ,

相对于指令集架构,CPU 在实现具体指令集功能的硬件电路设计就是 微架构(Micro Architecture)。 如果用软件的思考方式,ISA 就是 CPU 的功能接口,定义了 CPU 的标准规范,而微架构就是 CPU 的功能实现,定义了 CPU 的具体电路设计,一种指令集可以兼容不同的微架构。

2.2 两种主流的指令集架构

因为 CPU 位于整个计算机系统最底层且最核心的部件,如果 CPU 的兼容性都出问题了,那么以前开发的应用软件甚至操作系统将无法在新的 CPU 上运行,这对芯片厂商的生态破坏是致命的。因此,指令集架构是相对稳定的,芯片厂商在 ISA 中添加或删除指令时会非常谨慎。

目前,能够有效占领市场份额的只有 2 个 ISA ,它们也分别代表了复杂与精简 2 个发展方向:

  • x86 架构: Intel 公司在 1970 年代推出的复杂指令集架构;
  • ARM 架构: ARM 公司在 1980 年代推出的精简指令集架构,我们熟悉的 Apple M1 芯片、华为麒麟芯片和高通骁龙芯片都是 ARM 架构(其实,ARM 公司并不生产芯片,而是以技术授权的模式运行)。

2.3 复杂指令集和精简指令集

在 CPU 指令集的发展过程中,形成了 2 种指令集类型:

  • 复杂指令集(Complex Instruction Set Computer,CISC): 强调单个指令可以同时执行多个基本操作,用少量指令就可以完成大量工作,执行效率更高;
  • 精简指令集(Reduced Instruction Set Computer,RISC): 强调单个指令只能执行一个或少数基础操作,指令之间没有重复或冗余的功能,完成相同工作需要使用更多指令。

在早期的计算机系统中,指令集普遍很简单,也没有复杂和精简之分。随着应用软件的功能越来越丰富,应用层也在反向推动芯片架构师推出更强大的指令集,以简化程序编写和提高性能。例如,一些面向音视频的指令可以在一条指令内同时完成多个数据进行编解码。

这在当时的确是不错的选择。 原因是 CPU 和主存的速度差实在太大了,用更少的指令实现程序功能(指令密度更高)可以减少访存次数。 凭借这一点,复杂指令集对精简指令集的优势是几乎全面性的:

  • 优势 1: 可以减少程序占用的内存和磁盘空间大小;
  • 优势 2: 可以减少从内存或磁盘获取指令所需要的带宽,能够提高总线系统的传输效率;
  • 优势 3: CPU L1 Cache 可以容纳更多指令,可以提高缓存命中率。且现代计算机中多个线程会共享 L1 Cache,指令越少对缓存命中率越有利;
  • 优势 4: CPU L2 Cache 可以容纳更多数据,对操作大量数据的程序也有利于提高缓存命中率。

然而,这些优势都是有代价的:

  • 缺点 1 - 处理器设计复杂化: 指令越复杂,用于解析指令的处理器电路设计肯定会越复杂,执行性能功耗也越大;
  • 缺点 2 - 指令功能重叠: 很多新增的指令之间产生了功能重叠,不符合指令集的正交性原则,而且新增的很多复杂指令使用率很低,但处理器却付出了不成正比的设计成本;
  • 缺点 3 - 指令长度不统一: 指令长度不统一,虽然有利于使用哈夫曼编码进一步提高指令密度(频率高的指令用短长度,频率高的指令用大长度),但是指令长度不同,执行时间也有长有短,不利于实现流水线式结构。

因此,到 1980 年代,精简指令集 RISC 逐渐浮出水面。目前,大多数低端和移动系统都采用 RISC 架构,例如 Android 系统、Mac 系统和微软 Surface 系列。

相比于复杂指令集,精简指令集更加强调 “正交性” ,单个指令只能执行一个或少数基础操作,指令之间没有重复或冗余的功能。而且精简指令集的每条 指令长度相同 ,非常便于实现流水线式结构。

网上很多资料有一个误区: 精简指令集简化了指令集的大小。 这是不对的,准确的说法是简化了指令集的复杂度。

总结一下: 复杂指令集凭借更高的指令密度,在性能方面整体优于精简指令集(内存 / 磁盘占用、CPU Cache 命中率、TLB 未命中率),而精简指令集牺牲了指令密度换取更简单的处理器架构,以性能换取功耗的平衡。

指令集类型 CISC RISC
指令数量 指令数量庞大 指令数量相对较少
指令长度 长度不同 长度相同
指令功能 有重叠 正交
举例 x86 ARM、MIPS

  1. CPU 的性能指标

3.1 执行系统参数

  • 1、主频(Frequency/Clock Rate): 在 CPU 内部有一个 晶体振荡器(Oscillator Crystal) ,晶振会以一定的频率向控制器发出信号,这个信号频率就是 CPU 的主频。主频是 CPU 最主要的参数,主频越快,计算机单位时间内能够完成的指令越快。 CPU 的主频并不是固定的,CPU 在运行时可以选择低频、满频甚至超频运行, 但是工作频率越高,意味着功耗也越高;
  • 2、时钟周期(Clock Cycle): 主频的另一面,即晶振发出信号的时间间隔, 时钟周期=1/主频;
  • 3、外频: 外频是主板为 CPU 提供的时钟频率,早期计算机中 CPU 主频和外频是相同的,但随着 CPU 主频越来越高,而其他设备的速度还跟不上,所以现在主频和外频是不相等的;
  • 4、程序执行时间:
+ **4.1 流逝时间(Wall Clock Time / Elapsed Time):** 程序开始运行到程序结束所流逝的时间;
+ **4.2 CPU 时间(CPU Time):** CPU 实际执行程序的时间,仅包含程序获得 CPU 时间片的时间(用户时间 + 系统时间)。由于 CPU 会并行执行多个任务,所以程序执行时间会小于流逝时间;
+ **4.3 用户时间(User Time):** 用户态下,CPU 切换到程序上执行的时间;
+ **4.4 系统时间(Sys Time):** 内核态下,CPU 切换到程序上执行的时间;

3.2 存储系统参数

  • 字长(Word): CPU 单位时间内同时处理数据的基本单位,多少位 CPU 就是指 CPU 的字长是多少位,比如 32 位 CPU 的字长就是 32 位,64 位 CPU 的字长就是 64 位;
  • 地址总线宽度(Address Bus Width): 地址总线传输的是地址信号,地址总线宽度也决定了一个 CPU 的寻址能力,即最多可以访问多少数据空间。举个例子,32 位地址总线可以寻址 4GB 的数据空间;
  • 数据总线宽度(Data Bus Width): 数据总线传输的是数据信号,数据总线宽度也决定了一个 CPU 的信息传输能力。

区分其它几种容量单位:

  • 字节(Byte): 字节是计算机数据存储的基本单位,即使存储 1 个位也需要按 1 个字节存储;
  • 块(Block): 块是 CPU Cache 管理数据的基本单位,也叫 CPU 缓存行;
  • 段(Segmentation)/ 页(Page): 段 / 页是操作系统管理虚拟内存的基本单位。
  • 相关文章:计算机系统 #6 计算机的存储器金字塔长什么样?

  1. 影响 CPU 性能的因素

CPU 作为计算机的核心部件,未来一定是朝着更强大的性能出发。在看待 CPU 的视角上,我们也要具备一定的全局观:

  • 1、提升 CPU 性能不止是 CPU 的任务: 计算机系统是多个部件组成的复杂系统,脱离整体谈局部没有意义;
  • 2、平衡性能与功耗: 一般来说,CPU 的计算性能越高,功耗也越大。我们需要综合考虑性能和功耗的关系,脱离功耗谈性能没有意义。

4.1 提升 CPU 主频

提升主频对 CPU 性能的影响是最直接的,过去几十年 CPU 的主要发展方向也是在怎么提升 CPU 主频的问题上。

不过,最近几年 CPU 主频的速度似乎遇到瓶颈了。因为想要主频越快,要么让 CPU 满频或超频运行,要么升级芯片制程,在单位体积里塞进去更多晶体管。这两种方式都会提升 CPU 功耗,带来续航和散热问题。如果不解决这两个问题,就无法突破主频瓶颈。

主频的瓶颈

—— 图片引用自 Wikipedia

4.2 多核并行执行

既然单核 CPU 的性能遇到瓶颈,那么在 CPU 芯片里同时塞进去 2 核、4 核甚至更多,那么整个 CPU 芯片的性能不就直接翻倍提升吗?

理想很美好,现实是性能并不总是随着核心数线性增加。在核心数较小时,增加并行度得到的加速效果近似于线性提升,但增加到一定程度后会趋于一个极限, 说明增加并行度的提升效果也是有瓶颈的。

为什么呢?因为不管程序并行度有多高,最终都会有一个结果汇总的任务,而汇总任务无法并行执行,只能串行执行。例如,我们用 Java 的 Fork/Join 框架将一个大任务分解为多个子任务并行执行,最终还是需要串行地合并子任务的结果。

这个结论也有一个经验定律 —— 阿姆达尔定律(Amdahl’s Law) ,它解释了处理器并行计算后效率提升情况。我们把串行的部分称为串行分量 WsW_sWs ,把并行的部分称为并行分量 WpW_pWp ,正是串行分量限制了性能提升的极限,串行分量越大,极限越低。

  • 并行后的执行时间是 Wpp+Ws\frac{W_p}{p} + W_spWp+Ws
  • 并行后的加速倍数是 Ws+WpWs+Wpp\frac{W_s+W_p}{W_s+\frac{W_p}{p}}Ws+pWpWs+Wp ,当并行度 p 趋向于 无穷大时,提升极限就是 Ws+WpWs\frac{W_s+W_p}{W_s}WsWs+Wp

并行度、并行分量对提升效果的影响

—— 图片引用自 Wiki 百科

提示: 以绿色的曲线为例,程序可以的并行分量是 95%,串行分量是 5%,最终得出的提升极限就会 20 倍。

4.3 指令重排序

增加核心数是提升并行度最直接的方法,但并不是唯一的方法。

现代 CPU 为了提高并行度,会在遵守单线程数据依赖性原则的前提下,对程序指令做一定的重排序。事实上不止是 CPU,从源码到指令执行一共有 3 种级别重排序:

  • 1、编译器重排序: 例如将循环内重复调用的操作提前到循环外执行;
  • 2、处理器系统重排序: 例如指令并行技术将多条指令重叠执行,或者使用分支预测技术提前执行分支的指令,并把计算结果放到重排列缓冲区(Reorder Buffer)的硬件缓存中,当程序真的进入分支后直接使用缓存中的结算结果;
  • 3、存储器系统重排序: 例如写缓冲区和失效队列机制,即是可见性问题,从内存的角度也是指令重排问题。

指令重排序类型

  • 相关文章:计算机系统 #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?

4.4 SoC 芯片 —— 片内片外双总线结构

随着芯片集成电路工艺的进步,在冯·诺依曼架构中的五大部件(运算器、控制器、存储器、输入和输出设备接口)也可以集成在一个芯片上,形成一个近似于完整计算机的系统,这种芯片也叫 片上系统(System on Chip,Soc)。 SoC 芯片将原本分布在主板上的各个部件聚合到同一个芯片上,不同部件之间的总线信息传输效率更高。

  • 相关文章:计算机系统 #9 图解计算机内部的高速公路 —— 总线系统

  1. 总结

今天,我们简单了讨论了 CPU 的基本概念,很多问题只是浅尝辄止。在后续的文章里,我们将从执行系统、存储系统 和 I/O 系统三个角度与 CPU 串联起来。请关注。


参考资料

  • CPU 通识课 —— 靳国杰 张戈 著
  • 深入浅出计算机组成原理 —— 徐文浩 著,极客时间 出品
  • Code Density Concerns for New Architectures —— Vincent M. Weaver 等著
  • Central processing unit —— Wikipedia
  • Instruction set architecture —— Wikipedia
  • Complex instruction set computer —— Wikipedia
  • Reduced instruction set computer —— Wikipedia
  • Application binary interface —— Wikipedia
  • Clock Rate —— Wikipedia
  • Amdahl’s law —— Wikipedia

推荐阅读

计算机系统系列完整目录如下(2023/07/11 更新):

  • #1 从图灵机到量子计算机,计算机可以解决所有问题吗?
  • #2 一套用了 70 多年的计算机架构 —— 冯·诺依曼架构
  • #3 为什么计算机中的负数要用补码表示?
  • #4 今天一次把 Unicode 和 UTF-8 说清楚
  • #5 为什么浮点数运算不精确?(阿里笔试)
  • #6 计算机的存储器金字塔长什么样?
  • #7 程序员学习 CPU 有什么用?
  • #8 我把 CPU 三级缓存的秘密,藏在这 8 张图里
  • #9 图解计算机内部的高速公路 —— 总线系统
  • #10 12 张图看懂 CPU 缓存一致性与 MESI 协议,真的一致吗?
  • #11 已经有 MESI 协议,为什么还需要 volatile 关键字?
  • #12 什么是伪共享,如何避免?

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

本文正在参加「金石计划 . 瓜分6万现金大奖」

本文转载自: 掘金

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

Android 开源库

发表于 2022-11-16

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

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

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

前言

大家好,我是小彭。

在之前的文章里,我们聊到了 LRU 缓存淘汰算法,并且分析 Java 标准库中支持 LUR 算法的数据结构 LinkedHashMap。当时,我们使用 LinkedHashMap 实现了简单的 LRU Demo。今天,我们来分析一个 LRU 的应用案例 —— Android 标准库的 LruCache 内存缓存。


思维导图:


  1. 回顾 LRU 和 LinkedHashMap

在具体分析 LruCache 的源码之前,我们先回顾上一篇文章中讨论的 LRU 缓存策略以及 LinkedHashMap 实现原理。

LRU (Least Recently Used)最近最少策略是最常用的缓存淘汰策略。LRU 策略会记录各个数据块的访问 “时间戳” ,最近最久未使用的数据最先被淘汰。与其他几种策略相比,LRU 策略利用了 “局部性原理”,平均缓存命中率更高。

FIFO 与 LRU 策略

经过总结,我们可以定义一个缓存系统的基本操作:

  • 操作 1 - 添加数据: 先查询数据是否存在,不存在则添加数据,存在则更新数据,并尝试淘汰数据;
  • 操作 2 - 删除数据: 先查询数据是否存在,存在则删除数据;
  • 操作 3 - 查询数据: 如果数据不存在则返回 null;
  • 操作 4 - 淘汰数据: 添加数据时如果容量已满,则根据缓存淘汰策略一个数据。

我们发现,前 3 个操作都有 “查询” 操作,所以缓存系统的性能主要取决于查找数据和淘汰数据是否高效。为了实现高效的 LRU 缓存结构,我们会选择采用双向链表 + 散列表的数据结构,也叫 “哈希链表”,它能够将查询数据和淘汰数据的时间复杂度降低为 O(1)。

  • 查询数据: 通过散列表定位数据,时间复杂度为 O(1);
  • 淘汰数据: 直接淘汰链表尾节点,时间复杂度为 O(1)。

在 Java 标准库中,已经提供了一个通用的哈希链表 —— LinkedHashMap。使用 LinkedHashMap 时,主要关注 2 个 API:

  • accessOrder 标记位: LinkedHashMap 同时实现了 FIFO 和 LRU 两种淘汰策略,默认为 FIFO 排序,可以使用 accessOrder 标记位修改排序模式。
  • removeEldestEntry() 接口: 每次添加数据时,LinkedHashMap 会回调 removeEldestEntry() 接口。开发者可以重写 removeEldestEntry() 接口决定是否移除最早的节点(在 FIFO 策略中是最早添加的节点,在 LRU 策略中是最久未访问的节点)。

LinkedHashMap 示意图

LinkedHashMap#put 示意图


  1. 实现 LRU 内存缓存需要考虑什么问题?

在阅读 LruCache 源码之前,我们先尝试推导 LRU 内存缓存的实现思路,带着问题和结论去分析源码,也许收获会更多。

2.1 如何度量缓存单元的内存占用?

缓存系统应该实时记录当前的内存占用量,在添加数据时增加内存记录,在移除或替换数据时减少内存记录,这就涉及 “如何度量缓存单元的内存占用” 的问题。计数 or 计量,这是个问题。比如说:

  • 举例 1: 实现图片内存缓存,如何度量一个图片资源的内存占用?
  • 举例 2: 实现数据模型对象内存缓存,如何度量一个数据模型对象的内存占用?
  • 举例 3: 实现资源内存预读,如何度量一个资源的内存占用?

我将这个问题总结为 2 种情况:

  • 1、能力复用使用计数: 这类内存缓存场景主要是为了复用对象能力,对象本身持有的数据并不多,但是对象的结构却有可能非常复杂。而且,再加上引用复用的因素,很难统计对象实际的内存占用。因此,这类内存缓存场景应该使用计数,只统计缓存单元的个数,例如复用数据模型对象,资源预读等;
  • 2、数据复用使用计量: 这类内存缓存场景主要是为了复用对象持有的数据,数据对内存的影响远远大于对象内存结构对内存的影响,是否度量除了数据外的部分内存对缓存几乎没有影响。因此, 这里内存缓存场景应该使用计量,不计算缓存单元的个数,而是计算缓存单元中主数据字段的内存占用量,例如图片的内存缓存就只记录 Bitmap 的像素数据内存占用。

还有一个问题,对象内存结构中的对象头和对齐空间需要计算在内吗?一般不考虑,因为在大部分业务开发场景中,相比于对象的实例数据,对象头和对齐空间的内存占用几乎可以忽略不计。

度量策略 举例
计数 1、Message 消息对象池:最多缓存 50 个对象2、OkHttp 连接池:默认最多缓存 5 个空闲连接3、数据库连接池
计量 1、图片内存缓存2、位图池内存缓存

2.2 最大缓存容量应该设置多大?

网上很多资料都说使用最大可用堆内存的八分之一,这样笼统地设置方式显然并不合理。到底应该设置多大的空间没有绝对标准的做法,而是需要开发者根据具体的业务优先级、用户机型和系统实时的内存紧张程度做决定:

  • 业务优先级: 如果是高优先级且使用频率很高的业务场景,那么最大缓存空间适当放大一些也是可以接受的,反之就要考虑适当缩小;
  • 用户机型: 在最大可用堆内存较小的低端机型上,最大缓存空间应该适当缩小;
  • 内存紧张程度: 在系统内存充足的时候,可以放大一些缓存空间获得更好的性能,当系统内存不足时再及时释放。

2.3 淘汰一个最早的节点就足够吗?

标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。

因此,在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。这个问题,我们后文再看看 Android LruCache 是如何解决的。

2.4 策略灵活性

LruCache 的淘汰策略是在缓存容量满时淘汰,当缓存容量没有超过最大限制时就不会淘汰。除了这个策略之外,我们还可以增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。

在 Android Glide 图片框架中就有策略灵活性的体现:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。

Glide · LruResourceCache.java

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Override
public void trimMemory(int level) {
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Entering list of cached background apps
// Evict our entire bitmap cache
clearMemory();
} else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
// The app's UI is no longer visible, or app is in the foreground but system is running
// critically low on memory
// Evict oldest half of our bitmap cache
trimToSize(getMaxSize() / 2);
}
}

2.5 线程同步问题

一个缓存系统往往会在多线程环境中使用,而 LinkedHashMap 与 HashMap 都不考虑线程同步,也会存在线程安全问题。这个问题,我们后文再看看 Android LruCache 是如何解决的。


  1. LruCache 源码分析

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

3.1 LruCache 的 API

LruCache 是 Android 标准库提供的 LRU 内存缓存框架,基于 Java LinkedHashMap 实现,当缓存容量超过最大缓存容量限制时,会根据 LRU 策略淘汰最久未访问的缓存数据。

用一个表格整理 LruCache 的 API:

public API 描述
V get(K) 获取缓存数据
V put(K,V) 添加 / 更新缓存数据
V remove(K) 移除缓存数据
void evictAll() 淘汰所有缓存数据
void resize(int) 重新设置最大内存容量限制,并调用 trimToSize()
void trimToSize(int) 淘汰最早数据直到满足最大容量限制
Map<K, V> snapshot() 获取缓存内容的镜像 / 拷贝
protected API 描述
void entryRemoved() 数据移除回调(可用于回收资源)
V create() 创建数据(可用于创建缺省数据)
Int sizeOf() 测量数据单元内存

3.2 LruCache 的属性

LruCache 的属性比较简单,除了多个用于数据统计的属性外,核心属性只有 3 个:

  • 1、size: 当前缓存占用;
  • 2、maxSize: 最大缓存容量;
  • 3、map: 复用 LinkedHashMap 的 LRU 控制能力。

LruCache.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class LruCache<K, V> {
// LRU 控制
private final LinkedHashMap<K, V> map;

// 当前缓存占用
private int size;
// 最大缓存容量
private int maxSize;

// 以下属性用于数据统计

// 设置数据次数
private int putCount;
// 创建数据次数
private int createCount;
// 淘汰数据次数
private int evictionCount;
// 缓存命中次数
private int hitCount;
// 缓存未命中数
private int missCount;
}

3.3 LruCache 的构造方法

LruCache 只有 1 个构造方法。

由于缓存空间不可能设置无限大,所以开发者需要在构造方法中设置缓存的最大内存容量 maxSize。

LinkedHashMap 对象也会在 LruCache 的构造方法中创建,并且会设置 accessOrder 标记位为 true,表示使用 LRU 排序模式。

LruCache.java

1
2
3
4
5
6
7
8
9
10
java复制代码// maxSize:缓存的最大内存容量
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
// 缓存的最大内存容量
this.maxSize = maxSize;
// 创建 LinkedHashMap 对象,并使用 LRU 排序模式
this.map = new LinkedHashMap<K, V>(0, 0.75f, true /*LRU 模式*/);
}

使用示例

1
2
java复制代码private static final int CACHE_SIZE = 4 * 1024 * 1024; // 4Mib
LruCache bitmapCache = new LruCache(CACHE_SIZE);

3.4 测量数据单元的内存占用

开发者需要重写 LruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。

LruCache.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// LruCache 内部使用
private int safeSizeOf(K key, V value) {
// 如果开发者重写的 sizeOf 返回负数,则抛出异常
int result = sizeOf(key, value);
if (result < 0) {
throw new IllegalStateException("Negative size: " + key + "=" + value);
}
return result;
}

// 测量缓存单元的内存占用
protected int sizeOf(K key, V value) {
// 默认为 1
return 1;
}

使用示例

1
2
3
4
5
6
7
8
java复制代码private static final int CACHE_SIZE = 4 * 1024 * 1024; // 4Mib
LruCache bitmapCache = new LruCache(CACHE_SIZE){
// 重写 sizeOf 方法,用于测量 Bitmap 的内存占用
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
};

3.5 添加数据与淘汰数据

LruCache 添加数据的过程基本是复用 LinkedHashMap 的添加过程,我将过程概括为 6 步:

  • 1、统计添加计数(putCount);
  • 2、size 增加新 Value 内存占用;
  • 3、设置数据(LinkedHashMap#put);
  • 4、size 减去旧 Value 内存占用;
  • 5、数据移除回调(LruCache#entryRemoved);
  • 6、自动淘汰数据:在每次添加数据后,如果当前缓存空间超过了最大缓存容量限制,则会自动触发 trimToSize() 淘汰一部分数据,直到满足限制。

淘汰数据的过程则是完全自定义,我将过程概括为 5 步:

  • 1、取最找的数据(LinkedHashMap#eldest);
  • 2、移除数据(LinkedHashMap#remove);
  • 3、size 减去旧 Value 内存占用;
  • 4、统计淘汰计数(evictionCount);
  • 5、数据移除回调(LruCache#entryRemoved);
  • 重复以上 5 步,满足要求或者缓存为空,才会退出。

逻辑很好理解,不过还是拦不住一些小朋友出来举手提问了🙋🏻‍♀️:

  • 🙋🏻‍♀️疑问 1:为什么 LruCache 不支持 null 作为 Key 或 Value?

其实并没有一定不能为 null 的理由,我的理解是 Google 希望降低 LruCache 的理解成本。如果允许 Value 为 null,那么当 LruCache 需要计算 Value 的 size 时,Value 为 null 默认应该当作 0 还是当作 1呢?

再者,如果业务开发确实有 Key 或 Value 的需求,也可以选择重写 LruCache 的相关方法,或者直接自实现一个 LruCache,这都是可以接受的方案。例如,在 Android Glide 图片框架中的 LruCache 就是自实现的。

  • 🙋🏻‍♀️疑问 2:为什么 LruCache 淘汰数据没有重写 LinkedHashMap#removeEldestEntry() 接口?

这个问题其实跟上一节的 “淘汰一个最早的节点就足够吗?” 问题相同。由于只淘汰一个数据后,有可能还不满足最大容量限制的要求,所以 LruCache 直接放弃了 LinkedHashMap#removeEldestEntry() 接口,而是自己实现了 trimToSize() 淘汰方法。

LinkedHashMap#eldest() 是 Android SDK 添加的方法,在 OpenJDK 中没有这个方法,这个方法会返回 LinkedHashMap 双向链表的头节点。由于我们使用的是 LRU 排序模式,所以头节点自然是 LRU 策略要淘汰的最久未访问的节点。

在 trimToSize() 方法中,会循环调用 LinkedHashMap#eldest() 取最早的节点,移除节点后再减去节点占用的内存大小。所以 trimToSize() 将淘汰数据的逻辑放在 while(true) 循环中,直到满足要求或者缓存为空,才会退出。

添加数据示意图

LruCache.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
java复制代码public final V put(K key, V value) {
// 疑问 1:不支持 null 作为 Key 或 Value
if (key == null || value == null) {
throw new NullPointerException("key == null || value == null");
}

// 被替换的数据
V previous;
synchronized (this) {
// 1、统计添加计数
putCount++;
// 2、增加新 Value 内存占用
size += safeSizeOf(key, value);
// 3、设置数据
previous = map.put(key, value);
// 4、减去旧 Value 内存占用
if (previous != null) {
size -= safeSizeOf(key, previous);
}
}
// 5、数据移除回调(previous -> value)
if (previous != null) {
entryRemoved(false /*非淘汰*/, key, previous, value);
}
// 6、自动淘汰数据
trimToSize(maxSize);
return previous;
}

// -> 6、自动淘汰数据
public void trimToSize(int maxSize) {
// 淘汰数据直到不超过最大容量限制
while (true) {
K key;
V value;
synchronized (this) {
if (size < 0 || (map.isEmpty() && size != 0)) {
throw new IllegalStateException(getClass().getName() + ".sizeOf() is reporting inconsistent results!");
}

// 不超过最大容量限制,跳出
if (size <= maxSize) {
break;
}

// 6.1 取最早的数据
Map.Entry<K, V> toEvict = map.eldest();
// toEvict 为 null 说明没有更多数据
if (toEvict == null) {
break;
}

key = toEvict.getKey();
value = toEvict.getValue();
// 6.2 移除数据
map.remove(key);
// 6.3 减去旧 Value 内存占用
size -= safeSizeOf(key, value);
// 6.4 统计淘汰计数
evictionCount++;
}
// 6.5 数据移除回调(value -> null)
entryRemoved(true /*淘汰*/, key, value, null);
}
}

Android LinkedHashMap.java

1
2
3
4
5
java复制代码// 提示:OpenJDK 中没有这个方法,是 Android SDK 添加的

public Map.Entry<K, V> eldest() {
return head;
}

3.6 LruCache 的获取方法

在获取数据时,LruCache 增加了自动创建数据的功能,区分 2 种 情况:

  • 1、缓存命中: 直接返回缓存的数据;
  • 2、缓存未命中: 调用 LruCache#create 尝试创建数据,并将数据设置到缓存池中。这意味着 LruCache 不仅支持缓存数据,还支持创建数据。
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
java复制代码public final V get(K key) {
// 不支持 null 作为 Key 或 Value
if (key == null) {
throw new NullPointerException("key == null");
}

V mapValue;
synchronized (this) {
// 1. 尝试获取缓存的数据
// mapValue:旧数据
mapValue = map.get(key);
if (mapValue != null) { // <标记点>
// 1.1 缓存命中计数
hitCount++;
// 1.2 缓存命中,返回缓存数据
return mapValue;
}
missCount++;
}

// 疑问 3:为什么 create(key) 要放在 synchronized 块外部?
// 2. 尝试自动创建缓存数据(类似对象池)
V createdValue = create(key);
if (createdValue == null) {
return null;
}

synchronized (this) {
// 3.1 创建数据计数
createCount++;
// 3.2 设置创建的缓存数据
// mapValue:旧数据
mapValue = map.put(key, createdValue);

// 疑问 4:在 <标记点> 判断 mapValue 为 null,这里再次 get 又有可能非 null,岂不是矛盾?
if (mapValue != null) {
// 3.3 如果 mapValue 旧数据不为 null,说明在调用 create() 的过程中,有其他线程创建并添加了数据
// 那么放弃创建的数据,将 mapValue 重新设置回去。由于另一个线程在设置时已经累加 size 内存占用,所以这里不用重复累加
map.put(key, mapValue);
} else {
// 3.4 如果 mapValue 旧数据为 null,那么累加 createdValue 的内存占用
size += safeSizeOf(key, createdValue);
}
}

// 4. 后处理
if (mapValue != null) {
// 4.1 数据移除回调(createdValue -> mapValue)
entryRemoved(false /*非淘汰*/, key, createdValue, mapValue);
return mapValue;
} else {
// 4.2 增加了 createdValue 后,需要缩容
trimToSize(maxSize);
return createdValue;
}
}

protected V create(K key) {
return null;
}

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

  • 🙋🏻‍♀️疑问 3:为什么 create(key) 要放在 synchronized 块外部?

这是为了降低锁的颗粒度。

由于 create(key) 创建数据的过程可能是耗时的,如果将 create(key) 放到 synchronized 同步块内部,那么在创建数据的过程中就会阻塞其他线程访问缓存的需求,会降低缓存系统的吞吐量。

  • 🙋🏻‍♀️疑问 4:在 <标记点> 判断 mapValue 为 null,这里再次 get 又有可能非 null,岂不是矛盾?

这个问题与上一个问题有关。

由于 create(key) 放在 synchronized 块外部,那么在执行 create(key) 的过程中,有可能其他线程已经创建并添加了目标数据,所以在 put(createdValue) 的时候就会出现 mapValue 不为 null 的情况。

此时,会存在两个 Value 的情况,应该选择哪一个 Value 呢?LruCache 认为其他线程添加的数据的优先级优于默认创建的缺省数据,所以在 3.3 分支放弃了缺省数据,重新将 mapValue 设置回去。

获取数据示意图

3.7 LruCache 的移除方法

LruCache 的移除方法是添加方法的逆运算,过程我概括为 3 步:

  • 1、移除节点(LinkedHashMap#remove);
  • 2、size 减去 Value 的内存占用;
  • 3、数据移除回调(LruCache#entryRemoved);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public final V remove(K key) {
// 不支持 null 作为 Key 或 Value
if (key == null) {
throw new NullPointerException("key == null");
}

V previous;
synchronized (this) {
// 1. 移除数据
previous = map.remove(key);
// 2. 减去移除 Value 内存占用
if (previous != null) {
size -= safeSizeOf(key, previous);
}
}

// 3. 数据移除回调(previous -> null)
if (previous != null) {
entryRemoved(false, key, previous, null);
}

return previous;
}

移除数据示意图

至此,LruCache 源码分析结束。


  1. 总结

  • 1、LruCache 是 Android 标准库提供的 LRU 内存缓存框架,基于 Java LinkedHashMap 实现,当缓存容量超过最大缓存容量限制时,会根据 LRU 策略淘汰最久未访问的缓存数据;
  • 2、LruCache 需要重写 sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量;
  • 3、LruCache 放弃了 LinkedHashMap#removeEldestEntry() 接口,而是自己实现了 trimToSize() 淘汰方法;

今天,我们讨论了 LRU 缓存淘汰策略和一些内存缓存的设计问题,并且分析了 Android LruCache 源码。在我们熟悉的 Glide 图片框架中,也深入使用了 LRU 内存缓存策略,你能说出它的设计原理吗。这个问题我们在下一篇文章讨论,请关注。


版权声明

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

参考资料

  • Android 开源框架源码鉴赏:LruCache 与 DiskLruCache —— 郭孝星 著
  • LruCache 在美团 DSP 系统中的应用演进 —— 王粲 崔涛 霜霜(美团技术团队)著
  • LruCache —— Android 官方文档

推荐阅读

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 交流社群~

本文转载自: 掘金

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

Java & Android 集合框架

发表于 2022-11-15

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

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

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

前言

大家好,我是小彭。

在上一篇文章里,我们聊到了 ThreadLocal 的基本原理,这一节我们来结合 ThreadLocalMap 的源码做分析。

本文源码基于 Java 8 ThreadLocal。

  • Java & Android 集合框架 #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
  • Java & Android 集合框架 #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇

思维导图:


  1. ThreadLocalMap 源码分析

ThreadLocalMap 是 ThreadLocal 内部使用的散列表,也是 ThreadLocal 的静态内部类。这一节,我们就来分析 ThreadLocalMap 散列表中主要流程的源码。

4.1 ThreadLocalMap 的属性

先用一个表格整理 ThreadLocalMap 的属性:

属性 描述
Entry[] table 底层数组
int size 有效键值对数量
int threshold 扩容阈值(数组容量的 2/3)
int INITIAL_CAPACITY 默认数组容量(16)

可以看到,散列表必备底层数组 table、键值对数量 size、扩容阈值 threshold 等属性都有,并且也要求数组的长度是 2 的整数倍。主要区别在于 Entry 节点上:

  • 1、ThreadLocal 本身就是散列表的键 Key;
  • 2、扩容阈值为数组容量的 2/3;
  • 3、ThreadLocalMap#Entry 节点没有 next 指针,因为 ThreadLocalMap 采用线性探测解决散列冲突,所以不存在链表指针;
  • 4、ThreadLocalMap#Entry 在键值对的 Key 上使用弱引用,这与 WeakHashMap 相似。

ThreadLocal.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复制代码static class ThreadLocalMap {

// 默认数组容量(容量必须是 2 的整数倍)
private static final int INITIAL_CAPACITY = 16;

// 底层数组
private Entry[] table;

// 有效键值对数量
private int size = 0;

// 扩容阈值
private int threshold; // Default to 0

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

// 键值对节点
static class Entry extends WeakReference<ThreadLocal<?>> {
// next:开放寻址法没有 next 指针
// Key:与 WeakHashMap 相同,少了 key 的强引用
// Hash:位于 ThreadLocal#threadLocalHashCode
// Value:当前线程的副本
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k/*注意:只有 Key 是弱引用*/);
value = v;
}
}
}

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

  • 🙋🏻‍♀️疑问 3:为什么 ThreadLocalMap 要求数组的容量是 2 的整数幂?(回答过多少次了,把手给我放下)
  • 🙋🏻‍♀️疑问 4:为什么 Key 是弱引用,而不是 Entry 或 Value 是弱引用?

首先,Entry 一定要持有强引用,而不能持有弱引用。这是因为 Entry 是 ThreadLocalMap 内部维护数据结构的实现细节,并不会暴露到 ThreadLocalMap 外部,即除了 ThreadLocalMap 本身之外没有其它地方持有 Entry 的强引用。所以,如果持有 Entry 的弱引用,即使 ThreadLocalMap 外部依然在使用 Key 对象,ThreadLocalMap 内部依然会回收键值对,这与预期不符。

其次,不管是 Key 还是 Value 使用弱引用都可以实现自动清理,至于使用哪一种方法各有优缺点,适用场景也不同。Key 弱引用的优点是外部不需要持有 Value 的强引用,缺点是存在 “重建 Key 不等价” 问题。

由于 ThreadLocal 的应用场景是线程局部存储,我们没有重建多个 ThreadLocal 对象指向同一个键值对的需求,也没有重写 Object#equals() 方法,所以不存在重建 Key 的问题,使用 Key 弱引用更方便。

类型 优点 缺点 场景
Key 弱引用 外部不需要持有 Value 的强引用,使用更简单 重建 Key 不等价 未重写 equals
Value 弱引用 重建 Key 等价 外部需要持有 Value 的强引用 重写 equals

提示: 关于 “重建 Key 对象不等价的问题” 的更多详细论述过程,我们在这篇文章里讨论过 Java & Android 集合框架 #8 说一下 WeakHashMap 如何清理无效数据的?,去看看。

4.2 ThreadLocalMap 的构造方法

ThreadLocalMap 有 2 个构造方法:

  • 1、带首个键值对的构造方法: 在首次添加元素或首次查询数据生成缺省值时,才会调用此构造方法创建 ThreadLocalMap 对象,并添加首个键值对;
  • 2、带 Map 的构造方法: 在创建子线程时,父线程会调用此构造方法创建 ThreadLocalMap 对象,并添加批量父线程 ThreadLocalMap 中的有效键值对。

ThreadLocal.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
java复制代码// 带首个键值对的构造方法
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

// 带 Map 的构造方法
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap);
}

static class ThreadLocalMap {

// -> 带首个键值对的构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 创建底层数组(默认长度为 16)
table = new Entry[INITIAL_CAPACITY];
// 散列值转数组下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 添加首个元素(首个元素一定不会冲突)
table[i] = new Entry(firstKey, firstValue);
// 键值对数量
size = 1;
// 设置扩容阈值
setThreshold(INITIAL_CAPACITY);
}

// -> 带 Map 的构造方法
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
// 设置扩容阈值
setThreshold(len);
// 创建底层数组(使用 parent 的长度)
table = new Entry[len];

// 逐个添加键值对
for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
// 如果键值对的 Key 被回收,则跳过
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
// 构造新的键值对
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
// 散列值转数组下标
int h = key.threadLocalHashCode & (len - 1);
// 处理散列冲突
while (table[h] != null)
// 线性探测
h = nextIndex(h, len);
table[h] = c;
// 键值对数量
size++;
}
}
}
}
}

4.3 回顾线性探测的工作原理

ThreadLocalMap 后续的源码有难度,为了帮助理解,我将文章 “第一节 · 回顾散列表的工作原理” 中有关线性探测方法的部分移在这里。

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

开放寻址法示意图

可以看到,在线性探测中的 “连续段” 非常重要: 线性探测在判断节点是否存在于散列表时,并不是线性遍历整个数组,而只会线性遍历从散列值映射的数组下标后的连续段。

4.4 ThreadLocalMap 的获取方法

ThreadLocalMap 的获取方法相对简单,所以我们先分析,区分 2 种情况:

  • 1、数组下标直接命中目标 Key,则直接返回,也不清理无效数据(这就是前文提到访问 ThreadLocal 不一定会触发清理的源码体现);
  • 2、数组下标未命中目标 Key,则开始线性探测。探测过程中如果遇到 Key == null 的无效节点,则会调用 expungeStaleEntry() 清理连续段(说明即使触发清理,也不一定会扫描整个散列表)。

expungeStaleEntry() 是 ThreadLocalMap 核心的连续段清理方法,下文提到的 replaceStaleEntry() 和 cleanSomeSlots() 等清理方法都会直接或间接调用到 expungeStaleEntry()。 它的逻辑很简单:就是线性遍历从 staleSlot 位置开始的连续段:

  • 1、k == null 的无效节点: 清理;
  • 2、k ≠ null 的有效节点,再散列到新的位置上。

ThreadLocalMap#getEntry 方法示意图

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

  • 🙋🏻‍♀️疑问 5:清理无效节点我理解,为什么要对有效节点再散列呢?

线性探测只会遍历连续段,而清理无效节点会导致连续段产生断层。如果没有对有效节点做再散列,那么有效节点在下次查询时就有可能探测不到了。

ThreadLocal.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
java复制代码static class ThreadLocalMap {

// 获取 Key 匹配的键值对
private Entry getEntry(ThreadLocal<?> key) {
// 散列值转数组下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
// 数组下标直接命中,则直接返回,也不清理无效数据
return e;
else
// 线性探测,并且清理连续段中无效数据
return getEntryAfterMiss(key, i, e);
}

// -> 线性探测,并且清理连续段中无效数据
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
// 命中
return e;
if (k == null)
// Key 对象被回收,触发连续段清理
// 连续段清理在一个 while 循环中只会触发一次,因为这个段中 k == null 的节点都被清理出去了
// 如果连续段清理后,i 位置为 null,那么目标节点一定不存在
expungeStaleEntry(i);
else
// 未命中,探测下一个位置
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

// -> 清理连续段中无效数据
// staleSlot:起点
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 清理无效节点(起点一定是无效节点)
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// 线性探测直到遇到空闲位置
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
// 清理无效节点
e.value = null;
tab[i] = null;
size--;
} else {
// 疑问 5:清理无效节点我理解,为什么要对有效节点再散列呢?
// 再散列有效节点
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

// -> 线性探测下一个数组位置
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
}

4.5 ThreadLocalMap 的添加方法

ThreadLocalMap#set 的流程非常复杂,我将主要步骤概括为 6 步:

  • 1、先将散列值映射到数组下标,并且开始线性探测;
  • 2、如果探测中遇到目标节点,则将旧 Value 更新为新 Value;
  • 3、如果探测中遇到无效节点,则会调用 replaceStaleEntry() 清理连续段并添加键值对;
  • 4、如果未探测到目标节点或无效节点,则创建并添加新节点;
  • 5、添加新节点后调用 cleanSomeSlots() 方法清理部分数据;
  • 6、如果没有发生清理并且达到扩容阈值,则触发 rehash() 扩容。

replaceStaleEntry(): 清理连续段中的无效节点的同时,如果目标节点存在则更新 Value 后替换到 staleSlot 无效节点位置,如果不存在则创建新节点替换到 staleSlot 无效节点位置。

cleanSomeSlots(): 对数式清理,清理复杂度比全数组清理低,在大多数情况只会扫描 log(len) 个元素。如果扫描过程中遇到无效节点,则从该位置执行一次连续段清理,再从连续段的下一个位置重新扫描 log(len) 个元素,直接结束对数扫描。

ThreadLocalMap#set 示意图

ThreadLocal.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
java复制代码static class ThreadLocalMap {

private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 1、散列值转数组下标
int i = key.threadLocalHashCode & (len-1);

// 线性探测
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
// 2、命中,将旧 Value 替换为新 Value
e.value = value;
return;
}

if (k == null) {
// 3、清理无效节点,并插入键值对
replaceStaleEntry(key, value, i);
return;
}
}

// 4、如果未探测到目标节点或无效节点,则创建并添加新节点
tab[i] = new Entry(key, value);
int sz = ++size;
// cleanSomeSlots:清理部分数据
// 5、添加新节点后调用 cleanSomeSlots() 方法清理部分数据
if (!cleanSomeSlots(i, sz /*有效数据个数*/) && sz >= threshold)
// 6、如果没有发生清理并且达到扩容阈值,则触发 rehash() 扩容
rehash();
}

// -> 3、清理无效节点,并插入键值对
// key-value:插入的键值对
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// slotToExpunge:记录清理的起点
int slotToExpunge = staleSlot;
// 3.1 向前探测找到连续段中的第一个无效节点
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// 3.2 向后探测目标节点
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

if (k == key) {
// 3.2.1 命中,将目标节点替换到 staleSlot 位置
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// 3.2.2 如果连续段在 staleSlot 之前没有无效节点,则从 staleSlot 的下一个无效节点开始清理
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 3.2.3 如果连续段中还有其他无效节点,则清理
// expungeStaleEntry:连续段清理
// cleanSomeSlots:对数式清理
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// 如果连续段在 staleSlot 之前没有无效节点,则从 staleSlot 的下一个无效节点开始清理
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// 3.3 创建新节点并插入 staleSlot 位置
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// 3.4 如果连续段中还有其他无效节点,则清理
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len /*数组长度*/);
}

// 5、对数式清理
// i:起点
// n:数组长度或有效数据个数
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
// 发现无效节点,重新探测 log2(len)
n = len;
removed = true;
// 连续段清理
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0); // 探测 log2(len)
return removed;
}
}

4.6 ThreadLocalMap 的扩容方法

ThreadLocalMap 的扩容方法相对于添加方法比较好理解。在添加方法中,如果添加键值对后散列值的长度超过扩容阈值,就会调用 rehash() 方法扩容,主体流程分为 3步:

  • 1、先完整扫描散列表清理无效数据,清理后用较低的阈值判断是否需要扩容;
  • 2、创建新数组;
  • 3、将旧数组上无效的节点忽略,将有效的节点再散列到新数组上。

ThreadLocaoMap#rehash 示意图

ThreadLocal.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
java复制代码static class ThreadLocalMap {

// 扩容(在容量到达 threshold 扩容阈值时调用)
private void rehash() {
// 1、全数组清理
expungeStaleEntries();

// 2、用较低的阈值判断是否需要扩容
if (size >= threshold - threshold / 4)
// 3、真正执行扩容
resize();
}

// -> 1、完整散列表清理
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
// 很奇怪为什么不修改 j 指针
expungeStaleEntry(j);
}
}

// -> 3、真正执行扩容
private void resize() {
Entry[] oldTab = table;
// 扩容为 2 倍
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
// 清除无效键值的 Value
e.value = null; // Help the GC
} else {
// 将旧数组上的键值对再散列到新数组上
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
// 计算扩容后的新容量和新扩容阈值
setThreshold(newLen);
size = count;
table = newTab;
}
}

4.7 ThreadLocalMap 的移除方法

ThreadLocalMap 的移除方法是添加方法的逆运算,ThreadLocalMap 也没有做动态缩容。

与常规的移除操作不同的是,ThreadLocalMap 在删除时会执行 expungeStaleEntry() 清除无效节点,并对连续段中的有效节点做再散列,所以 ThreadLocalMap 是 “真删除”。

ThreadLocal.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码static class ThreadLocalMap {

// 移除
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
// 散列值转数组下标
int i = key.threadLocalHashCode & (len-1);
// 线性探测
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 清除弱引用关系
e.clear();
// 清理连续段
expungeStaleEntry(i);
return;
}
}
}
}

4.8 ThreadLocalMap 复杂度分析

总结下 ThreadLocalMap 的时间复杂度,以下 K 为连续段的长度,N 是数组长度。

  • 获取方法: 平均时间复杂度为 O(K);
  • 添加方法: 平均时间复杂度为 O(K),在触发扩容的添加操作中时间复杂度为 O(N),基于摊还分析后时间复杂度依然是 O(K);
  • 移除方法: 移除是 “真删除”,平均时间复杂度为 O(K)。

4.9 访问 ThreadLocal 一定会清理无效数据吗?

不一定。只有扩容会触发完整散列表清理,其他情况都不能保证清理,甚至不会触发。


  1. 总结

  • 1、ThreadLocal 是一种特殊的无锁线程安全方式,通过为每个线程分配独立的资源副本,从根本上避免发生资源冲突;
  • 2、ThreadLocal 在所有线程间隔离,InheritableThreadLocal 在创建子线程时会拷贝父线程中 InheritableThreadLocal 的有效键值对;
  • 3、虽然 ThreadLocal 提供了自动清理数据的能力,但是自动清理存在滞后性。为了避免内存泄漏,在业务开发中应该及时调用 remove 清理无效的局部存储;
  • 4、ThreadLocal 是采用线性探测解决散列冲突的散列表。

ThreadLocal 思维导图


版权声明

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

参考资料

  • 数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
  • 算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
  • 《阿里巴巴Java开发手册》 杨冠宝 编著
  • 数据结构与算法之美(第 18~22 讲) —— 王争 著,极客时间 出品
  • ThreadLocal 和 ThreadLocalMap源码分析 —— KingJack 著
  • Why 0x61c88647? —— Dr. Heinz M. Kabutz 著

推荐阅读

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 交流社群~

本文转载自: 掘金

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

1…828384…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%