Java 从入门到精通(五):字符串与常用类——String、包装类、日期时间与 BigDecimal

本文是《Java 从入门到精通》系列第五篇,默认读者已经掌握类与对象、异常、集合基础。全文代码均在 JDK 8 与 JDK 17 上实测通过,涉及版本差异的地方会明确标注。建议边读边把代码粘进 IDE 跑一遍,尤其是 ==、intern()、BigDecimal 构造器这几段,光看结论很容易记反。文中出现的 0.1 + 0.2 != 0.3 不是 bug,而是 IEEE 754 的必然结果。

一、String 的本质:不可变对象与内存布局

1.1 不可变性是如何实现的

String 的不可变(immutable)不是靠”约定”,而是靠三重硬约束:类被 final 修饰无法被继承、内部存储数组被 private final 修饰、以及没有任何对外暴露的修改入口——所有看起来会”改”字符串的方法(concat、replace、substring、toUpperCase)在 JDK 7u6 之后全部返回新对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {

// JDK 8:char 数组,每个字符固定 2 字节
// private final char value[];

// JDK 9+:byte 数组 + coder 编码标记(JEP 254 Compact Strings)
private final byte[] value;
private final byte coder; // 0 = LATIN1,1 = UTF16

/** 懒计算的 hash 缓存 */
private int hash;

static final byte LATIN1 = 0;
static final byte UTF16 = 1;
}

这里有个值得注意的细节:hash 并不是 final 的,它是惰性缓存。多线程同时首次调用 hashCode() 时可能重复计算,但结果恒等,所以即便存在数据竞争也不破坏不可变语义——这是”不可变对象的惰性缓存”的经典写法。

不可变性带来四个实打实的好处:线程安全(无需同步即可共享)、可安全作为 HashMap 的 key(hash 不会变,不会出现放进去取不出来的情况)、支持常量池复用、可作为安全参数传递(不用担心被调用方篡改)。类加载器、网络连接参数、数据库连接 URL 都依赖这个特性。

顺着 HashMap 这个点再深入一层:如果 String 可变,那么把一个字符串作为 key 放入 HashMap 之后再去修改它的内容,它的 hashCode 就会变化,而桶位却还停留在旧位置,get 时按新 hash 算出的下标必然落空——这个 key 就永久”失联”了,且还会因为没有被清理而造成隐性内存泄漏。JDK 在 String 类里缓存 hash 字段正是为了加速这一路径:同一个字符串在一次 JVM 生命周期内只需计算一次哈希,这让以字符串为 key 的 Map 在高频查找场景下非常高效。也正因为如此,任何打算放进 HashSet 或用作 Map key 的对象,都应该尽量设计成不可变的,这是 String 给所有 Java 类上了一堂课。

1.2 从 char[] 到 byte[]:JDK 9 Compact Strings

JDK 8 的 String 用 char[] 存储,每个 char 占 2 字节。但统计表明,绝大多数 Java 应用中的字符串都是纯拉丁字符(ASCII/Latin-1),另一半字节永远是 0。JEP 254 引入的 Compact Strings 把存储改成 byte[],并用 coder 字段标记编码方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.lang.reflect.Field;

// JDK 9+ 模块强封装,运行时需加 JVM 参数:
// --add-opens java.base/java.lang=ALL-UNNAMED
public class CompactStringsDemo {
public static void main(String[] args) throws Exception {
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);

String latin = "hello"; // 纯拉丁,coder = LATIN1
String cjk = "你好"; // 含中文,coder = UTF16

System.out.println("latin 字节数 = " + ((byte[]) valueField.get(latin)).length);
System.out.println("cjk 字节数 = " + ((byte[]) valueField.get(cjk)).length);
System.out.println("latin 字符数 = " + latin.length());
System.out.println("cjk 字符数 = " + cjk.length());
}
}
// JDK 17 输出:
// latin 字节数 = 5 (1 字节/字符)
// cjk 字节数 = 4 (2 字节/字符,UTF16)

这个改造的收益是纯内存的:字符串通常占 Java 堆的 25%~40%,Compact Strings 让纯拉丁场景的 String 体积直接下降约 50%,GC 扫描与复制压力同步下降。代价是 String 内部所有方法都要按 coder 分支处理,JDK 为此引入了 StringLatin1 与 StringUTF16 两个静态工具类。若想退回旧布局可用 -XX:-CompactStrings 关闭,但一般不推荐。

1.3 字符串常量池与堆的分工

字符串的创建有两条截然不同的路径:

  • 字面量(String s = "abc";):编译期进入 class 文件的 CONSTANT_String_info 常量表,类加载时”驻留”(intern)到字符串常量池(String Table),运行期直接返回池中引用。
  • new String("abc"):编译期同样会把 "abc" 放进池,但运行期一定会在堆上再 new 一个独立对象,其 value 指向同一份数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StringPoolDemo {
public static void main(String[] args) {
String a = "hello"; // 常量池
String b = "hello"; // 常量池,复用同一引用
String c = new String("hello"); // 堆上新对象
String d = c.intern(); // 返回池中引用

System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a == d); // true
System.out.println(a.equals(c)); // true,equals 只比值
System.out.println(a == c.intern()); // true
}
}

内存布局可以用下面这段文本示意(JDK 7 及以后):

1
2
3
4
5
6
7
栈 frame                    字符串常量池(JDK 7+ 位于堆中)          Java 堆
a ──────────────────────► "hello" ── value ──┐
b ──────────────────────► (同一引用) │
▼
byte[]{'h','e','l','l','o'}
c ──────────────────────────────────────► String 对象 ─┘(共享同一 byte[])
d ── c.intern() ─────────► "hello"

注意版本差异:JDK 6 及以前,字符串常量池位于永久代(PermGen),大小固定、GC 不积极,大量调用 intern() 极易导致 OutOfMemoryError: PermGen space。JDK 7 把字符串常量池搬到了 Java 堆,JDK 8 用元空间(Metaspace,本地内存)取代永久代,但常量池仍然留在堆中,所以 JDK 8+ 的常量池会参与 GC,可以被回收。

