☰
ThreadLocalRandom高并发随机数生成原理与实战优化
2026/10/12 23:21:20 网站建设 项目流程

1. 为什么需要重新认识ThreadLocalRandom

1.1 从一次压测事故说起

前阵子帮一个朋友排查线上问题,场景很典型:一个订单号生成服务,QPS 一上来就出现大量线程阻塞,CPU 飙高但吞吐上不去。用工具抓了线程栈,发现一堆线程卡在java.util.Random的nextInt方法上,准确说是卡在内部的AtomicLongCAS 自旋上。这个坑其实不新鲜,但每年还是能看到有人踩。

问题的根源在于Random的设计。它内部维护一个AtomicLong seed,每次生成随机数都要通过 CAS 去更新种子。单线程下没问题,多线程并发时,多个线程同时 CAS 同一个变量,失败的线程不断重试,这就是所谓的伪共享和 CAS 自旋带来的性能塌陷。线程越多,竞争越激烈,性能反而越差。

ThreadLocalRandom就是 JDK 7 引入用来解决这个问题的。它的核心思路很直接:既然多线程争抢同一个种子是瓶颈,那就让每个线程自己持有一份种子,各算各的,互不干扰。听起来简单,但里面有不少细节值得掰开讲。

1.2 ThreadLocalRandom到底解决了什么问题

一句话概括:在高并发场景下提供比 Random 更高吞吐、更低延迟的随机数生成能力。

它适合谁用?任何在多线程环境里需要生成随机数的人。比如:

  • 做压测工具、需要生成大量随机请求参数的开发者
  • 写游戏服务器、需要给玩家掉落随机奖励的后端工程师
  • 做分布式 ID、验证码、抽奖、洗牌等业务的人
  • 单纯想搞清楚 JDK 并发包里这个类到底怎么用的人

不适合谁?单线程场景下,Random和ThreadLocalRandom性能差异不大,用哪个都行。但如果你已经养成了习惯,统一用ThreadLocalRandom也没坏处,代码更统一。

这里要强调一个常见误区:很多人以为ThreadLocalRandom是ThreadLocal<Random>的封装。不是。它并没有为每个线程创建一个Random对象,而是直接把种子存在Thread对象自己的一个字段里,通过 Unsafe 直接读写。这个设计比ThreadLocal更轻量,后面会详细拆。

1.3 本文的讲解路线

我会按这个顺序展开:先讲清楚Random的瓶颈到底在哪,再拆ThreadLocalRandom的内部结构,然后给出可直接抄的用法和参数计算,接着是几个真实场景的实操,最后是踩坑记录和排查表。全程用大白话,代码可以直接复制跑。

2. 从Random的瓶颈看ThreadLocalRandom的设计动机

2.1 Random的种子竞争到底有多严重

先看Random的核心代码逻辑(简化版):

public 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)); }

关键就在这个do-while循环。compareAndSet失败就重来,直到成功。单线程时一次就过,多线程时可能重试几十上百次。

我做过一个粗略的测试,在 8 核机器上,16 个线程各自生成 100 万个随机整数:

实现方式耗时(毫秒)相对性能
Random(共享实例)约 42001x
ThreadLocalRandom约 380约 11x
每线程独立 Random约 400约 10.5x

可以看到,竞争激烈时Random慢了十倍以上。这个差距在 QPS 高的服务里就是致命的。

注意:这个测试数据因机器和 JDK 版本而异,但数量级的差距是稳定的。JDK 8 之后Random内部也做过一些优化,但 CAS 竞争的本质没变。

2.2 每线程一个Random行不行

有人会想,那我用ThreadLocal<Random>不就行了?每个线程一个Random实例,各用各的种子,不就没竞争了?

思路对,但有两个问题:

第一,内存开销。每个Random对象虽然不大,但线程多了也是负担。而且ThreadLocal本身有ThreadLocalMap的维护成本。

第二,种子初始化问题。如果每个线程创建Random时都用默认构造,它们可能拿到相同的种子(默认用System.nanoTime()或类似方式),导致不同线程生成相同的随机序列。这是个隐蔽的 bug,很多人不知道。

ThreadLocalRandom的做法更聪明:种子直接存在Thread对象的threadLocalRandomSeed字段里,不需要额外的对象。初始化时用mix64之类的函数把线程 ID、时间戳等混在一起,保证不同线程种子不同。

