常用类
Java 初级 · 面向对象
Java 标准库有一批高频类:String/StringBuilder、包装类、Object、日期时间 API。它们几乎出现在每个项目里。本节不只讲「怎么用」,更讲透 String 为什么不可变、常量池如何复用、equals/hashCode 契约与哈希桶定位、Integer 缓存这些底层机制——这些正是「字符串比较用 == 偶尔成立、HashMap 里放自定义对象却找不到」等诡异现象的根因。
一、String:不可变的代价与收益
String 在 Java 9 之后内部用 byte[](「紧凑字符串」Compact Strings,拉丁字符 1 字节/字符,省内存)存储字符,但对外永远是只读的。任何「修改」都产生新对象:
String s = "a";
s += "b"; // 不是改原对象,而是生成新 String "ab",原 "a" 还在为什么 String 要设计成不可变?
这不是随意决定,而是多个底层诉求共同要求的结果:
- 字符串常量池(String Pool)依赖不可变:池子里复用同一个
"abc"对象,若可变,一个地方改了会影响所有引用——灾难。不可变才敢共享。 - 能做 HashMap 的 key:
hashCode()结果恒定,对象放进哈希表后不会因内容变化而「找不到」。可变对象当 key 是经典 bug(见集合一节)。 - 线程安全:多个线程读同一字符串无需加锁。
- 安全:类加载器用字符串表示类名/路径,不可变防止被篡改加载恶意类。
循环里用 += 拼字符串 = 性能灾难
s += x 每次都 new 一个 StringBuilder 再 toString() 出新 String,循环 N 次就是 O(N²) 的对象创建与拷贝。百万次拼接会瞬间吃光内存、拖垮 GC。循环/大量拼接一律用 StringBuilder。
字符串常量池:为什么有的 == 成立
String a = "abc"; // 字面量:去常量池取/建,a 指向池里对象
String b = "abc"; // 同字面量:复用池里同一对象
System.out.println(a == b); // true(都指向池中同一个)
String c = new String("abc"); // new 强制在堆新建一个对象
System.out.println(a == c); // false(一个在池,一个在普通堆)
System.out.println(a.equals(c));// true(内容相同)intern() 方法可把字符串手动入池并返回池中引用(a.intern() == c.intern() 为 true)。总结:比较字符串内容永远用 .equals(),== 是否成立取决于是否都来自池——而「来自池」不可靠(只要有一个 new 或来自网络/数据库就失效)。
二、StringBuilder / StringBuffer:可变字符串
StringBuilder 内部持有一个可扩容的 byte[]/char 缓冲区,所有 append 在同一个对象上修改,最后 toString() 一次生成 String:
StringBuilder sb = new StringBuilder();
sb.append("a").append("b").append("c"); // 同一对象上修改,无中间对象
String result = sb.toString(); // "abc"StringBuilder:非线程安全,但更快,单线程(绝大多数场景)首选。StringBuffer:方法加了synchronized,线程安全但慢,多线程共享同一 builder 时才需要。
三、包装类与 Integer 缓存
int→Integer 等包装类提供 parseInt、缓存等能力。自动装箱调 Integer.valueOf(见变量一节),它缓存 [-128, 127]:
int x = Integer.parseInt("123"); // 字符串转 int
Integer a = 127, b = 127;
System.out.println(a == b); // true(命中缓存,同一对象)
Integer c = 200, d = 200;
System.out.println(c == d); // false(超出缓存,两个不同对象)
System.out.println(c.equals(d)); // true(比内容)拆箱 NPE 与 == 大数陷阱
- 包装类
null拆箱成基本类型会NullPointerException:int i = someInteger;(someInteger 为 null 即炸)。 - 比较包装类内容必须用
equals,不要用==——超过 127 缓存失效后==永远比地址。
四、Object:所有类的根与方法契约
Object 是所有类的父类,它定义的方法在自定义类里常被重写:
toString():默认返回类名@哈希,调试时重写为可读信息。equals(Object):内容相等判断,默认实现就是==(比地址)。hashCode():返回哈希码,默认与对象地址相关。clone()、finalize()(已废弃)、getClass()、notify/wait(并发用)。
equals / hashCode 契约(极其重要)
这是 Java 平台级约定,违反它会导致 HashMap/HashSet 行为错乱:
契约:若
a.equals(b) == true,则a.hashCode() == b.hashCode()必须成立;反之不要求(不同对象可同哈希,即哈希碰撞)。重写了equals就必须重写hashCode。
为什么?因为 HashSet/HashMap 先用 hashCode 定位「桶」,再用 equals 在桶内确认。若两个相等对象 hashCode 不同,会被放进不同的桶,集合认为它们是不同元素——Set 去重失效、Map get 找不到。
class User {
int id;
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User u)) return false;
return id == u.id;
}
@Override public int hashCode() { // 必须同步重写
return Integer.hashCode(id);
}
}只重写 equals 不写 hashCode 的后果
把只重写 equals(按 id 相等)的 User 放进 HashSet,再放入一个「id 相同」的第二个 User——集合会认为它们是两个不同元素(因为默认 hashCode 按地址不同,进了不同桶),去重彻底失效。凡是打算放进 HashMap/HashSet/Hashtable 的对象,equals 与 hashCode 必须成对、一致地重写。
五、日期时间 API:不可变且线程安全
旧 Date / Calendar 是可变且设计老旧,SimpleDateFormat 还非线程安全(多线程共用会出乱码/报错)。Java 8 引入 java.time:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
LocalDateTime now = LocalDateTime.now(); // 不可变对象
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String text = now.format(fmt); // 格式化
LocalDateTime later = now.plusDays(7); // 返回新对象,原 now 不变LocalDate/LocalDateTime/Instant 等都是不可变的(像 String 一样),方法返回新实例,天然线程安全。新的 DateTimeFormatter 也线程安全,可定义为 static final 共享。
六、常见误区与注意点
WARNING
- 循环里用
+=拼字符串:每次新建String,性能极差,改StringBuilder。 String用==比内容:常量池让部分相等看似成立,但来自new/网络就失效,统一equals()。Integer大数用==:超 127 不缓存,==为false,用equals。- 包装类 null 拆箱:
int x = nullInteger;抛 NPE。 - 只重写 equals 不重写 hashCode:放进哈希集合后去重/查找失效。
- 继续使用
Date/SimpleDateFormat:前者可变、后者非线程安全,新项目用java.time。
七、最佳实践
- 字符串频繁拼接:循环内用
StringBuilder(单线程);多线程共享才用StringBuffer。 - 包装类比较一律
.equals();拆箱前判null防 NPE。 - 重写
equals时一并重写hashCode,且两者基于相同字段。 - 日期时间统一
java.time:LocalDate/LocalDateTime+DateTimeFormatter(线程安全)。 - 自定义类重写
toString方便调试;把可能进哈希表的类,equals/hashCode当标配。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
java.lang.StringAPI:https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html- Java 教程 · 数字与字符串:https://docs.oracle.com/javase/tutorial/java/data/index.html
java.time包文档:https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/package-summary.html- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 用
StringBuilder把 1~100 拼成一个逗号分隔、末尾无逗号的字符串(提示:先append第一个,循环里append(",").append(i))。 - 写
User类重写equals和hashCode(按id判断相等),构造两个id相同的User放入HashSet,验证size() == 1;若只重写equals不写hashCode,观察size()变成多少并解释原因。 - 用
LocalDateTime计算「当前时间加 7 天」并格式化输出;对比若用旧SimpleDateFormat在多线程下会有什么问题。 - 解释
String a="abc"; String b=new String("abc");中a==b为false但a.equals(b)为true的内存原因(结合字符串常量池)。
本节小结
String 不可变(Java 9 起用 byte[] 紧凑存储),所有「修改」产新对象;不可变是为了常量池共享、可作 HashMap key、线程安全。== 比地址、equals 比内容,字符串比较永远用 equals。常量池让字面量 "abc" 复用同一对象,new 则在普通堆另建。大量拼接用 StringBuilder(非线程安全、快)/ StringBuffer(线程安全)。包装类 Integer 缓存 [-128,127],比较用 equals、拆箱防 NPE。Object 的 equals/hashCode 必须遵守「相等则同哈希」契约,二者务必同重写,否则哈希集合失效。日期时间首选不可变、线程安全的 java.time。