
忘了从哪次面试开始我意识到“会写 Random”和“理解随机数”完全是两码事。当时对方只问了一句new Random()和Math.random()底层到底差在哪我脑子里只有“一个是对象一个是静态方法”这种浅层答案。这几年写抽奖系统、做过验证码服务、也帮人review过安全风控模块发现绝大多数 Java 随机数问题恰恰出在刚入门就该搞懂、却被所有人跳过的基础逻辑上。这篇文章我会从种子、算法讲到并发、安全和真实业务里的工具方法代码全部可以直接复制。适合刚学 Java 的同学也适合准备跳槽面试时想快速把随机数这个主题焊死的朋友。如果你只想背结论直接看每节的小结和建议表就行如果你想把原理弄明白后面每一段都值得慢慢过。1. 你生成的可能不是“自由意志”随机数种子决定一切1.1 伪随机不是缺陷是成本换来的确定性很多初学者第一次听到“Java 的随机数是伪随机”时会下意识觉得这是件丢人的事。恰恰相反绝大多数业务场景需要的并不是纯粹的量子随机而是“看起来杂乱无章、实际按固定规律生成”的序列。这个序列只要足够均匀用起来就跟真随机没有本质区别而且生成速度飞快。Java 最基础的java.util.Random内部维护了一个 48 位的种子数每次生成新随机数字时都会拿当前种子做一次线性同余计算。简单概括就是新种子 (旧种子 × 乘数 增量) 掩码这串公式会生成 0 到 2^48 范围内的一个新数再从这个新数中提取需要的位数映射成 int、long、double 等类型返回。由于整个过程只跟种子和固定的常数有关只要初始种子相同以后生成的一整串数据也完全相同。数学上管这叫确定性伪随机所谓“随机”只是从使用者的视角看过去没有肉眼规律而已。种子在这里扮演了剧本起点。两个使用相同种子初始化的 Random 对象就像是站在同一条流水线上复制出来的双胞胎往后每一步行为都能对上。1.2 固定种子实验两次随机完全一样不信的话可以直接跑一段最朴素的代码验证Random r1 new Random(42); Random r2 new Random(42); for (int i 0; i 8; i) { System.out.println(r1: r1.nextInt(100) , r2: r2.nextInt(100)); }运行结果会是这样r1: 33, r2: 33 r1: 60, r2: 60 ...两份输出几乎逐行对应。这就是固定种子的特点测试环境里你希望随机结果可以稳定重现时可以故意传入固定种子但生产环境如果每次都用同一个固定种子那代码在相同输入下永远产生相同随机序列抽奖活动会被玩成“谁先掌握规律谁就稳赚”。1.3 无参默认种子与常见误解new Random()不传种子时源码会用当前系统纳秒时间跟一个内部递增计数器进行异或来生成种子。也就是说同一次 JVM 运行中连续 new 两个 Random默认种子极大概率不相同但并非绝对不可能碰撞。极端情况下如果两个对象在同一纳秒级窗口内被创建或者计数器状态撞上了还是可能出现相同序列。所以高并发环境、需要安全性的场景里无脑new Random()本身就是隐患这一点到第 5 节再展开聊。小结第一次整理Random 的工作原理可以记住一句话——种子确定了整条随机序列的“命运”就确定了。所谓随机核心是种子的不可预测性和序列的分布均匀性而不是“没有算法轨迹”。2. 基础三件套Math.random()、Random、ThreadLocalRandom 的差异2.1 Math.random() 其实是拿到了一个“现成 Random”很多老教程会说Math.random()是生成 0 到 1 之间的小数这没错但没说的关键点是它内部持有一个静态 Random 实例。你调用一次Math.random()底层等价于调用那个单例 Random 的nextDouble()。这里涉及一个隐藏信息因为 Math.random() 内部是单例多个线程同时调用它时会在随机数生成的核心方法上发生锁竞争或 CAS 竞争。日常写练习代码无所谓一旦它出现在高并发请求的核心路径上就可能成为性能瓶颈。很多团队一到压测就奇怪“Math.random 怎么会拖慢接口”根因就在于大家默认它是个纯静态的无状态函数实际上它有共享状态。2.2 Random 的常用方法别只会一个 nextInt平时最常看到的是new Random().nextInt(10)但它只是一整个随机工具库的冰山一角。常用方法包括nextInt()返回范围内全部的 int 随机值注意这里没有参数能取到负数范围是整个 int 区间。nextInt(bound)返回 0 到 bound - 1 之间的整数bound 必须为正数这是最常用方法。nextLong()随机 long。nextDouble()返回 0.0 到 1.0 之间的 double不包含 1.0。nextFloat()同上返回 float。nextBoolean()随机布尔值常用于流程分支。nextBytes(byte[])填充随机字节数组早期小项目里经常被拿来拼盐值。ints()、doubles()、longs()Java 8 起的随机流接口适合批量生成随机数还支持限制数量与范围比如rnd.ints(10, 1, 100)表示生成 10 个 1 到 99 的随机整数。2.3 什么时候用哪个一张选择表场景推荐方式理由单线程练习、写算法new Random()简单直接没有多余依赖需要大量随机 doubleMath.random()临时用方便但性能不是最优多线程工具类内部ThreadLocalRandom.current()线程隔离种子无共享竞争分布式 ID、安全令牌SecureRandom或专用 ID 方案可预测性低大数据批量流式随机RandomGeneratorints/doubles处理表达更清晰Java 17 以后引入了统一的RandomGenerator接口与RandomGeneratorFactory像Random、SplittableRandom、SecureRandom都实现了该接口。新项目里如果要写通用的随机数工具方法可以考虑面向RandomGenerator编程而不是直接锁死Random类。面试时能主动提到这层抽象会明显显得知识体系是成片状而不是点状。第二部分小结不要再看不起Math.random()或无脑每次新建 Random 了先把三件套的分工记牢后续写代码时才有选型意识。3. 边界区间是最容易送分的地方改写“生成 m~n 之间整数”的正确姿势3.1 开区间还是闭区间nextInt 的口径统一nextInt(100)返回多少很多人会下意识回答“0 到 99”也有人回答“0 到 99 之间”但更精确的口径是它返回 0包含到 100不包含之间的整数数学写法是[0, 100)。这个“含头不含尾”的口径来自底层的索引习惯从 0 开始计数取模范围天然是从 0 到 bound-1。理解了这个约定再遇到生成 1 到 10包括 1 和 10的需求时就不会写出nextInt(11)而应该用nextInt(10) 1。前者会多出一个 0而且在只想要 1~10 的场景里就是 bug。3.2 常见错误先把随机数取余、再进位网上很多旧代码喜欢这么写// 返回从 0 到 n-1但分布不均匀且存在严重特例 int badRandom Math.abs(rand.nextInt()) % n;这段代码至少有两个问题。第一nextInt()有超过一半概率返回负数Math.abs虽然能转正但一旦遇到Integer.MIN_VALUEMath.abs 之后仍是负数取余结果就会错得离谱第二用取模这种方式做区间缩放会产生概率偏差因为2^32的整数总数未必能被 n 整除某些余数会比其他余数更容易出现。JDK 的nextInt(bound)内部正是为了避免这种偏差才专门写了拒绝采样逻辑我们没必要重复造这个轮子。3.3 左右都能取到的工具方法与隐藏溢出风险想要覆盖[min, max]这个闭区间标准模板是public static int randomInt(Random random, int min, int max) { if (min max) { throw new IllegalArgumentException(min cannot be greater than max); } long bound (long) max - min 1; if (bound Integer.MAX_VALUE) { // 超大范围时降级为 nextLong 取模 long offset Math.floorMod(random.nextLong(), bound); return (int) (min offset); } return min random.nextInt((int) bound); }关键点是把max - min 1先转成 long 再参与计算。很多人直接写max - min 1当max Integer.MAX_VALUE, min 0时表达式溢出成负数nextInt 立刻抛异常。用 long 做中间变量能避开整个坑这也是老手和新手写工具方法时最直观的差距之一。另外如果你想用Math.random()生成[min, max]可以写成int value min (int)(Math.random() * (max - min 1));这里的max - min 1依然建议用 long 兜底然后再强转 int。平时范围不大时肉眼看不出来一旦范围逼近边界转不转 long 就是线上 bug 与安全代码的分界线。4. 多线程共享 Random 为什么慢线程隔离 ThreadLocalRandom 的真相4.1 CAS 抢种子位才是瓶颈很多人在多线程项目里会把 Random 定义成一个全局静态字段然后让所有线程共用它因为 Random 文档里写着“线程安全”。文档没说错但没说全Random 的线程安全依赖底层对种子的CASCompare And Swap原子更新。每次生成随机数都要读一次种子计算新种子再尝试把新种子写回去。如果这个过程中其他线程先写成功了当前线程就自旋重试直到成功。并发量不高时这种自旋无所谓一旦 QPS 上来几十个线程同时抢一个种子同一时间只能有一个线程更新成功其他线程全部空转等待。压力测试里经常看到的现象是接口本身没耗时Random 的自旋把 CPU 打满吞吐量直接腰斩。4.2 ThreadLocalRandom 的最佳用法与注意点ThreadLocalRandom的思路很直接不再共享一个种子而是把种子保存在每个线程自己的变量中。这个类在 JDK 7 引入到了 JDK 8 后成为多线程随机数的默认首选。用法上有个反直觉点它本身不需要也不能“new 出来”要调用ThreadLocalRandom.current()。每次调用都返回当前线程自己的随机数处理器因为线程本地变量天然隔离后续nextInt()更新种子时不再跟别人争抢性能和扩展性都更好。int luckyNumber ThreadLocalRandom.current().nextInt(1, 101); // 1 到 100顺带一提当你在 Main 主线程里调用ThreadLocalRandom.current()得到的是主线程对应的实例在线程池任务里再调用拿到的又是另一个线程对应的实例。只要你通过current()获取就不存在“把实例传到其他线程用”这种错误姿势。4.3 高并发下要不要“每个线程 new Random”有人会想既然共享 Random 争抢我每个线程里各 new 一个 Random 不就好了实际上在可控的线程模型里这种方式也成立但管理起来麻烦而且容易遗忘。线程池里线程是复用的谁也不敢保证局部创建的 Random 一定能活着也容易因为嵌套线程、异步任务导致线程切换时使用了一个别的线程创建的 Random 实例。正确思路是尽量用ThreadLocalRandom兜底如果必须手动维护独立 Random 实例建议用ThreadLocalRandom包裹并做好生命周期管理。绝大多数现代 Java 并发编程场景中ThreadLocalRandom 已经是“够用且最稳”的答案。5. 安全与随机性验证码、Token 别再用 Random 出货5.1 默认 Random 的可预测性从哪来很多开发者写验证码、重置密码 Token、抽奖券码时顺手就用 Random这是我在代码评审里看到频率最高的安全风险之一。原因还是回到第 1 节默认 Random 的种子空间是 48 位同时生成算法是公开的线性同余公式。攻击者只要连续采样到几个生成结果再结合种子更新时间就可能在可行时间内反推出内部种子状态进而预测后面每一个随机值。对验证码这种极短随机数来说这种预测能力足以让攻击者批量“猜到”别人重置密码的临时链接。密码学意义上的安全随机数要求攻击者即使拿到大量历史样本也无法推算后续输出。要达到这个强度不能在固定公式里滚种子而要依赖操作系统收集的熵源包括硬件噪声、内核事件间隔等。5.2 SecureRandom 的正确打开方式Java 官方推荐的安全随机类是java.security.SecureRandom。它底层会通过操作系统的安全随机源获取种子和熵例如 Linux 上的/dev/urandom或对应的系统调用。不像 Random 那样靠纳秒时间 计数器组合出种子SecureRandom 的不可预测性高得多。基础用法SecureRandom secureRandom new SecureRandom(); String token Long.toHexString(secureRandom.nextLong());默认构造器选择平台默认安全提供者在绝大多数主流环境都能自动配置好。不要手动 new 完立刻调用setSeed()因为人为添加的种子反而可能削弱系统熵源带来的随机强度除非你明确知道自己在做测试复现。5.3 密码学安全会拖慢速度怎么分级处理SecureRandom 的代价是生成速度和吞吐量都要低于普通 Random尤其是首次初始化时可能需要等待熵池填充。如果接口在启动阶段就拉取大量 SecureRandom可能造成明显卡顿。业界的常见分级策略是普通抽奖活动、展示类随机排序用ThreadLocalRandom就够了涉及用户登录验证码、找回密码 Token、CSRF Token、密码盐值时用 SecureRandom。把两者分开既能保障安全关键路径的不可预测性也不至于让全员随机都背上安全随机数的性能包袱。小技巧是可以让系统启动时先初始化一个 SecureRandom 实例再在业务中重复利用它来生成 Token每个Token 的内部分布依然是安全的且避免了频繁初始化拖慢首次请求。6. 实战组合拳洗牌、六位验证码、权重抽奖一次写对6.1 洗牌为什么推荐优先用 Collections.shuffle排序算法里的随机洗牌最规范的做法不是自己写两层循环交换而是直接使用Collections.shuffle()。它内部实现是 Fisher-Yates 洗牌算法从后往前扫描每次把当前位置的元素和前面某个随机位置交换天然保证所有排列出现的概率相等。ListString players new ArrayList(List.of(A, B, C, D)); Collections.shuffle(players, ThreadLocalRandom.current());注意第二个参数传ThreadLocalRandom.current()是合法的因为该类实现了Random的现代抽象接口。老版本写法是传入new Random()但在高并发场景下这种共享实例反而会成为瓶颈。如果你的代码还在给 shuffle 传固定种子那是为了测试可用不是生产标准。6.2 六位验证码从错误写法到标准版最常见的错误写法是循环 6 次然后拼接字符串这既慢又容易产生类似012345这种小于 6 位有效数字的码。实际业务中验证码如果是“六位数字”通常需要最高位也能是 0那用字符串生成没问题如果需要的是 100000 到 999999 这种六位数就应该写成int code ThreadLocalRandom.current().nextInt(100000, 1000000);如果允许验证码以 0 开头可以这样生成String code String.format(%06d, ThreadLocalRandom.current().nextInt(1000000));String.format 会自动补前导 0。不要小看这个细节短信通道里经常因为验证码位数不齐被运营商或前端格式校验拦下来。6.3 权重抽奖的正反例抽奖系统里的随机数本质是带权重的随机选择。最简单的场景是三个奖品权重分别为 1、2、7期望 10% 概率中一等奖、20% 中二等奖、70% 中“谢谢参与”。先看错误思路把“随机比例”等同于“把每个权重单独随机一次再选最大那个”。这种写法在权重数值接近时误差非常大稳定性很差。正确写法是一轮前缀和扫描public static int weightedPick(int[] weights, Random random) { long total 0; for (int w : weights) { total w; } long cursor (long) (random.nextDouble() * total); long accumulate 0; for (int i 0; i weights.length; i) { accumulate weights[i]; if (cursor accumulate) { return i; } } return weights.length - 1; }这里的一个隐藏细节是cursor应该落在[0, total)所以用random.nextDouble() * total转 long 是安全的。权重之和可能超过 int 上限所以用 long 来累加。如果你把权重前缀和预先算好并配上二分查找在高频抽奖服务里能进一步降复杂度这算是一个优化项。但底层“随机出一个游标再找游标落在哪个区间”的核心模型不能搞错。7. 面试连环问与项目中容易踩的性能坑7.1 你被问过的新旧特性RandomGenerator 是什么Java 17 开始增强了对伪随机数生成器的统一管理引入RandomGenerator接口。Random、SecureRandom、SplittableRandom都在这个抽象之下。如果面试官问“JDK 17 对随机数做了什么改进”可以提两点一是新增接口RandomGenerator所有随机数类有了统一方法签名二是引入了工厂RandomGeneratorFactory可以用名字创建指定的算法实现比如 “L128X1024MixRandom” 这种高周期低碰撞算法。这些新实现比旧的 LCG线性同余能生成周期更长、统计表现更好的序列适合 ForkJoinPool、Stream 场景下的并行随机计算。不过对于大部分 CRUD 项目沿用 ThreadLocalRandom 也完全够用不必强行升级。7.2 高频问题清单与常见口径我按项目老手的视角整理了几道高频题建议背下来但也要理解背后的产物逻辑问Math.random()与new Random().nextDouble()有关系吗 答Math.random() 内部通过一个静态 Random 实例调用 nextDouble()本质上等于全局单例 Random。问Random 是线程安全的吗为什么在高并发下表现差 答是线程安全的通过 CAS 更新共享种子来保证但因为所有线程争抢同一份种子在高并发下会有大量自旋重试导致性能劣化。问nextInt(10)是否能返回 10 答不能。它返回 0 到 9区间为[0, 10)如果需要 1 到 10正确写法是nextInt(10) 1。问固定 Random 的种子后会怎样 答同一种子下 Random 对象生成的整条随机序列完全一致可用于测试复现但生产中固定种子等于随机流程可以被预测。问Random能用来生成安全 Token 吗 答不能默认 Random 基于线性同余算法且种子空间有限可能被预测应使用SecureRandom等密码学安全随机数。问ThreadLocalRandom和Random使用姿势有什么区别 答一个通过静态 current() 获取当前线程专属实例一个靠对象共享并发越高越倾向使用 ThreadLocalRandom。7.3 最终沉淀下来的随机数使用口诀这几年的实际经历让我养成了一个非常机械的选型习惯基本不会出错不确定去哪找安全随机需求时先用 ThreadLocalRandom 写普通随机。遇到验证码、Token、短链、密码盐时停下来提醒自己换 SecureRandom。写单元测试需要固定复现随机路径时用固定 seed 的 Random只服务于测试环境。需要批量并行生成随机数时Java 17 以后优先考虑 SplittableRandom 或新算法工厂。有一次我在旧系统里发现所有抽奖都用同一个静态 Random 实例活动高峰期该实例所在的 CPU 核心等待率持续拉满TPS 上不去。换成 ThreadLocalRandom 后效果立竿见影。这类问题排查起来最坑的地方在于代码不报错、日志全正常只有压测曲线会告诉你随机数这一层成了瓶颈。希望看完这篇的人在被压测曲线教育之前就先把它换了。