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(共享实例) | 约 4200 | 1x |
| 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再映射。
这些细节平时文档里不会写,但真到高并发场景,每一个都可能成为瓶颈。希望这些经验能帮你少走弯路。