如果你写过Java随机数相关代码,一定被这几件事困扰过:Random、ThreadLocalRandom、SplittableRandom功能重叠却各有各的“脾气”,方法签名不统一,换一种实现就要改一堆调用;想用一个Random参数的方法,传入SplittableRandom还因为类型不匹配编译失败。Java 17引入的java.util.random包把这一团乱麻彻底理顺了,核心就是那个RandomGenerator接口,以及它提供的整套随机数流(ints()、longs()、doubles())与生成器工厂体系。这篇文章我会从接口设计讲起,把家族图谱、流式API的实操写法、选型原则和踩坑记录一次性说透。适合刚升级到JDK 17想重构随机数代码的同学,也适合已经在用随机数流但没搞清楚底层机制的开发者。
1. 从混乱到统一:为什么Java需要一个RandomGenerator接口
1.1 Java 17之前的随机数生态
在JDK 17之前,Java的随机数API可以说是“各自为政”。java.util.Random是最老牌的生成器,特点是线程安全,但高并发场景下内部CAS竞争严重,性能并不好看;ThreadLocalRandom通过线程本地存储解决竞争问题,但它的构造器是私有的,只能通过ThreadLocalRandom.current()获取,而且不支持指定种子;SplittableRandom是并行计算场景的好手,支持split()方法把一个生成器分裂成多个子生成器,但它的类型和前两者几乎没有共同父类,代码层面很难做到“一处接收、处处使用”。
更别扭的是Math.random()。它内部只是一个静态的Random实例,看起来很省事,但你没法换种子、没法控制算法、也没法在单元测试里复现同一条序列。一旦业务代码到处写Math.random(),后续想统一更换随机源就只能全局搜索替换,痛苦程度谁改谁知道。
这还只是API层面的割裂。从算法层面看,老的Random用的是线性同余算法,状态空间只有48位,在某些模拟场景下周期和统计质量其实不太够用;而JDK 17新增的一批算法(LXM系列、Xoshiro系、Mersenne Twister等)在速度、周期、统计均匀性上各有取舍。如果没有一个统一接口,这些算法就只能作为“孤岛”存在,使用者很难在不改动业务代码的前提下切换底层算法。
1.2 RandomGenerator接口的设计骨架
java.util.random.RandomGenerator就是为解决上述割裂而生的顶层接口。它把“随机数生成器”抽象成一种可以注入、可以替换的组件。接口里定义了一组生成随机数的基本契约:nextLong()、nextInt()、nextDouble()、nextFloat()、nextBoolean()、nextGaussian()、nextExponential()等,还提供了ints()、longs()、doubles()这些返回随机数流的方法。
注意,这个接口并不是一个“序列迭代器”形态,它不要求生成器维护一个可遍历的序列状态。它只是约定:每次调用nextXxx(),你都会得到一个符合某种分布约定的随机值。至于底层是LCG、是LXM混合算法还是硬件真随机源,调用方完全不关心。这就是面向接口编程的典型好处——依赖倒置。
接口的另一个设计点在于,多数方法都有默认实现。比如nextInt(int bound)默认就是通过nextLong()的返回值截取或取模换算得到的。这意味着你想实现一个自己的生成器时,只需要实现一个最核心的方法(通常是nextLong()),其他几十个方法就自动“白送”了。如果想进一步优化某些方法的性能,再单独覆写也不迟。
1.3 用RandomGenerator替换旧代码
统一接口带来的第一个直接好处是方法签名收口。以前写一个“抽奖工具类”,参数只能写成Random random,想传SplittableRandom进去还得包一层适配器。现在只需要面向RandomGenerator写:
public class LotteryPicker { private final RandomGenerator rng; public LotteryPicker(RandomGenerator rng) { this.rng = rng; } public int pick(int bound) { return rng.nextInt(bound); } }传入new Random()可以,传入new SplittableRandom(42L)也可以,传入RandomGenerator.of("L64X128MixRandom")同样没问题。随机源彻底变成了一个可插拔依赖。这一点对测试尤其友好:测试时传固定种子的实现,线上传默认实现,业务代码一个字都不用改。
2. RandomGenerator家族图谱:有哪些算法可以选,怎么选
2.1 四个细分接口分别管什么
RandomGenerator是个总接口,但它下面还按能力拆了几个子接口,许多人第一次看源码会被这些名字绕晕,其实记起来很容易。
RandomGenerator.SplittableGenerator:支持split()分裂。调用一次split()就能得到一个和当前生成器互不干扰的新生成器,两者各自继续生成随机数,序列不会重叠(统计意义上)。这个能力在并行计算里非常关键,后面写蒙特卡洛模拟时会用到。
RandomGenerator.JumpableGenerator:支持jump()跳跃。相当于把随机序列“快进”一大段距离。这个有什么用?在分布式任务或并行分片场景里,你可以给每个分片分配一个不同的起点,让各个分片运行在互不重叠的序列段上。
RandomGenerator.LeapableGenerator:比jump()更快、跳得更远,适合更大规模的分片,比如把整个序列切成几百个超大区块。它和JumpableGenerator的关系,可以理解为一个是“翻页”,一个是“快进到整部剧的下一季”。
RandomGenerator.StreamableGenerator:提供rngs()方法,直接返回一个“生成器流”。也就是说,你可以一次性产出一批独立生成器,配合Stream API做并行计算。
这些子接口继承自RandomGenerator,实际使用中如果你只需要“能生成随机数”这个最基础的能力,声明为RandomGenerator就够;但如果后面要split()或jump(),就要把类型声明为对应子接口,否则编译器不让你调用。
2.2 内置实现类与算法实现
JDK 17里可以直接用的生成器实现主要有这么几类。
Random:老牌实现,线程安全,状态空间48位,适合单线程或低频调用场景。不建议在高并发模拟里大面积使用。
ThreadLocalRandom:每个线程持有独立实例,几乎没有竞争,吞吐量高。但它不适合需要“可显式指定种子”的场景,构造器不公开,current()拿到的实例也无法重置种子。它也会出现在RandomGenerator的家族里,因为ThreadLocalRandom实现了RandomGenerator接口。
SplittableRandom:并行友好,支持split(),指定种子构造后也可以复现序列。它的算法基于混合拆分(SplitMix)的变体,统计质量在大量测试里表现稳定。ForkJoinPool这类并行框架里它是最常被推荐的实现之一。
SecureRandom:唯一的加密安全生成器,适合令牌、密钥、盐值等场景。它性能远不如普通生成器,千万不要在业务逻辑里滥用。
LXM系列(例如L64X128MixRandom、L64X256MixRandom、L128X256MixRandom等):这是JDK 17新引入的算法家族,名字里的字母代表状态空间的构成:L表示线性同余部分,X表示Xorshift部分,M表示最终混合函数。数字表示状态位宽。它们的周期极长、统计性能好、速度也快,是替代旧Random的首选。
MersenneTwister:经典算法,状态空间大、周期极其长,但初始化开销偏大,生成速度一般。如果你需要和某些历史系统保持序列兼容,可以考虑。
Xoshiro和Xoroshiro系:在现代硬件上速度非常快,状态小,特别适合需要超大随机数吞吐量的场景。它们也是模拟和游戏领域常见的选择。
| 实现 | 线程安全 | 支持split | 可指定种子 | 速度 | 推荐场景 |
|---|---|---|---|---|---|
| Random | 是 | 否 | 是 | 中 | 兼容老代码、低频调用 |
| ThreadLocalRandom | 是(线程隔离) | 否 | 否 | 高 | 高并发统计、无复现需求 |
| SplittableRandom | 否(仅单线程安全) | 是 | 是 | 高 | 并行模拟、分片并行 |
| LXM系列 | 否 | 视具体实现 | 是 | 高 | 大多数新代码首选 |
| SecureRandom | 是 | 否 | 否(不可依赖种子复现) | 低 | 加密、安全令牌 |
2.3 用RandomGeneratorFactory动态发现算法
RandomGeneratorFactory是这个体系里另一个容易忽略但非常实用的入口。它允许你通过算法名字符串来查找和创建生成器。比如:
RandomGenerator rng = RandomGenerator.of("L64X256MixRandom");RandomGenerator.of(String)在JDK 17里已经提供了。如果你想看看当前JDK版本到底内置了哪些算法,跑一下这段代码:
RandomGeneratorFactory.all() .forEach(f -> System.out.println( "name = " + f.name() + ", group = " + f.group()));不同JDK版本列出的算法会略有不同,但你可以直观地看到每个算法的分组(group),比如LXM、Xoshiro、Mersenne Twister等。这个能力让“配置化选算法”成为可能——把算法名字写进配置文件,重启就能切换随机数策略,而业务代码完全不用动。
更进阶的用法是自定义算法并注册到SPI机制里。你实现一个RandomGenerator接口,然后在META-INF/services/java.util.random.RandomGenerator文件里声明实现类全限定名,JDK就能通过RandomGeneratorFactory.all()发现它。这种方式适合技术团队做大一统的“随机数平台”,在内部统一注册自定义生成器。
3. 随机数流实操:ints、longs、doubles的正确打开方式
3.1 三个流方法的参数语义
RandomGenerator最吸引人的特性之一是随机数流。它直接把“生成大量随机数”和Stream API结合起来,让筛选、聚合、并行处理都变得很自然。
方法签名主要分两类:不带streamSize的返回无限流,带streamSize的返回有限流。
RandomGenerator rng = RandomGenerator.of("L64X128MixRandom"); // 有限流:10个随机int rng.ints(10).forEach(System.out::println); // 无限流:所有int rng.ints().limit(10).forEach(System.out::println); // 带上下界的有限流:[1, 7),左闭右开 rng.ints(10, 1, 7).forEach(System.out::println); // 带上下界的无限流,必须自己limit rng.ints(1, 7).limit(10).forEach(System.out::println);longs()和doubles()的用法完全对应。doubles()不带范围时生成[0.0, 1.0)区间的均匀分布,带范围时生成[origin, bound)区间的值。
这里藏着一个高频坑:很多初学者以为ints(origin, bound)就是“生成几个数”,其实它返回的是无限流。不写streamSize又不加limit()的话,流会一直往下游吐数据,最终把内存撑爆,甚至让程序“卡死”。我自己就见过同事在一个批处理任务里写rng.ints(0, 100).collect(...),以为是生成100个数字,结果直接把应用内存打满。正确的姿势要么给streamSize,要么先limit()。
另一个容易被忽略的细节是ints()不带参数时,生成范围覆盖整个int域,也就是可能生成负数。如果你要的是一个正整数ID或一个非负索引,请务必显式设置下界为0。
rng.ints(100, 0, 10_000_000) // 非负,正确 rng.ints(10_000_000, 0, Integer.MAX_VALUE) // 注意不能直接用 Integer.MAX_VALUE 当上界?实际上对于ints的范围,左边界必须有符号且在[-2^31, 2^31-1]内可表示,右边界同理且必须大于左边界。如果你需要上界正好达到Integer.MAX_VALUE,在有限个数场景下建议用无限流加过滤,或换个思路用longs归一化,避免边界计算的纠结。
3.2 并行随机数流:SplittableGenerator的正确姿势
随机数流天然适合并行:生成大量独立样本,然后合并统计结果。但这里有一个性能陷阱:如果你让一个普通的RandomGenerator实例在多线程并行流里被反复调用,它内部需要处理并发问题,要么加锁要么CAS,吞吐量会明显下降。
正确的并行姿势是使用SplittableGenerator.splits(),先分裂出一批子生成器,让每个线程拿到一个独立生成器,并行时完全没有共享竞争。下面的例子用随机数流估算圆周率:
long total = 2_000_000; RandomGenerator.SplittableGenerator seeder = RandomGenerator.SplittableGenerator.of("L64X128MixRandom"); long inside = seeder.splits(total) .parallel() .mapToLong(sub -> { double x = sub.nextDouble(-1.0, 1.0); double y = sub.nextDouble(-1.0, 1.0); return (x * x + y * y <= 1.0) ? 1L : 0L; }) .sum(); double pi = 4.0 * inside / total; System.out.printf("pi ≈ %.4f%n", pi);seeder.splits(total)会生成一个包含total个子生成器的流,每个子生成器负责自己的随机点。parallel()之后,公共线程池会把不同的子生成器分发到不同线程执行,彼此之间没有任何共享状态。
这里要特别说明一点:splits()流的元素是RandomGenerator,而生成器本身不一定是线程安全的,但因为每个线程使用的是不同实例,所以不存在并发访问问题。如果硬要把同一个生成器塞进多个线程,正确性上可能没问题(许多实现内部是线程安全的),但性能会回到“共享争用”的老路,并行优势就没了。
3.3 随机数流的业务场景延展
除了蒙特卡洛模拟,随机数流在业务代码里也很有用。比如生成测试数据:
List<Double> samples = rng.doubles(1000, 0.0, 1.0) .boxed() .toList();模拟掷骰子并统计分布:
Map<Integer, Long> counts = rng.ints(100_000L, 1, 7) .boxed() .collect(Collectors.groupingBy( Integer::intValue, Collectors.counting()));再比如从一批候选ID中随机抽取指定数量的ID:
List<Integer> ids = rng.ints(50, 0, userIds.size()) .distinct() .limit(5) .mapToObj(userIds::get) .toList();注意distinct()在无限流上是可以用的,但要配合limit()控制数量上限,否则去重计算可能永远等不到足够的“不重复样本”。
4. 选型与落地:生产环境随机数方案怎么定
4.1 一张表搞定生成器选择
很多人问,项目里到底该用哪个生成器?我的建议很直接:
如果你在写新代码,且没有特殊要求,直接用RandomGenerator.of("L64X128MixRandom"),或者干脆用RandomGenerator.getDefault()。这个算法在统计质量、速度、状态空间上都很均衡,是JDK团队的推荐选择。
RandomGenerator rng = RandomGenerator.of("L64X128MixRandom");如果你的场景是“每次请求并发很高、只生成几个随机数、不需要复现”,用ThreadLocalRandom.current()最划算。
如果你的场景是“ForkJoin并行计算、需要分片”,用SplittableRandom或带SplittableGenerator接口的LXM生成器。
如果你的场景是“生成验证码、令牌、盐值”,别想别的,直接SecureRandom。普通生成器容易被预测,攻击者只要拿到连续几个输出值,就能推导出后续序列,这在安全场景下是致命的。
4.2 种子管理与可重现性
生产环境里,一个很重要的需求是“复现随机序列”。最经典的做法就是固定种子:
long seed = 0x5EED1E5EEDL; RandomGenerator rng = RandomGeneratorFactory.of("L64X256MixRandom") .create(seed);相同算法、相同种子、相同执行进程,生成的序列完全一致。这在单元测试、回放调试、AB实验分组逻辑中非常有用。某次线上问题如果和随机数有关,只要能拿到当时的种子,就能完整重放整个随机过程。
但记住,可重现性是有限定条件的。并行流里用split()之后,子生成器的派发顺序可能随线程调度变化,所以哪怕两个进程用同一种子,只要并行度或调度不同,最终结果也可能不同。可复现序列要保证时,尽量在单线程场景使用,或者在并行场景下也把“子任务如何分配”固定下来。
另外还有一个细节:SecureRandom不要指望种子复现。即使你构造时传了种子,不同JDK版本或不同算法 provider 下,序列也可能完全不可复现,因为它可能混入系统熵。
4.3 别把CPU周期浪费在安全随机上
我在不少项目里见过这样的代码:每次生成一个红包ID、一个日志traceId,都去调用SecureRandom。理论上这没有错,但性能代价相当大。SecureRandom需要从操作系统收集熵,生成速度比SplittableRandom慢一个数量级以上。如果系统每秒要生成几万个随机ID,这个差距会被放大得非常明显。
建议做一次性能摸底,分清“安全用途”和“统计用途”。生成不可预测的认证令牌用SecureRandom;生成模拟样本、均匀抽签、随机分桶,用普通生成器就足够了。不要用高射炮打蚊子。
项目里的依赖注入做法也更规范:在配置里声明随机源类型和种子,业务模块只面向RandomGenerator接口编程。以后想换算法,改配置即可,不需要动任何一行业务代码。
5. 常见问题与排查:随机数流项目里的坑我都帮你踩过
5.1 症状、原因和解法速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 程序启动后内存暴涨 | 使用了无streamSize的ints()/longs()/doubles(),又没limit() | 优先指定数量,或加limit() |
| 并行随机流速度反而更慢 | 多个线程共享同一个RandomGenerator实例,发生严重竞争 | 改用SplittableGenerator.splits()为每个线程分配独立生成器 |
| 固定种子下结果仍然无法复现 | 并行流的分裂顺序不稳定,或使用了SecureRandom | 限制并行流里的随机源可复现范围,安全场景不要依赖种子 |
| 生成了一批负数ID | 使用了ints()全量范围 | 显式设置ints(size, 0, bound) |
调用nextInt(bound)抛异常 | bound传了0或负数 | 检查边界参数,bound必须为正数 |
测试环境无法构造ThreadLocalRandom | 构造器私有 | 用ThreadLocalRandom.current(),或把随机源抽象成RandomGenerator接口后传测试实现 |
| 全站随机数生成的token被攻击者猜中 | 使用了普通Random | 换成SecureRandom,普通生成器不能用于安全场景 |
5.2 我在实际项目里踩过的三个坑
第一个坑是无限流。当时要给一批模拟订单生成随机的创建时间偏移量,我写的是:
rng.ints(0, 86400).forEach(offset -> ...)写的时候觉得(0, 86400)已经是“范围”, 数字还在,完全没意识到这是一个无限流。线上压测时CPU跑满,内存持续上涨,排查半天才发现问题。后来团队里定了一条规矩:任何随机数流,要么显式传streamSize,要么在第一个操作里写limit(),否则代码审查不过。这条规矩后来救了不少人。
第二个坑是并行流里共享了同一个生成器。做一个大样本随机模拟,代码长这样:
Random rng = new Random(42L); rng.ints(count).parallel().forEach(...)Random本身线程安全,并行流不会崩,但性能奇差,多个线程在同一个原子变量上疯狂CAS。后来改成SplittableRandom配合splits(),整个模拟时间降了一个数量级。从那以后我写并行随机流前都会先问自己一句:每个线程拿到的随机源是不是独立的?
第三个坑更隐蔽。当时做一个AB实验分流服务,用固定种子给用户ID分配实验组。一开始在单机上是好的,后来扩展到多节点后发现同一用户在不同节点拿到的分组不一致。查了半天才发现,问题不在随机数算法,而在每个节点并行流的分词逻辑不同,没想到种子被“分流”成了不同的局部序列。这提醒我:需要全局可复现的分流场景,不要依赖并行流内部的分裂逻辑,而是用单线程确定性的方式直接生成结果,或者把分裂顺序在流外显式控制好。
6. 最后一点个人体会
从Java 17开始,随机数API才算真正“能打”。RandomGenerator不仅统一了所有生成器的入口,还让随机数变成了一种可以配置、注入、测试的普通依赖,配合ints()、longs()、doubles()这些流式方法,写随机数相关代码的体验比以前舒服太多了。
我个人现在的习惯是:所有新代码的入参一律写RandomGenerator或SplittableGenerator,默认实现用L64X128MixRandom,只有安全场景才换成SecureRandom。同时会在项目配置里固定一个默认种子,方便本地复现问题。最后再分享一个小技巧:如果你需要生成一批元素组成的列表,别用循环一个个add,直接rng.ints(100, 0, bound).boxed().toList()一行搞定,既简洁又不容易出并发问题。随机数流的这些细节看着小,用对了,代码质量和维护效率都完全不一样。