intern() 的语义在 JDK 7 也发生了微妙变化:JDK 6 中池里没有时会复制一份字符串放进永久代;JDK 7+ 中只把堆上那个 String 对象的引用登记进 pool(不复制),因此 JDK 7 之后首次 intern 时 s.intern() == s 也可能为 true。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class InternJdkDiff {
public static void main(String[] args) {
// 编译期无法折叠,运行期才生成 "ab"
String s = new String("a") + new String("b");
String t = s.intern(); // JDK 7+:池中原本没有 "ab",登记 s 自身的引用
System.out.println(t == s); // JDK 7+ => true;JDK 6 => false
// 注意:"a"、"b" 两个字面量本身已经进池了

// 经典陷阱:JVM 内部早就 intern 过 "java"
String j = new StringBuilder("ja").append("va").toString();
System.out.println(j.intern() == j); // false,池中已存在 "java"
}
}

intern() 的冷知识:String::intern 是 native 方法,底层依赖 StringTable 哈希表,冲突严重时查找会退化。池中的字符串通过弱引用机制登记,在没有其他强引用时可以被 GC 回收,这也是 JDK 7 把常量池搬到堆之后”intern 导致 OOM”风险大幅下降的原因。但无节制 intern 仍会拖慢 GC 与哈希查找,生产环境应谨慎;需要去重时优先考虑自己维护一个 ConcurrentHashMap 或者 -XX:+UseStringDeduplication(G1 的重复字符串去重)。

1.4 不可变 ≠ 不能被改:反射的黑魔法

严格来说反射可以破坏不可变性,但属于”玩火”,仅用于理解原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.lang.reflect.Field;

// 同样需要 --add-opens java.base/java.lang=ALL-UNNAMED
public class BreakImmutable {
public static void main(String[] args) throws Exception {
String s = "abc";
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
byte[] v = (byte[]) f.get(s); // JDK 8 这里拿到的是 char[]
v[0] = 'x';
System.out.println(s); // xbc
System.out.println("abc"); // 同样输出 xbc!因为共享常量池
}
}

这段代码一旦执行,整个 JVM 里所有值为 "abc" 的字面量都会变成 "xbc",足以说明字符串不可变对 JVM 安全模型(类名、权限检查、反射缓存)有多重要。生产环境绝对不要这么做。

二、字符串常用 API 与那些年踩过的坑

2.1 方法速查表

方法 作用 备注
length() 字符数(UTF-16 code unit 数) emoji 占 2,精确计数用 codePointCount
isEmpty() / isBlank() 长度为 0 / 全为空白 isBlank 为 JDK 11+
charAt(int) 取单个字符 越界抛 StringIndexOutOfBoundsException
substring(a,b) 左闭右开截取 JDK 7u6+ 会复制数组
indexOf / lastIndexOf 查找下标,未找到返回 -1 有 char 与 String 重载
contains(CharSequence) 是否包含子串 字面量匹配,非正则
startsWith / endsWith 前缀 / 后缀判断 -
replace(a,b) 字面量替换全部 不是正则
replaceAll(regex,rep) 正则替换全部 rep 中的 $ 有分组含义
replaceFirst(regex,rep) 正则替换首个 -
split(regex) 正则切分,默认丢弃尾部空串 见 2.2
trim() / strip() 去首尾空白 见 2.3
toUpperCase() 转大写 建议带 Locale 参数
join(delim, elems) 拼接(静态方法) 内部用 StringJoiner
repeat(int) JDK 11+ 重复 n 次 n < 0 抛异常
lines() 按行切分成 Stream JDK 11+
format / formatted 格式化 见 2.4
toCharArray / getBytes 转数组 getBytes 必须指定字符集
equals / equalsIgnoreCase 内容比较 equalsIgnoreCase 不依赖 Locale

2.2 substring 与 split 的两个陷阱

陷阱一:JDK 6 的 substring 内存泄漏。 JDK 6 的 String 由 char[] value、int offset、int count 三元组构成,substring 直接复用原数组、只改 offset/count。好处是 O(1),坏处是:从一个很大的字符串中截出 3 个字符,那份大数组仍然被小字符串强引用着,无法 GC。JDK 7u6 之后 substring 改为 new String(value, beginIndex, endIndex - beginIndex),即复制一份新数组,泄漏消失,代价是变成 O(n)。维护 JDK 6 老系统时,解决方式是 new String(big.substring(0, 3)) 显式切断引用。

陷阱二:split 的参数是正则。 split 的签名是 split(String regex),而 . 在正则里表示”任意字符”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.Arrays;
import java.util.regex.Pattern;

public class SplitTrap {
public static void main(String[] args) {
String ip = "192.168.1.1";

System.out.println(Arrays.toString(ip.split("."))); // [] 点号未转义
System.out.println(Arrays.toString(ip.split("\\."))); // [192, 168, 1, 1]
System.out.println(Arrays.toString(ip.split(Pattern.quote("."))));// 同上,更安全

String csv = "a,b,c,,";
System.out.println(Arrays.toString(csv.split(","))); // [a, b, c],尾部空串被丢弃
System.out.println(Arrays.toString(csv.split(",", -1))); // [a, b, c, , ],保留
System.out.println(Arrays.toString(csv.split(",", 3))); // [a, b, c,,],最多切 2 次
}
}

limit 参数规则要记牢:limit > 0 表示最多切 limit - 1 次,且保留尾部空串;limit = 0(默认)表示切到底但丢弃尾部空串;limit < 0 表示切到底并保留尾部空串。解析 CSV、日志行时若需要保住空字段,务必传 -1。此外 split 每次都会重新编译 Pattern,高频调用时应把 Pattern 提成 static final 常量。

2.3 trim 与 strip:Unicode 空白的战争

