Java随机数源码解析:Random与ThreadLocalRandom并发性能对比
很多Java开发者都看过一句约定俗成的结论并发环境下用ThreadLocalRandom单线程下用Random。但真被问到为什么的时候能讲透的人不多。我曾经在一台高并发的服务器上做过随机数压测发现固定使用共享Random实例时线程一多以后吞吐量掉了将近一个数量级罪魁祸首就是源码里那个不断自旋的 CAS 循环。这篇文章想从 JDK 源码粒度把Random和ThreadLocalRandom两兄弟彻底拆开它们分别靠什么公式产生随机数、种子存在哪里、为什么会有性能差异、哪些场景用错了会出问题。适合正在啃 Java 集合/并发源码的读者也适合准备 Java 面试想拿源码说事的人甚至只要你写过一次Math.random()也能从中获得一些平时文档里根本不会写的经验。1. 先搞清楚一个根本问题Java 为什么需要两套随机数方案1.1 随机数到底“随不随机”几乎所有程序员第一次接触随机数都是这样的调一个 API拿到一个看起来没有规律的数。但如果你是做底层开发或者面试被追问就必须接受一个反直觉的事实——Random和ThreadLocalRandom产生的都不是真正的随机数而是伪随机数。它们是拿一个确定的初始值种子套一个固定的数学公式按照某种规则推算出下一个数。这意味着两点。第一只要种子相同、使用顺序相同生成的整个序列就会一模一样所以伪随机数是可以复现的。第二这个随机质量完全取决于生成算法和种子位宽。Java 里的Random和ThreadLocalRandom使用的都是线性同余生成器LCG只不过Random保持了 JDK 1.0 时代的设计ThreadLocalRandom在 JDK 7 引入后做了一部分增强。1.2 两个类的家族出身从继承关系看java.util.Random是根类java.util.concurrent.ThreadLocalRandom是它的子类。但它不是普通子类——源码里把构造器故意设置成不可外部 new同时大部分重写的方法上都有非常特殊的访问标记。这个子类不像子类的设计本质上是为了打破Random中所有线程共享一个种子的模型。为什么打破因为Random在并发场景下为了保证线程安全会在更新种子时使用 CAS 自旋。看源码前先记下一个核心结论LCG 生成下一组随机数时必须读取当前种子计算nextseed再写回种子。这个读-写过程是典型的复合操作不能让两个线程交叉执行。Random的做法是让所有线程都去争抢同一个AtomicLong字段谁 CAS 成功谁才能更新种子。线程数量一多原本几纳秒的计算就会变成漫长的自旋等待。ThreadLocalRandom的思路就直白了每个线程自己持有一份种子互不干扰根本没有共享状态自然也就不需要锁和 CAS。所以它本质上是以空间换时间用每个线程留一份独立种子换走并发竞争下的性能损失。1.3 一个小类比两家银行柜台把Random想象成整个银行只有一个柜台。每个客户办理业务获取随机数都必须排队柜台工作人员要依次核对身份证、登记、修改记录CAS 更新种子。人少的时候很顺畅人数一多就全堵在大厅里。ThreadLocalRandom相当于给每个客户发了一个私人柜员大家各办各的谁也不用等别人。代价就是银行需要准备足够多的柜员席位每个线程额外存储种子但现代 JVM 里这个开销对于几十、几百个线程来说几乎可以忽略。2. Random 源码拆解48 位种子、LCG 公式与 CAS 自旋2.1 三个十六进制常量的真实身份打开java.util.Random源码最先看到的是一组常量private static final long multiplier 0x5DEECE66DL; private static final long addend 0xBL; private static final long mask (1L 48) - 1;这三个就是 LCG 的灵魂。线性同余算法的标准公式是nextseed (oldseed * multiplier addend) mod modulus对照源码里的multiplier是0x5DEECE66Daddend是0xBmask是(1L 48) - 1也就是低 48 位全为 1。所以 mask就等价于对2^48取模。这意味着Random的真实随机状态只有 48 位不是 64 位 long 的全部空间。有人会问既然种子字段是AtomicLong为什么只取低 48 位这是历史设计。JDK 引入Random时参考了早期 BSD 的线性同余实现48 位在当年足够满足大多数应用同时公式执行速度非常快。问题是 48 位的状态空间意味着周期上限是2^48连续取1L 48次数值之后序列必然周期性重复。在绝大部分业务场景里你根本用不到这么大的量但要设计加密或分布式唯一 ID 这类场景时就不能指望它。2.2 构造器与种子的初始化路径new Random()没传种子时源码会拿一个基于系统纳秒时间和 JVM 内部计数器混合出的值做种子public Random() { this(seedUniquifier() ^ System.nanoTime()); }这个seedUniquifier()在内部也是一个原子 CAS 更新。也就是说你每 new 一个无参Random都会发生原子操作。看起来只是构造器但大量实例化时也不是零成本。new Random(seed)则是显式指定种子直接走setSeed。setSeed方法里会处理seed.set(initialScramble(seed));initialScramble做了一次异或混淆把传入的种子和multiplier做乘法后再取低 48 位避免用户传入种子时因为低位规律太强导致随机质量差。很多人忽略这个细节以为传入 1 和传入 2 的区别只是整体序列偏移几格其实不是它们是两个完全不同的序列。2.3 next(int bits) 的 CAS 循环是怎么跑的Random所有对外方法最终都会落到next(int bits)这是全类最关键的一段源码protected 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)); }我把这段翻译成大白话先读一下当前种子oldseed。用公式算出下一颗种子nextseed。尝试用 CAS 把seed从oldseed改成nextseed。如果改成功循环结束如果失败说明有其他线程抢先改掉了种子立刻回到第 1 步重新读、重新算、重新 CAS。注意到那个do...while是关键。它不是拿到锁之后慢慢计算而是乐观锁思路假设没人抢先算再说CAS 失败就重来一次。这个设计在低并发下非常高效一个线程几乎不会重试。但一旦多个线程同时调用同一个Random.nextCAS 失败率急剧上升CPU 会白白消耗在自旋重试上。这也是为什么我把Random定位为线程安全但并发不高效。它线程安全没问题靠 CAS 保证每个种子都只会被一个线程成功更新可当争用激烈时大部分线程在做无意义的重算这个开销远超生成随机数本身的耗电。2.4 nextInt(bound) 里的拒绝采样细节很多程序员只背结论nextInt(n)返回 0 到 n-1 之间的整数。但源码里处理了一个边界问题——n不是2的幂时直接取模会出现模偏差modulo bias。public int nextInt(int bound) { if (bound 0) throw new IllegalArgumentException(bound must be positive); int r next(31); int m bound - 1; if ((bound m) 0) // i.e., bound is a power of 2 r (int)((bound * (long)r) 31); else { for (int u r; u - (r u % bound) m 0; u next(31)) ; } return r; }当bound是 2 的幂时走高位截断快速路径没有偏差。当bound不是 2 的幂时算法先用u % bound得到一个候选r然后判断u - r (bound - 1)是否为负数如果是负数说明余数落在了高位区间必须拒绝这个值重新调next(31)生成下一个 31 位随机数直到落入有效范围为止。这就是拒绝采样。拒绝采样的意义不是某些文章说的提高随机性而是消除模偏差。例如bound 10如果直接拿r % 100 和 1 出现的概率会略高因为2^31不是 10 的整数倍。源码里那个看起来令人困惑的for循环条件本质就是在做区间判断。这里有一个面试时能加分的细节当 bound 接近但不等于 2 的幂时拒绝率最高也就约 50%不会无限循环因为最坏情况下每次平均最多两次重试。2.5 为什么不用 synchronized 而用 CAS既然并发争用下 CAS 会自旋为什么作者不直接用synchronized源码注释曾经解释过这个设计考量。Random最早设计时面对的并发模型是几个线程偶尔共享同一个实例不是上百个线程高频调用。用独占锁一旦锁竞争线程会进入阻塞、唤醒挂起开销远大于 CAS 重试。CAS 属于轻量级乐观策略大部分业余时间几乎无额外成本即使争用高也只是浪费一点 CPU 循环不会引起线程上下文切换。所以这里的AtomicLong是对线程安全和低并发性能的一种折中。3. ThreadLocalRandom 源码拆解真正的并发解法与 mix32 混淆3.1 单例构造与 current() 的玄机ThreadLocalRandom跟Random最大的表象差异是你不能new ThreadLocalRandom()必须调ThreadLocalRandom.current()。源码里是这样的public static ThreadLocalRandom current() { if (UNSET UNSAFE.getReference(Thread.currentThread(), SEED)) localInit(); return instance; }这里用Unsafe直接读取当前线程对象里的某个字段。UNSET是一个占位种子表示这个线程还没初始化过随机种子。如果发现没初始化就调localInit()localInit()会往当前线程的该字段写一个种子。最后返回的那个instance是一个static final单例。注意一个容易误解的点ThreadLocalRandom实例本身是全局单例类自身没有存放可变种子状态。真正的种子是存放在线程对象内部的每个线程一份。因此所有线程调用current()拿到的是同一个实例但执行nextInt时操作的是各自线程里的种子字段。这正是它不需要同步的核心原因——根本没有共享的全局可变状态。3.2 线程里的种子字段与初始化流程JDK 内部给ThreadLocalRandom预留了两个关键字段threadLocalRandomSeed和threadLocalRandomProbe。前者是当前线程的 64 位随机种子后者是给其他并发工具如ThreadLocalRandom冷启动探测、LongAdder等用的探针值。它们不是放在ThreadLocalRandom类里的静态 Map而是直接加在Thread类字段上。这样设计的好处非常明显既避免了ThreadLocalMap的数组查找开销又让 JVM 在栈帧切换时可以直接访问这些字段。所以ThreadLocalRandom.current()在正常情况下几乎没有额外开销。初始化种子时localInit()会基于一个全局原子递增的种子生成器来产生初始种子并设置一个非零的probe。一个值得记录的细节每个线程的初始种子是不同且无规律的这正是避免多线程使用同一种子导致序列相关性的第一道防线。如果每个线程初始种子都一样那即使线程私有化多个线程拿到的随机序列也会雷同。3.3 新增的 mix32 与内部 next 的实现逻辑ThreadLocalRandom虽然继承自Random但它没有直接复用父类next(int bits)。它重写了一套内部生成流程核心分成两步。第一步更新种子。线程本地拿到自己的种子后会用一个比Random更进一步的递推公式nextSeed currentSeed GAMMA;这个GAMMA不是随便加的数字。JDK 选择了一个奇常数保证每一步产生的种子能快速遍历 64 位状态空间避免连续随机数之间存在明显的线性相关性。相比Random在每个线程里再做一次 48 位取模与乘法这个只加一个常数的设计要快得多。但有人要问了只加常数连续两个数不是极度规律吗这就引出了第二步。第二步混淆输出。得到新种子后源码并没有直接把种子返回而是经过一个mix32函数把 64 位种子做异或、位移、乘法混合再截取高位 32 位返回final int mix32(long t) { t ^ t 33; t * 0xff51afd7ed558ccdL; t ^ t 33; t * 0xc4ceb9fe1a85ec53L; t ^ t 33; return (int)(t 32); }内部看起来像Random64 位状态使用相同的 LCG 附加混淆。原理是虽然内部种子只是简单递增但最终对外输出的数字经过mix32的雪崩式混合后统计上已经无法看出序列规律。所以ThreadLocalRandom并不是换了一个全新的随机数算法而是改善了内部的快速种子更新 输出混合在保持伪随机质量的同时减少计算量。单线程的极限性能提升主要来自两点一是取消了共享种子的 CAS二是把Random的乘法和掩码替换成了常数加法加上之后的混合运算。别小看这一步在高频随机数调用时省下的就是几个 CPU 周期积少成多非常可观。3.4 为什么 ThreadLocalRandom 禁止 setSeedThreadLocalRandom从父类继承了setSeed(long)但源码直接抛UnsupportedOperationException。原因很明确种子的初始化时机是current()首次被调用时自动完成的种子的位置在不同线程里各不相同。如果你在一个线程里调用setSeed(123)另一个线程根本不知道这个种子因为你设置的只是当前线程的那一份。那开发者可能会误以为设置成功了实际其他线程的行为完全不受影响。这种看起来能用、实际不能复用的方法作者直接废弃掉反而更安全。所以当你需要在测试中复现某个多线程随机序列时不要试图依赖ThreadLocalRandom的种子正确做法是改用Random或者给每个线程单独创建一个带确定种子的Random实例。源码注释里也明确提醒真需要显式设种子就用Random。4. 实测对比不同并发线程数下的行为与性能差异4.1 一个可以复现的压测思路源码分析再多不如自己跑一次数据。我自己最常用的方式是固定生成一亿个随机整数分别测试单线程使用同一个 Random多线程各自持有 Random多线程共享同一个 Random多线程使用 ThreadLocalRandom四种模式。这里给出一个简化版的多线程共享Random与ThreadLocalRandom的对比骨架ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch start new CountDownLatch(1); long total 0; for (int t 0; t threadCount; t) { pool.submit(() - { try { start.await(); long sum 0; for (int i 0; i perThreadTasks; i) { sum sharedRandom.nextInt(); } total sum; } catch (InterruptedException ignored) {} }); } start.countDown(); pool.shutdown();注意写基准测试时务必不要让编译器把随机数调用优化掉最好把sum累加结果打印出来。另外JMH 更严谨但普通项目里用System.nanoTime()也能看出量级差距关键是控制变量每次都预热并忽略首轮结果。4.2 单线程下相差多少我实测下来的典型数据是单线程循环一亿次ThreadLocalRandom比共享Random快大约 20%-30%而不是一个数量级。这是因为单线程没有 CAS 竞争Random胜在没有mix32的高复杂度操作ThreadLocalRandom胜在种子更新只是加法。两者在这个场景下差距有限。还有一点值得留意单线程下Random的 CAS 虽然每次都成功但AtomicLong.get和compareAndSet本身也比普通 long 读写多一点点开销。所以单线程下ThreadLocalRandom仍然稳赢只是赢得的比例容易被夸大。4.3 多线程下的魔幻差距到 8 线程、每线程生成 1000 万随机数时差距开始拉开。共享Random的耗时可能会是ThreadLocalRandom的好几倍甚至一个数量级以上具体取决于你调用的方法和 JVM 版本。原因还是 CAS 自旋重试8 个线程都试图更新同一个AtomicLong大部分时间花在一个线程 CAS 成功、其余 7 个全失败重新计算的循环里。我在一次测试中16 线程共享Random时出现了大量线程等待CPU 占用飙升到接近核数上限但吞吐几乎不再增长换成ThreadLocalRandom后吞吐基本随着线程数线性扩展。这个对比在面试里可以直接当结论讲Random在低并发下安全可用在高并发下是性能瓶颈ThreadLocalRandom的出现就是专门解决这个问题的。4.4 分布性差异性能之外分布也要看。因为两个类底层都还是 LCG 家族统计分布都均匀。ThreadLocalRandom通过mix32让相邻种子的输出更不可预测在比较简单的分布测试如生成大量 0-9 整数统计频率里两者没有明显差别。如果你的需求是严谨的密码学强度两个都别用直接SecureRandom。这点后面会展开。5. 随机数使用的几个容易踩进去的坑5.1 把所有线程共用一个 Random性能隐患只是冰山一角很多人写工具类时习惯把Random作为类静态字段public class IdGenerator { private static final Random RANDOM new Random(); public static int next() { return RANDOM.nextInt(100000); } }这个写法线程安全Random确实能用。但它意味着所有线程都在抢同一颗种子在高并发下必然成为热点。如果你没有意识到这层的 CAS 自旋压测时改善线程数却看不到性能提升很可能就是这个静态Random在拖后腿。更隐蔽的问题是序列可预测。如果攻击者拿到几个相邻输出就有机会反推出当前种子进而预测后续 ID。业务上如果拿它做订单号、鉴权令牌那是安全事故。这类需求请换SecureRandom或者带时序组件的高位随机组合。5.2 new ThreadLocalRandom() 这种诡异写法ThreadLocalRandom的构造器是包私有或者故意没有公开外部无法new。但有些代码会通过反射或者父子类 hack 强行创建实例结果会发现随机序列完全不可控因为种子不在实例里而在当前线程里。更常见的误用是ThreadLocalRandom random ThreadLocalRandom.current(); random.setSeed(123L); // 直接抛异常正确的用法是只调静态方法每次需要的时候就ThreadLocalRandom.current().nextInt(...)不要试图长期持有它——虽然持有也没问题但持有后你会忍不住想给它设种子然后就会踩到前面说的UnsupportedOperationException。5.3 加密场景别用 Random也别用 ThreadLocalRandom有没有人问过Random和ThreadLocalRandom的种子只有 48 位或内部推算关系过于简单而SecureRandom是基于系统熵源文件描述符、硬件噪声、定时器等的加密安全生成器。把Random的种子设为当前纳秒时间攻击者可以通过穷举时间窗口缩小搜索范围非常危险。我见过有人拿Random生成重置 token结果接口被刷攻击者几分钟内就预测出 token 序列。后来改成SecureRandom并加大长度问题才消失。记住一个判断标准凡是不可泄露、不可预测的随机内容一律走SecureRandom。5.4 Java 8 之后的随机数流别忽视Random从 Java 8 开始支持ints()、longs()、doubles()流方法ThreadLocalRandom也有对应实现。用法很简单Random random new Random(); random.ints(100, 0, 100) .filter(n - n 50) .limit(20) .forEach(System.out::println); ThreadLocalRandom.current() .ints(1000, 1, 7) .summaryStatistics();流方法内部仍然是同一个 LCG 和同一套种子更新逻辑。所以在大数据量抽样场景也可以用并行流但并行流配合共享Random可能把 CAS 竞争进一步放大此时优先考虑ThreadLocalRandom或者每个任务自行持有一个Random。5.5 从 seed 相同说起测试时的确定性要想测试用例可复现用new Random(固定种子)是常见手段。这没问题但要清楚它保证的是同一 JVM 内、同一个实例、相同调用顺序下序列一致。不同 JVM 版本、不同平台之间序列不一定稳定。ThreadLocalRandom则完全不适合做确定性测试因为它的种子初始化依赖线程启动时机和全局种子生成器完全不可控。如果你需要一套确定性的随机数测试夹具我个人的做法是代码里预留一个开关生产环境用ThreadLocalRandom测试环境注入一个带固定种子的Random通过接口抽象把两者隔离。这样既保证生产并发性能又保留测试的可确定性。6. 如果让我重新设计一个随机数使用规范看完源码之后我一直在自己项目里推行一套简单的随机数使用规范这里直接分享出来你可以直接抄单线程、需要复现序列new Random(seed)只在一个线程内使用。多线程、只需要高性能随机数ThreadLocalRandom.current()。需要密码学安全的随机数SecureRandom并注意它可能阻塞熵源。需要分布式唯一 ID 且不想引入第三方组件不要只靠随机数要结合时间戳、机器标识和自增序列随机部分只做混淆。不要试图给ThreadLocalRandom设置种子不要用一个AtomicLong种子来自己拼装随机算法。最后再分享一个编码层面的小技巧。如果你在写框架或中间件经常要在当前线程的随机数和全局随机数之间切换可以这样写一个统一入口public final class Randoms { private static final ThreadLocalRandom LOCAL_RANDOM ThreadLocal.withInitial(Random::new); public static int nextInt(int bound) { return ThreadLocalRandom.current().nextInt(bound); } public static Random deterministic(long seed) { return new Random(seed); } }这个类的好处是把决策收敛到一个地方。以后团队里有人误用Random做高并发主键或者有人传固定种子给ThreadLocalRandom在代码审查阶段就能发现。随机数看起来是基础设施里最不起眼的一环但它恰恰是很多诡异性能问题和安全问题的发源地值得在源码层面把逻辑吃透。