跳过正文
全网最硬核 JDK 分析 - 2. Java 随机数演进
  1. 文章/

全网最硬核 JDK 分析 - 2. Java 随机数演进

·9740 字·20 分钟
NeatGuyCoding
作者
NeatGuyCoding

本系列将 Java 17 之前的随机数 API 以及 Java 17 之后的统一 API 都做了比较详细的说明,并且将随机数的特性以及实现思路也做了一些简单的分析,帮助大家明白为何会有这么多的随机数算法,以及他们的设计思路是什么。

如何生成随机数
#

我们一般使用随机数生成器的时候,都认为随机数生成器(Pseudo Random Number Generator, PRNG)是一个黑盒:

image

这个黑盒的产出,一般是一个数字。假设是一个 int 数字。这个结果可以转化成各种我们想要的类型,例如:如果我们想要的的其实是一个 long,那我们可以取两次,其中一次的结果作为高 32 位,另一次结果作为低 32 位,组成一个 long(boolean,byte,short,char 等等同理,取一次,取其中某几位作为结果)。如果我们想要的是一个浮点型数字,那么我们可以根据 IEEE 标准组合多次取随机 int 然后取其中某几位组合成浮点型数字的整数位以及小数位。

如果要限制范围,最简单的方式是将结果取余 + 偏移实现。例如我们想取范围在 1 ~ 100 之间,那么我们就将结果先对 99 取余,然后取绝对值,然后 +1 即可。当然,由于取余操作是一个性能消耗比较高的操作,最简单的优化即检查这个数字 N 与 N-1 取与运算,如果等于 0 即这个书是 2 的 n 次方(2 的 n 次方 2 进制表示一定是 100000 这样的,减去 1 之后 为 011111,取与肯定是 0);对于 2 的 n 次方取余相当于对 2 的 n 次方减一取与运算。这是一个简单的优化, 实际的优化要比这个复杂多。

初始化这个黑盒的时候,一般采用一个 SEED 进行初始化,这个 SEED 的来源可能多种多样,这个我们先按下不表,先来看一些这个黑盒中的一些算法。

image

线性同余算法
#

首先是最常见的随机数算法:线性同余(Linear Congruential Generator)。即根据当前 Seed 乘以一个系数 A,然后加上一个偏移 B,最后按照 C 进行取余(限制整体在一定范围内,这样才能选择出合适的 A 和 B,为什么要这么做后面会说),得出随机数,然后这个随机数作为下次随机的种子,即:

X(n+1) = ( A * X(n) + B ) % C

这种算法的优势在于,实现简单,并且性能算是比较好的。 A,B 取值必须精挑细算,让在 C 范围内的所有数字都是等可能的出现的。例如一个极端的例子就是 A = 2, B = 2, C = 10,那么 1,3,5,7,9 这些奇数在后续都不可能出现。为了能计算出一个合适的 A 和 B,要限制 C 在一个比较可控的范围内。一般为了计算效率,将 C 限制为 2 的 n 次方。这样取余运算就可以优化为取与运算。不过好在,数学大师们已经将这些值(也就是魔法数)找到了,我们直接用就好了。

这种算法生成的随机序列,是确定的,例如 X 下一个是 Y, Y 下一个是 Z,这可以理解成一个确定环(loop)。

image

这个环的大小,即 Period。由于 Period 足够大,初始 SEED 一般也是每次不一样的,这样近似做到了随机。但是,假设我们需要多个随机数生成器的时候,就比较麻烦了,因为我们虽然能保证每个随机生成器的初始 SEED 不一样,但是在这种算法下,无法保证某个随机数生成器的初始 SEED 就是另一个随机数生成器初始 SEED 的下一个(或者很短步骤内的) SEED。举个例子,假设某个随机数生成器的初始 SEED 是 X,另一个是 Z,虽然 X 和 Z 可能看上去差距很大,但是他们在这个算法的随机序列中仅隔了一个 Y。这样的不同的随机数生成器,效果不好

那么如何能保证不同的随机数生成器之间间隔比较大呢?也就是,我们能通过简单计算(而不是计算 100w 次从而调到 100w 次之后的随机数)直接使另一个随机数生成器的初始 SEED 与当前这个的初始 SEED,间隔一个比较大的数,这种性质叫做可跳跃性基于线性反馈移位寄存器算法的 Xoshiro 算法给我们提供了一种可跳跃的随机数算法

线性反馈移位寄存器算法
#

线性反馈移位寄存器(Linear feedback shift register,LFSR)是指给定前一状态的输出,将该输出的线性函数再用作输入的移位寄存器。异或运算是最常见的单比特线性函数:对寄存器的某些位进行异或操作后作为输入,再对寄存器中的每个 bit 进行整体移位。

但是如何选择这些 Bit,是一门学问,目前比较常见的实现是 XorShift 算法以及在此基础上进一步优化的 Xoshiro 的相关算法。Xoshiro 算法是一种比较新的优化随机数算法,计算很简单并且性能优异。同时实现了可跳跃性。

