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

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 | public final class String |
这里有个值得注意的细节: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 | import java.lang.reflect.Field; |
这个改造的收益是纯内存的:字符串通常占 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 | public class StringPoolDemo { |
内存布局可以用下面这段文本示意(JDK 7 及以后):
1 | 栈 frame 字符串常量池(JDK 7+ 位于堆中) Java 堆 |
注意版本差异: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 | public class InternJdkDiff { |
intern() 的冷知识:String::intern 是 native 方法,底层依赖 StringTable 哈希表,冲突严重时查找会退化。池中的字符串通过弱引用机制登记,在没有其他强引用时可以被 GC 回收,这也是 JDK 7 把常量池搬到堆之后”intern 导致 OOM”风险大幅下降的原因。但无节制 intern 仍会拖慢 GC 与哈希查找,生产环境应谨慎;需要去重时优先考虑自己维护一个 ConcurrentHashMap 或者 -XX:+UseStringDeduplication(G1 的重复字符串去重)。
1.4 不可变 ≠ 不能被改:反射的黑魔法
严格来说反射可以破坏不可变性,但属于”玩火”,仅用于理解原理:
1 | import java.lang.reflect.Field; |
这段代码一旦执行,整个 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 | import java.util.Arrays; |
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 | public class StripDemo { |
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 | public class FormatDemo { |
%n 比硬编码 \n 更正确,它会根据平台输出 \r\n 或 \n。另外 String.format 性能一般(每次都要解析格式串、装箱参数),日志框架里尽量用 {} 占位符,而不是先 format 再打印。
2.5 编码:乱码的真正根因
乱码的本质永远是”用 A 字符集编码,用 B 字符集解码”。Java 字符串在内存中统一是 Unicode(UTF-16),一旦要落盘、进网络、写数据库就必须指定字符集:
1 | import java.nio.charset.Charset; |
无参的 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 | // JDK 17 AbstractStringBuilder 核心源码(节选) |
扩容轨迹是 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 | public class ConcatFold { |
场景二: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 | // 编译:javac ConcatDemo.java 查看:javap -c -v ConcatDemo |
这也是为什么同一段拼接逻辑,从 JDK 8 升到 JDK 11 常有肉眼可见的性能提升,且无需改一行业务代码。
3.4 循环拼接:经典反例与正解
反例:JDK 8 的 javac 会在循环体内部每次新建 StringBuilder,循环一万次就新建一万个对象,每次 toString() 还触发一次数组拷贝,整体是 O(n²):
1 | // 反例:O(n²) 复杂度 |
正解:把 StringBuilder 提到循环外,并预分配容量:
1 | import java.util.StringJoiner; |
日常编码中,偶尔一两次 + 完全不用纠结(可读性优先,且 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 | // Integer.valueOf 与 IntegerCache 简化版 |
-XX:AutoBoxCacheMax=1000 会把缓存上限调到 1000,适合”频繁使用小整数字面量”的场景(状态码、枚举序号)。注意它只对 Integer 生效,Long/Short 的缓存范围是写死的。
4.3 用 == 比较包装类的经典陷阱
1 | public class WrapperTrap { |
结论简单但必须遵守:包装类之间比较一律用 equals()(或 Objects.equals(a, b) 避免 NPE),永远不要用 ==。
4.4 NPE 高发场景
1 | import java.util.HashMap; |
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 | public class MathDemo { |
StrictMath 与 Math 的区别在于可复现性:StrictMath 保证所有方法在不同平台上给出逐位相同的结果(底层用 fdlibm 纯软件实现),而 Math 允许 JVM 使用硬件指令与 intrinsic 优化,速度更快但最低几位可能因 CPU 而异。绝大多数业务代码用 Math 即可,需要跨环境确定性(科学仿真、对账系统)才考虑 StrictMath。
5.2 三种 Random 的正确打开方式
1 | import java.security.SecureRandom; |
选择建议: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 | import java.text.SimpleDateFormat; |
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 | import java.time.*; |
时间戳精度是个容易忽略的坑: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 | public class DoubleTrap { |
一旦涉及累加(订单明细求和、账户余额滚动),误差会放大到分位,直接造成对账不平。
7.2 构造器选择的三种结果
1 | import java.math.BigDecimal; |
结论:永远用 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 | import java.math.BigDecimal; |
放进 HashSet / HashMap 作为 key 时同样受 scale 影响,务必先归一化;stripTrailingZeros() 对 0.00 有特殊行为(JDK 8 曾返回 0.00,后续版本已修正),用之前最好先确认。
7.5 金额工具类模板(可直接落地)
1 | import java.math.BigDecimal; |
数据库侧的配套约定:金额列一律 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 | import java.util.Objects; |
8.2 高频面试题 10 条
String s = new String("abc")创建了几个对象? 若常量池中已有"abc",则只在堆上创建 1 个;若没有,则先在池中驻留 1 个字面量、再在堆上 new 1 个,共 2 个。- 为什么 String 要设计成不可变? 线程安全、hash 可缓存从而适合做 HashMap key、支持常量池复用、避免被恶意篡改(类名与安全检查依赖此特性)。
==与equals的区别?==比较引用(基本类型比较值),equals比较内容;String 重写了equals使其按字符序列比较。Integer a = 128, b = 128; a == b为什么是 false? 超出IntegerCache的 -128~127 范围,valueOf每次new;范围内则复用缓存对象,结果为 true。substring在 JDK 6 有什么问题? 复用原char[]只改 offset/count,截取大字符串的小片段会导致大数组无法回收(内存泄漏);JDK 7u6 起改为复制新数组。- 循环里用
+拼接字符串有什么问题? JDK 8 每轮循环新建StringBuilder并toString(),整体 O(n²);应把StringBuilder提到循环外并预分配容量,JDK 9+ 则由StringConcatFactory通过 invokedynamic 优化。 SimpleDateFormat为什么线程不安全?如何修? 内部共享可变Calendar字段。改用DateTimeFormatter(不可变)、ThreadLocal<SimpleDateFormat>,或每次新建。0.1 + 0.2为什么不等于0.3? IEEE 754 二进制无法精确表示十进制小数,存在表示误差。金额计算必须用BigDecimal(字符串构造)或用long存分。BigDecimal的equals与compareTo有何不同?equals同时比较数值与 scale(1.0与1.00不相等),compareTo只比较数值(返回 0)。比较金额大小一律用compareTo。divide什么时候抛ArithmeticException? 两种:除不尽且未指定 scale/舍入模式(Non-terminating decimal expansion);或除数为 0(Division by zero)。
最后给三条”面试加分但生产必守”的硬规则:其一,比较包装类永远用 equals,== 只在基本类型之间使用;其二,字符串与 byte[] 互转永远显式指定字符集,绝不依赖平台默认值;其三,金额一律 BigDecimal(字符串构造)或 long(分),永远不出现 double。这三条几乎覆盖了基础类库 80% 的线上事故。
下一篇预告:Java 从入门到精通(六):异常处理与断言——从 try-with-resources 到自定义异常体系设计。

















