硬核 - Java 随机数相关 API 的演进与思考(下)

Posted 干货满满张哈希

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了硬核 - Java 随机数相关 API 的演进与思考(下)相关的知识,希望对你有一定的参考价值。

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

本系列会分为两篇,第一篇讲述 Java 随机数算法的演变思路以及底层原理与考量,之后介绍 Java 17 之前的随机算法 API 以及测试性能,第二篇详细分析 Java 17 之后的随机数生成器算法以及 API 和底层实现类以及他们的属性,性能以及使用场景,如何选择随机算法等等,并对 Java 的随机数对于 Java 的一些未来特性的适用进行展望

这是第二篇

Java 17 之后的变化

之前的 API 的缺点

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

新的 API 定义

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

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

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

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

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

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 实现类是类似的。

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

@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);
    

微信搜索“我的编程喵”关注公众号,每日一刷,轻松提升技术,斩获各种offer

以上是关于硬核 - Java 随机数相关 API 的演进与思考(下)的主要内容,如果未能解决你的问题,请参考以下文章

硬核 - Java 随机数相关 API 的演进与思考(下)

硬核

学与思的关系?(中国文化史)

学与思

学与思有什么关系

区块链技术硬核产品——HPB芯链硬件真随机数