trim() 诞生于 Unicode 尚未普及的年代,它只删除码值 <= '\u0020' 的字符,因此对全角空格 U+3000、不换行空格 U+00A0 无能为力。JDK 11 引入的 strip() 基于 Character.isWhitespace(),覆盖 Unicode 全部空白字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StripDemo {
public static void main(String[] args) {
// U+3000 是全角空格,常见于中文排版与从 Excel 粘过来的数据
String s = "\u3000hello\u3000";
System.out.println("[" + s.trim() + "]"); // [ hello ],trim 失效
System.out.println("[" + s.strip() + "]"); // [hello]
System.out.println("[" + s.stripLeading() + "]"); // [hello ]
System.out.println("[" + s.stripTrailing() + "]"); // [ hello]

System.out.println(" ".isBlank()); // true(JDK 11+)
System.out.println(" ".isEmpty()); // false
System.out.println("ab".repeat(3)); // ababab
}
}

2.4 String.format 与 printf 占位符

String.format 底层就是 new Formatter(),System.out.printf 则是 out.format。常用转换符如下:

转换符 含义 示例 输出
%s 字符串 format("%s", "a") a
%d 十进制整数 format("%d", 10) 10
%x / %X 十六进制 format("%x", 255) ff
%o 八进制 format("%o", 8) 10
%f 定点浮点 format("%.2f", 3.14159) 3.14
%e / %E 科学计数法 format("%e", 1000.0) 1.000000e+03
%c 字符 format("%c", 65) A
%b 布尔 format("%b", null) false
%n 平台换行符 - 优于 \n
%% 字面量 % format("%d%%", 50) 50%
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FormatDemo {
public static void main(String[] args) {
// 宽度、左对齐(负号)、补零、千分位
System.out.println(String.format("%-10s|%05d|%,.2f", "Java", 42, 1234567.891));
// Java |00042|1,234,567.89

// 参数索引:%1$s 表示第 1 个参数,可重复引用
System.out.printf("%2$s 今年 %1$d 岁,明年就是 %3$d 岁了%n", 18, "小明", 19);

// 日期时间:%tF = yyyy-MM-dd,%tT = HH:mm:ss
long now = System.currentTimeMillis();
System.out.printf("%tF %tT%n", now, now);

// JDK 15+ 的实例方法版本
System.out.println("%s-%s".formatted("a", "b"));
}
}

%n 比硬编码 \n 更正确,它会根据平台输出 \r\n 或 \n。另外 String.format 性能一般(每次都要解析格式串、装箱参数),日志框架里尽量用 {} 占位符,而不是先 format 再打印。

2.5 编码:乱码的真正根因

乱码的本质永远是”用 A 字符集编码,用 B 字符集解码”。Java 字符串在内存中统一是 Unicode(UTF-16),一旦要落盘、进网络、写数据库就必须指定字符集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class CharsetDemo {
public static void main(String[] args) {
String s = "中文";

byte[] utf8 = s.getBytes(StandardCharsets.UTF_8); // 6 字节
byte[] gbk = s.getBytes(Charset.forName("GBK")); // 4 字节

System.out.println(new String(utf8, StandardCharsets.UTF_8)); // 中文
System.out.println(new String(gbk, StandardCharsets.UTF_8)); // 乱码
System.out.println(new String(gbk, Charset.forName("GBK"))); // 中文

// 千万别写无参版本,它依赖平台默认字符集
System.out.println("默认字符集 = " + Charset.defaultCharset());
}
}

无参的 getBytes() / new String(byte[]) 使用平台默认字符集,Windows 上是 GBK、Linux 上是 UTF-8,于是出现”本地好好的、线上全是问号”。JDK 18(JEP 400)把默认字符集统一为 UTF-8,很大程度上缓解了这个问题,但显式写 StandardCharsets.UTF_8 依然是必须遵守的编码习惯。

三、String、StringBuilder、StringBuffer 三兄弟

3.1 核心差异对照

维度 String StringBuilder StringBuffer
可变性 不可变(final byte[]) 可变 可变
线程安全 天生安全 不安全 安全(方法 synchronized)
出现版本 1.0 1.5 1.0
大量拼接性能 最低 最高 略低于 Builder
典型场景 少量拼接、作为 key 单线程循环拼接 多线程共享拼接

StringBuffer 的同步开销并不大(JIT 在逃逸分析后经常能锁消除),但既然绝大多数拼接都发生在方法内部的局部变量上,直接用 StringBuilder 就好。StringBuffer 还有个 JDK 8 引入的 toStringCache 字段,用于缓存最后一次 toString() 结果,一旦发生修改就置空——这是”可变对象 + 缓存失效”的典型写法。

3.2 StringBuilder 的扩容机制

StringBuilder 继承自 AbstractStringBuilder(JDK 9 起 StringBuffer 也是),底层同样是 byte[] value + byte coder。无参构造默认容量 16:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// JDK 17 AbstractStringBuilder 核心源码(节选)
abstract class AbstractStringBuilder {
byte[] value;
byte coder;
int count;

private int newCapacity(int minCapacity) {
// coder 为 UTF16 时,实际字符数是 value.length / 2,故右移 coder 位
int oldCapacity = value.length >> coder;
// 关键:扩容为 2 倍 + 2(ArrayList 是 1.5 倍,注意区分)
int newCapacity = (oldCapacity << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity; // 一次要得太多,直接按需给
}
int SAFE_BOUND = MAX_ARRAY_SIZE >> coder; // MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
return (newCapacity <= SAFE_BOUND) ? newCapacity : hugeCapacity(minCapacity);
}
}

扩容轨迹是 16 → 34 → 70 → 142 → 286 ……每次扩容都伴随一次 Arrays.copyOf(即 O(n) 拷贝)。所以能预估最终长度时就在构造时直接给出容量,这是成本最低的性能优化:

构造方式 初始容量 适用场景
new StringBuilder() 16 短字符串,如拼接几个字段
new StringBuilder(int n) n 已知长度,推荐
new StringBuilder(String s) s.length() + 16 在已有串基础上追加
new StringBuilder(CharSequence) seq.length() + 16 同上