这种算法是可跳跃的。假设我们要生成两个差距比较大的随机数生成器,我们可以使用一个随机初始 SEED 创建一个随机数生成器,然后利用算法的跳跃操作,直接生成一个间隔比较大的 SEED 作为另一个随机数生成器的初始 SEED。

image

还有一点比较有意思的是,线性同余算法并不可逆,我们只能通过 X(n) 推出 X(n + 1),而不能根据 X(n + 1) 直接推出 X(n)。这个操作对应的业务例如随机播放歌单,上一首下一首,我们不需要记录整个歌单,而是仅根据当前的随机数就能知道。线性反馈移位寄存器算法能实现可逆

线性反馈移位寄存器算法在生成不同的随机序列生成器也有局限性,即它们还是来自于同一个环,即使通过跳跃操作让不同的随机数生成器都间隔开了,但是如果压力不够均衡,随着时间的推移,它们还是有可能 SEED,又变成一样的了。那么有没有那种能生成不同随机序列环的随机算法呢

DotMix 算法
#

DotMix 算法提供了另一种思路,即给定一个初始 SEED,设置一个固定步长 M,每次随机,将这个 SEED 加上步长 M,经过一个 HASH 函数,将这个值散列映射到一个 HASH 值:

X(n+1) = HASH(X(n) + M)

这个算法对于 HASH 算法的要求比较高,重点要求 HASH 算法针对输入的一点改变则造成输出大幅度改变。基于 DotMix 算法的 SplitMix 算法使用的即 MurMurHash3 算法,这个即 Java 8 引入的 SplittableRandom 的底层原理。

这种算法好在,我们很容易能明确两个不同参数的随机生成器他们的生成序列是不同的,例如一个生成的随机序列是 1,4,3,7,… 另一个生成的是 1,5,3,2。这点正是线性同余算法无法做到的,他的序列无论怎么修改 SEED 也是确定的,而我们有不能随意更改算法中的 A、B、C 的值,因为可能会导致无法遍历到所有数字,这点之前已经说过了。Xoshiro 也是同理。而 SplitMix 算法不用担心,我们指定不同的 SEED 以及不同的步长 M 就可以保证生成的序列是不同的。这种可以生成不同序列的性质,称为可拆分性

image

这也是 SplittableRandomRandom (Random 基于线性同余)更适合多线程的原因:

  • 假设多线程使用同一个 Random,保证了序列的随机性,但是有 CompareAndSet 新 seed 的性能损失。
  • 假设每个线程使用 SEED 相同的 Random,则每个线程生成的随机序列相同。
  • 假设每个线程使用 SEED 不相同的 Random,但是我们不能保证一个 Random 的 SEED 是否是另一个 Random SEED 的下一个结果(或者是很短步长以内的结果),这种情况下如果线程压力不均匀(线程池在比较闲的时候,其实只有一部分线程在工作,这些线程很可能他们私有的 Random 来到和其他线程同一个 SEED 的位置),某些线程也会有相同的随机序列。

使用 SplittableRandom 只要直接使用接口 split 就能给不同线程分配一个参数不同SplittableRandom ,并且参数不同基本就可以保证生成不了相同序列。

思考:我们如何生成 Period 大于生成数字容量的随机序列呢?
#

最简单的做法,我们将两个 Period 等于容量的序列通过轮询合并在一起,这样就得到了 Period = 容量 + 容量 的序列:

image

我们还可以直接记录两个序列的结果,然后将两个序列的结果用某种运算,例如异或或者散列操作拼到一起。这样,Period = 容量 * 容量

如果我们想扩展更多,都可以通过以上办法拼接。用一定的操作拼接不同算法的序列,我们可以得到每种算法的随机优势。 Java 17 引入的 LXM 算法就是一个例子。

LXM 算法
#

这是在 Java 17 中引入的算法 LXM 算法(L 即线性同余,X 即 Xoshiro,M 即 MurMurHash)的实现较为简单,结合线性同余算法和 Xoshiro 算法,之后通过 MurMurHash 散列,例如:

  • L34X64M:即使用一个 32 位的数字保存线性同余的结果,两个 32 位的数字保存 Xoshiro 算法的结果,使用 MurMurHash 散列合并这些结果到一个 64 位数字。
  • L128X256M:即使用两个 64 位的数字保存线性同余的结果,4 个 64 位的数字保存 Xoshiro 算法的结果,使用 MurMurHash 散列合并这些结果到一个 64 位数字。

LXM 算法通过 MurMurhash 实现了分割性,没有保留 Xoshiro 的跳跃性。

SEED 的来源
#

由于 JDK 中所有的随机算法都是基于上一次输入的,如果我们使用固定 SEED 那么生成的随机序列也一定是一样的。这样在安全敏感的场景,不够合适,官方对于 cryptographically secure 的定义是,要求 SEED 必须是不可预知的,产生非确定性输出。

