引言
在日常项目工作中,我们经常会用到一些看似理所当然、凭直觉就能理解的概念,但当被要求在讨论或面试中清晰地阐述它们时,却可能发现难以准确表达。让我们来看看几个这样的例子。
1. null instanceof Object
的结果是 false
很多开发者以为 Java 里万物(除了基本类型)都继承自 Object
,但请看:
System.out.println(null instanceof Object); // 输出: false
instanceof
操作符的规则是:如果左操作数是 null
,那么无论右操作数是什么类型,结果都是 false
。这里的 Object
虽然是所有类的根类,但 null
本身并不代表任何对象的实例。
这种行为有助于避免在检查一个不确定是否为 null
的引用时,因为 instanceof
操作而意外抛出 NullPointerException
。
2. Integer.valueOf(127) == Integer.valueOf(127)
是 true
, 但 Integer.valueOf(128) == Integer.valueOf(128)
却是 false
这是因为 Integer
的缓存机制:
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b); // 输出: true (因为 127 在缓存范围内)
Integer x = Integer.valueOf(128);
Integer y = Integer.valueOf(128);
System.out.println(x == y); // 输出: false (因为 128 超出了缓存范围,创建了新对象)
Java 为了性能考虑,会缓存一部分 Integer
对象。默认情况下,数值在 -128 到 127 之间的 Integer
对象会被缓存。当你调用 Integer.valueOf(i)
时,如果 i
在这个范围内,就会返回缓存中的同一个对象实例。超出这个范围,则会创建新的 Integer
对象。
如果你使用 new Integer()
,那么无论数值多少,它总是会创建一个全新的对象。
(注意:在 Java 9 及以后,new Integer(int)
构造方法已被废弃,推荐使用 Integer.valueOf(int)
)。
3. HashMap
的容量总是 2 的幂次方
即使你用一个自定义的容量来初始化 HashMap
,Java 内部也会将这个容量向上取整到最接近的 2 的幂次方:
import java.util.HashMap;
import java.util.Map;
// ...
Map<String, String> map = new HashMap<>(9); // 尝试用容量 9 初始化
// 实际上,HashMap 内部可能会分配 16 的容量 (最接近 9 的 2 的幂次方)
System.out.println("map.size() is not capacity"); // map.size() 返回的是元素数量,不是容量
(要查看实际容量比较麻烦,通常需要通过反射访问内部字段,但其设计原则是容量为2的幂次方)
这样做有助于 HashMap
在计算元素存放位置(索引)时,使用位运算进行优化,提高效率。
4. "Hello" + 1 + 2 + 3
的结果是 "Hello123"
, 但 1 + 2 + 3 + "Hello"
的结果是 "6Hello"
Java 处理字符串拼接的方式有所不同,取决于操作的顺序:
System.out.println("Hello" + 1 + 2 + 3); // 输出: "Hello123"
System.out.println(1 + 2 + 3 + "Hello"); // 输出: "6Hello"
-
• 如果字符串 (
String
) 出现在拼接操作的最前面,那么后续的所有操作数(即使是数字)都会被转换为字符串,然后进行字符串连接。 -
• 如果数字运算出现在字符串之前,那么这些数字会先进行算术运算,得到结果后再与后续的字符串进行拼接。
5. Java 的字符串字面量存储在一个称为“字符串常量池 (String Pool)”的特殊内存区域
String s1 = "Java"; // "Java" 字面量,放入字符串常量池
String s2 = "Java"; // s2 也指向池中同一个 "Java" 对象
System.out.println(s1 == s2); // 输出: true (s1 和 s2 指向常量池中的同一个对象)
String s3 = new String("Java"); // 使用 new 关键字会强制在堆上创建一个新对象
System.out.println(s1 == s3); // 输出: false (s1 指向常量池,s3 指向堆上的新对象)
字符串字面量(比如直接用双引号定义的字符串)会被“驻留”(interned),也就是说,为了效率,它们在字符串常量池中通常只存储一份。
而使用 new String("Java")
则会强制在堆内存中分配一个新的字符串对象,即使常量池中已存在相同内容的字符串。
6. Math.max(Double.NaN, 5)
的结果是 NaN
即使 5
是一个有效的数字,任何涉及 NaN
(Not a Number) 的运算(包括比较)结果通常都是 NaN
:
System.out.println(Math.max(Double.NaN, 5)); // 输出: NaN
System.out.println(Math.min(Double.NaN, 5)); // 输出: NaN
System.out.println(Double.NaN == Double.NaN); // 输出: false (NaN 甚至不等于它自己)
System.out.println(Double.NaN > 5); // 输出: false
System.out.println(Double.NaN < 5); // 输出: false
NaN
(非数字)被认为是一个未定义的值,所以涉及 NaN
的比较运算(除了 !=
,NaN != NaN
是 true
)通常返回 false
,而算术运算或像 Math.max
这样的函数返回 NaN
。
7. System.exit(0)
可能会终止 finally
代码块的执行
通常情况下,无论 try
块中是否发生异常,finally
块中的代码总是会执行。但是,如果 System.exit(0)
是在 try
块内部被调用的,情况就不同了!
try {
System.out.println("尝试退出...");
System.exit(0); // JVM 在这里终止
} finally {
// 这段代码将没有机会执行
System.out.println("这段 finally 会打印吗?");
}
// 控制台输出: 尝试退出... (然后程序就结束了)
System.exit(0)
会直接终止 Java 虚拟机 (JVM) 的运行,这使得 finally
块(以及其他任何后续代码)都没有机会执行。
在生产环境中,应避免在 try
块(或任何可能阻止关键清理逻辑的地方)内部使用 System.exit()
。
8. 静态代码块 (static
block) 可以在 main()
方法之前执行
public class Surprise {
static {
// 这个静态代码块先执行
System.out.println("静态代码块已执行!");
}
public static void main(String[] args) {
System.out.println("main 方法已执行!");
}
}
输出:
静态代码块已执行!
main 方法已执行!
静态代码块在类加载(class loading) 期间执行,这通常发生在 main()
方法被调用之前(因为 main
方法所在的类也需要先被加载)。
9. Thread.sleep(1000)
并不保证精确地延迟 1 秒
Thread.sleep()
的实际延迟时间依赖于操作系统的调度器 (OS scheduler),所以实际的延迟可能会更长一些,原因包括:
-
• 操作系统的线程调度延迟
-
• 垃圾回收 (Garbage Collection) 导致的暂停
-
• CPU 当前的负载情况
long start = System.currentTimeMillis();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
long duration = System.currentTimeMillis() - start;
System.out.println("实际延迟: " + duration + "ms"); // 结果可能略大于 1000ms
10. ArrayList.remove(Object o)
移除第一个匹配项,而 remove(int index)
按索引移除
ArrayList
有两个重载的 remove
方法,它们的行为不同:
import java.util.ArrayList;
import java.util.List;
// ...
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 2, 4));
System.out.println("原始列表: " + list); // [1, 2, 3, 2, 4]
// 调用 remove(Object o) - 移除列表中第一个值为 2 的元素
list.remove(Integer.valueOf(2));
System.out.println("移除第一个 2 后: " + list); // [1, 3, 2, 4]
// 调用 remove(int index) - 移除索引为 2 的元素 (此时是值为 2 的那个元素)
list.remove(2);
System.out.println("移除索引为 2 的元素后: " + list); // [1, 3, 4]
当调用 list.remove(2)
时,如果 list
是 List<Integer>
,由于自动装箱/拆箱的特性和方法重载的解析规则,Java 会优先匹配 remove(int index)
这个版本,所以它会移除索引为 2
的元素,而不是值为 2
的元素。如果想按值移除,需要传递一个 Integer
对象,如 list.remove(Integer.valueOf(2))
。
11. HashSet.add(null)
可以成功,但 TreeSet.add(null)
会抛出 NullPointerException
import java.util.HashSet;
import java.util.TreeSet;
import java.util.Set;
// ...
Set<String> hashSet = new HashSet<>();
hashSet.add(null); // HashSet 允许添加一个 null 元素
System.out.println("HashSet 中可以有 null: " + hashSet.contains(null)); // true
Set<String> treeSet = new TreeSet<>();
try {
treeSet.add(null); // TreeSet 不允许添加 null,会抛出 NullPointerException
} catch (NullPointerException e) {
System.out.println("TreeSet 添加 null 时抛出异常: " + e.getMessage());
}
TreeSet
内部使用比较器 (Comparator
)或元素的自然顺序(实现Comparable
接口)来维护元素的有序性。由于null
值无法与其他非null
值进行有意义的比较(比如null.compareTo(anotherObject)
会导致NullPointerException
),所以TreeSet
不允许添加null
元素(除非你提供一个能处理null
的特殊比较器,但这通常不推荐)。而HashSet
不要求元素有序,所以它允许存储一个null
。
12. Java 没有“真正意义上”的引用传递 (pass-by-reference)
void changeValue(Integer numWrapper) {
// numWrapper 是一个指向外部 a 所指向的 Integer 对象的引用的副本
// 下面这行代码让 numWrapper 指向了一个新的 Integer(10) 对象
// 它并没有改变外部 a 的引用
numWrapper = 10;
}
// ...
Integer a = 5;
// changeValue(a); // 实际上这里并没有 changeValue 方法,假设它存在于某个类中并被调用
// 假设在一个类中有:
// public void testPassByValue() {
// Integer a = 5;
// changeValue(a);
// System.out.println(a); // 仍然是 5
// }
// public void changeValue(Integer numWrapper) {
// numWrapper = 10; // numWrapper 现在指向一个新的 Integer(10) 对象
// }
System.out.println("如果调用了 changeValue(a),a 的值仍然是: " + a); // 输出: 5 (假设 a=5)
Java 总是按值传递 (pass-by-value)。
当传递对象(或包装类型)时,实际上传递的是对象引用的一个副本(拷贝)。在方法内部,你可以通过这个引用的副本来修改对象本身的属性(如果对象是可变的),但如果你尝试将这个引用的副本指向一个新的对象(如 num = 10;
,它会创建一个新的 Integer(10)
并让 num
指向它),这并不会改变方法外部原始引用的指向。
13. 一个 char
占用 2 个字节,而不是 1 个
char c = 'A';
// Character.BYTES 是 char 类型占用的字节数
System.out.println("一个 char 占用 " + Character.BYTES + " 个字节"); // 输出: 2
Java 内部使用 UTF-16 编码来表示字符,所以即使 char
类型变量只存储一个简单的英文字母,它也占用 2 个字节(16位)。
14. "Hello".hashCode()
在不同的 JVM 运行中结果总是一样
不像大多数普通对象的 hashCode()
可能依赖于对象的内存地址(导致不同运行次结果不同),String
类的 hashCode()
是确定性的,并且在不同的 JVM 运行中其结果不会改变。
System.out.println("Hello".hashCode()); // 总是输出 69609650
System.out.println("World".hashCode()); // 总是输出 83766104
这是因为 String.hashCode()
的计算方法在 Java 规范中有明确定义:
s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1]
(其中 s[i]
是字符串的第 i
个字符,n
是字符串长度)
这种确定性确保了 String
对象作为 HashMap
的键等场景下,在不同平台或不同 JVM 运行间具有一致的行为和兼容性。
15. double
类型的精度误差可能导致不正确的结果
System.out.println(0.1 + 0.2); // 输出可能并非精确的 0.3,而是类似 0.30000000000000004
发生这种情况是因为浮点数(floating-point)的算术运算本身是不精确的,这是由于计算机使用二进制表示数字的固有限制造成的(很多十进制小数无法精确地用二进制浮点数表示)。
解决方案: 对于需要高精度计算的场景(如金融计算),应该使用 java.math.BigDecimal
类。
结语
由于其设计选择、性能优化以及一些历史决策,Java 中存在许多这类看似“令人意外”的行为和细节。了解它们有助于我们写出更健壮、更正确的代码,并在遇到问题时能更快地定位原因。