3.3 加号拼接:编译期折叠与运行期优化

同样是 +,在不同场景下走的是完全不同的路径。

场景一:编译期常量折叠。 当参与拼接的都是编译期常量(字面量或 static final 的字符串常量)时,javac 直接把结果算出来写进 class 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ConcatFold {
static final String A = "Hel";
static final String B = "lo";

public static void main(String[] args) {
String s1 = "Hel" + "lo"; // 编译期折叠为 "Hello"
String s2 = A + B; // 同样是常量,折叠为 "Hello"
String c = "lo";
String s3 = "Hel" + c; // c 是变量,运行期拼接
System.out.println(s1 == "Hello"); // true
System.out.println(s2 == "Hello"); // true
System.out.println(s3 == "Hello"); // false
}
}

场景二:JDK 8 的运行期拼接。 非常量参与的拼接,javac 翻译成 new StringBuilder().append(...).toString()。

场景三:JDK 9+ 的 invokedynamic。 JEP 280(Indify String Concatenation)把拼接点换成 invokedynamic 调用 StringConcatFactory.makeConcatWithConstants,由 JVM 在首次执行时动态选择最优策略(默认 MH_INLINE_SIZED_EXACT:预先按参数大小算好容量、直接写入 byte[],省掉 StringBuilder 的多余拷贝与方法调用):

1
2
3
4
5
6
7
8
9
10
// 编译:javac ConcatDemo.java   查看:javap -c -v ConcatDemo
public class ConcatDemo {
public static void main(String[] args) {
String name = "Java";
System.out.println("hello " + name); // JDK 9+ 生成 invokedynamic 指令
}
}
// javap 输出片段:
// invokedynamic #7, 0
// // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;

这也是为什么同一段拼接逻辑,从 JDK 8 升到 JDK 11 常有肉眼可见的性能提升,且无需改一行业务代码。

3.4 循环拼接:经典反例与正解

反例:JDK 8 的 javac 会在循环体内部每次新建 StringBuilder,循环一万次就新建一万个对象,每次 toString() 还触发一次数组拷贝,整体是 O(n²):

1
2
3
4
5
6
7
8
// 反例:O(n²) 复杂度
public static String badConcat(int n) {
String s = "";
for (int i = 0; i < n; i++) {
s += i; // 等价于 s = new StringBuilder(s).append(i).toString();
}
return s;
}

正解:把 StringBuilder 提到循环外,并预分配容量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.StringJoiner;

public class ConcatBest {
public static String goodConcat(int n) {
// 预留足够容量,避免中途多次扩容
StringBuilder sb = new StringBuilder(n * 5);
for (int i = 0; i < n; i++) {
sb.append(i).append(',');
}
if (sb.length() > 0) sb.setLength(sb.length() - 1); // 去掉末尾多余分隔符
return sb.toString();
}

// 更优雅:StringJoiner 自动处理分隔符与前后缀
public static String joinConcat(int n) {
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (int i = 0; i < n; i++) {
sj.add(String.valueOf(i));
}
return sj.toString();
}

public static void main(String[] args) {
System.out.println(joinConcat(5)); // [0, 1, 2, 3, 4]
System.out.println(goodConcat(5)); // 0,1,2,3,4
}
}

日常编码中,偶尔一两次 + 完全不用纠结(可读性优先,且 JDK 9+ 已经很聪明);只有在循环体内或单次拼接元素超过几十个时,才需要显式使用 StringBuilder。

四、包装类与自动装箱

4.1 八种包装类对照

基本类型 包装类 位宽 父类 缓存范围
byte Byte 8 Number 全部 256 个值
short Short 16 Number -128 ~ 127
int Integer 32 Number -128 ~ 127(上限可调)
long Long 64 Number -128 ~ 127
float Float 32 Number 无缓存
double Double 64 Number 无缓存
char Character 16 Object 0 ~ 127
boolean Boolean 1 Object TRUE / FALSE 两个常量

包装类都是 final 的,内部值字段 private final,因此包装类同样是不可变的——这一点常被忽略,但对线程安全很重要。除 Character 与 Boolean 外,其余都继承 Number。

4.2 装箱与拆箱的编译产物

自动装箱(autoboxing)与拆箱(unboxing)是 JDK 5 的语法糖,编译器在背后插入 valueOf / xxxValue 调用:源码 Integer a = 10; int b = a; 编译后等价于 Integer a = Integer.valueOf(10); int b = a.intValue();。而 Integer.valueOf 的实现揭示了缓存池的来源:

1
2
3
4
5
6
7
8
9
10
11
12
// Integer.valueOf 与 IntegerCache 简化版
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

private static class IntegerCache {
static final int low = -128;
static final int high; // 默认 127
// 可用 -XX:AutoBoxCacheMax=<n> 或 -Djava.lang.Integer.IntegerCache.high=<n> 调高
}

-XX:AutoBoxCacheMax=1000 会把缓存上限调到 1000,适合”频繁使用小整数字面量”的场景(状态码、枚举序号)。注意它只对 Integer 生效,Long/Short 的缓存范围是写死的。

4.3 用 == 比较包装类的经典陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class WrapperTrap {
public static void main(String[] args) {
Integer a = 127, b = 127;
Integer c = 128, d = 128;
Integer e = new Integer(127);

System.out.println(a == b); // true,命中缓存,同一对象
System.out.println(c == d); // false,超出缓存,各自 new
System.out.println(a == e); // false,new 绕过缓存
System.out.println(a.equals(e)); // true,equals 只比值
System.out.println(a.equals(127L)); // false!Long 与 Integer 永远不相等

int f = 128;
System.out.println(c == f); // true,一方是基本类型 → c 拆箱后比较数值
}
}

结论简单但必须遵守:包装类之间比较一律用 equals()(或 Objects.equals(a, b) 避免 NPE),永远不要用 ==。

4.4 NPE 高发场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.HashMap;
import java.util.Map;