在 Linux 中,会采集用户输入,系统中断等系统运行数据,生成随机种子放入池中,程序可以读取这个池子获取一个随机数。但是这个池子是采集一定数据后才会生成,大小有限,并且它的随机分布肯定不够好,所以我们不能直接用它来做随机数,而是用它来做我们的随机数生成器的种子。这个池子在 Linux 中被抽象为两个文件,这两个文件他们分别是:/dev/random/dev/urandom。一个是必须采集一定熵的数据才放开从池子里面取否则阻塞,另一个则是不管是否采集够直接返回现有的。

在 Linux 4.8 之前:

image

在 Linux 4.8 之后:

image

在熵池不够用的时候,file:/dev/random会阻塞file:/dev/urandom不会。对于我们来说,/dev/urandom 一般就够用,所以一般通过-Djava.security.egd=file:/dev/./urandom设置 JVM 启动参数,使用 urandom 来减少阻塞。

我们也可以通过业务中的一些特性,来定时重新设置所有 Random 的 SEED 来进一步增加被破解的难度,例如,每小时用过去一小时的活跃用户数量 * 下单数量作为新的 SEED。

测试随机算法随机性
#

以上算法实现的都是伪随机,即当前随机数结果与上一次是强相关的关系。事实上目前基本所有快速的随机算法,都是这样的

并且就算我们让 SEED 足够隐秘,但是如果我们知道算法,还是可以通过当前的随机输出,推测出下一个随机输出。或者算法未知,但是能从几次随机结果反推出算法从而推出之后的结果。

针对这种伪随机算法,需要验证算法生成的随机数满足一些特性,例如:

  • period 尽可能长:a full cycle 或者 period 指的是随机序列将所有可能的随机结果都遍历过一遍,同时结果回到初始 seed 需要的结果个数。这个 period 要尽可能的长一些。
  • 平均分布(equidistribution),生成的随机数的每个可能结果,在一个 Period 内要尽可能保证每种结果的出现次数是相同的。否则,会影响在某些业务的使用,例如抽奖这种业务,我们需要保证概率要准。
  • 复杂度测试:生成的随机序列是否够复杂,不会有那种有规律的数字序列,例如等比数列,等差数列等等。
  • 安全性测试:很难通过比较少的结果反推出这个随机算法。

目前,已经有很多框架工具用来针对某个算法生成的随机序列进行测试,评价随机序列结果,验证算法的随机性,常用的包括:

  • testU01 随机性测试:https://github.com/umontreal-simul/TestU01-2009/
  • NIST 随机性测试:https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-22r1a.pdf
  • DieHarder Suite 随机性测试

Java 中内置的随机算法,基本都通过了 testU01 的大部分测试。目前,上面提到过的优化算法都或多或少的暴露出一些随机性问题。目前, Java 17 中的 LXM 算法是随机性测试中表现最好的注意是随机性表现,而不是性能

Java 中涉及到的所有随机算法(不包括 SecureRandom)
#

image

为什么我们在实际业务应用中很少考虑随机安全性问题
#

主要因为,我们一般做了负载均衡多实例部署,还有多线程。一般每个线程使用不同初始 SEED 的 Random 实例(例如 ThreadLocalRandom)。并且一个随机敏感业务,例如抽奖,单个用户一般都会限制次数,所以很难采集够足够的结果反推出算法以及下一个结果,而且你还需要和其他用户一起抽。然后,我们一般会限制随机数范围,而不是使用原始的随机数,这就更大大增加了反解的难度。最后,我们也可以定时使用业务的一些实时指标定时设置我们的 SEED,例如:,每小时用过去一小时的(活跃用户数量 * 下单数量)作为新的 SEED。

所以,一般现实业务中,我们很少会用 SecureRandom。如果我们想初始 SEED 让编写程序的人也不能猜出来(时间戳也能猜出来),可以指定随机类的初始 SEED 源,通过 JVM 参数 -Djava.util.secureRandomSeed=true。这个对于所有 Java 中的随机数生成器都有效(例如,Random,SplittableRandom,ThreadLocalRandom 等等)

对应源码:

static {
        String sec = VM.getSavedProperty("java.util.secureRandomSeed");
        if (Boolean.parseBoolean(sec)) {
            //初始 SEED 从 SecureRandom 中取
            // SecureRandom 的 SEED 源,在 Linux 中即我们前面提到的环境变量 java.security.egd 指定的 /dev/random 或者 /dev/urandom
            byte[] seedBytes = java.security.SecureRandom.getSeed(8);
            long s = (long)seedBytes[0] & 0xffL;
            for (int i = 1; i < 8; ++i)
                s = (s << 8) | ((long)seedBytes[i] & 0xffL);
            seeder.set(s);
        }
    }

所以,针对我们的业务,我们一般只关心算法的性能以及随机性中的平均性,而通过测试的算法,一般随机性都没啥大问题,所以我们只主要关心性能即可

针对安全性敏感的业务,像是 SSL 加密,生成加密随机散列这种,则需要考虑更高的安全随机性。这时候才考虑使用 SecureRandom。SecureRandom 的实现中,随机算法更加复杂且涉及了一些加密思想,我们这里就不关注这些 Secure 的 Random 的算法了

Java 17 之前一般如何生成随机数以及对应的随机算法
#

