字符串与数据类型
开篇:String 为什么如此特殊?
在 Java 的世界里,如果要评选"最常用的类",String 一定高票当选。从你写下第一行 System.out.println("Hello World") 开始,字符串就如影随形——拼接日志、构建 SQL、传递参数、校验密码,几乎所有业务逻辑都离不开它。
但正因为太常用,很多人对 String 的理解停留在"会用"层面,却忽略了它背后精妙的设计:为什么它是不可变的?常量池到底怎么工作?== 和 equals 为什么有时结果不同?再加上基本类型与包装类的自动拆装箱"暗坑"、浮点数的精度陷阱……这些问题串起来,就构成了 Java 类型系统最核心也最容易踩坑的知识图谱。这篇文章,我们就来一次性讲透。
一、String 的不可变性
1.1 为什么设计成不可变的?
想象银行的保险箱:你把合同锁进去后,任何人都不能偷偷修改内容——你只能取出来看,或者重新写一份放到另一个保险箱里。String 就是 Java 世界里的"保险箱"。
Java 之父 James Gosling 曾在一次采访中被问到什么时候应该使用不可变变量,他的回答很干脆:
I would use an immutable whenever I can.
从源码上看,String 的不可变是通过三重防护实现的:
// JDK 8 源码(简化)
public final class String { // 1. 类是 final 的,不能被继承
private final char value[]; // 2. 内部数组是 final 的,引用不可变
// 3. 没有暴露任何修改 value[] 内容的方法
public String substring(int beginIndex) {
// 不修改原数组,而是创建新的 String 对象
return (beginIndex == 0) ? this
: new String(value, beginIndex, value.length - beginIndex);
}
public String concat(String str) {
// 同样是创建新对象返回
char buf[] = Arrays.copyOf(value, value.length + str.length());
str.getChars(buf, value.length);
return new String(buf, true);
}
}注意看 substring 和 concat 的实现——它们从不修改原来的 value[],而是创建新的 String 对象。这就是不可变的核心:所有看似"修改"的操作,实际上都是在创造新对象。
JDK 9 之后,
char[]变成了byte[],引入了 Compact String 优化。JVM 会通过一个coder字段来区分编码:如果字符串只包含 Latin-1 字符(英文、数字等),用LATIN1编码,每个字符只占 1 字节;否则用UTF16编码,每个字符占 2 字节。内存最多能省一半。
那 Java 之父为什么要费这么大劲让 String 不可变呢?主要有四个原因:
1. 缓存友好——因为内容不会变,多个变量可以安全地指向同一个字符串对象(这就是常量池的基础)。如果 String 可变,改了 s1 的内容,s2 也跟着变,整个常量池机制就不成立了。
String s1 = "hello";
String s2 = "hello";
// s1 和 s2 指向同一个对象,因为内容不可变,所以互不影响2. 线程安全——不可变对象天生是线程安全的。多个线程同时读同一个 String,完全不需要加锁,不需要 synchronized,不需要 volatile。在高并发场景下,这个特性价值千金。
3. 安全性——数据库连接 URL、文件路径、类名、密码这些敏感信息都用 String 传递。不可变意味着传进方法的字符串不会被偷偷篡改。假设有个方法接收文件路径参数,如果 String 是可变的,调用方传入 /safe/path 后,另一个线程把它改成 /etc/passwd,后果不堪设想。
4. hashCode 缓存——String 重写了 hashCode(),由于内容不变,hash 值只需要算一次就可以缓存起来。源码中有这样一行:
private int hash; // 缓存 hashCode,默认 0,计算一次后复用后续每次调用 hashCode() 直接返回缓存值,这让 HashMap、HashSet 等数据结构的性能大大提升。
有人可能会问:"我明明可以改 String 的值啊!"
String s = "abcd";
s = s.concat("ef");
System.out.println(s); // abcdef别被表象骗了。concat 并没有修改原来的 "abcd" 对象,而是在堆中新建了一个 "abcdef" 对象,然后让变量 s 的引用指向了新对象。原来的 "abcd" 还安静地待在内存里,纹丝不动。变的是引用,不是对象。
1.2 String vs StringBuilder vs StringBuffer
既然 String 不可变,每次修改都要创建新对象,那频繁拼接字符串岂不是很浪费?没错,所以 Java 提供了两个"可变版本":
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 是(天生) | 否 | 是(synchronized) |
| 性能 | 拼接慢 | 最快 | 略慢于 StringBuilder |
| 适用场景 | 少量字符串操作 | 单线程大量拼接 | 多线程大量拼接(极少见) |
StringBuilder 和 StringBuffer 内部都维护一个非 final 的 char[](JDK 9 后是 byte[]),并用一个 count 变量记录已使用的字符数。调用 append() 时直接往数组里追加内容,空间不够就扩容——不需要反复创建新对象。
看一下 StringBuilder 的 append 源码:
// StringBuilder.append 最终调用的是 AbstractStringBuilder.append
public AbstractStringBuilder append(String str) {
if (str == null) return appendNull();
int len = str.length();
ensureCapacityInternal(count + len); // 空间不够就扩容
str.getChars(0, len, value, count); // 直接拷贝字符到内部数组
count += len;
return this;
}而 StringBuffer 的区别就是加了一个 synchronized:
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}什么时候用哪个? 一句话总结:99% 的场景用 StringBuilder。StringBuffer 加了 synchronized,但实际上多线程拼接同一个字符串的场景几乎不存在。如果你的字符串操作很少(两三次拼接),直接用 + 也没问题,编译器会帮你优化成 StringBuilder。
重要提醒:不要在 for 循环里用 + 拼接字符串。
// 反面教材
String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
}反编译后你会发现,循环体变成了这样:
for (int i = 0; i < 10000; i++) {
result = new StringBuilder().append(result).append(i).toString();
}每次循环都 new 一个 StringBuilder,拼完再 toString 产生新的 String 对象——循环 1 万次就创建了 2 万个对象。正确做法:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();JDK 9 引入了
StringConcatFactory,基于invokedynamic指令在运行时动态选择最优拼接策略,取代了编译期固定翻译成 StringBuilder 的做法。JVM 可以根据实际情况选择 StringBuilder、MethodHandle 直接构建 byte 数组等多种策略。不过在循环场景下,手动用 StringBuilder 仍然是最佳实践。
二、字符串常量池
2.1 "abc" vs new String("abc")
这是面试经典题,也是理解 String 内存模型的关键。先看代码:
String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1.equals(s3)); // true为什么 == 的结果不同?因为 Java 在堆中维护了一个字符串常量池(String Pool)。用字面量 "abc" 创建字符串时,JVM 会先去常量池里找——找到了就直接返回引用,找不到就创建一个放进去。而 new String("abc") 则是在堆上额外创建一个新对象,不管常量池里有没有。
用一张图来理解内存布局:
所以 s1 == s2 为 true(指向同一个池中对象),而 s1 == s3 为 false(一个在池中,一个在堆上)。但 s1.equals(s3) 为 true,因为 equals 比较的是字符串内容。
new String("abc") 创建了几个对象? 答案是 1 个或 2 个:
- 如果常量池中还没有
"abc"(第一次遇到这个字面量),会先在池中创建一个,再在堆上 new 一个,共 2 个。 - 如果常量池中已经有
"abc"(之前被其他代码用过),就只在堆上 new 1 个。
更准确地说,字面量 "abc" 在编译时会进入 Class 文件的常量池,在运行时第一次被 ldc 指令加载时才在字符串常量池中创建对应的 String 实例。这是 HotSpot JVM 采用的延迟解析(lazy resolution)策略——不是类加载时就全部解析,而是等到实际用到时才处理。
再看一个编译期优化的例子:
String a = "ab";
String b = "a" + "b"; // 编译器直接优化为 "ab"(常量折叠)
System.out.println(a == b); // true
String x = "a";
String c = x + "b"; // 变量参与拼接,编译器无法优化
System.out.println(a == c); // false纯字面量的拼接在编译期就会被常量折叠(Constant Folding)合并成一个常量。但只要有变量参与,编译器无法在编译时确定结果,就会走 StringBuilder.append,产生一个新对象。
2.2 intern() 方法
intern() 的作用很简单:
- 如果常量池中已经有这个字符串,就返回池中的引用。
- 如果常量池中没有,就把它放进池里,再返回引用。
听起来简单,但配合不同的代码顺序,结果可能让人抓狂。先看一个 intern 返回 true 的例子:
String s1 = new String("a") + new String("a");
// 此时堆上有一个 "aa" 对象,但常量池里没有 "aa"
// (常量池里只有 "a",因为字面量 "a" 被用过)
s1.intern();
// 常量池没有 "aa",于是把 s1 的引用放入常量池(JDK 7+ 池在堆中)
String s2 = "aa";
// 去常量池找 "aa",找到了——就是 s1 的引用
System.out.println(s1 == s2); // true再看调换顺序后的结果:
String s0 = "aa";
// 常量池先有了 "aa"(s0 指向的就是池中对象)
String s1 = new String("a") + new String("a");
// 堆上新建了一个 "aa" 对象,s1 指向它
s1.intern();
// 常量池已有 "aa",intern 返回 s0 的引用,但没赋值给 s1
String s2 = "aa";
// 常量池中已有,返回 s0 的引用
System.out.println(s1 == s2); // false,s1 还是堆上的那个对象关键区别在于 intern() 被调用时,常量池里有没有这个字符串。如果没有,就把当前对象的引用放进去(JDK 7+ 的行为,因为常量池移到了堆中,可以直接存引用);如果有,就返回已有的引用,但不会改变调用者自身的引用指向——除非你把返回值赋给某个变量。
实际应用场景: 当你在运行时会产生大量重复字符串(比如从数据库批量读出的城市名、状态码),使用 intern() 可以让相同内容共享一个对象,节省内存。
// 假设从数据库读出 1000 万条记录,每条都有 city 字段
// 去重后只有几百个城市名,intern 可以大幅减少内存占用
String city = resultSet.getString("city").intern();但要注意,常量池本质是一个 HashTable,无节制地 intern 会导致池过大、查找变慢。只有在确定重复率很高时才值得用。
2.3 String 有长度限制吗?
有,而且编译期和运行期不一样:
编译期限制:字面量存储在 Class 文件的 CONSTANT_Utf8_info 结构中,其 length 字段是 u2 类型(2 字节无符号数),最大值 65535。而 javac 的源码中判断条件是 length >= 65535 就报错,所以字面量最长 65534 个字符。
// 这行代码如果 "111...1" 有 65535 个字符,javac 会报错:常量字符串过长
String s = "111...1";运行期限制:String 的 length() 返回 int,理论上限是 2^31 - 1(约 21 亿个字符,占用约 4GB 内存)。通过 StringBuilder 拼接或从文件读取,完全可以超过 65534。
// 运行期可以轻松超过编译期限制
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("x");
}
// sb.toString().length() == 100000实际开发中,如果你需要处理超大字符串(比如 Base64 编码的高清图片),建议改用流式处理或文件传输,避免 OOM。
三、基本类型与包装类
3.1 八大基本类型
Java 不是纯面向对象语言,它保留了 8 种基本类型以换取性能。基本类型直接存储在栈上(作为局部变量时),无需在堆上分配对象,没有对象头开销。
| 分类 | 基本类型 | 包装类 | 占用字节 | 范围 |
|---|---|---|---|---|
| 布尔 | boolean | Boolean | - | true / false |
| 整型 | byte | Byte | 1 | -128 ~ 127 |
| short | Short | 2 | -32768 ~ 32767 | |
| int | Integer | 4 | -2^31 ~ 2^31-1(约 21 亿) | |
| long | Long | 8 | -2^63 ~ 2^63-1 | |
| 字符 | char | Character | 2 | Unicode 字符 |
| 浮点 | float | Float | 4 | 约 +-3.4E38 |
| double | Double | 8 | 约 +-1.7E308 |
基本类型和包装类的核心区别:
- 默认值不同:基本类型的默认值是 0 / false / '2. 存储方式不同:基本类型作为局部变量在栈上,包装类是对象,在堆上(不考虑 JIT 的逃逸分析优化)。
- 能否用于泛型:基本类型不能,
List<int>编译不通过,必须用List<Integer>。
3.2 自动装箱与拆箱
Java 5 引入了自动装箱(基本类型 -> 包装类)和自动拆箱(包装类 -> 基本类型),本质是编译器帮你加了转换代码:
// 你写的代码
Integer a = 42;
int b = a;
// 编译器实际生成的代码
Integer a = Integer.valueOf(42); // 自动装箱
int b = a.intValue(); // 自动拆箱看起来很方便,但这里藏着一个经典陷阱——Integer 缓存:
Integer x = 100;
Integer y = 100;
System.out.println(x == y); // true
Integer m = 200;
Integer n = 200;
System.out.println(m == n); // false原因在于 Integer.valueOf() 的源码:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}JVM 启动时预先创建了 -128 到 127 的 Integer 对象并放入缓存数组。在这个范围内,valueOf 返回的是同一个对象,所以 == 为 true。超出范围每次都 new 新对象,== 比较的是不同地址,结果为 false。
各包装类的缓存范围:
| 包装类 | 缓存范围 | 备注 |
|---|---|---|
| Integer | [-128, 127] | 上限可通过 -XX:AutoBoxCacheMax 调整 |
| Short | [-128, 127] | 固定 |
| Byte | [-128, 127] | 覆盖全部 byte 值 |
| Long | [-128, 127] | 固定 |
| Character | [0, 127] | ASCII 范围 |
| Boolean | true, false | 只有两个值,全部缓存 |
| Float / Double | 无缓存 | 浮点数值太多,无法穷举 |
最佳实践:包装类比较,永远用 equals(),不要用 ==。
除了变量赋值,还有几个隐蔽的自动拆装箱场景需要警惕:
场景一:放入集合时自动装箱
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(i); // 自动装箱:Integer.valueOf(i)
}场景二:包装类参与运算时自动拆箱
Integer a = 10;
Integer b = 20;
int sum = a + b; // 先拆箱再相加:a.intValue() + b.intValue()场景三:三目运算符的隐式拆箱(NPE 高危!)
boolean flag = true;
Boolean nullWrapper = null;
boolean simpleVal = false;
// 当第二、第三操作数一个是包装类、一个是基本类型时,
// 包装类会被强制拆箱
boolean result = flag ? nullWrapper : simpleVal;
// 编译器翻译为:flag ? nullWrapper.booleanValue() : simpleVal
// nullWrapper 是 null -> NullPointerException!这个坑在很多团队中都造成过真实的线上事故。三目运算符的类型对齐规则会让包装类"悄悄"拆箱,如果恰好是 null,就直接 NPE。
场景四:函数参数和返回值
// 参数是 int,传入 Integer -> 自动拆箱
public int getNum(Integer num) { return num; }
// 返回值是 Integer,返回 int -> 自动装箱
public Integer getNum(int num) { return num; }3.3 为什么需要包装类?
既然基本类型性能更好,为什么还要包装类?三个原因:
1. 泛型和集合的需要——Java 的泛型基于类型擦除实现,只接受引用类型,基本类型无法参与。
// List<int> list = new ArrayList<>(); // 编译错误!
List<Integer> list = new ArrayList<>(); // 必须用包装类2. 表达 null 语义——这在业务场景中非常重要。比如 RPC 接口返回一个费率字段:
- 用
float rate:返回 0.0 时,你分不清是"费率确实为零"还是"出错了返回了默认值"。 - 用
Float rate:返回 null 就明确表示"数据异常"或"字段不适用",没有歧义。
3. 方法和工具——包装类提供了丰富的工具方法,比如 Integer.parseInt()、Integer.MAX_VALUE、Integer.toBinaryString() 等。
阿里巴巴 Java 开发手册的相关规约:
- POJO 类属性使用包装类型。
- RPC 接口的返回值和参数使用包装类型。
- 所有局部变量推荐使用基本数据类型。
- 布尔类型的变量不要加
is前缀(如不要写isSuccess),因为部分序列化框架会根据 getter 方法名推导属性名,isSuccess()会被反向解析为属性success,导致获取不到isSuccess属性,引发序列化错误。
四、精确计算:BigDecimal
4.1 浮点数精度陷阱
先看一段让人怀疑人生的代码:
System.out.println(0.1 + 0.2);
// 输出:0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3);
// 输出:false这不是 Java 的 Bug,而是 IEEE 754 浮点数标准的固有限制。
要理解这个问题,先看看十进制小数怎么转二进制:采用"乘 2 取整"法。以 0.1 为例:
0.1 x 2 = 0.2 -> 取 0
0.2 x 2 = 0.4 -> 取 0
0.4 x 2 = 0.8 -> 取 0
0.8 x 2 = 1.6 -> 取 1
0.6 x 2 = 1.2 -> 取 1
0.2 x 2 = 0.4 -> 取 0 <- 开始循环了!
...0.1 的二进制是 0.0001100110011... 无限循环。计算机只能存有限位数,所以 float/double 实际上只是近似值。两个近似值相加,误差就暴露出来了。
这不是 Java 独有的问题,几乎所有编程语言的浮点数都有相同的问题。
结论:涉及金额、费率等需要精确计算的场景,绝对不能用 float 和 double。
4.2 BigDecimal 正确用法
BigDecimal 通过"无标度值 + 标度"来精确表示小数,彻底绕开了二进制浮点数的限制。
实际值 = 无标度值 x 10^(-标度)
例:123.45 -> 无标度值 12345,标度 2
即 12345 x 10^(-2) = 123.45内部用一个 BigInteger(或压缩的 long)存储无标度值,用一个 int 存储标度,这样就能精确表示任何十进制小数。
但创建 BigDecimal 有一个大坑:
// 错误!double 本身就不精确,传进去的是近似值
BigDecimal bad = new BigDecimal(0.1);
System.out.println(bad);
// 输出:0.1000000000000000055511151231257827021181583404541015625
// 正确做法一:用字符串构造
BigDecimal good1 = new BigDecimal("0.1");
System.out.println(good1); // 0.1
// 正确做法二:用 valueOf(内部调用 Double.toString 再构造)
BigDecimal good2 = BigDecimal.valueOf(0.1);
System.out.println(good2); // 0.1new BigDecimal(0.1) 接收的是 double 值 0.1,而这个 0.1 在传入之前就已经是近似值了,BigDecimal 忠实地记录了这个不精确的值。而 new BigDecimal("0.1") 接收的是字符串 "0.1",可以精确解析为无标度值 1 和标度 1。
比较时用 compareTo,不要用 equals:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b)); // false!
System.out.println(a.compareTo(b)); // 0(相等)为什么 equals 返回 false?因为 BigDecimal 的 equals 同时比较值和标度。"1.0" 的标度是 1,"1.00" 的标度是 2,虽然数学上相等,但 equals 认为它们不同。而 compareTo 只比较数学上的值,返回 0 表示相等。
阿里巴巴 Java 开发手册明确建议:使用 compareTo 做 BigDecimal 的等值比较。
BigDecimal vs Long 表示金额:
实际业务中有两种流派:
| BigDecimal(单位:元) | Long(单位:分) | |
|---|---|---|
| 精度 | 可保留任意位小数 | 整数,精度到分 |
| 性能 | 较慢(对象操作) | 快(基本类型运算) |
| 适用场景 | 有费率/利率计算 | 无乘除运算,追求极致性能 |
| 典型系统 | 结算、支付、账单 | 额度、积分 |
两者都能用,但在涉及乘除(费率、利率、汇率)的场景中,Long 会在中间步骤强制丢精度。
举个具体例子:两笔 1 元的订单,服务费率 0.004。
- Long(分):每笔 100 x 0.004 = 0.4 分,四舍五入得 0 分,两笔合计 0 分。
- BigDecimal(元):每笔 1 x 0.004 = 0.004 元,两笔合计 0.008 元,最终四舍五入得 0.01 元(1 分钱)。
Long 在每一步计算后都被迫舍入(因为它只能存整数),误差层层累积。BigDecimal 可以保留中间过程的完整精度,到最后需要结算时再做一次整体舍入,结果更准确。
五、常见面试题精选
题目一:String、StringBuilder、StringBuffer 的区别?
面试官: 说说这三者的区别,什么时候用哪个?
思路: 从可变性、线程安全、性能三个维度展开,再结合实际场景。
参考答案: String 是不可变的,底层用 final char[](JDK 9 后是 final byte[])存储,每次"修改"都创建新对象。StringBuilder 和 StringBuffer 都是可变的,内部维护一个可扩容的字符数组,append 直接在原数组上追加。区别在于 StringBuffer 的方法加了 synchronized,是线程安全的,而 StringBuilder 没有。单线程环境下 StringBuilder 性能更好,实际开发中也几乎只用 StringBuilder。特别要注意:循环中拼接字符串一定要用 StringBuilder 的 append,不要用 +,因为 + 在循环里每次迭代都会 new 一个 StringBuilder 对象,再 toString 产生新 String,性能极差。
题目二:String s = new String("abc") 创建了几个对象?
面试官: 这行代码创建了几个对象?
思路: 区分常量池对象和堆对象,考虑"是否第一次遇到该字面量"。
参考答案: 1 个或 2 个。new 关键字一定会在堆上创建一个 String 对象,这是确定的。而字面量 "abc" 在 Class 文件编译后存入 Class 常量池,运行时第一次被 ldc 指令加载时会在字符串常量池中创建一个 String 实例。如果常量池中已经有了 "abc"(之前其他代码用过),就只创建堆上那一个对象。所以答案取决于执行这行代码时,常量池里有没有 "abc"。
题目三:Integer a = 128, b = 128; a == b 的结果?
面试官: 如果换成 127 呢?为什么?
思路: Integer 缓存机制 + 自动装箱原理。
参考答案: 128 时为 false,127 时为 true。自动装箱调用的是 Integer.valueOf(),该方法对 [-128, 127] 范围内的值使用了缓存(IntegerCache),返回同一个对象引用,所以 == 为 true。超出范围则每次 new Integer(i),两个对象地址不同,== 为 false。这个缓存的上限可以通过 JVM 参数 -XX:AutoBoxCacheMax 调整。结论是:包装类比较永远用 equals(),不要依赖 ==。
题目四:为什么不能用浮点数表示金额?
面试官: 0.1 + 0.2 为什么不等于 0.3?怎么解决?
思路: 从十进制转二进制的无限循环讲起,引出 IEEE 754 和 BigDecimal。
参考答案: 十进制小数转二进制采用"乘 2 取整"法,0.1 转成二进制是一个无限循环小数(0.000110011...),计算机只能存储有限位数,所以 float 和 double 只是近似值。两个近似值相加,结果就偏离了精确值。在 Java 中,0.1 + 0.2 实际等于 0.30000000000000004。解决方案有两个:一是用 BigDecimal(推荐用字符串构造 new BigDecimal("0.1")),二是用 Long 以"分"为单位存储金额(适合不涉及乘除的简单场景)。
题目五:BigDecimal 的 equals 和 compareTo 有什么区别?
面试官: new BigDecimal("1.0").equals(new BigDecimal("1.00")) 返回什么?
思路: BigDecimal 的 equals 同时比较值和标度(scale)。
参考答案: 返回 false。BigDecimal 的 equals 方法不仅比较数值大小,还比较标度。"1.0" 的标度是 1,"1.00" 的标度是 2,标度不同所以 equals 返回 false。如果只需要比较数学上的值是否相等,应该用 compareTo,返回 0 表示相等。阿里巴巴开发手册也明确建议使用 compareTo 做等值比较。另外,使用 new BigDecimal(double) 构造也要避免,因为 double 本身不精确,应该用 new BigDecimal(String) 或 BigDecimal.valueOf(double)。
题目六:三目运算符什么时候会导致 NPE?
面试官: 这段代码有什么问题?
Map<String, Boolean> map = new HashMap<>();
Boolean result = map != null ? map.get("key") : false;思路: 三目运算符的类型对齐规则 + 自动拆箱。
参考答案: 当三目运算符的第二个和第三个操作数类型不一致时(一个是包装类,一个是基本类型),Java 会将包装类自动拆箱以对齐类型。这里 map.get("key") 返回 Boolean(可能为 null),false 是基本类型 boolean,所以 map.get("key") 会被自动拆箱调用 booleanValue()。如果 map 中不存在这个 key,get 返回 null,对 null 调用 booleanValue() 就会 NPE。解决方案是把 false 改为 Boolean.FALSE,让两边类型一致,避免拆箱。
小结
回顾一下这篇文章的核心知识点:
String 不可变是刻意设计,带来了缓存、线程安全、安全性和 hashCode 缓存四大好处。大量拼接时用 StringBuilder,循环中尤其不要用
+。字符串常量池让相同字面量共享对象。
"abc"从池里拿,new String("abc")在堆上新建。==比较引用,equals比较内容。intern()可以手动将字符串放入池中。包装类缓存让 [-128, 127] 范围内的 Integer 等对象被复用,超出范围
==就不可靠——永远用equals()比较包装类。自动拆箱可能在三目运算符等隐蔽场景引发 NPE。浮点数是近似值,金额计算必须用 BigDecimal。创建时用
new BigDecimal("...")或BigDecimal.valueOf(),比较时用compareTo而不是equals。BigDecimal 和 Long 各有适用场景,涉及费率计算选 BigDecimal,纯整数计数选 Long。
记住一个原则:越是"看起来简单"的东西,越要搞清楚它背后的机制。 这些基础知识,面试时是区分度最高的考点,日常开发中则是避开"诡异"线上 Bug 的第一道防线。