public class BoxingNpe {
public static void main(String[] args) {
// 场景一:三元表达式强制统一类型,触发拆箱
Integer count = null;
// 两个分支分别是 Integer 与 int,编译器按 int 处理 → 拆箱 null → NPE
// int bad = flag ? count : 0;
int total = (count != null) ? count : 0; // 正确写法

// 场景二:Map.get 返回 null 后直接参与运算
Map<String, Integer> map = new HashMap<>();
// int score = map.get("missing"); // NPE!等价于 ((Integer)null).intValue()
int safe = map.getOrDefault("missing", 0); // 正确写法

// 场景三:拆箱参与算术
// Long totalBytes = null;
// long t = totalBytes + 1; // NPE

System.out.println("total=" + total + ", safe=" + safe);
}
}

4.5 选型建议

  • 是否允许 null:数据库可空列、DTO 中”未填写”与”填了 0”要区分的场景用包装类;否则用基本类型。
  • 集合元素只能用包装类(泛型不支持基本类型),此时注意内存开销:每个元素一个对象头加 4 字节,比 int[] 大好几倍。大数据量推荐 fastutil 的 IntArrayList 或 IntStream。
  • 局部变量、循环计数、算术运算一律用基本类型,避免无谓装箱。
  • 不要用 == 比较,也不要用包装类做锁对象(缓存共享可能导致跨模块死锁)。

最后补充一个容易被忽视的语义差异:方法重载时,装箱不会与基本类型自动”择优”。当同时存在 f(int) 与 f(Integer) 两个重载时,f(10) 会优先匹配不需要装箱的 f(int);只有在精确匹配不存在时,编译器才会考虑装箱/拆箱,再之后才是可变参数。这条优先级规则是”基本类型 → 装箱/拆箱 → 可变参数”,理解它能解释很多看起来莫名其妙的重载选择问题。同理,List.remove(1) 与 List.remove(Integer.valueOf(1)) 的行为完全不同——前者按下标删除,后者按对象删除,混用包装类时尤其要小心。

五、Math 与 Random

5.1 Math 常用方法

方法 作用 边界注意
abs(x) 绝对值 Math.abs(Integer.MIN_VALUE) 仍是负数!
max / min 最值 有 int/long/float/double 重载
pow(a,b) a 的 b 次方 返回 double
sqrt / cbrt 平方根 / 立方根 负数 sqrt 返回 NaN
floor / ceil 向下 / 向上取整 返回 double
round(x) 四舍五入取整 见下方代码
random() [0,1) 随机 double 内部共享一个 Random 实例
hypot(x,y) sqrt(x²+y²) 比手写更稳定,防溢出
addExact / multiplyExact 溢出则抛异常的算术 JDK 8+,计数场景推荐
floorDiv / floorMod 向下取整除 / 取模 解决负数取模符号问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MathDemo {
public static void main(String[] args) {
System.out.println(Math.round(1.5)); // 2
System.out.println(Math.round(-1.5)); // -1(不是 -2!)
System.out.println(Math.round(-1.6)); // -2
// 规则:Math.round(x) == Math.floor(x + 0.5),即"向正无穷方向靠拢"的四舍五入

System.out.println(Math.floor(-1.5)); // -2.0
System.out.println(Math.ceil(-1.5)); // -1.0

System.out.println(Math.abs(Integer.MIN_VALUE)); // -2147483648,溢出
System.out.println(Math.floorMod(-1, 3)); // 2,而 -1 % 3 == -1

try {
Math.multiplyExact(Integer.MAX_VALUE, 2);
} catch (ArithmeticException ex) {
System.out.println("捕获溢出:" + ex.getMessage());
}

System.out.println(Math.hypot(3, 4)); // 5.0
}
}

StrictMath 与 Math 的区别在于可复现性:StrictMath 保证所有方法在不同平台上给出逐位相同的结果(底层用 fdlibm 纯软件实现),而 Math 允许 JVM 使用硬件指令与 intrinsic 优化,速度更快但最低几位可能因 CPU 而异。绝大多数业务代码用 Math 即可,需要跨环境确定性(科学仿真、对账系统)才考虑 StrictMath。

5.2 三种 Random 的正确打开方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

public class RandomDemo {
public static void main(String[] args) {
// 1) Random:线性同余 + AtomicLong 种子,多线程下 CAS 竞争激烈
Random r = new Random(42); // 固定种子 → 结果可复现(测试用)
Random r2 = new Random(42);
System.out.println(r.nextInt(100) == r2.nextInt(100)); // true

// 2) ThreadLocalRandom:每线程一份种子,无 CAS 竞争,高并发首选
int n = ThreadLocalRandom.current().nextInt(1, 100); // 左闭右开 [1,100)
long v = ThreadLocalRandom.current().nextLong(1000L, 2000L);
System.out.println(n + " " + v);
// 注意:不要把它存到字段里跨线程复用,也不能调用 setSeed()

// 3) SecureRandom:密码学安全,用于 token、盐值、验证码、密钥
SecureRandom sr = new SecureRandom();
byte[] salt = new byte[16];
sr.nextBytes(salt);
System.out.println(Base64.getEncoder().encodeToString(salt));
}
}

选择建议:Random 用于单线程或低并发的一般随机;高并发下必须用 ThreadLocalRandom(16 线程并发取随机数时,Random 的 CAS 自旋会让吞吐暴跌,实测差距可达一个数量级);SecureRandom 只用于安全敏感场景,它会读取系统熵源(如 /dev/urandom),初始化可能较慢,getInstanceStrong() 甚至可能阻塞。另外要记住 Math.random() 内部是一个全局共享的 Random,高并发下同样是竞争点,别放在热路径里。

六、日期时间 API:从 Date/Calendar 到 java.time

6.1 老 API 的历史包袱

java.util.Date 名字叫 Date,实际表示”某一瞬间”(自 epoch 起的毫秒数),却同时暴露了 getYear()、getMonth()、getDay() 等早已废弃的日期字段——年份要 +1900、月份从 0 开始,且对象可变(setTime)。Calendar 试图补救,但引入了更多问题:依旧可变、MONTH 仍然从 0 开始(Calendar.JANUARY == 0)、API 极其笨重、非线程安全。