首先放出算法与实现类的对应关系:

image

使用 JDK 的 API
#

1.使用 java.util.Random 和基于它的 API

Random random = new Random();
random.nextInt();

Math.random() 底层也是基于 Random

java.lang.Math

public static double random() {
    return RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble();
}
private static final class RandomNumberGeneratorHolder {
    static final Random randomNumberGenerator = new Random();
}

Random 本身是设计成线程安全的,因为 SEED 是 Atomic 的并且随机只是 CAS 更新这个 SEED:

java.util.Random

protected int next(int bits) {
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
        oldseed = seed.get();
        nextseed = (oldseed * multiplier + addend) & mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int)(nextseed >>> (48 - bits));
}

同时也看出,Random 是基于线性同余算法的

2.使用 java.util.SplittableRandom 和基于它的 API

SplittableRandom splittableRandom = new SplittableRandom();
splittableRandom.nextInt();

前面的分析我们提到了,SplittableRandom 基于 SplitMix 算法实现,即给定一个初始 SEED,设置一个固定步长 M,每次随机,将这个 SEED 加上步长 M,经过一个 HASH 函数(这里是 MurMurHash3),将这个值散列映射到一个 HASH 值。

SplittableRandom 本身不是线程安全的java.util.SplittableRandom

public int nextInt() {
    return mix32(nextSeed());
}   
private long nextSeed() {
    //这里非线程安全
    return seed += gamma;
}

ThreadLocalRandom 基于 SplittableRandom 实现,我们在多线程环境下使用 ThreadLocalRandom

ThreadLocalRandom.current().nextInt();

SplittableRandom 可以通过 split 方法返回一个参数全新,随机序列特性差异很大的新的 SplittableRandom,我们可以将他们用于不同的线程生成随机数,这在 parallel Stream 中非常常见:

IntStream.range(0, 1000)
    .parallel()
    .map(index -> usersService.getUsersByGood(index))
    .map(users -> users.get(splittableRandom.split().nextInt(users.size())))
    .collect(Collectors.toList());

但是由于没有做对齐性填充以及其他一些多线程性能优化的东西,导致其多线程环境下的性能表现还是比基于 SplittableRandomThreadLocalRandom 要差。

3. 使用 java.security.SecureRandom 生成安全性更高的随机数

SecureRandom drbg = SecureRandom.getInstance("DRBG");
drbg.nextInt();

一般这种算法,基于加密算法实现,计算更加复杂,性能也比较差,只有安全性非常敏感的业务才会使用,一般业务(例如抽奖)这些是不会使用的。

测试性能
#

单线程测试:

Benchmark                                      Mode  Cnt          Score          Error  Units
TestRandom.testDRBGSecureRandomInt            thrpt   50     940907.223 ±    11505.342  ops/s
TestRandom.testDRBGSecureRandomIntWithBound   thrpt   50     992789.814 ±    71312.127  ops/s
TestRandom.testRandomInt                      thrpt   50  106491372.544 ±  8881505.674  ops/s
TestRandom.testRandomIntWithBound             thrpt   50   99009878.690 ±  9411874.862  ops/s
TestRandom.testSplittableRandomInt            thrpt   50  295631145.320 ± 82211818.950  ops/s
TestRandom.testSplittableRandomIntWithBound   thrpt   50  190550282.857 ± 17108994.427  ops/s
TestRandom.testThreadLocalRandomInt           thrpt   50  264264886.637 ± 67311258.237  ops/s
TestRandom.testThreadLocalRandomIntWithBound  thrpt   50  162884175.411 ± 12127863.560  ops/s

多线程测试:

Benchmark                                      Mode  Cnt          Score           Error  Units
TestRandom.testDRBGSecureRandomInt            thrpt   50    2492896.096 ±     19410.632  ops/s
TestRandom.testDRBGSecureRandomIntWithBound   thrpt   50    2478206.361 ±    111106.563  ops/s
TestRandom.testRandomInt                      thrpt   50  345345082.968 ±  21717020.450  ops/s
TestRandom.testRandomIntWithBound             thrpt   50  300777199.608 ±  17577234.117  ops/s
TestRandom.testSplittableRandomInt            thrpt   50  465579146.155 ±  25901118.711  ops/s
TestRandom.testSplittableRandomIntWithBound   thrpt   50  344833166.641 ±  30676425.124  ops/s
TestRandom.testThreadLocalRandomInt           thrpt   50  647483039.493 ± 120906932.951  ops/s
TestRandom.testThreadLocalRandomIntWithBound  thrpt   50  467680021.387 ±  82625535.510  ops/s

结果和我们之前说明的预期基本一致,多线程环境下 ThreadLocalRandom 的性能最好。单线程环境下 SplittableRandomThreadLocalRandom 基本接近,性能要好于其他的。SecureRandom 和其他的相比性能差了几百倍。

测试代码如下(注意虽然 Random 和 SecureRandom 都是线程安全的,但是为了避免 compareAndSet 带来的性能衰减过多,还是用了 ThreadLocal。):

package prng;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Random;
import java.util.SplittableRandom;
import java.util.concurrent.ThreadLocalRandom;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

