硬核 - 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​​种不同组合,​​Xoshiro​  ​2^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芯链硬件真随机数