最致命的是 SimpleDateFormat 线程不安全:它内部持有 Calendar 成员变量,format/parse 会修改其状态,多线程共享一个 static 实例时会输出错乱日期或直接抛异常。

除了线程安全,老 API 还有两个隐性成本。一是性能:SimpleDateFormat 每次 parse 都要重建内部状态并逐字符扫描模式串,在批量解析百万级日志行的场景下,它的开销往往占整条链路的三成以上,替换成 DateTimeFormatter 通常能拿到数倍提升。二是语义混乱:new Date() 的 toString() 会按 JVM 默认时区打印,导致同一个时间戳在不同机器上日志格式不同;而 Date 的 equals 比较的是毫秒值,却又能通过 setTime 被就地修改,这让它在集合中作为元素时存在与 HashMap key 类似的风险。因此在 JDK 8 之后的新代码里,Date 与 Calendar 只应在与遗留库、老接口对接时被动出现,业务逻辑内部一律使用 java.time。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.text.SimpleDateFormat;
import java.util.Date;

public class SdfNotSafe {
public static void main(String[] args) {
// 方案一:每次新建(简单但开销大)
System.out.println(new SimpleDateFormat("yyyy-MM-dd").format(new Date()));

// 方案二:ThreadLocal 持有,兼顾性能与安全(老项目改造常用)
ThreadLocal<SimpleDateFormat> TL =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
System.out.println(TL.get().format(new Date()));

// 方案三(推荐):改用不可变、线程安全的 DateTimeFormatter,见 6.3
}
}

6.2 java.time 体系

类 含义 是否含时区 典型用途
LocalDate 日期(年-月-日) 否 生日、账单日
LocalTime 时间(时:分:秒.纳秒) 否 营业时间
LocalDateTime 日期 + 时间 否 本地业务时间
Instant 时间线上的瞬时点(epoch) UTC 时间戳、日志
Duration 基于秒 / 纳秒的时间量 - 耗时、超时
Period 基于年 / 月 / 日的日期量 - 年龄、账期
ZonedDateTime 带时区的完整时间 是 国际化展示
OffsetDateTime 带偏移量的时间 是(偏移量) 协议交互、JSON
DateTimeFormatter 格式化 / 解析 - 不可变、线程安全

核心设计原则:java.time 中所有类都是不可变的,所有”修改”方法(plusDays、withHour)都返回新对象,因此天然线程安全;月份也从 1 开始(Month.JANUARY == 1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;

public class JavaTimeDemo {
public static void main(String[] args) {
LocalDate today = LocalDate.now();
LocalDate birth = LocalDate.of(1995, 3, 8);
Period age = Period.between(birth, today); // 日期量:年/月/日
System.out.println("年龄 = " + age.getYears());

LocalDateTime now = LocalDateTime.now();
System.out.println("本月最后一天 = " + today.with(TemporalAdjusters.lastDayOfMonth()));
System.out.println("加 90 天 = " + now.plusDays(90));
System.out.println("截断到小时 = " + now.truncatedTo(ChronoUnit.HOURS));

// Duration:时间量,适合算耗时
Instant start = Instant.now();
Instant end = start.plus(Duration.ofMinutes(90));
System.out.println("间隔分钟 = " + Duration.between(start, end).toMinutes());

// 时区与时间戳互转
ZoneId shanghai = ZoneId.of("Asia/Shanghai");
ZonedDateTime zdt = now.atZone(shanghai);
Instant instant = zdt.toInstant();
long epochMilli = instant.toEpochMilli(); // 毫秒时间戳
System.out.println(epochMilli);

LocalDateTime back = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), shanghai);
System.out.println(back);

// DateTimeFormatter 线程安全,可以放心做成 static final
DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
System.out.println(now.format(FMT));
System.out.println(LocalDateTime.parse("2026-10-13 09:00:00", FMT));
}
}

时间戳精度是个容易忽略的坑:Instant.now() 的精度取决于 JDK 与操作系统。JDK 8 只能拿到毫秒;JDK 9 之后 Clock.systemUTC() 通常能提供微秒甚至纳秒精度,但不保证(Windows 上通常仍是毫秒)。因此不要用 Instant.now().getNano() 去做唯一性判断(比如拼进 ID),也不要假设两次 now() 一定不同。需要单调递增的耗时测量请用 System.nanoTime();需要精确到微秒的业务时间戳,建议在应用层自己补位或使用外部时钟源。

6.3 与数据库交互的最佳实践

MySQL 侧要注意两件事:一是 TIMESTAMP 范围只到 2038-01-19(著名的 2038 问题),且写入时按会话时区转成 UTC 存储、读取时再转回会话时区,服务器迁移时区会导致数据偏差;DATETIME 不做时区转换、范围到 9999 年,但语义上依赖应用自己约定时区。二是 MySQL 5.6+ 支持 DATETIME(3) / DATETIME(6) 的小数位(毫秒 / 微秒)。

场景 推荐方案
业务发生时间(需展示) 列用 DATETIME(3),Java 用 LocalDateTime,连接参数显式指定 serverTimezone=Asia/Shanghai
需要跨时区、做排序比较 列用 BIGINT 存 epoch 毫秒,Java 用 Instant / long
只需要日期 列用 DATE,Java 用 LocalDate
审计字段 create_time 数据库默认值 + Java 侧 LocalDateTime,不要混用数据库时区与应用时区

千万不要在 Java 层把 java.sql.Timestamp 与 String 来回倒腾,那是老项目里最常见的性能黑洞与 bug 温床。MyBatis 3.4+ 原生支持 java.time 全部类型;JPA 用 @Column(columnDefinition = "datetime(3)") 配合 LocalDateTime 即可;Jackson 需要引入 jackson-datatype-jsr310 模块并按 ISO-8601 输出。

七、BigDecimal:金额计算的唯一正解