//测试指标为吞吐量
@BenchmarkMode(Mode.Throughput)
//需要预热,排除 jit 即时编译以及 JVM 采集各种指标带来的影响,由于我们单次循环很多次,所以预热一次就行
@Warmup(iterations = 1)
//线程个数
@Threads(10)
@Fork(1)
//测试次数,我们测试50次
@Measurement(iterations = 50)
//定义了一个类实例的生命周期,所有测试线程共享一个实例
@State(value = Scope.Benchmark)
public class TestRandom {
	ThreadLocal<Random> random = ThreadLocal.withInitial(Random::new);
	ThreadLocal<SplittableRandom> splittableRandom = ThreadLocal.withInitial(SplittableRandom::new);
	ThreadLocal<SecureRandom> drbg = ThreadLocal.withInitial(() -> {
		try {
			return SecureRandom.getInstance("DRBG");
		}
		catch (NoSuchAlgorithmException e) {
			throw new IllegalArgumentException(e);
		}
	});

	@Benchmark
	public void testRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(random.get().nextInt());
	}

	@Benchmark
	public void testRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
		blackhole.consume(random.get().nextInt(1, 100));
	}

	@Benchmark
	public void testSplittableRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(splittableRandom.get().nextInt());
	}

	@Benchmark
	public void testSplittableRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
		blackhole.consume(splittableRandom.get().nextInt(1, 100));
	}

	@Benchmark
	public void testThreadLocalRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(ThreadLocalRandom.current().nextInt());
	}

	@Benchmark
	public void testThreadLocalRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
		blackhole.consume(ThreadLocalRandom.current().nextInt(1, 100));
	}

	@Benchmark
	public void testDRBGSecureRandomInt(Blackhole blackhole) {
		blackhole.consume(drbg.get().nextInt());
	}

	@Benchmark
	public void testDRBGSecureRandomIntWithBound(Blackhole blackhole) {
		//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
		blackhole.consume(drbg.get().nextInt(1, 100));
	}

	public static void main(String[] args) throws RunnerException {
		Options opt = new OptionsBuilder().include(TestRandom.class.getSimpleName()).build();
		new Runner(opt).run();
	}
}

Java 17 之后的变化
#

之前的 API 的缺点
#

  1. 没有统一的接口:之前的 Random 还有 SplittableRandom 没有统一的继承类,以及统一的抽象接口,虽然 他们内部方法基本一致,互相替换的麻烦并不多,但是这样我们要想实现自己的随机算法也比较麻烦,因为没有统一的接口。
  2. ThreadLocalRandom 与未来的 Project Loom 的虚拟线程相性比较差。虚拟线程是可以不断创建的资源,在大量虚拟线程中如果还是用 ThreadLocalRandom 一一对应的话,会有随机性减弱的问题。所以,我们需要寻找新的实现方法,并且从现在开始为 Project Loom 铺路。

新的 API 定义
#

在 Java 17 中的 JEP 356: Enhanced Pseudo-Random Number Generators 中,统一了随机数生成器的接口,即 RandomGenerator

image

其中,针对我们前面提到的可跳跃性(可以通过简单计算,跳过序列环中的很多元素)抽象了对应的接口 JumpableGenerator,如果跳跃的步长希望更大一些的话,对应的就是 LeapableGenerator

针对我们前面提到的可拆分性(可以通过简单计算,拆分出生成完全不同序列的随机数生成器)也抽象了接口 SplitableGenerator

前面提到的算法,与对应的实现类是:

image

统一抽象后,我们就可以这样创建不同实现类型的随机数字生成器:

RandomGenerator random = RandomGeneratorFactory.of("Random").create();
RandomGenerator secureRandom = RandomGeneratorFactory.of("SecureRandom").create();
RandomGenerator splittableRandom = RandomGeneratorFactory.of("SplittableRandom").create();
RandomGenerator xoroshiro128PlusPlus = RandomGeneratorFactory.of("Xoroshiro128PlusPlus").create();
RandomGenerator xoshiro256PlusPlus = RandomGeneratorFactory.of("Xoshiro256PlusPlus").create();
RandomGenerator l64X256MixRandom = RandomGeneratorFactory.of("L64X256MixRandom").create();
RandomGenerator l64X128StarStarRandom = RandomGeneratorFactory.of("L64X128StarStarRandom").create();
RandomGenerator l64X128MixRandom = RandomGeneratorFactory.of("L64X128MixRandom").create();
RandomGenerator l64X1024MixRandom = RandomGeneratorFactory.of("L64X1024MixRandom").create();
RandomGenerator l32X64MixRandom = RandomGeneratorFactory.of("L32X64MixRandom").create();
RandomGenerator l128X256MixRandom = RandomGeneratorFactory.of("L128X256MixRandom").create();
RandomGenerator l128X128MixRandom = RandomGeneratorFactory.of("L128X128MixRandom").create();
RandomGenerator l128X1024MixRandom = RandomGeneratorFactory.of("L128X1024MixRandom").create();

