ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Random深度解析:线性同余、ThreadLocalRandom与SecureRandom选型

Java Random深度解析:线性同余、ThreadLocalRandom与SecureRandom选型 1. Random 的基本用法里藏着几个容易被忽略的细节我最早遇到 Random 的坑是在一次排查线上重复单号的时候。代码逻辑没有任何问题数据库主键也没有冲突最后定位到一个特别基础的地方两个线程各 new 了一次java.util.Random生成出的随机数序列居然高度相似。从那之后我养成了一个习惯凡是看到new Random()我都会停下来多看一眼因为很多人写了几年 Java对 Random 的理解其实停留在能出随机数就行的层面。先说最基本的。java.util.Random是 JDK 自带的一个伪随机数生成器它最常用的方法有这么几个nextInt()返回一个随机的int范围覆盖整个int区间包括负数。nextInt(int bound)返回[0, bound)之间的随机int这个在业务代码里用得最多。nextLong()返回一个随机的long。nextDouble()返回[0.0, 1.0)之间的随机小数。nextBoolean()返回 true 或 false。nextFloat()返回[0.0, 1.0)之间的随机 float。构造方法有两个一个无参一个接收long seed。这两个构造方法的区别很多人没搞明白。无参构造并不是没有种子而是每次从系统当前时间、线程状态等来源推导出一个种子值保证两次创建的实例大概率有不同序列。传了固定种子的构造则完全相反它保证无论运行多少次只要种子相同生成的随机数序列就完全相同。这一点在写单元测试、做数据脱敏复现时特别好用但在生产环境乱用固定种子就会出大事。举个例子你在循环里通过new Random()生成了两个实例然后各自调nextInt()Random r1 new Random(); Random r2 new Random(); System.out.println(r1.nextInt()); System.out.println(r2.nextInt());某些情况下这两个值会非常接近甚至在特定 JDK 版本和极短时间差内出现相同结果。原因在于无参构造的种子来源与纳秒时间有关两个对象创建间隔过短时底层状态可能撞在一起。我不建议在任何需要每次随机的场景里频繁new Random()更稳妥的做法是全局共享一个实例或者直接上ThreadLocalRandom。还有一点容易被新手忽略nextInt() % n这种写法得到的不是均匀分布后面我会专门讲这里先记住一个结论——如果 n 是 2 的幂nextInt(n)可以等价写成nextInt() (n - 1)但两者的定位完全不同前者是 API 层面的语义后者是位运算层面的实现细节业务代码里直接用nextInt(n)就够了不需要自己动手做这些。1.1 bound 参数的隐藏规则nextInt(int bound)的入参必须是正数。传 0 或者负数会直接抛IllegalArgumentException异常信息是bound must be positive。我在实际项目里见过有人把服务配置里的随机上限写成负数结果每次调用到那个功能就报错最后查出来是配置文件被手工改坏了。所以用这个 API 之前如果 bound 来自外部输入一定要先做校验给一个合理的兜底值。1.2 固定种子到底什么时候用固定种子最有价值的场景是测试。比如你要复现某个只在特定数据分布下才出现的问题用new Random(42)生成一批测试数据每次跑都是同一批数问题现象就稳定可复现了。线上代码如果出现new Random(固定值)基本可以断定是开发时从测试代码里顺手复制过来的这种问题排查起来非常隐蔽因为代码不报错也不影响功能就是随机数不那么随机。2. 为什么 nextInt(n) 不能直接写成 nextInt() % n这是一个非常经典的问题也是面试里 Java 随机数相关提问率最高的一题。先说结论nextInt(n)在底层不是简单地取模它做了额外的均匀性保证。如果写成nextInt() % n会出现什么问题nextInt()返回的取值范围是[-2147483648, 2147483647]一共2^32个可能值。当 n 不能整除2^32时余数 0 到2^32 % n - 1这些值出现的次数会比其它余数多一次。差值只有一次单独看每次调用几乎无法察觉但如果在一个抽样场景里执行几十亿次这个偏差就会被累积放大最终影响统计结果的置信度。这里有一个直观例子。假设用一个类似 Random 的机制生成[0, 10)的随机数但底层分布只覆盖 100 个值0 到 9 各有 10 次这时取模是均匀的。如果底层分布覆盖 103 个值取模的话 0、1、2 会各多出一次多次运行后 0、1、2 的出现频率就是比其他数字高那么一点点。业务场景里如果涉及抽奖、流量分配、AB 实验分组这种细微偏差可能导致某些组别长期偏多最终影响数据分析结论。JDK 源码里nextInt(n)是这样处理的先拿到一个 31 位的随机数然后判断 n 是否是 2 的幂。如果是就用位运算直接把低log2(n)位取出来效率最高如果不是就使用拒绝采样方案在循环里不断生成新随机数直到生成的值落在允许的范围内。判断 n 是否是 2 的幂在代码里有一个很巧妙的写法(n -n) n。当这个条件成立时n 是 2 的幂随机数直接bits % n等同于按位与否则进入拒绝采样分支。拒绝采样的关键逻辑是int bits next(31); int val bits % n; while (bits - val (n - 1) 0) { bits next(31); val bits % n; } return val;这里的bits - val (n - 1) 0是在判断什么它的本质是检查当前bits是否处于一个不能被 n 均匀切分的区间内。当一个随机数超出该区间就直接丢弃重新生成这样保证了每个返回值在允许范围内出现的概率严格相等。这种拒绝一部分样本以保证均匀的思路在随机数领域非常常见也是为什么nextInt(n)虽然在最坏情况下可能多循环几次但整体分布比取模更可靠的原因。2.1 什么时候必须自己实现取模逻辑绝大多数业务场景直接用nextInt(bound)就足够了。但有一种情况需要自己处理当 bound 是动态的且非常大接近 2^31 上限时直接取模的性能开销其实也还好真正要注意的是负数问题。比如你想生成一个[-100, 100]区间的随机整数常见做法是先取nextInt(201)再减 100这个思路没问题。但如果直接对nextInt()取模就可能拿到负数语义就完全不对了。实测下来JDK 的nextInt(bound)在大多数场景只执行一到两次循环就能返回性能损失可以忽略不计所以完全没必要为了省几次循环去手写仙人掌式的位运算替代方案。3. 线性同余与 48 位种子伪随机到底是怎么来的Random 之所以叫伪随机数生成器是因为它实际上是一个数学公式驱动的确定性序列。这个公式就是经典的线性同余生成器LCGseed (seed * a c) mod mJava 里的参数是a 0x5DEECE66D常数乘法因子c 0xB常数增量m 2^48模数每次生成随机数时先根据当前种子算出新种子然后从新种子里取出需要的位数作为返回值。seed在AtomicLong中保存所以某种程度上说Random 是线程安全的但这种线程安全是以性能为代价的后面说。3.1 为什么种子是 48 位而不是 64 位这是当年 Java 设计者基于两个因素做的选择一是 LCG 算法的周期上限是模数 m48 位意味着周期最大是 2^48已经足够覆盖绝大多数应用二是 48 位可以很方便地用位运算处理同时保留一定的随机性。nextInt()返回的是 32 位实际取的是种子高 32 位因为 LCG 的低位比特周期较短、随机性差取高位能有效规避这个问题。很多人在面试时说Random 用的是线性同余公式这只能算 60 分。真正加分的地方在于能说清楚公式的确定性决定了只要种子相同整个序列就完全一样而随机只体现在序列的统计性质上这也是为什么随机数生成器的测试重点在于分布均匀性、周期长度、位相关性而不是不可预测。3.2 同一种子可复现是优点还是风险固定种子的可复现性是一把双刃剑。优势刚才说过测试场景可以稳定复现问题风险在于如果使用 Random 生成验证码、Token、抽奖结果攻击者只要能拿到一小段输出借助对 LCG 公式的推导就能大概率预测后续输出。这个我在后面讲 SecureRandom 时会详细展开。这里有一个实操判断标准如果你的随机数只用于内部逻辑不暴露给用户比如随机休眠时间、随机负载分配、随机抽样那 Random 完全够用。如果随机数要落到响应报文里给用户看或者参与任何与安全、资金、权益相关的计算立刻换 SecureRandom没有第二种选择。4. 多线程下的 Random 性能陷阱以及 ThreadLocalRandom 的思路回到文章开头我遇到的那个场景两个线程各自 new Random() 导致序列相似这只是浅层问题。更深层的问题是即便你改成全局共享一个 Random 实例多线程高并发下性能依然很难看。原因在 Random 更新种子时用了AtomicLong它的compareAndSet操作在高竞争环境下会导致大量线程自旋等待。我曾经用 8 个线程并发各生成 100 万次随机整数做过简单对比共享一个Random实例的方案耗时约 3 秒多而改用ThreadLocalRandom后耗时不到 0.5 秒差距接近 7 倍。线程数越多竞争越激烈这个差距会更明显。ThreadLocalRandom的设计思路并不复杂每个线程内部持有独立的种子状态互不干扰因此完全不需要 CAS。用法也很简单ThreadLocalRandom.current().nextInt(100);注意current()返回的是当前线程专属的实例。如果你把这个对象保存到成员变量里在线程池场景下被另一个线程调用就会产生不可预期的行为。我见过有人把ThreadLocalRandom.current()赋给类的静态字段然后在多个线程里共用这本质上还是在单点竞争只是把竞争点从Random的AtomicLong转移到了一个共享对象上性能收益直接归零。正确做法是每次使用都通过静态方法获取或者把它封装成ThreadLocal局部变量。4.1 Math.random() 的真实身份很多人误以为Math.random()是独立实现的随机数其实它是包裹了一个Random实例的静态内部类RandomNumberGeneratorHolder持有唯一的static final Random randomNumberGenerator。换句话说Math.random()底层就是在调用Random.nextDouble()同样有AtomicLong竞争的问题。所以在高并发环境里用Math.random()并不会比new Random()好到哪里去同样应该换成ThreadLocalRandom.current().nextDouble()。4.2 业务里的选型决策单线程、内部使用、量级不大new Random()或者共享一个 Random 实例都行。多线程、不涉及安全、追求吞吐量ThreadLocalRandom.current()。无并发、但在测试里希望稳定复现new Random(固定种子)。涉及验证码、Token、抽奖、密钥材料SecureRandom。JDK 8 及以上才有ThreadLocalRandomJDK 17 开始ThreadLocalRandom对伪随机生成器接口做了更完整的实现老项目升级时注意兼容性即可。5. 验证码、Token、抽奖场景为什么必须换成 SecureRandomRandom 的伪随机特性意味着它的输出序列完全由初始种子决定。假设攻击者拿到了你使用 Random 生成的连续若干次验证码那他在理论上完全可以把你的状态压缩到 48 位种子空间然后暴力尝试最多 2^48 次逐个预测后续验证码。2^48 这个数字听起来很大但现代 GPU 并行计算下已经不再是安全的熵来源。前几年就有过用 Random 生成加密 Token 导致被预测的经典案例。SecureRandom跟 Random 最大的区别在于它的种子来源不是固定的数学公式而是操作系统收集的环境熵包括设备驱动中断、鼠标键盘事件、网络包时间戳等。这些信息来源对攻击者来说是不可预测的所以基于它们生成的随机数序列不具备从输出反推状态的攻击路径。Java 里使用SecureRandom很简单SecureRandom random new SecureRandom(); byte[] bytes new byte[16]; random.nextBytes(bytes);它的代价也很明显性能比 Random 低一个数量级以上有些平台在熵池不足时还会阻塞等待。所以在非安全场景用 SecureRandom 属于杀鸡用牛刀。我的建议是在项目里做一个随机数工具类把所有随机数生成入口收敛起来根据业务场景分类选择底层实现。这样后续如果想换算法、加熵源、做审计只需要改一个类。5.1 SecureRandom 的使用误区一是每次 new 一个 SecureRandom。很多实现里构造时会向操作系统申请熵源频繁创建会加重系统负担甚至拖慢启动速度。建议做成全局单例或者从 Spring 容器里共享一个实例它是线程安全的。二是不区分算法提供方。JDK 9 之后SecureRandom支持多种算法比如DRBG基于哈希的确定性随机位生成器。如果你的运行环境是 Java 17 及以上可以显式指定SecureRandom.getInstanceStrong()如果只是想拿到一个安全的默认实现new SecureRandom()就够了。强随机算法在某些场景会阻塞反而不适合请求链路里直接调用需要结合业务做取舍。6. 面试里围绕 Random 的高频追问与答题思路Java 面试对 Random 的考察通常围绕六个角度展开这里我把常见问题和能加分的答法整理出来。6.1 追问一Random 是线程安全的吗是。Random 内部用AtomicLong保存种子状态next()方法通过 CAS 更新所以并发调用不会产生种子错乱。但线程安全不等于性能好高并发下 CAS 竞争严重所以 JDK 7 之后引入了ThreadLocalRandom。答出这两层基本就覆盖了考点。6.2 追问二nextInt(10) 和 nextInt() % 10 的区别前者使用拒绝采样保证均匀分布后者存在取模偏差当 2^32 不能整除 10 时部分余数的出现概率会略高。应该补充一句实际的业务单次调用这个偏差微乎其微但大量数据统计时会导致分布倾斜。这样回答显得既有原理又有工程视角。6.3 追问三Math.random() 和 Random.nextDouble() 是什么关系Math.random()底层就是静态持有的new Random().nextDouble()两者本质相同。在多线程高并发场景Math.random()同样存在竞争问题。可以顺带提一句替代方案ThreadLocalRandom.current().nextDouble()。6.4 追问四如果两个 Random 对象种子相同生成的序列一样吗一定一样。这是 LCG 的属性也是单元测试可复现性的基础。加分点在于指出这同时也意味着攻击者可以基于输出反推种子所以安全场景不能用 Random。6.5 追问五为什么 Random 叫伪随机因为它是确定性的数学公式给定种子就能推导出完整序列。真正的随机性来自外部熵源SecureRandom才更接近真随机。但不能把伪随机理解成假随机在统计学意义上Random 的输出已经能通过大多数均匀性测试。6.6 追问六除了 Random 你还了解哪些随机生成器可以提ThreadLocalRandom、SplittableRandomJDK 8 新增用于 ForkJoinPool 并行流场景、SecureRandom。如果能再说一句SplittableRandom 的性能比 ThreadLocalRandom 更高但它的实例不应被多线程共享就是很明显的加分项。6.7 追问七SecureRandom 一定安全吗安全的定义是相对的。SecureRandom的安全性依赖于熵源质量和算法实现它排除了从输出反推种子的攻击路径但如果随机数被直接暴露给攻击者多次任何随机生成器都会被压缩部分熵。所以工程上还要配合长度限制、使用次数限制、失效时间控制才能构成完整的防护体系。我在实际项目中最后沉淀下来一套做法也挺简单的非安全场景统一用ThreadLocalRandom安全场景统一用静态的单例SecureRandom测试场景用固定种子的Random然后所有入口收敛在一个RandomUtils里不让业务代码直接 new。做了这个收敛之后我再也没有踩过随机数导致的线上问题排查这类问题的成本也降到了基本为零。如果你们也在维护一个长期迭代的 Java 项目建议尽早把这一步做了收益比想象中大得多。
返回列表