7.1 为什么 double 不能算钱

double 遵循 IEEE 754 二进制浮点标准,而二进制小数无法精确表示大多数十进制小数——就像十进制无法精确表示 1/3。0.1 在二进制里是无限循环小数,只能截断存储:

1
2
3
4
5
6
7
8
9
public class DoubleTrap {
public static void main(String[] args) {
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.0 - 0.9); // 0.09999999999999998
System.out.println(0.1 * 3); // 0.30000000000000004
System.out.println(4.0 - 3.1); // 0.8999999999999999
System.out.println(0.1 + 0.2 == 0.3); // false
}
}

一旦涉及累加(订单明细求和、账户余额滚动),误差会放大到分位,直接造成对账不平。

7.2 构造器选择的三种结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.math.BigDecimal;

public class BigDecimalCtor {
public static void main(String[] args) {
// 错误:double 构造器会"忠实"还原这个 double 的真实二进制值
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

// 正确:字符串构造器,精确表达十进制
System.out.println(new BigDecimal("0.1")); // 0.1

// 正确:valueOf 内部走 Double.toString(),等价于字符串构造器
System.out.println(BigDecimal.valueOf(0.1)); // 0.1
System.out.println(BigDecimal.valueOf(0.1).equals(new BigDecimal("0.1"))); // true
}
}

结论:永远用 new BigDecimal(String) 或 BigDecimal.valueOf(...),绝不用 new BigDecimal(double)(除非你明确想要那个二进制值)。从数据库读出来的 BigDecimal 本身就是精确的,无需二次转换。

7.3 运算与舍入模式

加减乘都是精确的(乘法结果的 scale 为两个操作数 scale 之和),只有除法可能除不尽:a.divide(b) 遇到 1/3 这类无限小数会直接抛 ArithmeticException: Non-terminating decimal expansion,必须显式指定精度与舍入模式。

舍入模式 规则 1.5 结果 -1.5 结果
UP 远离零方向进位 2 -2
DOWN 向零方向截断 1 -1
CEILING 向正无穷靠拢 2 -1
FLOOR 向负无穷靠拢 1 -2
HALF_UP 四舍五入(远离零) 2 -2
HALF_DOWN 五舍六入 1 -1
HALF_EVEN 银行家舍入(向偶数靠) 2 -2
UNNECESSARY 断言无需舍入,否则抛异常 抛异常 抛异常

HALF_EVEN 是 IEEE 754 的默认模式,也是金融、统计场景的推荐选择:它让 0.5 总是舍入到最近的偶数,长期大量运算时不会系统性向上偏移,避免”每次都多收半分钱”。注意 HALF_UP 在负数上是”远离零”,仍符合直觉的四舍五入。

7.4 equals 与 compareTo 的 scale 陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.math.BigDecimal;
import java.math.RoundingMode;

public class BigDecimalCompare {
public static void main(String[] args) {
BigDecimal a = new BigDecimal("1.0"); // scale = 1
BigDecimal b = new BigDecimal("1.00"); // scale = 2

System.out.println(a.equals(b)); // false!equals 同时比较值与 scale
System.out.println(a.compareTo(b)); // 0,compareTo 只比数值大小

// 因此:判断金额相等一律用 compareTo() == 0
System.out.println(a.stripTrailingZeros().equals(b.stripTrailingZeros())); // true

// 归一化到 2 位小数,便于入库与比较
BigDecimal price = new BigDecimal("19.9").setScale(2, RoundingMode.HALF_UP);
System.out.println(price); // 19.90
}
}

放进 HashSet / HashMap 作为 key 时同样受 scale 影响,务必先归一化;stripTrailingZeros() 对 0.00 有特殊行为(JDK 8 曾返回 0.00,后续版本已修正),用之前最好先确认。

7.5 金额工具类模板(可直接落地)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

/** 金额计算工具类:统一精度(2 位)与舍入模式,禁止直接 new BigDecimal(double)。 */
public final class MoneyUtils {
private MoneyUtils() {}

private static final int SCALE = 2;
private static final RoundingMode ROUND = RoundingMode.HALF_UP;

/** 推荐入口:字符串构造,精确无误差 */
public static BigDecimal of(String val) {
return new BigDecimal(Objects.requireNonNull(val, "amount"));
}

/** 唯一允许的 double 入口,内部走 Double.toString */
public static BigDecimal of(double val) {
return BigDecimal.valueOf(val);
}

/** 归一化:补齐到 2 位小数 */
public static BigDecimal normalize(BigDecimal v) {
return nz(v).setScale(SCALE, ROUND);
}

public static BigDecimal add(BigDecimal a, BigDecimal b) {
return nz(a).add(nz(b));
}

public static BigDecimal subtract(BigDecimal a, BigDecimal b) {
return nz(a).subtract(nz(b));
}

/** 乘法:单价 * 数量,结果按 2 位结算 */
public static BigDecimal multiply(BigDecimal a, BigDecimal b) {
return nz(a).multiply(nz(b)).setScale(SCALE, ROUND);
}

/** 除法:必须给精度与舍入模式,否则 1/3 直接抛异常 */
public static BigDecimal divide(BigDecimal a, BigDecimal b) {
return nz(a).divide(nz(b), SCALE, ROUND);
}

/** 按比率计算,如折扣、税率;rate 用字符串传入 */
public static BigDecimal rate(BigDecimal amount, String rate) {
return nz(amount).multiply(new BigDecimal(rate)).setScale(SCALE, ROUND);
}

/** 判断是否为 0,比 equals(BigDecimal.ZERO) 安全(后者受 scale 影响) */
public static boolean isZero(BigDecimal v) {
return v != null && v.compareTo(BigDecimal.ZERO) == 0;
}

private static BigDecimal nz(BigDecimal v) {
return Objects.requireNonNull(v, "amount must not be null");
}

public static void main(String[] args) {
System.out.println(add(of("19.99"), of(0.1))); // 20.09
System.out.println(multiply(of("3.33"), of(3))); // 9.99
System.out.println(divide(of("10"), of(3))); // 3.33
System.out.println(rate(of("200"), "0.85")); // 170.00
System.out.println(isZero(of("0.00"))); // true
}
}