每种算法实现的随机数生成器类的属性
#

1.Random:底层是基于线性同余算法生成的是一个 48 位的数字,选择的参数保证了每个数字都能随机出来,所以 Period 为 2^48。nextInt 和 nextLong 都不能做到完全均匀随机分布,因为生成的数字是 48 位的数字,nextInt 即取其中的 32 位,nextLong 是取两次组合在一起。之前的算法分析我们提到过,这种算法不能跳跃,不能分割

2.SplittableRandom: 底层基于 SplitMix 算法生成的一个 64 位的数字,通过 MurMurhash 保证了区间内每个数字都会出现(所以 Period 是 2^64),并且是完全均匀分布的。对于 nextInt 是一个 Period 内每个结果都会出现两次,对于 nextLong 是一个 Period 内每个结果都会出现一次。之前的算法分析我们提到过,这种算法不能跳跃,可以分割

3.Xoroshiro128PlusPlus:底层基于 Xoroshiro128++ 算法,使用两个 64 位数字记录状态,这两个数字不会同时为 0,这两个数字经过计算组合成为一个 64 位随机数。由于是两个 64 位数字组合而成,那么就有 2^64 * 2^64 = 2^128 种不同组合,两个数字不会同时为 0,那么就少了一种情况,所以一共是 2^128 - 1 种情况,所以 Period 是 2^128 - 1。之前的算法分析我们提到过,这种算法可以跳跃,不能分割

4.Xoshiro256PlusPlus:底层基于 Xoshiro256++ 算法,使用四个 64 位数字记录状态,这四个数字不会同时为 0,这四个数字经过计算组合成为一个 64 位随机数。由于是四个 64 位数字组合而成,那么就有 2^64 * 2^64 * 2^64 * 2^64 = 2^256 种不同组合,两个数字不会同时为 0,那么就少了一种情况,所以一共是 2^256 - 1 种情况,所以 Period 是 2^256 - 1。之前的算法分析我们提到过,这种算法可以跳跃,不能分割

5. L64X256MixRandom:底层基于 LXM 算法,使用一个 64 位数字保存线性同余的结果,4 个 64 位数字记录 Xoshiro 组合,线性同余有 2^64 种不同组合,Xoshiro2^256 - 1 种组合,一共 2^64(2^256 - 1) 种组合,所以 Period 是 2^64(2^256 - 1)。之前的算法分析我们提到过,这种算法可以分割,不能跳跃

其他的 LXM 实现类是类似的。

image

其实可以从每个算法的实现类的 RandomGeneratorProperties 注解上,看出他们的这些属性:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RandomGeneratorProperties {
    /**
     * 算法名称
     */
    String name();

    /**
     * 算法类别
     */
    String group() default "Legacy";

    /**
     * period 大小,由 i, j, k 三个数字描述,即:
     * period = (2^i - j) * 2^k
     */
    int i() default 0;
    int j() default 0;
    int k() default 0;

    /**
     * 均匀分布性,0 或者最大值则不是均匀分布,这个值描述在一个 Period 内每个数字出现多少次,但是不是那么精准的,会忽略一些小的偏差,例如 Xoroshiro128++ 认为每个数字出现 2^64 次而不是 2^64 - 1 次。
     */
    int equidistribution() default Integer.MAX_VALUE;

    /**
     * 是否是基于系统 Entropy(参考前面的 SEED 来源章节)的算法
     */
    boolean isStochastic() default false;

    /**
     * 是否是硬件辅助的算法
     */
    boolean isHardware() default false;
}

我们还可以通过下面的代码,查看每种实现的属性,同样的,也可以通过这些 API 对算法进行过滤,找到适合我们业务的实现类:

RandomGeneratorFactory.all()
	.map(fac -> fac.group()+":"+fac.name()
			+ " {"
			+ (fac.isSplittable()?" splitable":"")
			+ (fac.isStreamable()?" streamable":"")
			+ (fac.isJumpable()?" jumpable":"")
			+ (fac.isLeapable()?" leapable":"")
			+ (fac.isHardware()?" hardware":"")
			+ (fac.isStatistical()?" statistical":"")
			+ (fac.isStochastic()?" stochastic":"")
			+ " stateBits: "+fac.stateBits()
			+ " }"
	)
	.sorted().forEach(System.out::println);

输出是:

LXM:L128X1024MixRandom { splitable streamable statistical stateBits: 1152 }
LXM:L128X128MixRandom { splitable streamable statistical stateBits: 256 }
LXM:L128X256MixRandom { splitable streamable statistical stateBits: 384 }
LXM:L32X64MixRandom { splitable streamable statistical stateBits: 96 }
LXM:L64X1024MixRandom { splitable streamable statistical stateBits: 1088 }
LXM:L64X128MixRandom { splitable streamable statistical stateBits: 192 }
LXM:L64X128StarStarRandom { splitable streamable statistical stateBits: 192 }
LXM:L64X256MixRandom { splitable streamable statistical stateBits: 320 }
Legacy:Random { statistical stateBits: 48 }
Legacy:SecureRandom { stochastic stateBits: 2147483647 }
Legacy:SplittableRandom { splitable streamable statistical stateBits: 64 }
Xoroshiro:Xoroshiro128PlusPlus { streamable jumpable leapable statistical stateBits: 128 }
Xoshiro:Xoshiro256PlusPlus { streamable jumpable leapable statistical stateBits: 256 }

