Java面试官也懵圈?!这15个“坑”专治各种不服!

引言

在日常项目工作中,我们经常会用到一些看似理所当然、凭直觉就能理解的概念,但当被要求在讨论或面试中清晰地阐述它们时,却可能发现难以准确表达。让我们来看看几个这样的例子。


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 中存在许多这类看似“令人意外”的行为和细节。了解它们有助于我们写出更健壮、更正确的代码,并在遇到问题时能更快地定位原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值