很多 Java 初学者都有这个困惑单看数组、集合、循环、异常都懂但真要独立写一个像样的项目就不知道从哪里下手。我一直推荐一个被低估的练手项目——双色球大乐透随机选号生成器。它规则清晰、边界明确却能自然牵扯出随机数源选择、不重复抽样算法、参数校验、格式化输出这一串非常工程化的问题。这篇文章就把我从一个 5 行代码工具类迭代到完整可复用工具类的全过程写透包括原理、完整 Java 实现、验证手段和踩过的坑。先说清楚这个项目本质上是解决“从 33 个红球里抽 6 个不重复、从 16 个蓝球里抽 1 个”这类不重复抽样问题。听懂这句话后面所有代码都是围绕它转。文章适合两类人一是学完 Java 基础想找项目练手的初学者二是想把自己的代码写得健壮、可复用、经得起推敲的开发者。下面直接进入正题。1. 需求拆解双色球和大乐透规则背后的算法约束1.1 两条玩法的规则明细把规则说清楚代码才不会写歪。双色球分两个区红球 1~33每注选 6 个蓝球 1~16每注选 1 个。红球内部不允许重复蓝球和红球互相独立。大乐透也是两区前区 1~35每注选 5 个后区 1~12每注选 2 个。前区内部不能重复后区内部也不能重复。注意一个关键点这两个玩法的规则模型是完全一样的都是“从长度为 n 的连续整数区间里等概率抽出 k 个不重复的整数”。所以没必要分别写两套逻辑一个好的抽号方法应该同时服务这两种玩法。双色球就是draw(1, 33, 6)加draw(1, 16, 1)大乐透就是draw(1, 35, 5)加draw(1, 12, 2)。这样一个抽象代码复用率直接拉满。1.2 把需求抽象成一个抽样问题从算法角度拆解随机选号本质上是一个不重复等概率抽样问题从候选集合[start, end]中抽取count个元素要求每个元素被选中的概率严格相等且最终结果互相不重复。很多人第一反应是“随机生成 6 个数就行”但随即就会遇到重复问题如果直接生成 6 个 1~33 的随机整数这 6 个数字可能撞车。要解决碰撞最朴素的想法是生成一个就存一个遇到重复就重新生成——这就是后面要讲的 Set 去重方案。另一种思路是借鉴洗牌把 33 个球排成一排随机打乱顺序然后取前 6 个这就是 Fisher-Yates 洗牌方案。两种方案在 33 选 6 这种小规模下都能跑但背后的性能和适用边界差距很大第 3 章会专门对比。在这里我们可以顺手算一下组合总数对理解需求边界有帮助双色球红球组合数是C(33, 6) 1,107,568再乘 16 个蓝球总组合数是 17,721,088大乐透前区C(35, 5) 324,632后区C(12, 2) 66总组合数是 21,425,712。两个玩法的一等奖概率都在千万分之一这个量级。这说明如果采用“枚举所有组合再随机取一个索引”的思路你得在内存里摆下 1700 多万个组合对象非常不现实。而按号码级别做均匀采样每个号码等概率出现组合自然也是等概率的这才是正确且高效的做法。1.3 需求边界这个工具做什么、不做什么写代码之前必须明确边界否则需求会无限膨胀只做随机选号不做任何预测。历史开奖结果、冷热号、走势图这些概念对下一次开奖没有任何数学意义。不考虑中奖概率加权。所有号码一视同仁。不考虑历史号码过滤。如果你要求“生成的 5 注号码彼此不重复”那是另一个更复杂的全局去重问题本文会在第 5 章简单延伸但不是核心需求。把这个边界想清楚后续设计就不会跑偏。这个工具的价值在于帮你生成一组完全随机的号码组合它承担的是“输出格式合法、概率均匀”的职责而不是替你“分析”什么。2. 随机数源选型Random、ThreadLocalRandom 与 SecureRandom 怎么选2.1 三种随机数源的实现与差异Java 里最常见的三个随机数源我整理了一张表先看结论随机源实现原理线程安全是否可预测性能java.util.Random线性同余生成器LCG48 位种子安全CAS 更新种子可预测高ThreadLocalRandom每个线程独立种子无共享状态安全可预测极高SecureRandom基于操作系统熵源安全不可预测较低Random的实现核心是一个线性同余公式newSeed (oldSeed * 0x5DEECE66DL 0xBL) ((1L 48) - 1)然后取高 32 位作为输出。这种算法速度快但它是伪随机——如果两个实例种子相同生成的序列完全一致。而且理论上一旦别人拿到你足够多的输出值是可以逆向推算出后续序列的。ThreadLocalRandom是 JDK 7 引入的高并发优化版。它不给全局共享的种子加锁而是把种子放到每个线程自己的字段里彻底消除了锁竞争所以在多线程场景下性能最好。但它同样基于确定性算法属于可预测的伪随机。SecureRandom走的是另一条路。它从操作系统的熵源Linux 上是/dev/urandom或更底层的 getrandom 系统调用Windows 上是 CryptoAPI收集真随机性即使你把它的输出全拿走也无法推导出后续内容。代价是每次生成可能涉及系统调用吞吐量比前两者低一个数量级。2.2 我的选型建议与可插拔设计具体到彩票随机选号这个场景说实话Random已经完全够用。毕竟中奖概率千万分之一你拿线性同余生成的伪随机序列和拿系统熵源生成的真随机序列结果对用户来说没有可感知的区别。真正需要SecureRandom的场景是什么是抽奖系统、令牌生成、密码学相关的安全敏感场景——那些地方如果有人能预测序列游戏就结束了。但作为工程师更好的做法是不要把随机源写死。我推荐用构造器注入的方式LotteryUtil util new LotteryUtil(); // 默认 Random LotteryUtil util new LotteryUtil(new SecureRandom()); // 安全场景 LotteryUtil util new LotteryUtil(new Random(42L)); // 固定种子测试复现这样做有三个好处测试时传入固定种子Random(42L)程序每次生成的号码完全一致适合做回归断言。生产环境想升级随机源不用改任何核心逻辑。代码职责更清晰核心抽样算法只依赖Random的抽象能力不关心具体实现。有一个细节要提醒ThreadLocalRandom不是通过new创建的而是通过ThreadLocalRandom.current()获取当前线程自己的实例。所以如果要用它做注入得写成方法内直接调用或者用SupplierRandom的方式动态获取不能简单塞进构造器否则跨线程使用会出问题。这个坑我放到第 6 章单独讲。3. 不重复抽样为什么我更推荐 Fisher-Yates 洗牌法3.1 方案一HashSet 去重重试法先看最直观的写法public static ListInteger drawWithSet(int start, int end, int count) { SetInteger set new HashSet(); Random random new Random(); while (set.size() count) { int num random.nextInt(end - start 1) start; set.add(num); } ListInteger result new ArrayList(set); Collections.sort(result); return result; }这代码逻辑一目了然生成随机数塞进 SetSet 天然去重直到数量够。对于 33 选 6平均只需要调用nextInt约 6.5 次就能完成性能损失可以忽略。但它有个隐患当count接近end - start 1时碰撞会非常严重。比如从 1~35 选 34 个前 33 个都能顺利加入最后一个要反复抽到唯一的那个缺失数字期望重试次数是 35 次。如果极端的1~35 选 35理论上会出现无限重试——虽然概率极小但这是个活锁风险在实时系统里不可接受。从算法复杂度角度Set 方法的时间复杂度是O(k * 平均重试次数)而这个“平均重试次数”会随着k/n增大急剧上升。写通用工具类时这种不确定性是很糟糕的。3.2 方案二部分 Fisher-Yates 洗牌法Fisher-Yates 洗牌是教科书经典把候选数组从后往前遍历每一轮把当前位置和随机位置交换。经过 n 次交换后数组就是完全随机排列的。彩票场景其实不需要把整个数组都洗一遍只需要决定“取哪几个”。于是有更省时间的“部分洗牌”版本public static ListInteger drawWithShuffle(int start, int end, int count) { int total end - start 1; int[] pool new int[total]; for (int i 0; i total; i) { pool[i] start i; } Random random new Random(); // 只需要洗“尾部 count 个位置”前面的位置不关心 for (int i total - 1; i total - count; i--) { int j random.nextInt(i 1); int tmp pool[i]; pool[i] pool[j]; pool[j] tmp; } ListInteger result new ArrayList(count); for (int i total - count; i total; i) { result.add(pool[i]); } Collections.sort(result); return result; }原理很简单每次从[0, i]里随机挑一个下标j把pool[i]和pool[j]交换。因为每轮参与交换的元素集合都在缩小且每一步都是等概率选择所以最终尾部的count个元素构成的子集数学上严格等概率。这个方案的优势无重试、无碰撞。运行次数是固定的count次交换加一次数组遍历。均匀性可证明。每一步nextInt(i1)都是均匀的组合的层次上自然也是均匀的。代码非常短而且逻辑容易用肉眼看明白。代价是需要一个长度为total的int[]数组。对双色球 33 个红球来说微不足道。3.3 方案三Floyd 采样法处理超大区间如果区间非常大比如要从 1 到 1 亿之间选 6 个不重复数字这时候给洗牌法初始化一个 1 亿长度的数组占用 400MB 内存基本不可行。Floyd 采样算法是更好的选择它只用大小为count的集合public static SetInteger drawWithFloyd(int start, int end, int count, Random rnd) { int total end - start 1; SetInteger result new LinkedHashSet(); for (int i total - count; i total; i) { int candidate rnd.nextInt(i 1); if (!result.contains(candidate)) { result.add(candidate); } else { result.add(i); } } return result; }这段代码的精妙之处在于生成[0, i]的随机候选时如果候选已经在集合里就把当前轮次对应的i放进去。用数学归纳法可以证明任意一个元素被选中的概率都是count / total。Floyd 算法复杂度是O(k log k)空间只有O(k)完全不依赖区间长度。但在 33 选 6 这个场景我并不推荐用它因为LinkedHashSet有装箱和哈希开销几乎必然比int[]的洗牌慢。选算法一定要看数据规模不能一上来就堆高级知识。3.4 三种方案如何取舍汇总一下决策依据其实就一张表方案时间复杂度空间复杂度是否重试适用场景Set 去重O(k × 平均重试)O(k)是k 接近 n 时恶化k 远小于 n实现简单部分 Fisher-YatesO(n k)O(n)否n 在可接受内存范围内通用性最强Floyd 采样O(k log k)O(k)否n 极大上亿、k 相对很小我的建议是双色球和大乐透都采用部分 Fisher-Yates。因为 33、35、16、12 这些候选集都非常小初始化数组的代价几乎为零而且换来的是确定性的执行时间和可证明的均匀性对工具类而言这是最稳妥的选择。Floyd 当成储备知识在将来处理超大区间采样时自然会用上。4. 代码实战一个可复用的 LotteryUtil 工具类4.1 环境准备与类设计环境要求很低JDK 8 以上都行我在 JDK 17 上验证过代码完全兼容。不需要任何第三方依赖纯 JDK 直接编译运行。项目结构也很简单src/ me/example/lottery/ LotteryUtil.java LotteryVerify.java Main.java核心类LotteryUtil的设计要点有三条核心方法draw(int start, int end, int count)只解决“区间抽样”一个问题。双色球和大乐透方法只是对draw的组合调用不重复逻辑。随机源通过构造器注入默认Random可灵活换成SecureRandom或固定种子。4.2 核心 draw 方法实现package me.example.lottery; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Random; public class LotteryUtil { private final Random random; public LotteryUtil() { this(new Random()); } public LotteryUtil(Random random) { this.random random; } /** * 从 [start, end] 区间抽取 count 个不重复整数。 * 采用部分 Fisher-Yates 洗牌保证每个子集被抽中的概率完全相同。 */ public ListInteger draw(int start, int end, int count) { if (start end) { throw new IllegalArgumentException(start( start ) must end( end )); } int total end - start 1; if (count 0 || count total) { throw new IllegalArgumentException(count( count ) must between 0 and total); } int[] pool new int[total]; for (int i 0; i total; i) { pool[i] start i; } // 只需要保证“尾部 count 个位置”完成随机交换 for (int i total - 1; i total - count; i--) { int j random.nextInt(i 1); int tmp pool[i]; pool[i] pool[j]; pool[j] tmp; } ListInteger result new ArrayList(count); for (int i total - count; i total; i) { result.add(pool[i]); } Collections.sort(result); return result; } }有两个细节值得说明。第一random.nextInt(i 1)返回的是[0, i]闭区间的随机数因为Random.nextInt(bound)是左闭右开的传i1才能覆盖到i。第二为什么最后要Collections.sort因为用户看号码习惯从小到大排列排序不影响随机性——选出的子集已经确定只是展示顺序变了。4.3 双色球、大乐透封装与输出格式化接着把两种玩法封装成方法同时定义一个不可变的号码对象/** * 双色球红球 33 选 6 蓝球 16 选 1 */ public LotteryTicket doubleColorBall() { ListInteger red draw(1, 33, 6); ListInteger blue draw(1, 16, 1); return new LotteryTicket(red, blue); } /** * 大乐透前区 35 选 5 后区 12 选 2 */ public LotteryTicket superLotto() { ListInteger front draw(1, 35, 5); ListInteger back draw(1, 12, 2); return new LotteryTicket(front, back); } public static class LotteryTicket { private final ListInteger mainNumbers; private final ListInteger bonusNumbers; public LotteryTicket(ListInteger mainNumbers, ListInteger bonusNumbers) { // 防御性拷贝 不可变包装防止外部修改内部状态 this.mainNumbers Collections.unmodifiableList(new ArrayList(mainNumbers)); this.bonusNumbers Collections.unmodifiableList(new ArrayList(bonusNumbers)); } public ListInteger getMainNumbers() { return mainNumbers; } public ListInteger getBonusNumbers() { return bonusNumbers; } Override public String toString() { return format(mainNumbers) format(bonusNumbers); } private String format(ListInteger numbers) { StringBuilder sb new StringBuilder(); for (int i 0; i numbers.size(); i) { if (i 0) { sb.append( ); } sb.append(String.format(%02d, numbers.get(i))); } return sb.toString(); } } }LotteryTicket被设计成不可变对象是因为号码一旦生成就不应该被任何人改掉。即便你拿到getMainNumbers()返回的List试图add或remove也会直接抛UnsupportedOperationException。防御性拷贝加不可变包装是 Java 开发里非常推荐的工程实践。4.4 运行效果写一个入口验证package me.example.lottery; public class Main { public static void main(String[] args) { LotteryUtil util new LotteryUtil(); System.out.println( 双色球 5 注 ); for (int i 0; i 5; i) { System.out.println(util.doubleColorBall()); } System.out.println( 大乐透 5 注 ); for (int i 0; i 5; i) { System.out.println(util.superLotto()); } } }运行后的输出效果如下 双色球 5 注 03 11 18 25 29 33 06 01 05 12 22 26 28 15 07 09 14 17 23 30 02 02 08 13 19 24 32 11 04 10 16 21 27 31 08 大乐透 5 注 03 07 12 24 31 04 09 05 11 17 22 34 02 10 01 09 15 23 28 06 11 06 08 19 27 32 03 12 02 10 16 25 33 01 08String.format(%02d, num)的作用是补零对齐比如03而不是3看票面的体验更自然。注意这个格式化只适用于 1~99 的号码双色球和大乐透区间都满足所以可以安心用。5. 验证算法分布均匀性、卡方检验与性能实测5.1 频次统计肉眼先看分布写完代码只是第一步还要验证算法真的均匀。最简单的验证思路跑足够多期统计每个号码出现的次数看是否都靠近理论期望。以双色球红球为例理论期望次数 总注数 × 6 ÷ 33。如果跑了 50 万注每个红球的理论出现次数是500000 * 6 / 33 ≈ 90909.09。实际统计结果会在期望值附近上下浮动只要大部分号码的偏差不超过几百分之一就先认为没问题。这段验证代码可以直接跑package me.example.lottery; import java.util.HashSet; import java.util.List; public class LotteryVerify { public static void main(String[] args) { int trials 500_000; LotteryUtil util new LotteryUtil(); int[] redCount new int[34]; // 下标 1..33 for (int i 0; i trials; i) { ListInteger red util.draw(1, 33, 6); // 验证一注内不重复 if (new HashSet(red).size() ! 6) { throw new IllegalStateException(发现重复号码); } for (int num : red) { redCount[num]; } } double expected trials * 6.0 / 33; System.out.printf(试验次数: %d, 期望每个号码出现: %.2f 次%n, trials, expected); double chiSquare 0.0; for (int i 1; i 33; i) { double diff redCount[i] - expected; chiSquare diff * diff / expected; System.out.printf(%02d号: %d 次%n, i, redCount[i]); } System.out.printf(卡方统计量: %.4f%n, chiSquare); } }这段代码还顺带做了一个最关键的完整性检查把一注 6 个红球装进HashSet如果去重后数量不是 6说明抽样算法有 bug直接抛异常终止。5.2 卡方检验用统计学判断分布是否均匀肉眼看频次表只能判断“大差不差”要严谨就得用卡方检验。卡方统计量的公式是chi2 Σ (observed - expected)² / expected自由度等于号码个数减 1也就是 32。查卡方分布表自由度 32、显著性水平 0.05 的临界值约为 46.19。如果算出来的chi2 46.19说明在 5% 显著性水平下没有足够证据认为号码分布不均匀反之就要怀疑随机源或算法出了问题。我在实验里跑过一次 50 万注卡方统计量通常落在 25~40 之间远小于 46.19。这个结论和随机数理论是一致的虽然某个号码可能在某一段统计里多出几个但这种波动完全在随机误差允许范围内。你可以在验证代码后面加一行判断double criticalValue 46.19; System.out.println(chiSquare criticalValue ? 结论无充分证据证明分布不均匀 : 结论分布可能存在偏差请检查随机源);这是把数学工具用到代码里的典型思路比单纯“跑了几次看起来没问题”专业得多。5.3 性能实测与结果解读再验证一下性能毕竟如果用户要批量生成几百注不能等半天。我写了一个简单的性能测试连续生成 100 万注双色球测耗时。long start System.nanoTime(); for (int i 0; i 1_000_000; i) { util.doubleColorBall(); } long costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); System.out.println(100 万注耗时: costMs ms);在我本机普通笔记本JDK 17跑下来100 万注大约在 500~800 毫秒之间。这个性能对于个人工具甚至单机服务都完全够用。性能瓶颈主要在int[]初始化和最后的Collections.sort而这两部分都是必要开销。如果你追求极致可以考虑用Arrays.parallelSort但在 6 个元素的排序上并行化的开销反而更大得不偿失。还有一个实用技巧用固定种子做性能回归基准。把new LotteryUtil(new Random(42L))跑 100 万次记录耗时以后每次改了代码再跑一次对比能立刻发现性能退化。6. 避坑指南六个容易翻车的细节6.1 ThreadLocalRandom 的 nextInt 区间是左闭右开很多人第一次用ThreadLocalRandom.current().nextInt(1, 33)以为取的是 1~33实际取的是 1~32。因为nextInt(origin, bound)返回[origin, bound)之间的值bound是不包含的。正确的 1~33 写法是nextInt(1, 34)。这个坑在面试和实际代码里出现的频率都很高值得单独记一笔。6.2 循环里反复新建 Random 的隐患如果你的代码写成这样for (int i 0; i 100; i) { Random r new Random(); int num r.nextInt(33) 1; }在比较老的 JDK 版本里new Random()默认种子取自当前纳秒时间。如果循环执行得足够快多个实例可能拿到相同的种子进而生成完全一样的随机序列。JDK 8 之后修正了种子的生成方式但无论如何复用同一个Random实例都是更好的习惯——既省对象创建开销又避免序列碰撞。6.3 ThreadLocalRandom 实例不能跨线程共享这是很多并发代码里隐蔽的 Bug。ThreadLocalRandom.current()返回的是当前线程专属的上下文官方文档明确说不要在多线程之间共享同一个ThreadLocalRandom实例。如果你把它存到类的静态字段让多个线程一起调用运行结果是不确定的甚至可能抛出异常。所以在多线程批量生成场景下有两种正确姿势不用共享随机源直接在每个线程内部调用ThreadLocalRandom.current().nextInt(...)。使用ThreadLocalRandom包装每个线程持有自己的Random。这也是为什么我推荐构造器注入Random而不是直接写死ThreadLocalRandom——注入的抽象给了你并发替换的余地。6.4 返回不可变列表别把内部集合暴露出去如果LotteryTicket的getMainNumbers()直接返回内部List调用方就能随意clear()或者add()把已经生成的号码改得面目全非。这是 Java 里典型的“封装泄漏”。解法就是我代码里做的构造器里先new ArrayList(传入List)做防御性拷贝再用Collections.unmodifiableList包装不可变。成本极低但能避免一堆诡异问题。6.5 校验参数的边界值尤其是 count 为 0 和 totaldraw方法的参数校验不能只防“负数”和“太大会越界”还要处理两个特殊边界count 0返回空列表不应该抛异常。count total等于把整个区间全选出来洗牌后返回所有号码结果就是[start, end]的完整序列。我在代码里用count 0 || count total做校验恰好让这两种情况都走正常逻辑。这种边界思考方式是写工具类的基本功很多新人容易漏掉count 0的情况导致空指针。6.6 格式化输出保持对齐但别过度封装String.format(%02d, num)能为两位数补零让输出对齐美观这是彩票场景的正确选择。但我见过有人为了通用性把格式化逻辑封装成支持任意位数的“万能工具”越搞越复杂。在这个项目里号码范围明确限定在 1~99%02d就是最优解。工程上要拒绝过度设计能三行写清楚的东西不要去抽象出五个类。另外提醒一个细节格式化之前把号码排序是最舒服的阅读顺序。先排序再格式化这套顺序在LotteryTicket.toString()里已经天然保证。最后再分享一点点个人体会这个工具类我前后迭代过好几版最大的教训不是说算法多难而是“设计模式”和“防御性编程”这类东西看着很高深实际在高价值的小代码里同样值得用。构造器注入、不可变对象、参数校验、边界判断每一行都是经验堆出来的。你要是能把这个 200 行的工具类吃透Java 基础阶段最容易被面试官问到的集合、随机数、可变参数、字符串格式化、异常设计基本都覆盖到了。