哪种算法最快(不用测也很明显)
#

这个根据之前的分析,应该还是 SplittableRandom 在单线程环境下最快,多线程环境下使用 ThreadLocalRandom 最快。新增的随机算法实现类,Period 约大需要的计算越多, LXM 的实现需要更多计算,加入这些算法是为了适应更多的随机应用,而不是为了更快。不过为了满足大家的好奇心,还是写了如下的代码进行测试,从下面的代码也可以看出,新的 RandomGenerator API 使用更加简便:

package prng;

import java.util.random.RandomGenerator;
import java.util.random.RandomGeneratorFactory;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

//测试指标为吞吐量
@BenchmarkMode(Mode.Throughput)
//需要预热,排除 jit 即时编译以及 JVM 采集各种指标带来的影响,由于我们单次循环很多次,所以预热一次就行
@Warmup(iterations = 1)
//线程个数
@Threads(10)
@Fork(1)
//测试次数,我们测试50次
@Measurement(iterations = 50)
//定义了一个类实例的生命周期,所有测试线程共享一个实例
@State(value = Scope.Benchmark)
public class TestRandomGenerator {
	@Param({
			"Random", "SecureRandom", "SplittableRandom", "Xoroshiro128PlusPlus", "Xoshiro256PlusPlus", "L64X256MixRandom",
			"L64X128StarStarRandom", "L64X128MixRandom", "L64X1024MixRandom", "L32X64MixRandom", "L128X256MixRandom",
			"L128X128MixRandom", "L128X1024MixRandom"
	})
	private String name;
	ThreadLocal<RandomGenerator> randomGenerator;
	@Setup
	public void setup() {
		final String finalName = this.name;
		randomGenerator = ThreadLocal.withInitial(() -> RandomGeneratorFactory.of(finalName).create());
	}

	@Benchmark
	public void testRandomInt(Blackhole blackhole) throws Exception {
		blackhole.consume(randomGenerator.get().nextInt());
	}

	@Benchmark
	public void testRandomIntWithBound(Blackhole blackhole) throws Exception {
		//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
		blackhole.consume(randomGenerator.get().nextInt(1, 100));
	}

	public static void main(String[] args) throws RunnerException {
		Options opt = new OptionsBuilder().include(TestRandomGenerator.class.getSimpleName()).build();
		new Runner(opt).run();
	}
}

测试结果:

Benchmark                                                  (name)   Mode  Cnt          Score           Error  Units
TestRandomGenerator.testRandomInt                          Random  thrpt   50  276250026.985 ± 240164319.588  ops/s
TestRandomGenerator.testRandomInt                    SecureRandom  thrpt   50    2362066.269 ±   1277699.965  ops/s
TestRandomGenerator.testRandomInt                SplittableRandom  thrpt   50  365417656.247 ± 377568150.497  ops/s
TestRandomGenerator.testRandomInt            Xoroshiro128PlusPlus  thrpt   50  341640250.941 ± 287261684.079  ops/s
TestRandomGenerator.testRandomInt              Xoshiro256PlusPlus  thrpt   50  343279172.542 ± 247888916.092  ops/s
TestRandomGenerator.testRandomInt                L64X256MixRandom  thrpt   50  317749688.838 ± 245196331.079  ops/s
TestRandomGenerator.testRandomInt           L64X128StarStarRandom  thrpt   50  294727346.284 ± 283056025.396  ops/s
TestRandomGenerator.testRandomInt                L64X128MixRandom  thrpt   50  314790625.909 ± 257860657.824  ops/s
TestRandomGenerator.testRandomInt               L64X1024MixRandom  thrpt   50  315040504.948 ± 101354716.147  ops/s
TestRandomGenerator.testRandomInt                 L32X64MixRandom  thrpt   50  311507435.009 ± 315893651.601  ops/s
TestRandomGenerator.testRandomInt               L128X256MixRandom  thrpt   50  187922591.311 ± 137220695.866  ops/s
TestRandomGenerator.testRandomInt               L128X128MixRandom  thrpt   50  218433110.870 ± 164229361.010  ops/s
TestRandomGenerator.testRandomInt              L128X1024MixRandom  thrpt   50  220855813.894 ±  47531327.692  ops/s
TestRandomGenerator.testRandomIntWithBound                 Random  thrpt   50  248088572.243 ± 206899706.862  ops/s
TestRandomGenerator.testRandomIntWithBound           SecureRandom  thrpt   50    1926592.946 ±   2060477.065  ops/s
TestRandomGenerator.testRandomIntWithBound       SplittableRandom  thrpt   50  334863388.450 ±  92778213.010  ops/s
TestRandomGenerator.testRandomIntWithBound   Xoroshiro128PlusPlus  thrpt   50  252787781.866 ± 200544008.824  ops/s
TestRandomGenerator.testRandomIntWithBound     Xoshiro256PlusPlus  thrpt   50  247673155.126 ± 164068511.968  ops/s
TestRandomGenerator.testRandomIntWithBound       L64X256MixRandom  thrpt   50  273735605.410 ±  87195037.181  ops/s
TestRandomGenerator.testRandomIntWithBound  L64X128StarStarRandom  thrpt   50  291151383.164 ± 192343348.429  ops/s
TestRandomGenerator.testRandomIntWithBound       L64X128MixRandom  thrpt   50  217051928.549 ± 177462405.951  ops/s
TestRandomGenerator.testRandomIntWithBound      L64X1024MixRandom  thrpt   50  222495366.798 ± 180718625.063  ops/s
TestRandomGenerator.testRandomIntWithBound        L32X64MixRandom  thrpt   50  305716905.710 ±  51030948.739  ops/s
TestRandomGenerator.testRandomIntWithBound      L128X256MixRandom  thrpt   50  174719656.589 ± 148285151.049  ops/s
TestRandomGenerator.testRandomIntWithBound      L128X128MixRandom  thrpt   50  176431895.622 ± 143002504.266  ops/s
TestRandomGenerator.testRandomIntWithBound     L128X1024MixRandom  thrpt   50  198282642.786 ±  24204852.619  ops/s