2.3 ThreadLocalRandom的核心字段结构

打开 JDK 源码,ThreadLocalRandom继承自Random,但重写了几乎所有关键方法。它有几个关键点:

public class ThreadLocalRandom extends Random { // 这个字段在Thread类里,不在ThreadLocalRandom里 // Thread.threadLocalRandomSeed // Thread.threadLocalRandomProbe // Thread.threadLocalRandomSecondarySeed private static final long SEED = 0x9E3779B97F4A7C15L; private static final long PROBE = 0x9E3779B97F4A7C15L; // 单例 private static final ThreadLocalRandom instance = new ThreadLocalRandom(); public static ThreadLocalRandom current() { if (UNSAFE.getInt(Thread.currentThread(), PROBE) == 0) localInit(); return instance; } }

注意current()方法:它先检查当前线程的threadLocalRandomProbe是否为 0,如果是就初始化。这个 probe 字段还用于ThreadLocal的哈希计算,所以初始化时会一并设置。

instance是个单例,所有线程共用同一个ThreadLocalRandom对象。但别担心线程安全,因为真正的种子在每个线程自己的Thread对象里,instance只是个无状态的入口。

2.4 为什么用Unsafe而不是普通字段

你可能会问,既然种子在Thread对象里,为什么不直接写Thread.currentThread().seed,非要用Unsafe?

原因有两个:

第一,Thread类的这些字段是包级私有的,ThreadLocalRandom在java.util.concurrent包下,访问不到。用Unsafe可以绕过访问控制。

第二,性能。Unsafe的getLong/putLong是直接内存操作,比通过方法调用或反射快得多。在随机数这种高频调用的场景,每一纳秒都值得抠。

实操心得:如果你在 JDK 17 以上版本看源码,会发现ThreadLocalRandom的实现有变化,因为Unsafe的部分方法被标记为废弃,JDK 引入了VarHandle来替代。但对外 API 没变,用法完全一样。

3. ThreadLocalRandom的正确用法与参数计算

3.1 基本用法:三行代码搞定

最简单的用法:

import java.util.concurrent.ThreadLocalRandom; public class Demo { public static void main(String[] args) { // 获取当前线程的实例 ThreadLocalRandom random = ThreadLocalRandom.current(); // 生成 [0, 100) 的随机整数 int num = random.nextInt(100); // 生成 [10, 50) 的随机整数 int num2 = random.nextInt(10, 50); // 生成 [0.0, 1.0) 的随机 double double d = random.nextDouble(); // 生成 [5.0, 10.0) 的随机 double double d2 = random.nextDouble(5.0, 10.0); System.out.println(num + ", " + num2 + ", " + d + ", " + d2); } }

关键点:永远通过ThreadLocalRandom.current()获取实例,不要用new ThreadLocalRandom()。构造函数是私有的,你也 new 不了,但有些人会试图用反射,没必要。

3.2 区间参数的边界规则

这是最容易出错的地方。nextInt(origin, bound)的规则是:

  • 返回值的范围是[origin, bound),左闭右开
  • 如果origin >= bound,抛IllegalArgumentException
  • origin和bound的差值不能超过Integer.MAX_VALUE

举个例子,你想生成 1 到 6 的骰子点数:

// 正确:nextInt(1, 7) 返回 1~6 int dice = ThreadLocalRandom.current().nextInt(1, 7); // 错误:nextInt(1, 6) 返回 1~5,永远掷不出 6 int wrong = ThreadLocalRandom.current().nextInt(1, 6);

这个左闭右开的规则和Random.nextInt(bound)一致,但很多人第一次用双参数版本时会想当然地以为两边都包含。

对于nextLong,规则一样,但要注意nextLong(long bound)在 JDK 8 之前没有,JDK 8 才加上。如果你用的是 JDK 7,只能自己用nextLong()取模,但取模会有偏差,后面会讲。

3.3 生成指定范围随机数的通用公式

有时候你需要生成一个[min, max]闭区间的随机数,通用公式是:

// 生成 [min, max] 闭区间的整数 int min = 10, max = 20; int result = ThreadLocalRandom.current().nextInt(min, max + 1);

对于long:

long min = 1000000L, max = 9999999L; long result = ThreadLocalRandom.current().nextLong(min, max + 1);

对于double,因为浮点数本身是连续的,闭区间和开区间的区别可以忽略,直接用nextDouble(min, max)即可。

注意:当max是Integer.MAX_VALUE时,max + 1会溢出变成负数。这种情况要特殊处理,比如用nextInt()然后映射,或者用long中转。

3.4 种子初始化与probe的关系

前面提到current()会检查 probe 是否为 0。这个 probe 字段其实和ThreadLocal的哈希有关。ThreadLocal在计算索引时,会用threadLocalRandomProbe作为哈希值的一部分。所以ThreadLocalRandom初始化时,会顺便把这个 probe 设置好,避免ThreadLocal再单独计算。

这个设计有个副作用:如果你在代码里大量使用ThreadLocal,ThreadLocalRandom的初始化可能会影响ThreadLocal的哈希分布。不过实际影响很小,不用太担心。

初始化种子的逻辑大致是:

static final void localInit() { int p = probeGenerator.addAndGet(PROBE_INCREMENT); int probe = (p == 0) ? 1 : p; // 跳过 0 long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT)); Thread t = Thread.currentThread(); UNSAFE.putLong(t, SEED, seed); UNSAFE.putInt(t, PROBE, probe); }

mix64是个雪崩函数,把输入打散,保证种子分布均匀。seeder是个AtomicLong,每次加一个增量,保证不同线程拿到的初始值不同。

4. 高并发场景下的实操与性能验证

4.1 压测代码:对比Random和ThreadLocalRandom

写个简单的压测,你可以直接跑:

import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class RandomBenchmark { private static final int THREADS = 16; private static final int COUNT = 1_000_000; public static void main(String[] args) throws Exception { // 测试 Random Random sharedRandom = new Random(); long t1 = test(() -> { for (int i = 0; i < COUNT; i++) { sharedRandom.nextInt(1000); } }); System.out.println("Random 共享实例: " + t1 + " ms"); // 测试 ThreadLocalRandom long t2 = test(() -> { ThreadLocalRandom random = ThreadLocalRandom.current(); for (int i = 0; i < COUNT; i++) { random.nextInt(1000); } }); System.out.println("ThreadLocalRandom: " + t2 + " ms"); } private static long test(Runnable task) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(THREADS); CountDownLatch latch = new CountDownLatch(THREADS); long start = System.currentTimeMillis(); for (int i = 0; i < THREADS; i++) { pool.submit(() -> { task.run(); latch.countDown(); }); } latch.await(); long end = System.currentTimeMillis(); pool.shutdown(); return end - start; } }

跑下来你会看到,ThreadLocalRandom通常快 5 到 15 倍,线程越多差距越大。

4.2 在业务代码中替换Random的步骤

如果你有个老项目,里面到处是new Random()或共享的Random实例,想换成ThreadLocalRandom,按这个步骤来:

第一步,全局搜索new Random()和Random类型的字段声明。把共享的Random字段标记出来。

第二步,对于每个使用点,判断是否在多线程环境。如果是单线程(比如某个只在启动时跑一次的工具方法),可以不动。如果是多线程,替换成ThreadLocalRandom.current()。

第三步,注意方法签名的变化。Random的nextInt(int bound)和ThreadLocalRandom一样,但Random没有nextInt(int origin, int bound),所以原来用nextInt(bound)再手动加偏移的代码,可以简化成双参数版本。

第四步,跑一遍单元测试,重点测边界值。因为左闭右开的规则,原来nextInt(10) + 1生成 1~10,换成nextInt(1, 11)才是对的,别写成nextInt(1, 10)。

4.3 生成随机字符串的实战

一个常见需求:生成指定长度的随机字符串,用于 token、验证码等。用ThreadLocalRandom可以这样写:

public class RandomStringUtil { private static final char[] CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); public static String randomString(int length) { if (length <= 0) { throw new IllegalArgumentException("length must be positive"); } ThreadLocalRandom random = ThreadLocalRandom.current(); char[] buf = new char[length]; for (int i = 0; i < length; i++) { buf[i] = CHARS[random.nextInt(CHARS.length)]; } return new String(buf); } public static void main(String[] args) { System.out.println(randomString(16)); } }

这个实现比用Random快,而且没有线程安全问题。注意CHARS.length是 62,nextInt(62)返回 0~61,正好覆盖所有字符。

实操心得:如果对安全性有要求(比如生成密码重置 token),不要用ThreadLocalRandom,它用的是线性同余算法,不是密码学安全的。这种场景要用SecureRandom。ThreadLocalRandom适合的是性能敏感、安全要求不高的场景。

4.4 洗牌算法的正确实现

洗牌是随机数的经典应用。用ThreadLocalRandom实现 Fisher-Yates 洗牌:

public static <T> void shuffle(List<T> list) { ThreadLocalRandom random = ThreadLocalRandom.current(); for (int i = list.size() - 1; i > 0; i--) { int j = random.nextInt(i + 1); // 交换 i 和 j T tmp = list.get(i); list.set(i, list.get(j)); list.set(j, tmp); } }

注意nextInt(i + 1)返回 0~i,包含 i 本身。如果写成nextInt(i),最后一个元素永远不会被交换到,洗牌就不均匀了。这个 bug 很隐蔽,测试时不容易发现,但理论上会导致某些排列的概率为 0。

5. 常见问题与排查技巧实录

5.1 为什么我的随机数每次都一样

这是新手最常遇到的问题。原因通常有两个:

第一,在循环里反复调用ThreadLocalRandom.current()但每次都在新线程里。如果每个线程只生成一个随机数就结束,而线程的种子初始化又碰巧用了相同的时间戳,可能生成相同的值。不过这种情况很少见,因为mix64会把时间戳打散。

第二,更常见的是,你在单线程里测试,但种子初始化用了固定的值。检查一下是不是用了setSeed。ThreadLocalRandom重写了setSeed,调用它会抛UnsupportedOperationException,所以不可能是这个原因。

真正的原因往往是:你在多个线程里用了同一个Random实例,而且没有正确同步。或者你在测试时用了固定的种子。

排查方法:打印每个线程的Thread.currentThread().getId()和生成的随机数,看是不是线程 ID 相同导致种子相同。

5.2 nextInt(bound)的边界陷阱

前面提过左闭右开,这里再强调一个具体场景。假设你要从数组里随机取一个元素:

String[] arr = {"a", "b", "c"}; // 正确 String s = arr[ThreadLocalRandom.current().nextInt(arr.length)]; // 错误:会数组越界 String wrong = arr[ThreadLocalRandom.current().nextInt(arr.length + 1)];

nextInt(arr.length)返回 0~length-1,正好是合法索引。加 1 就溢出了。

另一个陷阱是负数。nextInt(-5)会抛IllegalArgumentException,因为 bound 必须是正数。如果你想生成负数范围的随机数,用双参数版本:nextInt(-10, 0)生成 -10~-1。

5.3 性能没提升反而下降的情况

有人换了ThreadLocalRandom后发现性能没变甚至更差,可能的原因:

第一,你的瓶颈根本不在随机数生成上。如果业务逻辑里有更耗时的操作(比如数据库查询、网络调用),随机数的性能差异被淹没了。

第二,你在循环里反复调用ThreadLocalRandom.current()。虽然current()很快,但每次调用都有一次 probe 检查和可能的初始化。正确做法是在循环外获取一次,循环内复用:

// 好 ThreadLocalRandom random = ThreadLocalRandom.current(); for (int i = 0; i < 10000; i++) { random.nextInt(100); } // 差 for (int i = 0; i < 10000; i++) { ThreadLocalRandom.current().nextInt(100); }

第三,你的线程数太少。单线程或双线程下,Random的 CAS 竞争不激烈,两者性能接近。

5.4 常见问题速查表

问题现象可能原因解决方法
抛 UnsupportedOperationException调用了 setSeed不要调用 setSeed,ThreadLocalRandom 不支持
随机数每次都一样多线程共享 Random 且未同步改用 ThreadLocalRandom
数组越界nextInt(bound) 边界理解错误记住左闭右开,索引用 nextInt(length)
性能没提升瓶颈不在随机数或循环内反复 current()循环外获取实例,检查真实瓶颈
生成的随机数有规律用了取模且模数不是 2 的幂用 nextInt(origin, bound) 代替取模
多线程下结果重复种子初始化冲突确保用 current() 而非 new

5.5 取模偏差问题

如果你用nextInt() % n来生成 0~n-1 的随机数,会有偏差。因为nextInt()返回的范围是[-2^31, 2^31-1],总共 2^32 个值,如果 n 不是 2 的幂,取模后某些余数的概率会略高。

正确做法是用nextInt(n),它内部用了拒绝采样,保证均匀分布。虽然偶尔会多算几次,但正确性有保障。

// 有偏差 int biased = Math.abs(ThreadLocalRandom.current().nextInt()) % 100; // 无偏差 int unbiased = ThreadLocalRandom.current().nextInt(100);

这个偏差在 n 较小时不明显,但在抽奖、洗牌等场景可能导致概率不均,被用户感知到。

6. 进阶话题与版本差异

6.1 JDK 7到JDK 17的演进

ThreadLocalRandom在 JDK 7 引入,之后几个版本有一些调整:

JDK 8 增加了nextLong(long bound)和nextLong(long origin, long bound),之前只有nextLong()。

JDK 9 开始,Unsafe的部分方法被标记为废弃,ThreadLocalRandom内部逐步迁移到VarHandle。但对外 API 没变。

JDK 17 里,ThreadLocalRandom的实现已经用VarHandle替代了大部分Unsafe调用。性能基本持平,但代码更安全。

如果你维护的是老项目,用 JDK 7 的话,注意nextLong的双参数版本不可用,需要自己实现。

6.2 和SplittableRandom的区别

JDK 8 还引入了SplittableRandom,它和ThreadLocalRandom的区别是:

  • ThreadLocalRandom是线程绑定的,每个线程一个种子,适合多线程各自生成随机数
  • SplittableRandom是可分裂的,可以从一个实例派生出子实例,适合 fork/join 框架和并行流

如果你在用并行流,SplittableRandom可能更合适。但普通的线程池场景,ThreadLocalRandom就够了。

6.3 在并行流中的使用注意

并行流里用ThreadLocalRandom是可以的,因为每个工作线程会调用current()获取自己的实例。但要注意,并行流的线程是 ForkJoinPool 的公共池,线程可能被复用,种子也会被复用。这通常没问题,但如果你需要可复现的随机序列,就要用SplittableRandom并显式传递种子。

// 并行流里用 ThreadLocalRandom List<Integer> list = IntStream.range(0, 1000) .parallel() .map(i -> ThreadLocalRandom.current().nextInt(100)) .boxed() .collect(Collectors.toList());

这段代码是线程安全的,可以放心用。

6.4 种子泄露与安全考量

ThreadLocalRandom的种子存在Thread对象里,通过Unsafe读写。理论上,如果能读取到Thread对象的内存,就能预测随机序列。但在正常的 Java 应用里,这需要反射或Unsafe,普通代码做不到。

不过,如果你的应用有沙箱或需要防御恶意代码,要注意ThreadLocalRandom不是密码学安全的。安全敏感场景用SecureRandom,它的性能差一些,但抗预测。

实操心得:我在做抽奖系统时,一开始用ThreadLocalRandom,后来安全团队要求换成SecureRandom,因为抽奖结果涉及利益,必须防预测。性能从每秒几十万次降到几万次,但抽奖本身 QPS 不高,完全够用。选型要看场景,不要盲目追求性能。

7. 我踩过的坑和最后的小技巧

说几个我实际踩过的坑。

第一个坑:在静态初始化块里用ThreadLocalRandom.current()。那时候类加载线程可能还没完全初始化,probe 字段可能异常。虽然概率很低,但确实遇到过。解决办法是延迟到方法调用时再获取。

第二个坑:在ForkJoinTask里用ThreadLocalRandom,结果发现不同任务拿到的随机数有重复。原因是 ForkJoinPool 的工作线程会窃取任务,同一个线程可能执行多个任务,种子被复用。如果任务需要独立随机序列,要么用SplittableRandom,要么在任务里重新初始化。

第三个坑:用nextDouble()生成金额,结果出现0.30000000000000004这种浮点误差。随机数本身没问题,是浮点表示的问题。金额场景应该用nextInt生成分,再除以 100。

最后分享一个小技巧:如果你需要生成一个随机的布尔值,不要用nextInt(2) == 0,直接用nextBoolean(),它内部实现更高效。同理,生成随机字节数组用nextBytes(byte[]),比循环nextInt(256)快得多。

还有一个:ThreadLocalRandom的nextInt(origin, bound)在 origin 和 bound 差距很大时(接近Integer.MAX_VALUE),内部会做特殊处理,性能会下降。如果差距超过 2^30,考虑用nextLong再映射。

这些细节平时文档里不会写,但真到高并发场景,每一个都可能成为瓶颈。希望这些经验能帮你少走弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询