1. 为什么需要重新认识ThreadLocalRandom1.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是ThreadLocalRandom的封装。不是。它并没有为每个线程创建一个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共享实例约 42001xThreadLocalRandom约 380约 11x每线程独立 Random约 400约 10.5x可以看到竞争激烈时Random慢了十倍以上。这个差距在 QPS 高的服务里就是致命的。注意这个测试数据因机器和 JDK 版本而异但数量级的差距是稳定的。JDK 8 之后Random内部也做过一些优化但 CAS 竞争的本质没变。2.2 每线程一个Random行不行有人会想那我用ThreadLocalRandom不就行了每个线程一个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抛IllegalArgumentExceptionorigin和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);对于longlong 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再单独计算。这个设计有个副作用如果你在代码里大量使用ThreadLocalThreadLocalRandom的初始化可能会影响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是 62nextInt(62)返回 0~61正好覆盖所有字符。实操心得如果对安全性有要求比如生成密码重置 token不要用ThreadLocalRandom它用的是线性同余算法不是密码学安全的。这种场景要用SecureRandom。ThreadLocalRandom适合的是性能敏感、安全要求不高的场景。4.4 洗牌算法的正确实现洗牌是随机数的经典应用。用ThreadLocalRandom实现 Fisher-Yates 洗牌public static T void shuffle(ListT 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不要调用 setSeedThreadLocalRandom 不支持随机数每次都一样多线程共享 Random 且未同步改用 ThreadLocalRandom数组越界nextInt(bound) 边界理解错误记住左闭右开索引用 nextInt(length)性能没提升瓶颈不在随机数或循环内反复 current()循环外获取实例检查真实瓶颈生成的随机数有规律用了取模且模数不是 2 的幂用 nextInt(origin, bound) 代替取模多线程下结果重复种子初始化冲突确保用 current() 而非 new5.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 ListInteger 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再映射。这些细节平时文档里不会写但真到高并发场景每一个都可能成为瓶颈。希望这些经验能帮你少走弯路。