在之前的结果验证中,我们已经知道了 SplittableRandom 的在单线程中的性能最好,多线程环境下表现最好的是算法与它类似但是做了多线程优化的 ThreadLocalRandom.

如何选择随机算法
#

原则是,看你的业务场景,所有的随机组合到底有多少个,在什么范围内。然后找大于这个范围的 Period 中,性能最好的算法。例如,业务场景是一副扑克除了大小王 52 张牌,通过随机数决定发牌顺序:

  • 第一张牌:randomGenerator.nextInt(0, 52),从剩余的 52 张牌选
  • 第二张牌:randomGenerator.nextInt(0, 51),从剩余的 51 张牌选
  • 以此类推

那么一共有 52! 这么多结果,范围在 2^225 ~ 2^226 之间。如果我们使用的随机数生成器的 Period 小于这个结果集,那么某些牌的顺序,我们可能永远生成不了。所以,我们需要选择一个 Period > 54! 的随机数生成器。

未来展望
#

Project Loom 中的随机数生成器
#

如果 Project Loom 中没有针对 ThreadLocal 的优化,那么 ThreadLocalRandom 的随机性表现也会变差,因为虚拟线程是一个可以不断生成回收的类似于对象的东西,ThreadLocalRandom 并不能无限枚举下去。这时候我们可能需要使用固定的多个随机数生成器给所有的虚拟线程使用,而不是使用 ThreadLocalRandom:

ExecutorService vte = Executors.newVirtualThreadExecutor();
SplittableGenerator source = RandomGeneratorFactory.<SplittableGenerator>of("L128X1024MixRandom").create();
//分割出 128 个不同的随机数生成器
List<RandomGenerator> rngs = source.splits(128);

AtomicInteger i = new AtomicInteger(0);

vte.submit(() -> {
    long random = rngs.get(Math.abs(i.incrementAndGet() & 127)).nextLong();
    ...
});

Scoped Local 特性下的随机数生成器
#

Scoped Local 是一种更通用的域变量(例如 ThreadLocal 即当前线程域本地,Scoped Local 可以支持不同的域,包括虚拟线程,线程,方法域,包域等等),目前还没公布哪个版本会计划开发,不过按现在的爆料来看,我们可以使用下面这种方式更好的给每个线程分配随机数生成器:

private final static ScopeLocal<SplittableGenerator> rng_scope =
                                        ScopeLocal.inheritableForType(SplittableGenerator.class);

public static void main(String[] args) throws InterruptedException {

    SplittableGenerator rng1 =
            RandomGeneratorFactory.<SplittableGenerator>of("L128X1024MixRandom").create();
    SplittableGenerator rng2 =
            RandomGeneratorFactory.<SplittableGenerator>of("L32X64MixRandom").create();

    try (ExecutorService vte = Executors.newVirtualThreadExecutor()) {
        for (int i = 0; i < 5; i++) {
            ScopeLocal.where(rng_scope, rng1.split(), () -> { vte.submit(new Task()); });
        }
        for (int i = 0; i < 5; i++) {
            ScopeLocal.where(rng_scope, rng2.split(), () -> { vte.submit(new Task()); });
        }
    }
}

private static class Task implements Runnable {
    @Override public void run() {
        SplittableGenerator rng = rng_scope.get();
        System.out.println(rng);
    }
}

相关文章

全网最硬核 JDK 分析 - 3. Java 新内存模型解析与实验

·25486 字·51 分钟
从规范到实现深入探讨 Java 内存模型(JMM),涵盖内存屏障、CPU 重排序和 Java 9+ VarHandle API。了解一致性、因果性、共识性,以及 volatile、final 和其他同步机制在底层的工作原理,并提供实用的 jcstress 示例。