标准库里最容易踩坑的不是 API 数量,而是几个反复出现的语义:字符串不可变、常量池、时间不可变、BigDecimal 不等于浮点。把这几条钉住,剩下的按用途查表即可。
一、字符串处理
String 不可变——所有“修改”方法都返回新串,原串不变。这条性质换来三件实际好处:字符串会参与类加载、网络协议和密钥这类敏感数据,内容不可改才不会在别处被悄悄篡改;不可变对象可以安全跨线程共享;相同字面量才能共享常量池里的同一个对象。
| 场景 | 选择 |
|---|---|
| 常量、配置键、错误消息 | String |
| 循环、频繁累加拼接 | StringBuilder |
| 多线程共享的可变容器 | StringBuffer(方法带同步) |
| 多值连接 | String.join(",", "A", "B", "C") |
+ 从左向右求值,一旦有一侧是 String 就转为拼接,这是初学者最常算错的地方:
1 + 2 // 3
"1" + 2 // "12"
1 + 2 + "3" // "33",先算出 3 再拼接
"v=" + 20 + 20 // "v=2020"
"v=" + (20 + 20) // "v=40"常量池与 intern 是必须理解的一段机制:字面量 "hello" 创建时先查池,没有就放进去、已有就复用;new String("hello") 明确要求再造一个对象,即便池里已经有。
String a = "hello";
String b = "hello";
String c = new String("hello");
String d = c.intern();
a == b // true,字面量复用同一对象
a == c // false
a.equals(c) // true,内容相同
a == d // true,intern 返回池中该内容的规范引用intern() 准确说是“返回常量池中与该内容对应的规范引用”,并非把 new 出来的对象搬进池。业务上比较内容永远用 equals。
常用方法按用途成组记,比逐个背快:
| 类别 | 方法 |
|---|---|
| 取长与取字符 | length() charAt(i) isEmpty() isBlank() |
| 截取 | substring(begin, end) strip() lines() |
| 查找 | indexOf lastIndexOf contains startsWith endsWith |
| 变换 | toUpperCase toLowerCase replace concat |
| 拆分合并 | split String.join formatted |
注意:索引从 0 开始;
substring(begin, end)的end不含在内,"abcdef".substring(1, 3)得到"bc";越界抛StringIndexOutOfBoundsException而不是返回null;trim()只去 ASCII 空格,新代码优先strip()。
多行文本用文本块(Java 15):
String json = """
{"name":"John","age":30}
""";三个双引号后必须换行;共同缩进会被去掉,结束分隔符的位置决定末尾换行是否保留。
二、日期与时间
旧 Date / Calendar 的问题很明确:可变、非线程安全、Calendar 的月份从 0 开始。新代码统一用 java.time,它全部是不可变类型——运算返回新实例,原值不动。
| 类型 | 存什么 | 关键方法 |
|---|---|---|
LocalDate |
年月日,无时区 | of now plusDays withYear isBefore |
LocalTime |
时间,无日期 | of withHour |
LocalDateTime |
本地钟面,无时区 | of toLocalDate toLocalTime |
ZonedDateTime |
钟面 + 时区 + 偏移 | atZone withZoneSameInstant |
Instant |
UTC 时间点 | now toEpochMilli atZone |
DateTimeFormatter |
格式规则 | ofPattern format parse |
LocalDate today = LocalDate.of(2018, 2, 1);
today.plusDays(100); // 2018-05-12,新实例,today 不变
today.isLeapYear(); // false
today.withYear(2016); // 2016-02-01
long days = ChronoUnit.DAYS.between(today, LocalDate.now());跨时区才需要 ZonedDateTime:本地时间挂上时区得到偏移,形如 2026-09-24T12:00+08:00[Asia/Shanghai];换算用 withZoneSameInstant,时间点不变、钟面变化。
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime parsed = LocalDateTime.parse("2026-09-24 12:00:00", fmt);
Instant.atZone(ZoneId.of("Asia/Shanghai")); // 2026-09-24T12:00+08:00[Asia/Shanghai]LocalDate.of(2018, 2, 1) 里月份是 2 而不是 1——这正是 java.time 相对 Calendar 少一个坑的地方。
提示:
DateTimeFormatter不可变且线程安全,适合定义成static final全局共享;存库排序用Instant,只在展示层atZone转时区。
三、精确数值与 Optional
BigDecimal 表达十进制精确数,两个要点决定它能否用对:必须从字符串构造,除法要指定精度。
var a = new BigDecimal("0.1");
var b = new BigDecimal("0.2");
a.add(b); // 0.3
a.divide(b, 2, RoundingMode.HALF_UP); // 0.50它同样不可变,add/multiply 都返回新对象,也不支持 + 运算符。判等有个细节:
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0,相等
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false,scale 不同equals 连小数位数一起比,所以比例、金额比较一律用 compareTo(...) == 0。
Optional 把“值可能不存在”写进方法签名,用法见集合篇:ofNullable 包裹、ifPresent 消费、orElseGet 提供惰性默认值、orElseThrow 把缺失当异常。
var user = Optional.ofNullable(findUser());
user.ifPresent(u -> log.info("用户: {}", u));
String name = user.orElseGet(() -> loadDefaultName());四、正则表达式
匹配先记元字符:\d 数字、\w 字母数字下划线、. 任意单字符(换行除外)、* 零次或多次、+ 一次或多次、? 零次或一次、() 分组、{n,m} 重复 n 到 m 次。
"10012345678".matches("\\d{11}"); // true
"abc_123".matches("\\w+"); // true四条使用规则:
String.matches()要求整个字符串匹配,适合校验;找子串要用Pattern+Matcher.find()。- 分组从 1 开始编号,0 号是整体;具名分组
(?<year>\d{4})比数字下标好读,用group("year")取。 - 默认贪婪匹配,
.*会一路吃到最后一个结尾;逐个取出时写成.*?并配合while (find())。 - 切分用
split("\\s*,\\s*"),替换用replaceAll,定位用start()/end()。
Pattern p = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})");
Matcher m = p.matcher("今天 2026-09-24 晴");
if (m.find()) System.out.println(m.group("year")); // 2026注意:Java 字符串里正则的
\d要写成"\\d",那一层反斜杠来自字符串转义,不是正则多写了一杠。
五、文件与 NIO.2
三个角色分工明确:Path 描述一个位置,Path.of 是创建入口,Files 真正执行读取、写入、查询与遍历。
Path dir = Path.of("/tmp", "app", "seed");
Files.createDirectories(dir); // 先建父目录,否则写失败
Path file = dir.resolve("data.txt");
Files.write(file, List.of("apple", "bat", "cat"));目录遍历有三种,覆盖不同需求:
| 方法 | 深度 | 谓词拿到什么 | 适用 |
|---|---|---|---|
Files.list |
不递归,只一层 | Path |
看当前目录内容 |
Files.walk |
递归,需给最大深度 | Path |
扫子树 |
Files.find |
递归,需给最大深度 | Path + BasicFileAttributes |
按大小、修改时间、是否文件过滤 |
try (var stream = Files.find(dir, 3, (path, attrs) ->
attrs.isRegularFile() && path.toString().endsWith(".txt"))) {
stream.forEach(System.out::println);
}Files.find 比 walk 强的地方是谓词能拿到文件属性,不用对每个文件额外调一次 Files.readAttributes。
读整文件有两个快捷方法,默认使用 UTF-8:
String text = Files.readString(file);
Files.writeString(target, text);按大小选读写方式:
| 方法 | 适用 | 说明 |
|---|---|---|
readString / writeString |
KB 级文本 | 一次进出 |
readAllLines |
行处理、数据量可装下内存 | 全部进内存 |
Files.lines |
大文件 | 懒流,必须关闭 |
try (var lines = Files.lines(file)) { // 持有句柄,必须 try-with-resources
long count = lines.filter(l -> l.contains("error")).count();
}警告:
readAllLines遇到百万行文件会把全部内容装进内存;Files.list/walk/find返回的流持有目录句柄,不关闭会一直占用。
六、网络编程:回环先跑通
| 方式 | 连接方式 | 写法 | 适用 |
|---|---|---|---|
| TCP | 长连接,一问一答 | ServerSocket + Socket |
自定义协议 |
| UDP | 无连接发包 | DatagramSocket + DatagramPacket |
丢包无妨的通知 |
| HTTP | 短连接请求响应 | HttpClient + HttpRequest |
调接口、拉数据 |
本地验证一律先在回环上跑通,端口写 0 让系统自动分配,省掉“端口被占”的干扰:
try (var server = new ServerSocket(0)) { // 0 = 系统分配
int port = server.getLocalPort();
try (var socket = new Socket("127.0.0.1", port)) { ... }
}两个真实项目里最容易出问题的点:TCP 是字节流没有消息边界,两条消息可能粘成一条,读端要按行或按长度自己定帧;套接字与流一定要 try-with-resources 收口,否则连接越跑越少。
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
resp.statusCode(); // 200七、XML 与 JSON
XML 用 JDK 内置的 DOM 处理小文件(整树解析、按标签取值),大文件只读或内存吃紧时用 SAX 流式。JSON 没有内置 API,需要引入 Jackson。
// XML 读:内置 javax.xml,无需额外依赖
var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new InputSource(new StringReader(xml)));
var name = doc.getElementsByTagName("name").item(0).getTextContent(); // alan
// XML 写:建 Document → appendChild 挂元素 → setAttribute 设属性 → Transformer 输出
// JSON:需要 com.fasterxml.jackson.core:jackson-databind
var mapper = new ObjectMapper();
var user = mapper.readValue("{\"id\":1,\"name\":\"alan\"}", User.class);
String json = mapper.writeValueAsString(user);| 方式 | 归属 | 适用 |
|---|---|---|
| DOM | JDK 内置 | 小 XML 读写 |
| SAX | JDK 内置 | 大 XML 只读 |
| Jackson | 外部依赖 | JSON 与对象互转 |
警告:
ObjectMapper来自com.fasterxml.jackson.core:jackson-databind,没声明依赖就编译不过;读写统一显式指定 UTF-8,中文乱码先查编码是否两端一致。
八、序列化与对象持久化
Java 原生序列化把对象状态转成字节流,用于存文件、缓存或跨进程传递。
class Rectangle implements Serializable {
private static final long serialVersionUID = 1L;
int length;
int breadth;
transient int area; // 不参与序列化
}try (var out = new ObjectOutputStream(new FileOutputStream("rect.ser"))) {
out.writeObject(rectangle);
}
try (var in = new ObjectInputStream(new FileInputStream("rect.ser"))) {
Rectangle rectangle = (Rectangle) in.readObject();
}四个必须知道的点:
transient字段不写进流,反序列化后是默认值。派生字段(area)、敏感数据(密码、密钥)、本身不可序列化的字段都该标记。serialVersionUID是兼容性标识。新增非 transient 字段通常可以不改;删除字段或改变字段类型属于破坏性变更,需要更新它。不显式声明时结构一变可能导致旧数据无法反序列化。- 反序列化不调用构造器,而是直接恢复先前状态;
Serializable与Externalizable在这一点上行为不同。 Externalizable让你完全控制写什么、怎么写:
public void writeExternal(ObjectOutput out) throws IOException {
out.writeInt(length);
out.writeInt(breadth);
}
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
length = in.readInt();
breadth = in.readInt();
area = length * breadth; // 读回来后重算派生字段
}警告:反序列化本身是危险的入口,不要对不可信数据直接反序列化。跨服务传输数据更常用的选择是 JSON、Protobuf 这类显式格式,而不是原生序列化。
finalize()已在 Java 9 弃用,不要用它管理资源。
九、内置类速览
几个到处都会遇到的内置类,重点是各自的使用方式与陷阱:
| 类 | 常用入口 | 注意事项 |
|---|---|---|
Object |
toString equals hashCode getClass |
所有类的根类;wait/notify 必须在同步上下文中调用 |
System |
getProperty getenv nanoTime exit |
exit 直接结束 JVM,不能当普通返回用 |
Runtime |
getRuntime availableProcessors freeMemory exec |
gc() 只是请求;exec 是异步启动进程 |
UUID |
randomUUID() |
适合主键、事务 ID、会话 ID |
Locale |
Locale.CHINA、getDisplayLanguage |
影响格式化结果,进程内是全局设置 |
Timer |
schedule(task, delay, period) |
延迟执行 + 固定周期重复 |
ThreadLocal |
withInitial set get |
每个线程一份副本,用完记得清理 |
几点展开:
System.getProperty("java.version"); // JVM 系统属性
System.getenv("PATH"); // 操作系统环境变量,来源不同
long start = System.nanoTime(); // 测耗时就用单调递增的纳秒时钟
Runtime.getRuntime().availableProcessors();
UUID.randomUUID(); // 名字里是 UUID,不是 UID计时用 nanoTime 而不是 currentTimeMillis:后者返回的是墙上时钟,可能被系统时间调整影响。
金额展示需要注意 Locale:
NumberFormat us = NumberFormat.getCurrencyInstance(Locale.US);
NumberFormat uk = NumberFormat.getCurrencyInstance(Locale.UK);
System.out.println(us.format(new BigDecimal("12.34")));
System.out.println(uk.format(new BigDecimal("12.34"))); // 金额显示会随地区变化注意:
ThreadLocal在线程池里最容易被忽略的问题是没有清理——线程复用时残留数据会带到下一个任务,用完应调用remove()。
十、易错点总表
| 现象 | 原因 | 处理 |
|---|---|---|
1 + 2 + "3" 得 "33" |
加号从左向右、遇 String 即拼接 | 需要先算就加括号 |
| 金额算错或抛异常 | double 构造 BigDecimal / 除法没给精度 | 字符串构造 + 指定精度与舍入模式 |
equals 判金额不等 |
equals 比较 scale |
比例值用 compareTo 判 0 |
| 时区对不上 | LocalDateTime 不带时区 |
跨时区用 ZonedDateTime + withZoneSameInstant |
| 正则编译不过 | 少写一层反斜杠 | \d 在字符串里写 "\\d" |
| 匹配吃到最后一个结尾 | 贪婪匹配 | 改 .*? 配合 while (find()) |
| 大文件内存溢出 | readAllLines 全量进内存 |
换 Files.lines 流式处理 |
| 目录句柄泄漏 | list/walk/find 的流未关 |
try-with-resources |
| 端口被占 / 消息粘连 | 固定端口 / TCP 无消息边界 | 本地用端口 0;按行或长度定帧 |
ObjectMapper 找不到 |
缺 Jackson 依赖 | 声明 jackson-databind 坐标 |
| 反序列化旧数据失败 | 字段结构变了但 serialVersionUID 未变 |
破坏性变更时更新 UID |
| 金额显示格式随环境变 | 进程级 Locale 影响格式化 |
显式传 Locale 给 NumberFormat |
| 线程池里数据串号 | ThreadLocal 未清理 |
用完调用 remove() |
字符串看不可变与常量池,时间看不可变与时区,金额看 BigDecimal 与 Locale,文件看大小决定整读还是流式,网络看字节流定帧。这几条固定下来,剩下的都是查表。