" />
文章

Java 基础面试随记(一)补充:位移、equals/hashCode、finally 与值传递

Java 基础面试随记(一)补充:位移、equals/hashCode、finally 与值传递

面试指北 · 通用

题意

这篇文章整理的是 Java 基础面试里几个“看起来简单、但追问起来很容易翻车”的点:位移运算符、equals() 与 hashCode() 的合同、finally 中的 return,以及 Java 方法调用到底是值传递还是引用传递。

这些题通常不会单独考很难的算法,而是考你对 Java 语言细节的理解是否准确。下面按原始笔记的顺序展开,并补充一些面试中常见的追问点。

结论

  • << 是左移,低位补 0,在没有溢出的情况下等价于乘以 2^n。
  • >> 是带符号右移,高位补符号位;正数右移仍为正,负数右移仍为负。
  • >>> 是无符号右移,高位一律补 0,负数右移后可能变成一个很大的正数。
  • Object.equals() 默认比较的是引用是否指向同一个对象,常被口语化说成“比较内存地址”;但 String、包装类等重写后比较的是内容。
  • equals() 相等,则 hashCode() 必须相等;hashCode() 相等,equals() 不一定相等,这就是哈希冲突。
  • finally 中的 return 会覆盖 try 中的 return,也会吞掉异常,实际开发中应避免这样写。
  • Java 只有值传递。对象参数传递的是“对象引用的副本”,所以方法内重新赋值不会影响调用方变量,但通过引用修改对象内容会影响原对象。

要点展开

一、位移运算符

Java 中的整数使用补码表示。int 是 32 位,long 是 64 位。理解位移运算符时,核心是看两件事:往哪边移,以及空出来的高位补什么。

<< 左移运算符

5 << 2 = 5 * 2^2 = 20。

左移时低位补 0,高位溢出部分会被丢弃。对于正数,在没有溢出的情况下,x << n 可以理解为 x * 2^n。

例如:

System.out.println(5 << 2); // 20

>> 右移运算符

20 >> 2 = 20 / 2^2 = 5。

>> 是带符号右移,也叫算术右移。它右移时高位补的是符号位:

  • 正数高位补 0;
  • 负数高位补 1。

所以负数右移后仍然是负数。例如:

System.out.println(20 >> 2);  // 5
System.out.println(-20 >> 2); // -5

>>> 无符号右移运算符

无符号右移运算符 >>> 不考虑符号位,左边始终补 0。让我们来看具体的步骤:

  • 原始二进制表示(补码):11111111 11111111 11111111 11101100(-20)

  • 无符号右移两位,左边补 0,结果为:

java 00111111 11111111 11111111 11111011

这个新的二进制值对应的十进制数是 1073741819,这是因为无符号右移使负数的二进制补码转换成了一个非常大的正数。

可以这样对比:

表达式 结果 说明
20 >> 2 5 正数带符号右移
-20 >> 2 -5 负数带符号右移,高位补 1
-20 >>> 2 1073741819 无符号右移,高位补 0

另外有一个常见坑:Java 对移位距离会做掩码处理。对于 int,实际只取移位次数的低 5 位,也就是相当于 n & 31;对于 long,只取低 6 位,相当于 n & 63。所以:

System.out.println(1 << 32); // 1,不是 0

因为 32 & 31 = 0,相当于没有移位。这个点在面试中偶尔会作为追问出现。

二、equals()

原文说 equals() 比较的是内存地址,这句话需要稍微修正一下。

更准确的说法是:Object 类中的默认 equals() 实现比较的是引用是否指向同一个对象,也就是 this == obj。口语上常把它说成“比较内存地址”,但严格来说,它比较的是引用相等性。

public boolean equals(Object obj) {
    return this == obj;
}

不过很多 Java 类都重写了 equals():

  • String 重写后比较字符串内容;
  • Integer、Long 等包装类重写后比较包装的数值;
  • List、Set、Map 等集合类也会按内容进行比较。

例如:

String a = new String("abc");
String b = new String("abc");

System.out.println(a == b);      // false,两个不同对象
System.out.println(a.equals(b)); // true,内容相同

所以面试中不要把 == 和 equals() 混为一谈:

  • == 对于基本类型比较值,对于引用类型比较是否为同一个对象;
  • equals() 默认是引用比较,但重写后通常表示“逻辑相等”。

三、hashCode() 与 equals() 的合同

hashCode() 和 equals() 是共同作用的。它们必须遵守一个重要的合同(规则),特别是在使用哈希表时:

  • 如果两个对象根据 equals() 方法被认为是相等的,那么它们的 hashCode() 必须相等。
  • 如果两个对象的 hashCode() 不相等,那么它们必定不相等(根据 equals() 的比较)。
  • 如果两个对象的 hashCode() 相等,它们不一定相等(根据 equals() 的比较),但可能发生哈希冲突。

