最近带的学生项目里,有个小A同学被Educoder平台上一套Java练习搞得有点缩手缩脚,跑过来跟我吐槽:Object类、包装类、String那三个类、还有Random类,上课都听懂了,一到平台在线评测就红一片。我扒着题目看了一圈,发现问题不是语法没背熟,而是这几个知识点正好卡在“看得懂”和“写得出”之间,属于典型的“你以为你会了,但其实你只是看懂了”。
这四关练习,说白了是在帮你补齐Java对象体系里相对底层的一块拼图:Object类告诉你所有类的共同行为长什么样,包装类告诉你基本类型如何混进对象的世界,String三兄弟教你处理字符串时怎么做取舍,Random类则是你写工具方法、做模拟数据时经常要碰的随机数来源。这篇文章我打算站在刷题者加复盘者的角度,把这四关的核心考点、底层原理以及我在实际调试中踩过的坑全部拆开讲一遍,适合刚学完Java语法正准备刷题巩固、或者面试前想快速过一遍基础类库的人。
1. 这套练习到底想考什么?四关之间的逻辑线
别看只是一个平台练习关卡的列表,这四关的排列顺序其实藏了一条线:从“一切皆对象”的根源,到基本类型如何与对象互通,再到最常用的字符串对象家族,最后落到生成随机数据的工具类。四个话题看似独立,实际上是在帮你打通“类、对象、常用API”这条基本功链条。
1.1 为什么第一关是Object类
在Java里,任何一个类不写extends,编译器也会让它默认继承Object。所以Object是所有类的祖宗类。平台把Object类放在第一关,目的很直白:先让你意识到,你写的任何一个自定义类,从诞生那天起就自带一堆可以重写的方法,比如toString、equals、hashCode。这三兄弟在你后面学集合框架时,几乎是绕不开的。我在实际开发里见过不少同事在实体类上只重写toString不重写equals,结果对象丢进Set里出现一堆“重复元素”,排查半天才意识到是hashCode没跟上。
1.2 第二关到第四关在补充什么能力
第二关的包装类解决的是一个对称问题:Java把基本类型和引用类型刻意分开,但实际编码中你又不得不把int当成Object用,比如放进集合、泛型、判空。于是包装类应运而生。第三关的String家族则是日常编码里出现频率最高的对象群,字符串拼接、截取、比较这些操作几乎每天都在写。第四关的Random类相对独立,但它属于“你不知道底层机制也能跑,知道底层机制才能跑得准”的典型。比如生成验证码时,你如果用new Random()每次在循环里调用,很容易出现重复序列;理解了种子机制之后,这个问题就能从根上避免。
1.3 配合平台评测模式来理解做题思路
在线实训平台的评测方式和在公司写业务代码有个明显的差异点:平台只认输出,格式差一个空格、多一个换行都可能被判错。所以刷这类练习时,第一优先级不是把代码写“好看”,而是把题目要求的输出格式看清。我的习惯是先在本地把题目给的示例输入跑一遍,确认输出与自己预想一致,再粘到评测区。这一点在后面第六节我会单独展开。
2. 第1关 Object类:所有类的根上最容易错的三件事
Object类真正需要你动手重写的方法没几个,但每一个都自带经典的坑。平台这一关的题目,十有八九会让你定义一个自定义类,然后实现toString、equals、hashCode中的一个或多个。
2.1 重写toString时,要看清输出模板
很多初学者愣是分不清默认情况和重写情况下的打印结果。直接System.out.println(对象)时,如果没有重写toString,打印的是“全限定类名@散列码”这种形式;重写之后才会按你定义的格式输出。
平台题目的评判逻辑通常是对比字符串。假设题目定义一个Person类,里面有name和age两个字段,要求输出“姓名:xxx 年龄:xx”这样的格式,那么你重写toString时就必须一字不差地把冒号、空格、顿号都对上。我见过有人明明逻辑正确,结果中文冒号写成了英文冒号,整题判错。
2.2 equals与hashCode的约定,比你想的更严格
重写equals的时候,有一个规范是所有Java程序员都该刻在脑子里的:如果两个对象通过equals方法比较是相等的,那么它们的hashCode值也必须相等。反过来不成立:hashCode相等,equals不一定相等。
为什么要有这个约束?因为HashMap、HashSet这类散列集合的第一步是看hashCode决定桶的位置,第二步才是用equals确认桶里有没有相同元素。如果你重写了equals却不改hashCode,两个语义上相等的对象会被分到不同桶里,集合里就会出现“内容重复但检查不出来”的脏数据。平台练习一般会用一个测试用例来验证这个约定,比如创建两个内容相同的对象,把它们都放进一个HashSet,检查集合大小是不是1。
顺带说一句,equals重写时判断类型用instanceof还是getClass()也有讲究。在实体类里,我习惯用getClass()比较严格类型,避免子类继承后被误判为相等;如果业务上允许子类对象与父类对象相等,再考虑instanceof配合字段比较。
2.3 实训题目示例:一个典型自定义类的重写
public class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return "姓名:" + name + " 年龄:" + age; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } Person person = (Person) obj; return age == person.age && name.equals(person.name); } @Override public int hashCode() { int result = name.hashCode(); result = 31 * result + age; return result; } }这段代码里最重要的一行是return age == person.age && name.equals(person.name),它先用基本类型比较age,再调用String的equals去比较name。有人会手滑写成name == person.name,这一写就直接出bug了——因为==在引用类型上比较的是地址,不是内容。平台评测时,只要两个Person对象name相同但来自不同引用,你的equals就会返回false,题目直接扣分。
3. 第2关 包装类:自动装箱与“==”陷阱
包装类是八个小兄弟的“对象化变身”,目的很简单:让基本类型也能在需要Object的地方出现。但包装类带来的最大迷惑点,是自动装箱和自动拆箱似乎让基本类型和包装类型“长得一样”了,结果一个==判断就把多少人打得措手不及。
3.1 自动装箱与拆箱的底层手法
自动装箱其实是你写了Integer i = 100时,编译器帮你悄悄变成了Integer i = Integer.valueOf(100)。自动拆箱则是你写了int j = i + 1时,编译器帮你调用i.intValue()得到int值再进行运算。所以装箱拆箱不是魔法,而是语法糖。
平台练习大概率会让你回答“下列代码输出什么”:
Integer a = 127; Integer b = 127; System.out.println(a == b); Integer c = 128; Integer d = 128; System.out.println(c == d);输出结果会非常反直觉:第一个打印true,第二个打印false。原因是Integer类内部维护了一个缓存池,范围是-128到127。在这个范围内,valueOf不会新建对象,而是直接返回缓存里的同一个对象;超出范围,才会new一个新的Integer。所以a和b指向同一个缓存对象,而c和d是两个不同对象,==自然就false了。
3.2 缓存机制不是所有包装类都有
需要注意,Byte、Short、Integer、Long这四种整数型包装类都有缓存机制,范围也是-128到127;Character缓存的是0到127;Boolean直接缓存了true和false两个实例。但是Float和Double没有缓存,因为它们能表示的数值密度太大,缓存没有意义。所以Float f1 = 1.0f; Float f2 = 1.0f; f1 == f2的结果是false,这一点在练习里也是个隐蔽考点。
3.3 比较包装类值,什么时候用==什么时候用equals
我的建议是无脑用equals,除了一个特例:当你确保比较的值的范围在缓存区间内、且你非常清楚底层是同一批对象的时候,可以用==。但实际写业务代码时,你真的没必要去赌缓存范围,直接用equals或者包装类的compareTo方法更稳。
另外还有一个高频考点:三元运算符会造成包装类的自动拆箱和类型提升。
Integer num = null; boolean flag = false; int result = flag ? 0 : num;这段代码运行时,你会拿到NullPointerException。因为三元运算符要求第二和第三操作数统一类型,flag为false时会走num这个分支,然后需要把Integer拆箱成int,于是对null调用了intValue(),直接NPE。很多人在练习里栽跟头,就是只看到了“0”和“Integer”,没意识到拆箱步骤的存在。没过脑子就想当然地认为result会等于0,但实际上代码在取num的值时就崩溃了。这一关的实训题里,如果涉及null的安全判断,务必先把装箱对象判空再做数值运算。
4. 第3关 三个字符串类:不可变、可变与线程安全的取舍
String、StringBuilder、StringBuffer是Java里被问烂的一个“三连”。很多人背住了结论:String不可变、StringBuilder线程不安全但快、StringBuffer线程安全但慢。但实训题不会只问结论,它会拿具体场景让你选型,或者让你判断某段代码的结果。
4.1 三个类的本质区别
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可否修改 | 不可变,任何修改都生成新对象 | 可变,直接修改内部字符数组 | 可变,直接修改内部字符数组 |
| 线程安全 | 不可变天然线程安全 | 非线程安全 | 方法级synchronized,线程安全 |
| 性能 | 大量拼接时最差 | 拼接性能最好 | 比StringBuilder略慢 |
| 典型场景 | 字符串常量、少量拼接、作为HashMap的key | 单线程环境下的字符串拼接 | 多线程共享同一个可变字符串 |
为什么String不可变却适合做HashMap的key?因为不可变意味着对象哈希值稳定。如果key可变,放进HashMap后再被改掉,hashCode就变了,而桶的位置是基于原来的hashCode算的,后面就再也找不到这个key了。
4.2 String的拼接陷阱与编译器优化
很多人以为字符串用“+”拼接是最简单的,实际上编译器对“+”的处理分两种情况:如果拼接的双方都是字符串常量,编译器会直接合并成一个常量,比如"a" + "b"直接变成"ab";如果拼接里出现了变量,编译器就会自动转成类似new StringBuilder().append(...).append(...).toString()的形式。所以短代码里“+”并不会慢得离谱,但如果你在循环里反复拼接,问题就大了。
看下面这段代码:
String result = ""; for (int i = 0; i < 10000; i++) { result = result + i; }每次循环都会创建一个新的StringBuilder,然后append再toString,生成的临时对象数量非常可观。平台练习里如果让你比较两种拼接方式的性能差异,大概率就是考这个点。正确的做法是:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();4.3 StringBuilder的扩容机制与题点
StringBuilder构造时默认容量是16个字符,如果你append的内容超过了当前容量,它会自动扩容。扩容的大致策略是取“原容量乘以2再加2”和“实际所需最小容量”的较大值,然后调用Arrays.copyOf把旧数据搬进新数组。这个机制你在写业务代码时一般不用深究,但实训题如果考到“底层是字面量数组/StringBuilder扩容方向”这种判断题,你需要知道它并不是每次append都新建底层数组,而是有预分配和增量扩容的。
4.4 字符串常见API的考点
这一关练习往往会混合考察String的常用方法,比如length、charAt、substring、indexOf、equals、replace、split等。有两个细节特别容易翻车:
第一,substring的参数是左闭右开,即substring(0, 3)取的是索引0、1、2三个字符,不包含3。
第二,String的equals比较的是内容,而==比较的是引用。如果你用==去比较两个内容相同的String,可能true也可能false,取决于它们是否都指向常量池中的同一个对象。new String("abc")产生的是一个新对象,和字面量"abc"用==比较结果是false。这个点在String类练习里属于必考级别,几乎每个版本的题目都会出现。
5. 第4关 Random类:伪随机数的原理与边界控制
Random类表面上看就是一个“能生成随机数的工具”,但要真正把这关练习做明白,你得理解一个概念:它不是真正的随机,而是伪随机。所谓伪随机,就是它内部维护着一个“种子数”,每次生成新数都基于当前种子进行一系列数学运算,算出一个结果,再把种子更新。只要种子确定,生成的序列就完全可复现。
5.1 种子机制决定一切
new Random()不传参数的时候,种子用的是当前系统时间的纳秒值。因为每次运行时间不一样,所以序列看起来是随机的。但如果你显式指定种子,比如new Random(42L),那么每次运行生成的序列都一样。这在实际开发里有个好处:复用性。比如做测试数据,你想让每次跑测试都生成相同的数据集,就指定固定种子。平台题目很可能让你判断“两个相同种子的Random对象各自nextInt(),结果是否相同”,答案是相同。
5.2 常用方法与区间控制
| 方法 | 返回值范围 | 说明 |
|---|---|---|
| nextInt() | -2^31 到 2^31-1 | 完整int范围 |
| nextInt(n) | 0 到 n-1 | 左闭右开,n必须为正 |
| nextLong() | 完整long范围 | 无参 |
| nextDouble() | 0.0 到 1.0(不含1.0) | 左闭右开 |
| nextBoolean() | true或false | 按概率各半 |
你如果想生成一个[min, max)区间的随机整数,有一个常用公式:
int randomNum = random.nextInt(max - min) + min;比如生成5到10之间的随机数(包含5,不包含10),就是random.nextInt(5) + 5。这个公式在实训题里大概率会出现,因为平台经常用“猜数字”“随机点名”之类的场景来包装这个知识点。
注意,nextInt(n)的n必须是正数,如果你传0或负数,会抛出IllegalArgumentException。所以写完区间公式后,一定要确认max - min大于0。
5.3 多线程环境下为什么推荐ThreadLocalRandom
Random类是线程安全的,因为它内部用CAS更新种子。但正因为线程安全,在高并发下会有比较明显的竞争开销。如果每个线程都共享同一个Random实例,性能并不理想。Java并发包里提供了ThreadLocalRandom(属于多线程进阶话题),它让每个线程维护独立的Random实例,从而避免竞争。虽然平台这一关通常不考线程安全,但你在做扩展练习题或者面试聊到Random时,千万别张嘴就说“Random线程不安全”,这个说法是错的。
另外还要注意一个常见的错误写法:在循环里反复new Random()。由于无参构造取的是当前时间,如果循环速度极快,两个Random实例的种子可能一样,于是生成的随机数序列一模一样。我在实际项目里见过有人写循环生成验证码,结果连续两次生成了一模一样的数字,后来排查发现就是循环里new Random()导致的。正确做法应该是复用同一个Random对象,或者直接用ThreadLocalRandom.current().nextInt(...)。
6. 刷题实录:易错点、报错信息与排查思路
这一节是纯实战向的内容,我把自己当年刷这类练习时最有代表性的几个翻车现场整理成速查表,也把排查思路讲清楚。哪怕你不刷这套题,换个平台换套题,这些坑大概率也长一样。
6.1 高频错误速查表
| 错误现象 | 常见原因 | 排查建议 |
|---|---|---|
| 编译失败:Cannot override toString | 方法签名写成了toString()带了参数,或者返回类型不是String | 检查方法名、参数列表、返回值,保证和Object签名一致 |
| equals比较结果与预期不符 | 直接用了引用类型==,或者没有判空 | 全部改用equals,并在equals开头判this == obj和obj == null |
| 输出格式不符被判错 | 冒号、空格、换行、全半角字符不一致 | 先把示例输出原样复制到文本编辑器里逐字符对,再改代码 |
| 包装类比较结果反直觉 | Integer值超过127或使用了Float/Double | 用equals比较,不要用== |
| 拼接性能有明显卡顿 | 循环里用String的+拼接 | 改成StringBuilder append |
| 随机数序列重复 | 循环里重复new Random() | 把Random对象提到循环外 |
6.2 我在实训过程里的三条避坑心得
第一,println到底打什么,别“猜”,去“读”。题目要求输出“姓名:xxx 年龄:xx”,那就严格对照这个模板。最后一段用println还是print,也要看题目是要求换行还是纯字符串对比,平台评测一般对结尾换行不会太苛刻,但有时会。保险起见,把文档里的输出样例当作正则表达式来精读。
第二,代码里只要涉及两个对象相等判断,先停下来问自己:这里是引用比较还是内容比较?这个问题值得每次写代码都过一遍,因为99%的编程新手在初期都会在==和equals上犯晕。特别是字符串和包装类,这两个坑在项目里至今最常出现。
第三,本地调试时如果你看不到平台具体的测试用例,就自己写几个边界值的测试,比如null、空串、最小值、最大值、缓存边界127/128、随机数的边界值等。实训平台的隐藏用例几乎全部由边界值组成,把这些场景测一遍,基本能覆盖评测定的大部分坑。
最后再分享一个小技巧:这套练习我建议配合着IDE的调试功能来过,遇到结果不一致就在equals、hashCode、StringBuffer的append这些方法调用处打断点,一步步看对象的引用地址和字段值。比对着控制台干想高效得多。刷完这四关,再去翻集合框架的源码,你会发现HashMap、ArrayList的很多设计都在为这些基础类库做铺垫,那时候回头看,这套题的价值就不是“过一遍”这么简单了。