Skip to content

常用类 ​

Java 初级 · 面向对象

Java 标准库有一批高频类:String/StringBuilder、包装类、Object、日期时间 API。它们几乎出现在每个项目里。本节不只讲「怎么用」,更讲透 String 为什么不可变、常量池如何复用、equals/hashCode 契约与哈希桶定位、Integer 缓存这些底层机制——这些正是「字符串比较用 == 偶尔成立、HashMap 里放自定义对象却找不到」等诡异现象的根因。

一、String:不可变的代价与收益 ​

String 在 Java 9 之后内部用 byte[](「紧凑字符串」Compact Strings,拉丁字符 1 字节/字符,省内存)存储字符,但对外永远是只读的。任何「修改」都产生新对象:

java
String s = "a";
s += "b";          // 不是改原对象,而是生成新 String "ab",原 "a" 还在

为什么 String 要设计成不可变? ​

这不是随意决定,而是多个底层诉求共同要求的结果:

  1. 字符串常量池(String Pool)依赖不可变:池子里复用同一个 "abc" 对象,若可变,一个地方改了会影响所有引用——灾难。不可变才敢共享。
  2. 能做 HashMap 的 key:hashCode() 结果恒定,对象放进哈希表后不会因内容变化而「找不到」。可变对象当 key 是经典 bug(见集合一节)。
  3. 线程安全:多个线程读同一字符串无需加锁。
  4. 安全:类加载器用字符串表示类名/路径,不可变防止被篡改加载恶意类。

循环里用 += 拼字符串 = 性能灾难

s += x 每次都 new 一个 StringBuilder 再 toString() 出新 String,循环 N 次就是 O(N²) 的对象创建与拷贝。百万次拼接会瞬间吃光内存、拖垮 GC。循环/大量拼接一律用 StringBuilder。

字符串常量池:为什么有的 == 成立 ​

java
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:

java
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]:

java
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 找不到。

java
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:

java
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。

七、最佳实践 ​

  1. 字符串频繁拼接:循环内用 StringBuilder(单线程);多线程共享才用 StringBuffer。
  2. 包装类比较一律 .equals();拆箱前判 null 防 NPE。
  3. 重写 equals 时一并重写 hashCode,且两者基于相同字段。
  4. 日期时间统一 java.time:LocalDate/LocalDateTime + DateTimeFormatter(线程安全)。
  5. 自定义类重写 toString 方便调试;把可能进哈希表的类,equals/hashCode 当标配。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 用 StringBuilder 把 1~100 拼成一个逗号分隔、末尾无逗号的字符串(提示:先 append 第一个,循环里 append(",").append(i))。
  2. 写 User 类重写 equals 和 hashCode(按 id 判断相等),构造两个 id 相同的 User 放入 HashSet,验证 size() == 1;若只重写 equals 不写 hashCode,观察 size() 变成多少并解释原因。
  3. 用 LocalDateTime 计算「当前时间加 7 天」并格式化输出;对比若用旧 SimpleDateFormat 在多线程下会有什么问题。
  4. 解释 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。