可以把这几条理解成:

  1. equals 是最终判断“是不是同一个逻辑对象”的方法;
  2. hashCode 是哈希表用来快速定位桶的“粗筛”值;
  3. 哈希表通常先比较 hashCode,定位到某个桶后,再用 equals() 比较桶里的元素。

因此,如果重写了 equals() 却没有重写 hashCode(),就可能出现两个对象 equals() 为 true,但 hashCode() 不同,最终被放进不同的桶里。这样在 HashMap 或 HashSet 中就会出现“明明相等却找不到”或者“重复存入”的问题。

例如下面这个类只重写了 equals():

class Person {
    private String name;

    // 省略构造器、getter/setter

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }

    // 没有重写 hashCode()
}

使用时可能出现:

Map<Person, String> map = new HashMap<>();
map.put(new Person("小张"), "A");

System.out.println(map.get(new Person("小张"))); // 可能输出 null

正确的做法是同时重写 hashCode(),并保证参与 equals() 比较的字段也参与 hashCode() 计算,例如:

@Override
public int hashCode() {
    return Objects.hash(name);
}

另外,equals() 本身也应满足几个基本性质:自反性、对称性、传递性、一致性,以及 x.equals(null) 应返回 false。这些性质在面试中不一定都会问,但属于同一个知识体系。

四、finally 中的 return

先看原始代码:

public static void main(String[] args) {
    System.out.println(f(2));
}

public static int f(int value) {
    try {
        return value * value;
    } finally {
        if (value == 2) {
            return 0;
        }
    }
}

输出:

0

原因是:try 中的 return value * value 会先计算出返回值 4,但在方法真正返回之前,finally 代码块仍然会执行。此时 value == 2 成立,finally 中执行了 return 0,这个 return 会直接结束方法,并覆盖掉 try 中原本准备返回的 4。

如果调用 f(3),finally 中的条件不成立,没有新的 return,那么方法最终返回 9。

这里有几个易错点:

  • finally 通常会在 try 退出前执行,包括正常结束、return、抛出异常等情况;
  • 但如果 finally 中写了 return,它会覆盖 try 中的返回值;
  • 如果 try 中抛出了异常,而 finally 中又 return,异常也可能被吞掉;
  • 极少数情况下 finally 不会执行,例如 JVM 被强制退出、System.exit()、进程崩溃等。

所以实际开发中一般不建议在 finally 中写 return,它会让返回值变得不直观,也容易掩盖异常。

五、Java 是值传递

Java 方法调用只有值传递。对于对象参数,传递的是“对象引用的副本”,而不是对象本身,也不是调用方变量的别名。

看原始案例:

public class Person {
    private String name;
   // 省略构造函数、Getter&Setter方法
}

public static void main(String[] args) {
    Person xiaoZhang = new Person("小张");
    Person xiaoLi = new Person("小李");
    swap(xiaoZhang, xiaoLi);
    System.out.println("xiaoZhang:" + xiaoZhang.getName());
    System.out.println("xiaoLi:" + xiaoLi.getName());
}

public static void swap(Person person1, Person person2) {
    Person temp = person1;
    person1 = person2;
    person2 = temp;
    System.out.println("person1:" + person1.getName());
    System.out.println("person2:" + person2.getName());
}

输出:

person1:小李
person2:小张
xiaoZhang:小张
xiaoLi:小李

为什么 swap() 里面看起来交换成功了,外面却没有变化?

因为 xiaoZhang 和 xiaoLi 是 main 方法里的两个局部变量,它们分别保存两个 Person 对象的引用。调用 swap() 时,Java 会把这两个引用的副本传给 person1 和 person2。

在 swap() 内部:

  • person1 和 person2 只是两个新的局部变量;
  • 交换 person1 和 person2,只是让这两个副本指向了不同的对象;
  • 调用方的 xiaoZhang 和 xiaoLi 并没有被重新赋值。

所以方法内部打印是“小李、小张”,方法外部仍然是“小张、小李”。

但要注意:如果方法通过引用修改了对象的属性,调用方是能看到变化的。例如:

public static void changeName(Person person) {
    person.setName("老王");
}

这种情况下,方法内部和调用方持有的是同一个对象的引用副本,所以修改对象内容会生效。但如果写成:

public static void changePerson(Person person) {
    person = new Person("新对象");
}

这只是让方法内部的局部变量指向了新对象,不会影响调用方原来的引用。

可以记成一句话:Java 传递的是引用的值,不是引用本身;方法内重新赋值不影响调用方,修改对象内容会影响调用方。

易错点与对比

对比点 说明
>> 与 >>> >> 高位补符号位,负数右移仍为负;>>> 高位补 0,负数可能变成很大的正数
<< 的溢出 左移可能溢出,溢出后高位被丢弃,不能无脑当成乘法
移位距离 `
License:  CC BY 4.0