ThreadLocalRandom探秘(深度硬核)

315 阅读17分钟

ThreadLocalRandom探秘(深度硬核)

原版英文链接

https://alvaro-videla.com/2016/10/inside-java-s-threadlocalrandom.html

阅读之前

希望你可以先了解下Random类的实现,还有常见随机数生成算法,如线性同余、梅森旋转。

以下的内容都是基于jdk8,因为不同的jdk实现逻辑会有差异。

翻译缘由

为什么要翻译这篇文章?

前几天我正在阅读Java的工具类实现。先是看了Random类的实现,他是基于线性同余实现的随机数生成。即X(n+1) = ( A * X(n) + B ) % C。从代码中很容易理解随机数是如何生成的。但当我看到ThreadLocalRandom类的随机数生成方法时有一丝不解,代码如下所示:

public int nextInt() {
    return mix32(nextSeed());
}
private static int mix32(long z) {
    z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL;
    return (int)(((z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L) >>> 32);
}
final long nextSeed() {
    Thread t; long r; // read and update per-thread seed
    UNSAFE.putLong(t = Thread.currentThread(), SEED,
                   r = UNSAFE.getLong(t, SEED) + GAMMA);
    return r;
}

这里的实现给人的感觉就是:“看起来一切正常,但这真的能生成随机数吗?”。

于是我在国内搜索引擎中开始寻找答案。遗憾的是,找到的大多数教程和博文都是:

  • 教如何使用ThreadLocalRandom
  • ThreadLocalRandom为什么线程安全
  • ThreadLocalRandom和Random的性能对比
  • 非常浅显的原理解析,仅浮于代码表面

而鲜有提及ThreadLocalRandom使用的随机数算法是什么,为什么会有这么多常量等。

后来我在外网搜索过程中找到了这篇英文博客,作者在阅读ThreadLocalRandom时,遇到了和我同样的疑问(我把最后一段话进行了加粗显示):

Let’s check the implementation of that method:

public int nextInt() {
    return mix32(nextSeed());
}

So we compute the next seed and that’s mixed and returned to the user. Let’s see what nextSeed() is doing:

final long nextSeed() {
    Thread t; long r; // read and update per-thread seed
    UNSAFE.putLong(t = Thread.currentThread(), SEED,
                   r = UNSAFE.getLong(t, SEED) + GAMMA);
    return r;
}

This doesn’t look random at all, for some defintion of the word random anyway. If we go back to Knuth, the Linear Congruence Method proposed in his book involves a calculation like this:

nextseed = (oldseed * multiplier + addend) & mask;

So we have a multiplier, and addend, and then we apply a mask to that value.

The method used by TLR lacks the multiplier part but in TAOCP Knuth is very clear that the lack of multiplier has the effect of producing a sequence that’s “everything but random”. The TLR case is like setting the multiplier to 1 in the Linear Congruence Method.

通读文章后觉得写得非常不错,于是决定将译文呈现给大家

译文

在这篇博文中,我想介绍一些我在尝试理解 jdk8 ThreadLocalRandom类的实现时发现的东西。我做这件事情的动机是什么?周末我读了 Knuth 的 TAOCP(The Art of Computer Programming;计算机程序设计艺术)“Seminumerical Algorithms”的第二卷,他在整个第 3 章中描述了用计算机生成随机数的技术,然后解释了如何测试这些随机数生成器(当然我们是谈论PRNG(伪随机数生成器))。在本章末尾,Knuth 提出了以下练习:

查看您组织中每台计算机安装的子程序库,并将随机数生成器替换为好的随机数生成器。不要对你发现的结果感到太惊讶。

这似乎是一个很好的练习,但虽然我不想替换我组织中的 PRNG,但我决定亲自看看它们的代码实现逻辑。我从OpenJDK中抓取代码,并直接查看ThreadLocalRandom的实现 。ThreadLocalRandom 类(下文简称 TLR)是Random类提供的接口的实现 。Random是Knuth在TAOCP上提出的线性同余法的一种实现 。快速翻阅了一下TLR,发现了一些(与Random类)完全不同的东西。

为了理解TLR,我下载了该类的源代码,并将其导入IntelliJ。我试着忽略注释,按原样去阅读代码。这是多么有趣的经历!该代码遍地都是硬编码常量,如下所示:

private static int mix32(long z) {
    z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL;
    return (int)(((z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L) >>> 32);
}

什么是0xff51afd7ed558ccdL0xc4ceb9fe1a85ec53L?他们是如何得出这些常数的?还有其他的像:

0x9e3779b97f4a7c15L
0x9e3779b9
0xbb67ae8584caa73bL

简单地谷歌之后发现,0x9e3779b97f4a7c15L来自0x9e3779b9 Rivest 的 The R5 Encryption Algorithm(将 R 提供给 RSA 的同一个人)。在论文中,我们可以看到 Rivest 采用了黄金比例数,并在以下公式中使用它来生成 0x9e3779b97f4a7c15Lor 0x9e3779b9

Q = Odd((φ - 1)2^w)

其中 Odd 是一个函数,它返回最接近输入参数的整数。然后w是我们要使用的位的大小(wordsize),例如 32 或 64。您可能已经猜到了,0x9e3779b97f4a7c15L是64 位版本的常量, 0x9e3779b9是 32 位版本的常量。注意, 0x9e3779b9也被用于 Tiny Encryption Algorithm

常量0x9e3779b97f4a7c15L在 TLR 源代码中被称为GAMMA0x9e3779b9被称为PROBE_INCREMENT

然后我们得到0xbb67ae8584caa73bL,在 TLR 中被称为 SEEDER_INCREMENT。这个就简单了,它是3的平方根的分数部分,像这样:

frac(sqrt(3)) * 2^64

碰巧 SHA512算法中使用了该常量,该算法对前 8 个质数执行类似的过程以提取算法中也使用的 8 个常量。

然后,在上面的mix32方法中还有常量0xff51afd7ed558ccdL0xc4ceb9fe1a85ec53L。谷歌显示,这些常量来自MurmurHash3算法的最后一步。

到目前为止一切顺利,但我们仍未知道这些数字是用来做什么的。要理解这一点,我们需要了解 ThreadLocalRandom 的用途。顾名思义,这个想法是每个线程都有一个随机数源,这样我们就可以同时获得随机数。这意味着每次线程初始化 TLR 的实例时,代码都需要为该特定线程初始化随机种子。种子被初始化在混合版本的SEEDER_INCREMENT上;同时,通过将PROBE_INCREMENT添加到当前的probeGenerator值中来初始化该特定线程的PROBE值。probe值是用来做什么的?他被部分类用于计算map的key,如ConcurrentHashMap

于是,我们有了取自 RC5 和 TEA、SHA512 和 MurmurHash3 的常量。这根本没有任何意义,不妨让我们检查一下将所有这些都放在一起时如何工作的,以探究其中的奥秘。

获取随机数

要从 ThreadLocalRandom 中获取随机数,我们可以调用nextInt()方法,该方法也是Random公开接口的一部分。让我们看看该方法的实现:

public int nextInt() {
    return mix32(nextSeed());
}

所以我们计算下一个种子,然后将其混合并返回给用户。让我们看看nextSeed()在做什么:

final long nextSeed() {
    Thread t; long r; // read and update per-thread seed
    UNSAFE.putLong(t = Thread.currentThread(), SEED,
                   r = UNSAFE.getLong(t, SEED) + GAMMA);
    return r;
}

该方法获取种子的当前值,并将其与GAMMA上面给出的常量值相加。这看起来根本不是随机的。如果我们回到 Knuth,他的书中提出的线性同余法涉及这样的计算:

nextseed = (oldseed * multiplier + addend) & mask;

所以我们有一个乘数和加数,然后我们对该值应用掩码。

TLR 使用的方法缺少乘数部分,但在 TAOCP 中,Knuth 非常清楚,缺少乘数会产生“除了随机之外的一切”序列。TLR 的情况就像在线性同余法中将乘数设置为 1。

因此,ThreadLocalRandom 要么有问题,要么我遗漏了一些东西。考虑到 ThreadLocalRandom 是数百万开发人员使用的 JDK 的一部分,我很确定我有错,而且我肯定遗漏了一些东西。是时候阅读那些注释了。

就在类的顶部,有这样的注释:

尽管此类是 java.util.Random 的子类,但它使用与 java.util.SplittableRandom 相同的基础算法。

因此,让我们调出 SplittableRandom并看看我们在那里发现了什么,但这次让我们也阅读那里的注释。

ThreadLocalRandom 背后的理论

当我们打开 SplittableRandom 的源代码时,我们会发现这个说明性注释:

/*
 * This algorithm was inspired by the "DotMix" algorithm by
 * Leiserson, Schardl, and Sukha "Deterministic Parallel
 * Random-Number Generation for Dynamic-Multithreading Platforms",
 * PPoPP 2012, as well as those in "Parallel random numbers: as
 * easy as 1, 2, 3" by Salmon, Morae, Dror, and Shaw, SC 2011.  It
 * differs mainly in simplifying and cheapening operations.
 */

提到了两篇论文,一篇Deterministic Parallel Random-Number Generation for Dynamic-Multithreading Platforms ,一篇Parallel random numbers: as easy as 1, 2, 3.

Leiserson、Schardl 和 Sukha 的第一篇论文(以下简称 LSS)的目标是了解如何为 dthreading 平台(与 POSIX 的 pthreading 相反)创建高效的确定性并行随机数生成器 Dthreading是Fork-Join并行计算模型的一种实现 。问题在于传统的 DPRNG 无法扩展到数十万条链( strands (读作绿色线程),因为它们是在考虑 pthread 模型的情况下创建的。在那篇论文中,他们提出了一种称为 DotMix 的 DPRNG 算法,该算法使用谱系积,然后 使用RC6算法将结果混合。他们声称 DotMix的统计质量可以与Mersenne Twister算法相媲美 ,并且应该适用于数十万条链(strands)

他们说那些其他算法不能扩展是什么意思?并行随机数流的问题是必须有某种方法来防止两个流产生相同的随机数序列。我们可以保持状态(state)并在线程之间使用锁进行同步,但这会很慢。他们正在尝试为每个线程找到一种方法,使其能够为其随机数提供一个不依赖于共享状态的种子。

什么是谱系?

他们使用以下定义来解释一个谱系,如果我们不阅读原始论文,它就没有多大意义:

谱系方案以独立于调度程序的方式去唯一标识 dthreaded 程序的每个链。

让我们尝试在 fork-join 并行计算模型的上下文中理解该定义。每个线程都可以通过调用 fork( LSS 论文中的spawn )来分叉多个线程,或者可以生成一个值。同时,生成的线程也可以做同样的事情:要么生成一个新的子线程,要么生成更多的值。现在让我们使用以下树来表示该模型:

fork-join-tree.png

我们让根任务A生成值6A,分叉线程BC,生成值81;然后任务B生成3个值1274C7;然后C生成DB9;等等。

LSS 声称的谱系进行简单地描述一下就是:在树中,从根节点到叶子节点生成的值作为标签向量是唯一的,独立于任务的调度。例如,该值74具有以下谱系:<2, 2>。

每个线程都有一个唯一的向量,但是我们如何根据它生成随机数呢?DotMix 的作者谈到了将向量的值压缩为单个机器字的想法 。这是DotMix 的Dot部分发挥作用的地方。他们计算谱系向量与“随机均匀选择”的另一个整数向量的点积(具体细节请参见他们的论文)。

这个由点积产生的整数散列与其他线程产生的整数散列有很小的可能性发生冲突。问题是现在两个相似的谱系可以产生相似的哈希值,但 DotMix 的作者希望产生的值在 统计上与其他哈希值不同。为了解决这个问题,他们将混合部分引入了 DotMix。那么,什么是混合?

什么是混合?

该算法的混合步骤对从系谱中获得的哈希值应用一个函数,以减少两个哈希值的统计相关性,因此很难预测它们的顺序。在 DotMix 的情况下,mix 函数交换哈希值的高位和低位,也就是说,例如,在 64 位哈希中,将第一个 32 位部分与第二个部分交换的函数。对于 DotMix,他们使用的混合函数是基于RC6 加密

基于计数器的 PRNG

从 DotMix 的描述中我们可以看到,ThreadLocalRandom 正在使用一个混合步骤,该步骤应用于从nextSeed获得的种子 ,但正如我们所见,nextSeed只是通过常量GAMMA增加来currentSeed 的值,这意味着这与谱系无关。这是注释中提到的另一篇论文发挥作用的地方。

Salmon、Morae、Dror 和 Shaw 的论文“Parallel random numbers: as easy as 1, 2, 3”提出了基于计数器的 PRNG 的想法。在他们的论文中,他们试图解决“大规模并行高性能计算”的问题,为此他们说传统的 PRNG 不能很好地扩展。

像 TAOCP 中的方法这样的传统 PRNG 的问题在于它们是串行的。因此,要计算随机数 N+1,我们需要先计算出第 N 个随机数。如果我们想要生成多个随机数流,那么如果我们的目标是确保流不同,那么该方法将无法扩展,因为用自己的种子初始化每个流会变得很复杂。

为了解决这个问题,他们提出了一个简单的函数来生成数字序列:

f(s) = (s + 1) mod 2^p

该函数只是一个简单的计数器,它将输入值增加 1,然后 mod 2 的某个幂。由于它只是一个计数器,因此这种方法称为基于计数器的PRNG。

在这一点上,我们可能会因为太多的翻白眼而开始伤害我们自己的眼部肌肉,但请听我说。计数器函数可以只使用 1 作为增量,就像在那个例子中那样,或者使用一个稍微复杂一点的数字,比如ThreadLocalRandom 中使用的GAMMA值。不过,我们还没有随机数。

该论文的作者提出的是,我们将加密安全函数应用于计数器函数产生的值。特别是他们建议使用 AES 或 Threefish 的部分来计算计数器产生的值。在他们的论文中,他们没有将计数器递增 1,而是提出了一组常量,这些常量也在 ThreadLocalRandom中被使用:0xbb67ae8584caa73b0x9e3779b97f4a7c15,也就是我们之前提到的SEEDER_INCREMENTGAMMA 值。他们说,通过使用这些常量和 AES 或 Threefish 的一些变体,他们设法通过了PRNG的TestU01的BigCrush测试 ,并且他们的 PRNG 产生了2^128数字周期。

将谜题拼在一起

所以现在我们正在设法拼凑 ThreadLocalRandom 的拼图。从DotMix中我们获得了混合函数;从Salmon et al.我们得到了基于计数器的 PRNG 的想法。但是仍然缺少一块:为什么 ThreadLocalRandom 使用的似乎是自定义混合函数?

碰巧 ThreadLocalRandommix32mix64根本不是自定义的。它们实际上是基于 MurmurHash3 终结器函数。 该函数背后的想法是对传递给混合函数的值的位产生雪崩效应。 雪崩效应是一种通过翻转一个位的技术,设法改变足够多的位(雪崩部分),使得结果数字与输入数字完全不同。因此,如果我们将两个非常相似的值传递给函数,混合函数将确保它们最终完全不同。如果我们再看一遍mix32,我们会在看到一对常量,0xff51afd7ed558ccdL0xc4ceb9fe1a85ec53L

private static int mix32(long z) {
    z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL;
    return (int)(((z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L) >>> 32);
}

根据 MurmurHash3 的创建者的说法,选择这些常量是因为它们会产生概率接近 0.5 的雪崩效应,但故事并没有就此结束。SplittableRandom不使用相同的常量!

// SplittableRandom 的实现
private static long mix64(long z) {
    z = (z ^ (z >>> 30)) * 0xbf58476d1ce4e5b9L;
    z = (z ^ (z >>> 27)) * 0x94d049bb133111ebL;
    return z ^ (z >>> 31);
}

此处使用的常量是David Stafford在他的博客中建议的, 他在运行一些实验后发现了它们。由于某种原因 SplittableRandom具有更好的常数,而 ThreadLocalRandom 则没有。

ThreadLocalRandom 一个随机算法?

Knuth 试图在他的书中阐明的一件事是,我们不应该使用随机算法来生成 PRNG。也就是说,算法的步骤不能随意选择,比如从这里拿一块,从那里拿一块,把它们放在一起,摇晃一下,啪嗒!我们得到了一个 PRNG。到目前为止,这似乎是 ThreadLocalRandom 的情况。我们缺少什么?

还有另一篇名为 Fast Splittable Pseudorandom Number Generators 的论文。它的作者是 Guy Steele、Doug Lea 和 Christine H. Flood,你可能不知道,他们都是参与 JDK 开发的人。这篇论文是关于什么的?它解释了SplittableRandom背后的算法 ,这也是用于的算法ThreadLocalRandom(如上所述,有一些小的差异)。

在那篇论文中,他们解释说他们采用 DotMix 并在 Scala 中实现它,因为该语言将允许他们整洁地实现逻辑,他们可以对其进行分析以进一步改进,然后将其转换为 Java。这是 Scala 的一个非常有趣的用例。

一旦他们实施了 DotMix,他们就试图改进它,重点是简化它的步骤,试图提高算法的性能,同时保持它足够安全以通过 TestU01 电池测试。因此,基于谱系的 PRNG 变成了基于计数器的 PRNG;和加密安全的混合(但可以说是缓慢的)混合函数成为 MurmurHash3 的终结函数。当然,他们将新的 PRNG 算法置于 TestU01 提供的一系列测试之下,TestU01 是测试 PRNG 的行业标准。

该论文中有一个历史记录值得注意。该论文发表于 2014 年 10 月。同年 12 月,一篇来自INRIA 的论文被提交发表,其中讨论了该MRG32k3a算法作为一种替代 ThreadLocalRandom 实现的方法。如果我们阅读 Steele 等人的论文,我们会看到他们对MRG32k3a进行了审查,但他们发现它不符合他们的选择标准,因为它不允许将流拆分超过 192 个子线程。我推测 INRIA 论文的作者在发表时并不知道Steele的论文。

该论文的另一个有趣的注释是他们对 Haskell 的 System.Random 实现的评论:

API很漂亮;实现存在严重缺陷。

结论

在这个有趣的 ThreadLocalRandom 穿越之旅中,虽然我们一路上发现了很多似乎没有逻辑解释的东西,但实际上它们存在是有原因的。在这种情况下,ThreadLocalRandom 的作者采用了两种算法来生成 PRNG,并改进了它们的实现,直到他们达成了 SplittableRandom(以及随后的 ThreadLocalRandom)。即使该方法看起来不错,PRNG 也需要测试其统计属性。而且Steele 等人告诉我们,他们的 SplitMix 通过了 TestU01 电池测试。

论文与连接

最后

翻译不易(半机译半人工校对),如有纰漏欢迎指出,有能力的建议直接看原文。 如需转载请注明出处!