数据库侧的配套约定:金额列一律 DECIMAL(19, 4) 或 DECIMAL(19, 2),绝不用 FLOAT/DOUBLE;MyBatis 直接映射 BigDecimal;JPA 使用 @Column(precision = 19, scale = 2);对外 JSON 输出建议序列化为字符串(@JsonSerialize(using = ToStringSerializer.class)),避免前端 JS Number 再次引入精度损失。计算一律在 Java 层用 BigDecimal 完成,不要交给 SQL 的 SUM(double) 去做。

关于性能:BigDecimal 是不可变对象,每次运算都产生新对象,吞吐大约是 double 的百分之一量级。如果业务是高频行情计算、风控打分这类每秒百万次、且精度只要求到 6 位有效数字的场景,可以用 long 存”最小货币单位的整数倍”(例如分),性能高且完全精确。只有当金额位数可能超过 long 范围、或需要复杂的小数位规则时,才必须使用 BigDecimal。另外,除法一定要先判零:divide 除数为 0 会抛 ArithmeticException,业务上应提前校验。

八、常用工具类速查与高频面试题

8.1 System / Objects / Runtime 速查

类 方法 说明
System currentTimeMillis() 当前毫秒时间戳,受系统时钟调整影响
System nanoTime() 单调纳秒时间,只用于计算耗时差,不可当时间戳
System arraycopy(...) 高性能数组拷贝,native 实现
System getProperty(key) / getenv(key) JVM 属性 / 环境变量
System lineSeparator() 平台换行符
System identityHashCode(obj) 无视 hashCode() 重写的对象身份哈希
System exit(int) 终止 JVM,会触发 shutdown hook
Objects equals(a,b) / hash(...) null 安全的比较与哈希
Objects requireNonNull(obj, msg) 参数校验,NPE 快速失败
Objects isNull / nonNull 适合做 Stream 的 filter 谓词
Objects toString(obj, default) null 时返回默认值
Objects compare(a,b,cmp) 用比较器比较两个对象
Runtime getRuntime().availableProcessors() 可用 CPU 核数,常用于线程池 sizing
Runtime totalMemory / freeMemory / maxMemory 堆内存现状,注意 freeMemory 含义易误读
Runtime addShutdownHook(Thread) 注册优雅停机钩子
Runtime exec(String) 启动外部进程,推荐改用 ProcessBuilder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Objects;

public class UtilsDemo {
public static void main(String[] args) {
long t0 = System.nanoTime(); // 单调时间,适合算耗时
int[] src = {1, 2, 3, 4, 5};
int[] dst = new int[5];
System.arraycopy(src, 0, dst, 0, src.length);

System.out.println(Objects.equals(null, "a")); // false,null 安全
System.out.println(Objects.toString(null, "N/A")); // N/A
Objects.requireNonNull(src, "src must not be null");

Runtime rt = Runtime.getRuntime();
System.out.println("CPU 核数 = " + rt.availableProcessors());
System.out.println("最大堆 = " + rt.maxMemory() / 1024 / 1024 + " MB");

rt.addShutdownHook(new Thread(() -> System.out.println("JVM 正在退出,执行清理")));
System.out.println("耗时(ns) = " + (System.nanoTime() - t0));
}
}

8.2 高频面试题 10 条

  1. String s = new String("abc") 创建了几个对象? 若常量池中已有 "abc",则只在堆上创建 1 个;若没有,则先在池中驻留 1 个字面量、再在堆上 new 1 个,共 2 个。
  2. 为什么 String 要设计成不可变? 线程安全、hash 可缓存从而适合做 HashMap key、支持常量池复用、避免被恶意篡改(类名与安全检查依赖此特性)。
  3. == 与 equals 的区别? == 比较引用(基本类型比较值),equals 比较内容;String 重写了 equals 使其按字符序列比较。
  4. Integer a = 128, b = 128; a == b 为什么是 false? 超出 IntegerCache 的 -128~127 范围,valueOf 每次 new;范围内则复用缓存对象,结果为 true。
  5. substring 在 JDK 6 有什么问题? 复用原 char[] 只改 offset/count,截取大字符串的小片段会导致大数组无法回收(内存泄漏);JDK 7u6 起改为复制新数组。
  6. 循环里用 + 拼接字符串有什么问题? JDK 8 每轮循环新建 StringBuilder 并 toString(),整体 O(n²);应把 StringBuilder 提到循环外并预分配容量,JDK 9+ 则由 StringConcatFactory 通过 invokedynamic 优化。
  7. SimpleDateFormat 为什么线程不安全?如何修? 内部共享可变 Calendar 字段。改用 DateTimeFormatter(不可变)、ThreadLocal<SimpleDateFormat>,或每次新建。
  8. 0.1 + 0.2 为什么不等于 0.3? IEEE 754 二进制无法精确表示十进制小数,存在表示误差。金额计算必须用 BigDecimal(字符串构造)或用 long 存分。
  9. BigDecimal 的 equals 与 compareTo 有何不同? equals 同时比较数值与 scale(1.0 与 1.00 不相等),compareTo 只比较数值(返回 0)。比较金额大小一律用 compareTo。
  10. divide 什么时候抛 ArithmeticException? 两种:除不尽且未指定 scale/舍入模式(Non-terminating decimal expansion);或除数为 0(Division by zero)。

最后给三条”面试加分但生产必守”的硬规则:其一,比较包装类永远用 equals,== 只在基本类型之间使用;其二,字符串与 byte[] 互转永远显式指定字符集,绝不依赖平台默认值;其三,金额一律 BigDecimal(字符串构造)或 long(分),永远不出现 double。这三条几乎覆盖了基础类库 80% 的线上事故。


下一篇预告:Java 从入门到精通(六):异常处理与断言——从 try-with-resources 到自定义异常体系设计。