Skip to content

S03-01 核心类-Object ​

[TOC]

概述 ​

什么是 Object ​

Object:Java 所有类的“根父类”:所有类(自定义类、内置类、数组)都直接或间接继承它。

  • 自动继承:任何没有显式声明父类的类,默认继承 java.lang.Object(比如 class Person {} 等价于 class Person extends Object {});
  • 间接继承:显式继承的类会通过继承链间接继承 Object(如 class Dog extends Animal → Animal 继承 Object → Dog 间接继承 Object);
  • 特殊类型也继承:数组、枚举、注解等特殊类型也属于 Object 类型(如 String[] arr = new String[5]; arr instanceof Object 返回 true)。

本质:统一所有对象的基础行为:

Object 类封装了所有 Java 对象的通用能力,比如:

  • 判断对象是否相等(equals());
  • 生成对象的哈希值(hashCode());
  • 描述对象信息(toString());
  • 线程间通信(wait()/notify())。

这些方法保证了所有 Java 对象都具备统一的基础接口,是面向对象设计的“最小约定”。

核心特性 ​

核心特性:

  • 无需手动导入:java.lang 包下的类(包括 Object)由 JVM 自动导入,可直接使用;
  • 仅含无参构造:Object 只有一个无参构造方法,子类构造方法默认通过 super() 调用它;
  • 不可被重写的核心方法:getClass()、wait()、notify() 等被 final 修饰,无法子类重写。

核心方法 ​

Object 类的核心方法(逐个详解):

Object 类的方法可分为 5 大类,以下是每个方法的签名、默认实现、作用、重写规则和实战示例(新手重点掌握前 4 个方法):

== 运算符 ​

在 Java 中,== 是一个比较运算符(Comparison Operator)。

虽然它看起来很简单,但它是 Java 新手最容易混淆的概念之一,因为它在处理基本数据类型和引用数据类型时,行为截然不同。

我们可以用一句话总结它的核心逻辑:

== 永远比较的是变量内存中存储的“值”。

只是对于不同类型,“内存中的值”代表的意义不同。

基本数据类型 ​

场景 1:基本数据类型 (Primitives):

(byte, short, int, long, float, double, char, boolean)

对于基本数据类型,变量在栈内存(Stack)中直接存储的是实际的数据值。

  • 行为: == 比较的是它们的数值是否相等。
  • 规则: 只要数值一样,结果就是 true。

代码示例:

java
int a = 10;
int b = 10;
double c = 10.0;

System.out.println(a == b); // true (10 等于 10)
System.out.println(a == c); // true (10 等于 10.0,自动类型提升后数值相等)

引用数据类型 ​

场景 2:引用数据类型 (Reference Types):

(类、接口、数组)

对于引用数据类型,变量在栈内存(Stack)中存储的不是对象本身,而是对象在堆内存(Heap)中的内存地址(Reference)。

  • 行为: == 比较的是它们的内存地址。
  • 本质: 它判断的是“这两个引用是否指向同一个物理对象”。
  • 比喻: 就像你有两把钥匙。== 问的是:“这两把钥匙是不是同一把(完全一模一样的那把金属)?”,而不是问“它们能不能开同一扇门?”。

代码示例:

java
// 在堆内存中创建了两个不同的对象,虽然内容一样
User u1 = new User("张三");
User u2 = new User("张三");
User u3 = u1; // u3 复制了 u1 的地址

System.out.println(u1 == u2); // false (地址不同,是两个独立的对象)
System.out.println(u1 == u3); // true  (指向同一个对象)

陷阱:String 字符串 ​

特殊陷阱:String 字符串:

这是面试中关于 == 最著名的坑。由于 Java 对字符串进行了特殊优化(字符串常量池 String Constant Pool),导致 == 的结果有时让人困惑。

方式一:字面量创建 (Literal):

java
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // true!

原因: Java 发现常量池里已经有 "Hello" 了,就直接把 s2 指向了同一个地址。为了省内存,它们共享了同一个对象。

方式二:new 关键字创建:

java
String s3 = new String("Hello");
String s4 = new String("Hello");
System.out.println(s3 == s4); // false!

原因: new 关键字强制在堆内存中开辟一块新空间。不管内容是不是一样,地址绝对不同。

陷阱:包装类的缓存 ​

进阶陷阱:包装类的缓存 (Integer Cache):

这是另一个高频考点。Java 的包装类(Integer, Long 等)也有缓存机制。

java
Integer i1 = 100;
Integer i2 = 100;
System.out.println(i1 == i2); // true (命中缓存 -128 到 127)

Integer i3 = 200;
Integer i4 = 200;
System.out.println(i3 == i4); // false (超出缓存范围,创建了新对象)

结论: 比较包装类对象时,永远不要用 ==,一定要用 equals(),否则可能会遇到这种“薛定谔的相等”。

== vs equals() ​

== vs equals() 终极对比:

维度== 运算符equals() 方法
类型Java 语言内置的操作符Object 类定义的方法
基本数据类型比较数值 (可用)编译错误 (不可用)
引用数据类型比较内存地址 (判断是否同一对象)默认比地址,重写后比内容
运行速度极快 (CPU 指令级比较)较慢 (涉及方法调用和逻辑判断)
空指针安全安全 (null == null 为 true)不安全 (调用 null.equals 会崩)

最佳实践总结 ​

最佳实践总结:

  1. 基本类型: 只能用 ==。
  2. 字符串 (String): 死都别用 ==,一定要用 equals()。
  3. 包装类 (Integer, Long): 别用 ==,用 equals(),以防缓存坑。
  4. 枚举 (Enum): 可以用 ==,也可以用 equals()(枚举单例不仅值相等,地址也相等,官方甚至建议用 == 以避免空指针)。
  5. 自定义对象:
  • 想判断是不是“同一个对象实例”?用 ==。
  • 想判断“业务含义是否相等”?用 equals()(前提是重写了它)。

toString() ​

String toString():返回该对象的字符串表示。

  • 无参数
  • 返回:String,该对象的文本描述。结果应是一个“简洁但内容丰富,且易于阅读”的字符串。
  • 抛出:无。

基本示例:

默认实现 vs 重写实现:

java
class User {
  private int id;
  private String name;

  public User(int id, String name) {
    this.id = id;
    this.name = name;
  }

  // 场景2:重写 toString (推荐)
  @Override
  public String toString() {
    return "User{id=" + id + ", name='" + name + "'}";
  }
}

public class Main {
  public static void main(String[] args) {
    Object rawObj = new Object();
    User user = new User(1, "Alice");

    // 场景1:默认实现 (类名 + @ + 十六进制哈希)
    // 输出示例: java.lang.Object@74a14482
    System.out.println(rawObj.toString());

    // 场景2:重写后输出业务数据
    // 输出: User{id=1, name='Alice'}
    System.out.println(user); // 自动调用 toString()
  }
}

核心特性:

  1. 默认实现的剖析:

    在 Object 类中,toString() 的源代码如下:

    java
    public String toString() {
     return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

    它由三部分组成:

    • 类名:全限定类名(如 java.lang.Object)。
    • 分隔符:@ 符号。
    • 身份标识:对象的哈希码的无符号十六进制表示。

    深度解读:对于未重写该方法的对象,打印出来的字符串(如 User@4554617c)实际上是该对象在 JVM 堆内存中的逻辑身份指纹。虽然它通常不直接等同于物理内存地址, 但在 HotSpot 虚拟机默认的 hashCode 实现中,它与对象的内存地址紧密相关。

  2. 隐式调用机制:

    toString() 是 Java 中被调用最频繁的方法之一,因为它被深度集成到了语言特性中。

    • 字符串拼接:"Data: " + obj 编译器会自动将被拼接对象转换为 obj.toString()(若为 null 则拼接 "null")。
    • 打印输出:System.out.println(obj) 内部直接调用 String.valueOf(obj),进而调用 toString()。
    • 调试工具:IDE(IntelliJ IDEA, Eclipse)在 Debug 模式下的变量视图中,默认展示的就是对象的 toString() 结果。

注意事项:

  1. 无限递归(StackOverflowError):

    这是重写 toString 时最致命的坑。如果两个对象存在双向引用(例如:父子节点、ORM 框架中的多对一关系),且两者的 toString 都试图打印对方,就会瞬间导致栈溢出。

    java
    class Parent {
      Child child;
      @Override
      public String toString() { return "Parent{child=" + child + "}"; }
    }
    
    class Child {
      Parent parent;
      @Override
      public String toString() { return "Child{parent=" + parent + "}"; } // 💣 触发无限递归
    }

    解决方案:打破引用链。在 toString 中,对于反向引用的字段,不要直接打印对象,而是打印其 ID 或名称,或者使用 @ToString.Exclude (Lombok) 排除该字段。

  2. 数组的打印陷阱:

    Java 数组也是对象,但它们没有重写 toString()。直接打印数组会输出难以理解的类型签名(如 [I@1b6d3586)。

    java
    int[] nums = {1, 2, 3};
    System.out.println(nums); // 输出 [I@xxxx (无意义)
    
    // 正确做法:
    System.out.println(java.util.Arrays.toString(nums)); // 输出 [1, 2, 3]
  3. 敏感信息泄露:

    日志系统通常会记录对象的 toString 结果。切勿在 toString 中包含密码、密钥、身份证号等敏感字段。

扩展知识:

  1. 自动化生成工具:

    手写 toString 既繁琐又容易在新增字段时遗漏。推荐使用以下方案:

    • Lombok:使用 @ToString 注解,编译期自动生成代码。

      java
      import lombok.ToString;
      
      @ToString
      public class User {
        private String name;
        private int age;
        // 编译时,Lombok 会自动帮你生成标准的 toString 代码
      }
    • IDE 生成:IDEA 中 Alt + Insert -> toString()。

      java
      @Override
      public String toString() {
        return "User{" +
          "name='" + name + '\'' +
          ", age=" + age +
          ", email='" + email + '\'' +
          '}';
      }
    • Apache Commons Lang:ToStringBuilder.reflectionToString(this)(通过反射实现,方便但性能较差,生产环境慎用高频调用)。

  2. Java 14+ Records (记录类):

    Java 14 引入的 record 关键字定义的数据类,编译器会自动生成包含所有字段值的标准 toString(),格式清晰且规范。

    java
    public record Point(int x, int y) {}
    
    // 自动生成的 toString 输出:Point[x=10, y=20]

equals() ​

boolean equals(Object obj):指示其他某个对象是否与此对象“逻辑相等”。

  • obj:Object,要与之进行比较的引用对象。
  • 返回:如果此对象与 obj 参数在逻辑上相同,则返回 true;否则返回 false。
  • 抛出:无(但在实现不当时可能抛出 NullPointerException 或 ClassCastException)。

基本示例:

最佳实践模板:以下是遵循《Effective Java》建议的标准覆写模板,兼顾了性能、健壮性和类型安全。

java
public class Employee {
  private long id;
  private String name;
  private double salary;

  @Override
  public boolean equals(Object o) {
    // 1. 自反性检查:如果是同一个对象引用,直接返回 true(性能优化)
    if (this == o) return true;

    // 2. 类型检查:处理 null 并确保类型兼容
    // 注意:这里使用 instanceof 允许子类相等(如 Hibernate 代理对象),
    // 若要求严格类型匹配(拒绝子类),则改用 if (o == null || getClass() != o.getClass())
    if (!(o instanceof Employee)) return false;

    // 3. 类型转换
    Employee employee = (Employee) o;

    // 4. 关键域比较:基本类型用 ==,引用类型用 Objects.equals,浮点型用 Float/Double.compare
    return id == employee.id &&
      Double.compare(employee.salary, salary) == 0 && // 处理 NaN 和 -0.0
      java.util.Objects.equals(name, employee.name);
  }

  // 警告:覆写 equals 必须覆写 hashCode
  @Override
  public int hashCode() {
    return java.util.Objects.hash(id, name, salary);
  }
}

核心特性:

  1. 引用相等 vs 逻辑相等:

    Object 类中默认的 equals 实现仅仅是检查引用相等性(Reference Equality),即比较内存地址(this == obj)。

    然而,对于像 String、Integer、Date 以及自定义的数据类(DTO/Entity),我们通常关注的是逻辑相等性(Logical Equality)。即只要两个对象的关键状态(成员变量)一致,它们就被视为相等,即使它们存储在堆内存的不同位置。

  2. 数学上的等价关系(Equivalence Relation):

    Java 语言规范(JLS)严格定义了 equals 方法必须满足的五个特性。任何违反这些特性的实现都会导致 HashMap、HashSet 或 Collections.sort 等基础类库出现不可预知的行为。

    • 自反性 (Reflexive):对于任何非空引用 x,x.equals(x) 必须返回 true。

    • 对称性 (Symmetric):对于任何非空引用 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 必须返回 true。

    • 传递性 (Transitive):如果 x.equals(y) 为 true 且 y.equals(z) 为 true,则 x.equals(z) 必须返回 true。

    • 一致性 (Consistent):对于任何非空引用 x 和 y,只要对象中的信息没有被修改,多次调用 x.equals(y) 必须一致地返回 true 或 false。

    • 非空性 (Non-nullity):对于任何非空引用 x,x.equals(null) 必须返回 false。

注意事项:

  1. hashCode 协定 (The Golden Rule):

    这是 Java 中最常见也是最严重的 Bug 来源之一。

    契约规定:如果两个对象根据 equals(Object) 方法是相等的,那么调用这两个对象中任一对象的 hashCode 方法必须产生相同的整数结果。

    如果在覆写 equals 时忽略了 hashCode,使用该类的对象作为 HashMap 的 Key 或存入 HashSet 时,会导致哈希逃逸(Hash Escape):即使两个对象逻辑相同,由于哈希值不同,它们会被映射到哈希表的不同桶(Bucket)中,导致 get() 返回 null 或 add() 产生重复数据。

    java
    // 错误示范:只重写 equals 未重写 hashCode
    Person p1 = new Person(1, "Jack");
    Person p2 = new Person(1, "Jack");
    
    Map<Person, String> map = new HashMap<>();
    map.put(p1, "Data");
    
    // 灾难现场:虽然 p1.equals(p2) 为 true,但 p2 计算出的 hash 不同
    // 导致无法在 Map 中定位到 p1 存放的数据
    System.out.println(map.get(p2)); // 输出 null
  2. 浮点数的特殊性:

    对于 float 和 double 类型的字段,切勿直接使用 == 进行比较。

    • Float.NaN 和 Double.NaN 不等于自身(NaN == NaN 为 false),但 equals 规范要求自反性。
    • 0.0f 和 -0.0f 是相等的(== 为 true),但它们在哈希表中应该被视为不同的键。

    解决方案:使用 Float.compare(f1, f2) 或 Double.compare(d1, d2),或者直接转换成 long (Double.doubleToLongBits) 进行比较。

  3. 继承中的对称性崩塌:

    当一个类继承自通过 instanceof 实现 equals 的父类,并添加了新的值组件(Value Component)时,无法同时满足对称性和传递性。

    • 如果子类 equals 试图比较新字段,则 super.equals(sub) 忽略新字段返回 true,而 sub.equals(super) 检查新字段返回 false(违反对称性)。
    • 如果为了满足对称性忽略类型差异,往往会牺牲传递性。

    解决方案:

    1. 使用 组合优先于继承(Composition over Inheritance)。

    2. 如果必须继承,且父类和子类都需要参与比较,考虑将 equals 定义为 final,或者在父类中使用 getClass() 代替 instanceof(但这违反了里氏替换原则,导致子类对象无法等于父类对象)。

扩展知识:

  1. Lombok 与 AutoValue:

    在实际工程中,手写 equals 和 hashCode 容易出错且繁琐。强烈建议使用 Lombok 的 @EqualsAndHashCode 注解或 Google 的 AutoValue 框架自动生成代码。

  2. Java 14+ Records:

    Java 14 引入的 record(记录类型)会自动生成正确的 equals、hashCode 和 toString 方法,非常适合用于仅作为数据载体的类(DTO)。

    java
    public record Point(int x, int y) { }
    // 自动拥有基于 x 和 y 的 equals 实现

hashCode() ​

native int hashCode():返回该对象的哈希码数值。

  • 参数:无。
  • 返回:int,一个代表该对象特征的 32 位整数。
  • 抛出:无。

基本示例:

标准实现范式:以下展示了不依赖第三方库的手动实现方式(性能最优),以及现代 IDE 自动生成的标准逻辑。

java
public class User {
  private int id;
  private String name;
  private boolean active;

  @Override
  public int hashCode() {
    // 1. 初始化一个非零常数 (通常是质数,如 17)
    int result = 17;

    // 2. 为每个参与 equals 的字段计算哈希并累加
    // 核心公式:result = 31 * result + fieldHash

    // 基本类型直接转换或计算
    result = 31 * result + id;
    result = 31 * result + (active ? 1 : 0);

    // 引用类型调用其自身的 hashCode (需处理 null)
    result = 31 * result + (name != null ? name.hashCode() : 0);

    return result;
  }

  // 简易写法 (性能稍差,因为会创建 Object[] 数组导致 GC 压力):
  // return Objects.hash(id, name, active);
}

核心特性:

  1. 协定与一致性 (The Contract):

    hashCode 的核心存在意义是为了支撑基于散列的集合(Hash-based Collections,如 HashMap, HashSet, ConcurrentHashMap)的高效运作。Java 规范对其有严格的三条协定:

    • 一致性:在 Java 应用的一次执行过程中,只要对象 equals 比较所用的信息未被修改,多次调用 hashCode 必须返回相同的整数。
    • 相等性映射:如果 x.equals(y) 返回 true,则 x 和 y 的 hashCode() 必须相等。
    • 碰撞允许性:如果 x.equals(y) 返回 false,并不要求 x 和 y 的 hashCode() 必须不同。但在哈希表中,不相等的对象具有不同的哈希码可以显著提高性能(减少哈希碰撞)。
    java
    public static void main(String[] args) {
      AA aa = new AA();
      AA aa2 = new AA();
      AA aa3 = aa;
    
      // 1. aa 和 aa2 指向不同的对象,它们的 hashCode 值也不同
      System.out.println(aa.hashCode() == aa2.hashCode()); // false
    
      // 2. aa 和 aa3 指向同一个对象,它们的 hashCode 值相同
      System.out.println(aa.hashCode() == aa3.hashCode()); // true
    }
  2. 为什么是 31 (The Magic Number):

    在手动生成 hashCode 时,你会发现几乎所有的 IDE(IntelliJ, Eclipse)和 JDK 源码(如 String 类)都使用 31 作为乘数。

    • 质数特性:31 是一个奇质数。如果乘数是偶数,乘法溢出相当于左移,会导致信息丢失(低位被补 0)。质数能更好地保留各字段的特征,降低哈希碰撞概率。
    • 位运算优化:31 有一个很好的位运算特性,即 31 * i == (i << 5) - i。现代 JVM 的 JIT 编译器能自动将乘法优化为移位和减法操作,在底层 CPU 指令级别执行效率极高。
  3. 默认行为 (Identity Hash Code):

    如果一个类没有重写 hashCode(),它将调用 Object 类的本地 (native) 实现。

    • 在 HotSpot JVM 中,默认实现并不总是直接返回内存地址(因为对象在 GC 时会移动,地址会变)。
    • 它通常是基于线程状态、随机数或内存地址计算出的一个身份哈希码,并存储在对象头(Object Header)的 Mark Word 中。一旦计算过一次,该值就会被固化在对象头中,即使对象被 GC 移动,该值也不再改变。

注意事项:

  1. 可变对象的哈希灾难:

    切勿使用可变字段作为计算 hashCode 的依据,尤其是在该对象已经存入 Set 或 Map 之后。

    这是新手最容易踩的坑。如果在对象存入 HashMap 后修改了参与 hashCode 计算的字段,对象的哈希值会发生变化,但它在 Map 中的位置(桶下标)还是旧的。这会导致你明明拿着完全相同的对象去 get 或 remove,却返回 null 或无法删除,造成内存泄漏。

    java
    // 错误示范:修改了参与 hash 计算的字段
    User u = new User(1, "Jack");
    Set<User> set = new HashSet<>();
    set.add(u); // 此时根据 hash(Jack) 放入 bucket A
    
    u.setName("Rose"); // 修改字段,hashCode 变了!
    
    // 虽然 u 还在 set 里,但 contains 会去 bucket B 找,结果找不到
    System.out.println(set.contains(u)); // 输出 false (这是个严重的 Bug)
  2. DoS 攻击风险:

    如果你的 hashCode 实现过于简单(例如直接返回字段值)或可预测,恶意用户可以构造大量具有相同哈希值的对象(哈希碰撞)。这将导致 HashMap 退化为链表(或红黑树),将查找时间复杂度从 O(1) 拖慢至 O(n) 或 O(log n),从而消耗大量 CPU 资源,造成 Hash DoS 攻击。

    • 防护:JDK 8 之后的 HashMap 引入了红黑树机制来缓解此问题,但在高安全需求的场景下,需谨慎设计哈希算法。
  3. 性能权衡:

    • 缓存哈希值:对于不可变类(Immutable Class,如 String),如果在 hashCode 计算开销很大,建议将计算结果缓存起来(Lazy Initialization)。
    • 避免自动装箱:在高性能场景下,尽量避免使用 Objects.hash() 处理基本数据类型,因为它会触发自动装箱和数组创建。

扩展知识:

  • System.identityHashCode():

    即使一个对象重写了 hashCode(),你依然可以通过 System.identityHashCode(obj) 获取该对象原本的、由 JVM 提供的默认哈希码。这在判断两个引用是否指向绝对同一个对象实例(不仅仅是逻辑相等)时非常有用。

    java
    String s = new String("Hello");
    // String 重写了 hashCode,基于内容计算
    System.out.println(s.hashCode());
    // 获取基于对象身份的原始 hash,类似 Object.hashCode()
    System.out.println(System.identityHashCode(s));

getClass() ​

final native Class<?> getClass():返回此 Object 的运行时类。

  • 无参数
  • 返回:Class<?>,表示此对象运行时类的 Class 对象。返回的 Class 对象是被该对象所属的类加载器加载的那个单例对象。
  • 抛出:无(但在 null 对象上调用会抛出 NullPointerException)。

基本示例:

示例关键词:多态、运行时类型识别

java
public class Main {
  public static void main(String[] args) {
    Object str = "Hello Java";
    Object num = 100; // 自动装箱为 Integer

    // 尽管引用类型都是 Object,但 getClass() 返回实际的运行时类型
    System.out.println("str 运行时类: " + str.getClass().getName());
    // 输出: java.lang.String

    System.out.println("num 运行时类: " + num.getClass().getName());
    // 输出: java.lang.Integer

    // 验证 Class 对象的单例性
    System.out.println(str.getClass() == String.class); // true
  }
}

核心特性:

  1. 运行时类型识别 (RTTI) 与多态:

    getClass() 是 Java 运行时类型识别(RTTI)的核心机制之一。与引用变量声明的静态类型(Static Type)不同,getClass() 返回的是对象在堆内存中实际创建的动态类型(Dynamic Type)。

    这意味着,即使你将一个子类对象赋值给父类引用,getClass() 依然能洞察其本质。这是通过 JVM 在对象头中维护元数据来实现的。

  2. JVM 底层实现:对象头与 Klass Pointer:

    该方法是一个 native 方法,其实现直接映射到 JVM 内部。

    • 对象布局:在 HotSpot 虚拟机中,Java 对象在堆内存中的布局包含“对象头”(Object Header)。
    • 类型指针:对象头中包含一个类型指针(Klass Pointer / _klass),它指向方法区(Metaspace)中该类的元数据对象(Klass 结构)。
    • 映射机制:当你调用 getClass() 时,JVM 不会去分析代码,而是直接读取对象头中的这个指针,找到对应的 Class 对象并返回。这就是为什么它非常快且不可伪造。
  3. Final 不可重写性:

    该方法被声明为 final。这是为了保证 Java 类型系统的安全性和一致性。如果允许子类重写 getClass() 并返回错误的类型(例如 String 的子类谎称自己是 Integer),将导致 JVM 的类型检查机制、反射机制以及安全沙箱完全崩溃。

注意事项:

  1. 泛型类型擦除 (Type Erasure):

    这是 getClass() 最常见的误区。Java 的泛型是伪泛型,在编译后会进行类型擦除。因此,你无法通过 getClass() 获取泛型的具体参数类型。

    java
    List<String> list1 = new ArrayList<>();
    List<Integer> list2 = new ArrayList<>();
    
    // 看起来是不同的类型,但运行时类完全相同
    System.out.println(list1.getClass() == list2.getClass()); // 输出 true
    System.out.println(list1.getClass()); // 输出 class java.util.ArrayList
    // 无法获取 <String> 或 <Integer> 信息
  2. equals() 方法中的使用陷阱:getClass vs instanceof:

    在重写 equals 方法时,使用 getClass() 还是 instanceof 决定了比较的严格程度。

    • getClass():要求两个对象必须是完全相同的类。这违反了里氏替换原则(LSP),因为子类无法等于父类,即便它们逻辑上相等。

    • instanceof:允许子类与父类进行比较(只要子类是父类的实例)。

      java
      class Point { int x, y; }
      class ColorPoint extends Point { int color; }
      
      Point p = new Point();
      ColorPoint cp = new ColorPoint();
      
      // 如果 Point.equals 使用 getClass():
      p.equals(cp); // return false (因为 Point.class != ColorPoint.class)
      
      // 如果 Point.equals 使用 instanceof:
      p.equals(cp); // 可能 return true (取决于逻辑,但类型检查是通过的)

    建议:如果你希望子类对象在逻辑上等同于父类对象(如 Hibernate 代理对象),请慎用 getClass(),改用 instanceof。如果你需要绝对的类型匹配,才使用 getClass()。

  3. 空指针异常风险:

    getClass() 是实例方法,必须通过对象实例调用。如果引用为 null,JVM 无法通过对象头寻找类型信息,因此会立即抛出 NullPointerException。

    java
    Object obj = null;
    // obj.getClass(); // 抛出 NPE
    
    // 安全替代方案(仅用于检查,非获取 Class)
    // java.util.Objects.requireNonNull(obj);

扩展知识:

  1. 关联知识:.class 字面量 vs getClass():

    • ClassName.class:在编译期确定。如果类尚未加载,会触发类加载器加载该类,但不会初始化(不执行静态代码块,取决于具体 JVM 实现和使用场景,通常仅引用 .class 不会触发初始化,但 Class.forName 会)。
    • obj.getClass():在运行期确定。前提是必须先有一个实例化对象。
  2. 数组的特殊性:

    数组也是对象,也有 getClass()。不同维度的数组、不同类型的数组,其 Class 对象是不同的。

    java
    int[] arr1 = new int[10];
    int[][] arr2 = new int[10][10];
    double[] arr3 = new double[10];
    
    System.out.println(arr1.getClass().getName()); // [I (代表 int[])
    System.out.println(arr2.getClass().getName()); // [[I (代表 int[][])
    System.out.println(arr1.getClass() == arr2.getClass()); // false
  3. 反射入口:

    getClass() 通常是反射操作的第一步。拿到 Class 对象后,你可以调用 getMethods(), getDeclaredFields(), getConstructor() 等方法来动态操纵代码。

clone() ​

native Object clone():创建并返回此对象的一个副本。

  • 无参数
  • 返回:Object,该对象的拷贝。
  • 抛出:CloneNotSupportedException - 如果对象的类不支持 Cloneable 接口,则抛出此异常。

基本示例:

示例关键词:实现 Cloneable、提升访问权限

java
// 1. 必须实现 Cloneable 接口(标记接口),否则抛异常
class Person implements Cloneable {
  String name;
  int age;

  public Person(String name, int age) {
    this.name = name;
    this.age = age;
  }

  // 2. 必须重写 clone 方法,并将 protected 改为 public
  @Override
  public Object clone() {
    try {
      // 3. 调用 super.clone() 获取底层拷贝
      return super.clone();
    } catch (CloneNotSupportedException e) {
      // 理论上不会发生,因为我们实现了 Cloneable
      throw new AssertionError();
    }
  }
}

public class Main {
  public static void main(String[] args) {
    Person p1 = new Person("Java", 25);
    // 调用 public 的 clone 方法
    Person p2 = (Person) p1.clone();

    System.out.println(p1 != p2); // true (地址不同)
    System.out.println(p1.getClass() == p2.getClass()); // true
  }
}

核心特性:

  1. 浅拷贝 (Shallow Copy) 机制:

    Object.clone() 的默认实现是浅拷贝。

    • 底层逻辑:JVM 会在堆内存中开辟一块新的内存空间,大小与原对象一致,然后直接将原对象内存块中的二进制数据(Bitwise Copy)复制到新内存中。

    • 值传递:

    • 对于基本数据类型(int, long, boolean 等),复制其具体的值。

    • 对于引用数据类型(String, List, 对象引用),复制的是内存地址(引用)。

    • 后果:新旧对象共享同一个引用类型的成员变量。如果其中一个修改了该引用指向的可变对象内部数据,另一个也会受到影响。

  2. Cloneable 接口的魔法:

    Cloneable 是一个没有任何方法的标记接口(Marker Interface)。

    • JVM 检查:当调用 Object.clone() 时,JVM 会检查该对象的类是否实现了 Cloneable。

    • 行为差异:

    • 若已实现:JVM 执行内存复制操作。

    • 若未实现:JVM 直接抛出 CloneNotSupportedException。

    • 设计缺陷:通常接口定义的是“能做什么”(Capabilities),但 Cloneable 却修改了父类 Object 中 clone 方法的行为,这被公认为 Java 早期的一个设计瑕疵。

  3. 不调用构造器:

    clone() 是极其特殊的实例化方式。它不会调用构造方法。

    • 原理:它是直接的内存复制。
    • 风险:如果你的构造器中有复杂的初始化逻辑(如计数器自增、资源绑定、依赖注入),使用 clone() 将会绕过这些逻辑,产生不完整的对象状态。

注意事项:

  1. 深拷贝 (Deep Copy) 的实现难题:

    如果对象包含可变的引用类型字段(如 ArrayList、Date 或自定义对象),必须手动实现深拷贝,否则会产生严重的副作用。

    java
    class Team implements Cloneable {
      String name;
      ArrayList<String> members; // 可变引用类型
    
      @Override
      public Object clone() {
        try {
          Team cloned = (Team) super.clone();
          // 必须手动对可变引用字段再次 clone
          // 否则 cloned.members 和 this.members 指向同一个 List
          cloned.members = (ArrayList<String>) this.members.clone();
          return cloned;
        } catch (CloneNotSupportedException e) {
          throw new AssertionError();
        }
      }
    }
  2. protected 访问权限的限制:

    Object 中的 clone() 是 protected 的。这意味着你不能直接调用外部对象的 clone() 方法,除非该对象明确重写并公开了该方法。

    java
    Object obj = new Object();
    // obj.clone(); // 编译错误!'clone()' has protected access in 'java.lang.Object'
  3. Final 字段的冲突:

    如果类中包含 final 修饰的可变引用字段,实现深拷贝将非常困难。因为 final 字段一旦赋值(在 super.clone() 的浅拷贝阶段已赋值),就无法在 clone 方法中再次赋值(指向新的深度拷贝对象)。

    • 解决方案:去掉 final 修饰符,或者不使用 clone() 机制。

扩展知识:

  1. 最佳实践:拷贝构造器 (Copy Constructor):

    鉴于 clone() 的诸多设计缺陷(异常检查、类型转换、浅拷贝陷阱、构造器绕过),《Effective Java》建议慎用 clone,推荐使用拷贝构造器或静态工厂方法。

    java
    // 推荐方案:清晰、可控
    public class User {
      private String name;
      private Date birth;
    
      // 拷贝构造器
      public User(User other) {
        this.name = other.name;
        // 手动处理深拷贝逻辑,清晰明了
        this.birth = new Date(other.birth.getTime());
      }
    }
  2. 序列化实现深拷贝:

    对于复杂的对象图,手动递归重写 clone 非常容易出错。一种“偷懒”但性能较低的深拷贝方式是利用序列化(Serialization)。

    • 原理:将对象写出到字节流,再从字节流读回来。
    • 工具:Apache Commons Lang 的 SerializationUtils.clone(obj) 或使用 JSON 库(Jackson/Gson)进行转存。

wait() ​

final void wait():导致当前线程进入等待状态,直到另一个线程调用此对象的 notify() 或 notifyAll() 方法。

  • 重载版本:

    • wait(long timeout):等待指定毫秒数。
    • wait(long timeout, int nanos):等待指定毫秒数加纳秒数。
  • 返回:void。

  • 抛出:

    • InterruptedException - 如果任何线程在当前线程等待之前或期间中断了当前线程。
    • IllegalMonitorStateException - 如果当前线程不是此对象监视器(锁)的所有者。

基本示例:

标准等待/通知模式(Producer-Consumer 简化版):

此示例展示了 wait() 必须配合 synchronized 和 while 循环使用的标准范式。

java
public class WaitNotifyDemo {
  private static final Object lock = new Object();
  private static boolean isReady = false;

  public static void main(String[] args) {
    Thread consumer = new Thread(() -> {
      synchronized (lock) {
        // 1. 必须在循环中检查条件 (防止虚假唤醒)
        while (!isReady) {
          try {
            System.out.println("Consumer: 条件不满足,开始 wait...");
            // 2. 释放锁并挂起
            lock.wait();
            System.out.println("Consumer: 被唤醒,重新获得锁");
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
          }
        }
        System.out.println("Consumer: 执行业务逻辑");
      }
    });

    Thread producer = new Thread(() -> {
      try { Thread.sleep(1000); } catch (InterruptedException e) {}
      synchronized (lock) {
        System.out.println("Producer: 准备数据...");
        isReady = true;
        // 3. 唤醒等待线程(注意:此时锁并未立即释放,需执行完同步块)
        lock.notify();
        System.out.println("Producer: 已发出通知");
      }
    });

    consumer.start();
    producer.start();
  }
}

核心特性:

  1. Monitor 机制与 Wait Set:

    Java 对象头(Object Header)关联了一个监视器(Monitor)。Monitor 内部维护了两个重要的队列:

    • Entry Set(锁池):等待获取锁的线程队列。
    • Wait Set(等待池):调用了 wait() 的线程队列。

    当线程调用 wait() 时,它会:

    1. 释放当前持有的对象锁(这是与 Thread.sleep 的核心区别)。
    2. 进入该对象的 Wait Set。
    3. 线程状态变为 WAITING 或 TIMED_WAITING,并不再参与 CPU 调度。

    只有当其他线程调用 notify()(随机唤醒一个)或 notifyAll()(唤醒所有)时,线程才会从 Wait Set 移动到 Entry Set,重新竞争锁。

  2. 虚假唤醒(Spurious Wakeup):

    操作系统层面的条件变量(Condition Variable)实现可能会在没有收到 notify 信号的情况下莫名其妙地唤醒线程。这是底层 OS 的一种允许行为(通常为了性能或处理信号)。

    因此,Java 规范强制要求:wait() 应该总是在循环中调用。

    java
    // 正确写法:循环检查
    synchronized (obj) {
      while (<condition does not hold>)
        obj.wait();
      // Perform action appropriate to condition
    }
    
    // 错误写法:if 检查
    synchronized (obj) {
      if (<condition does not hold>)
        obj.wait(); // 如果发生虚假唤醒,或者被错误唤醒,这里将直接执行,导致逻辑错误
      // ...
    }
  3. 原子性释放:

    wait() 方法不仅挂起线程,还是一个原子操作(Atomic Operation),它保证了“释放锁”和“进入等待状态”之间不会插入其他指令。这避免了“丢失信号”的问题(即在判断条件和挂起之间,另一个线程刚好发出了 notify)。

注意事项:

  1. IllegalMonitorStateException:

    wait()、notify()、notifyAll() 必须在同步代码块(synchronized)内部调用,且调用对象必须是锁对象本身。

    如果不持有该对象的锁直接调用 wait(),JVM 会抛出 IllegalMonitorStateException。这是为了确保线程安全地修改共享的条件变量。

  2. wait() vs Thread.sleep():

    这是最高频的面试题,本质区别在于锁的处理:

    特性Object.wait()Thread.sleep()
    所属类Object (实例方法)Thread (静态方法)
    锁行为释放当前持有的锁不释放任何锁 (抱着锁睡觉)
    使用场景线程间通信/协作暂停执行/时间控制
    唤醒方式notify(), notifyAll(), 中断时间到, 中断
    前置条件必须持有 Monitor 锁无需持有锁
    java
    // 错误示范:在 synchronized 中 sleep 导致死锁风险
    synchronized(lock) {
      // 其他线程无法获得 lock,整个系统卡死
      Thread.sleep(10000);
    }

扩展知识:

  1. JUC Condition (现代替代方案):

    在 JDK 5 引入 java.util.concurrent 包后,推荐使用 Lock 和 Condition 接口来替代原始的 wait/notify。

    Condition 提供了更灵活的等待队列:

    • 多路通知:一个 Lock 可以创建多个 Condition(例如 notFull 和 notEmpty),从而实现精准唤醒(例如生产者只唤醒消费者),而 Object.notify 是随机唤醒,效率较低。

    • await() / signal():对应 wait() / notify()。

      java
      Lock lock = new ReentrantLock();
      Condition condition = lock.newCondition();
      
      lock.lock();
      try {
        while (!ready) {
          condition.await(); // 释放 lock
        }
      } finally {
        lock.unlock();
      }

notify() ​

final void notify():唤醒在此对象监视器(Monitor)上等待的单个线程。

  • 返回:void。
  • 抛出:IllegalMonitorStateException - 如果当前线程不是此对象监视器的所有者(即没有在 synchronized 块中调用)。

基本示例:

精准唤醒(配合 wait 使用):

此示例展示了 notify() 如何将挂起的线程“复活”。注意观察输出顺序,证明 notify 后并不会立即释放锁。

java
public class NotifyDemo {
  private static final Object lock = new Object();

  public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
      synchronized (lock) {
        System.out.println("T1: 获得锁,准备 wait");
        try {
          lock.wait(); // 释放锁,进入 Wait Set
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        System.out.println("T1: 被唤醒,重新获得锁,继续执行");
      }
    });

    Thread t2 = new Thread(() -> {
      try { Thread.sleep(1000); } catch (InterruptedException e) {}
      synchronized (lock) {
        System.out.println("T2: 获得锁,准备 notify");
        lock.notify(); // 唤醒 T1,但 T2 此时仍持有锁
        System.out.println("T2: 已发出通知,但还要睡 2秒...");
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
        System.out.println("T2: 释放锁");
      } // T2 退出同步块后,T1 才有机会竞争锁
    });

    t1.start();
    t2.start();
  }
}

核心特性:

  1. 监视器状态迁移(Wait Set -> Entry Set):

    notify() 的本质操作并非立即运行线程,而是改变线程的存储位置。

    • Wait Set(等待池):调用 wait() 的线程在此休眠。
    • Entry Set(锁池):等待获取锁的线程在此排队。

    当调用 obj.notify() 时,JVM 会随机(取决于具体实现,通常不可预测)从 obj 的 Wait Set 中选择一个线程,将其移动到 Entry Set。

    关键点:被唤醒的线程不会立即执行,它必须等待当前持有锁的线程(即调用 notify 的线程)释放锁,然后在 Entry Set 中与其他线程再次竞争锁的所有权。

  2. 选择策略的任意性:

    Java 规范并未规定 notify() 必须唤醒哪一个等待线程(例如,不保证是等待时间最长的那个)。这意味着在特定的 JVM 实现中,这种选择可能是随机的。

    结论:如果你依赖特定的唤醒顺序,绝对不要使用 notify(),而应使用 Lock + Condition 或其他并发工具。

  3. 非即时释放锁:

    这是一个极其常见的误区。调用 notify() 是一条极其轻量的指令,它不会导致当前线程释放锁。当前线程必须执行完 synchronized 代码块的所有剩余语句,或者显式调用 wait(),锁才会被释放,被唤醒的线程才真正有机会运行。

    java
    synchronized(lock) {
     lock.notify();
     // 此时被唤醒的线程依然被阻塞在 Entry Set 中
     // 因为当前线程还没有走出 synchronized 块
     heavyCalculation();
    }
    // 代码块结束,锁释放,被唤醒线程开始竞争

注意事项:

  1. 信号丢失(Missed Signal)风险:

    如果 notify() 在 wait() 之前执行,该信号将被直接丢弃。因为 notify 针对的是当前的 Wait Set,如果 Wait Set 为空,这个通知就“虽然发出了但无人接收”。这会导致后来的 wait() 线程永远等待下去。

    解决方案:始终使用变量标识状态(如 boolean isReady),并在 wait 前检查该状态(即标准的 while 循环模式)。

  2. Notify vs NotifyAll(死锁风险):

    这是 notify() 最危险的坑。notify() 每次只唤醒一个线程。在“多生产者-多消费者”场景下,如果所有线程都在同一个锁上等待,极易发生信号劫持。

    场景:

    • 消费者 A 被唤醒,消费了一个元素,释放锁并发出 notify()。
    • 本意是唤醒生产者 B 生产数据。
    • 结果运气不好,唤醒了另一个消费者 C。
    • 消费者 C 发现队列为空,于是 wait()。
    • 此时,所有线程(包括生产者)都在 wait,系统陷入死锁。

    黄金法则:除非你非常确定只唤醒同一类线程(且每次唤醒一个就能满足逻辑),否则永远优先使用 notifyAll()。

扩展知识:

  1. Condition.signal() 的优势:

    为了解决 Object.notify() 无法区分唤醒对象的问题,JDK 5 引入了 Condition。

    java
    ReentrantLock lock = new ReentrantLock();
    Condition notFull  = lock.newCondition(); // 专门存放生产者的队列
    Condition notEmpty = lock.newCondition(); // 专门存放消费者的队列
    
    // 生产者只唤醒消费者,避免了唤醒同类的无用功
    notEmpty.signal();

notifyAll() ​

final void notifyAll():唤醒在此对象监视器(Monitor)上等待的所有线程。

  • 返回:void。
  • 抛出:IllegalMonitorStateException - 如果当前线程不是此对象监视器的所有者(即没有在 synchronized 块中调用)。

基本示例:

“发令枪”模式(One-Shot Latch):

此示例演示了 notifyAll() 如何一次性激活所有挂起的线程。与之相比,notify() 只会唤醒其中一个,导致其他线程“永久沉睡”。

java
public class NotifyAllDemo {
  private static final Object lock = new Object();
  private static boolean startSignal = false;

  public static void main(String[] args) throws InterruptedException {
    // 启动 5 个运动员线程
    for (int i = 0; i < 5; i++) {
      new Thread(() -> {
        synchronized (lock) {
          try {
            System.out.println(Thread.currentThread().getName() + " 准备就绪,等待发令...");
            while (!startSignal) {
              lock.wait(); // 所有线程在此挂起
            }
            System.out.println(Thread.currentThread().getName() + " 起跑!");
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
          }
        }
      }, "Athlete-" + i).start();
    }

    Thread.sleep(1000); // 确保所有运动员都已进入 wait 状态

    synchronized (lock) {
      System.out.println("裁判:各就各位... 预备... 跑!(调用 notifyAll)");
      startSignal = true;
      lock.notifyAll(); // 唤醒所有等待在 lock 上的 5 个线程
    }
  }
}

核心特性:

  1. 全量迁移(Wait Set -> Entry Set):

    调用 notifyAll() 时,JVM 会将该对象 Wait Set(等待池) 中的所有线程全部移入 Entry Set(锁池)。

    • 状态变更:线程状态从 WAITING 变为 BLOCKED。
    • 竞争机制:这些被唤醒的线程不会同时运行,而是必须在当前线程释放锁之后,在 Entry Set 中与其他线程(以及可能新来的线程)共同竞争这把锁。
    • 执行顺序:最终只有一个线程能抢到锁并执行 wait() 之后的代码,其他失败者继续在 Entry Set 中阻塞等待下一次锁释放。
  2. 解决“信号劫持”与死锁:

    这是 notifyAll() 存在的最大意义。在多生产者-多消费者场景中,如果使用 notify(),可能会出现“消费者唤醒了另一个消费者”的情况(同类唤醒),导致生产者一直处于等待状态,最终所有线程都在等待,形成死锁(Deadlock)。

    使用 notifyAll() 的逻辑:

    • 虽然唤醒了所有消费者和生产者,效率看似低了。
    • 但是,它保证了至少有一个正确的线程(比如生产者)被唤醒并能够推进状态(生产数据),从而打破僵局。
    • 结论:除了极简单的 1 对 1 通信,默认应使用 notifyAll() 以确保程序的活跃性(Liveness)。

注意事项:

  1. 惊群效应(Thundering Herd Problem):

    这是 notifyAll() 的主要性能弊端。

    • 现象:你唤醒了 100 个线程,但只有一个能抢到锁并处理任务(例如队列里只来了一个数据)。
    • 代价:剩下的 99 个线程虽然被唤醒了,但抢锁失败后又得重新阻塞,或者抢到锁后发现条件不满足(while 循环检查)又调用 wait() 挂起。
    • 结果:导致大量的 CPU 上下文切换(Context Switch)和无效的锁竞争,造成系统负载瞬间飙升。
  2. 必须配合 while 循环:

    即使使用了 notifyAll(),所有被唤醒的线程也会依次获取锁。

    • 线程 A 抢到锁,消费了数据,将条件置为 false。
    • 线程 A 释放锁。
    • 线程 B(也被 notifyAll 唤醒了)抢到锁。
    • 如果线程 B 使用 if 判断,它会直接往下执行(即使数据已经被 A 消费完了),导致逻辑错误。
    • 因此,必须使用 while 循环,让 B 醒来后再次检查条件,发现没数据了,乖乖回去 wait()。

扩展知识:

  1. JUC Condition 的精准通知:

    为了解决 notifyAll() 的“惊群效应”,java.util.concurrent.locks.Condition 提供了分组唤醒机制。

    • lock.newCondition() 可以创建多个等待队列。

    • 优化方案:将生产者放在 notFull 队列,消费者放在 notEmpty 队列。

    • 精准打击:生产者生产完数据,只调用 notEmpty.signalAll()(唤醒所有消费者),而不打扰其他生产者。

      java
      // 优化的架构
      private final Lock lock = new ReentrantLock();
      private final Condition notFull  = lock.newCondition();
      private final Condition notEmpty = lock.newCondition();
      
      public void put(Object x) throws InterruptedException {
        lock.lock();
        try {
          while (count == items.length)
            notFull.await();
          // ... 生产逻辑
          notEmpty.signal(); // 只通知消费者
        } finally {
          lock.unlock();
        }
      }

finalize()@Deprecated ​

void finalize():[已废弃] 当垃圾回收器(GC)确定不存在对该对象的更多引用时,由 GC 在对象上调用此方法。子类可以重写finalize方法来处理系统资源的释放或执行其他清理操作。

  • 返回:void。
  • 抛出:Throwable — 此方法抛出的任何异常都会被 GC 线程捕获并忽略,不会导致程序终止。
  • 状态:Deprecated (Since Java 9, Marked for Removal)。在 JEP 421 中被标记为“终结过时”,未来版本将被彻底删除。

基本示例:

模拟资源回收(仅作原理演示,严禁生产使用):

此代码演示了 finalize 的触发机制及其执行的不确定性。

java
public class FinalizeDemo {

  static class HeavyResource {
    private String name;

    public HeavyResource(String name) {
      this.name = name;
    }

    // 子类可以重写`finalize`方法来处理系统资源的释放或执行其他清理操作。
    @Override
    protected void finalize() throws Throwable {
      // 务必调用 super.finalize(),虽然 Object 中是空的,但这是好习惯
      super.finalize();
      System.out.println("Resource [" + name + "] is being finalized by thread: "
                         + Thread.currentThread().getName());
    }
  }

  public static void main(String[] args) throws InterruptedException {
    HeavyResource resource = new HeavyResource("DB-Connection");

    // 1. 断开引用,使其变为不可达(Unreachable)
    resource = null;

    // 2. 建议 JVM 进行垃圾回收(仅是建议,不保证立即执行)
    System.gc(); // 非阻塞的异步方法

    // 3. 必须等待,因为 Finalizer 线程优先级极低
    System.out.println("Main thread waiting...");
    Thread.sleep(1000);
  }
}

核心特性:

  1. F-Queue 与 Finalizer 线程机制(GC 回收过程):

    当一个重写了 finalize() 方法的对象被创建时,JVM 会创建一个额外的 java.lang.ref.Finalizer 对象指向它。

    • 第一阶段(标记):GC 发现对象不可达,且该对象重写了 finalize 方法,将其放入 F-Queue(Finalization Queue)。
    • 第二阶段(执行):一个低优先级的守护线程(Finalizer 线程)会不断从 F-Queue 中取出对象,并执行其 finalize() 方法。
    • 第三阶段(回收):只有在 finalize() 执行完毕后,GC 再次扫描发现该对象依然不可达,才会真正回收其内存。

    这意味着:Finalize 对象至少需要两次 GC 周期才能被回收,这严重拖慢了回收效率。

  2. 对象复活(Object Resurrection):

    这是 finalize 最诡异的特性。在 finalize() 方法中,你可以把 this 赋值给某个全局静态变量,从而让对象“死而复生”。

    • 单次执行保证:JVM 保证每个对象的 finalize() 只会被调用一次。如果对象复活后再次变成不可达,GC 将直接回收它,不再调用 finalize()。

      java
      public class Zombie {
        public static Zombie INSTANCE;
        @Override
        protected void finalize() {
          System.out.println("Resurrecting...");
          INSTANCE = this; // 复活!重新建立强引用
        }
      }

注意事项:

  1. 不确定性与性能灾难:

    • 执行时机不确定:你无法保证 finalize 何时运行,甚至无法保证它一定会运行(例如 JVM 直接退出时)。因此,绝不能用它来释放关键资源(如数据库连接、文件句柄),否则极易导致资源耗尽(Resource Leak)。
    • 吞吐量下降:创建带有 finalize 的对象比较慢(需要注册),且回收需要至少两个 GC 周期。在大量创建此类对象的场景下,由于 Finalizer 线程优先级低,处理速度可能赶不上创建速度,导致 F-Queue 堆积,最终引发 OutOfMemoryError。
  2. 异常被吞没:

    如果 finalize() 方法中抛出未捕获的异常,该异常会被忽略,且不会打印任何堆栈信息。这使得调试变得极其困难,系统可能处于一种损坏的中间状态而无人知晓。

  3. 安全漏洞(Finalizer Attack):

    如果一个类的构造函数抛出异常(例如权限检查失败),对象本不应被创建。但如果有恶意子类覆盖了 finalize,该方法仍会被执行,攻击者可以在其中获取部分初始化的对象实例,绕过安全检查。

    • 防御:将类声明为 final,或在 finalize 中抛出异常(虽然没用),最佳方案是使用 final 的 finalize 方法阻止重写:protected final void finalize() {}。

扩展知识:

  1. 替代方案(Best Practices):
    • AutoCloseable + try-with-resources(首选):

      显式管理资源生命周期,确定性强,代码清晰。

      java
      try (FileInputStream fis = new FileInputStream("file.txt")) {
        // 使用资源
      } // 自动调用 close()
    • java.lang.ref.Cleaner (Java 9+):

      finalize 的官方替代品。它利用虚引用(PhantomReference)机制,比 finalize 更轻量、更安全(无法复活对象),但依然不保证立即执行。通常用于兜底(Safety Net)。

补充 ​

IDEA 中查看 JDK 源码 ​

在 IntelliJ IDEA 中查看 JDK 源码(例如 String, ArrayList, Object 的底层实现)是 Java 进阶必经之路。IDEA 对此支持得非常完美。

以下是三种最常用的方法,以及如何解决“只能看反编译代码(.class)看不到源码(.java)”的问题。

方法1:快捷键/鼠标跳转 ​

方法一:快捷键/鼠标跳转(最常用):

这是日常开发中最快的方式。当你代码中写到了某个类(比如 String)或者某个方法(比如 System.out.println)时:

  1. 鼠标操作:Ctrl + 左击

    • 按住 Ctrl (Mac 是 Command) 键。

    • 鼠标移动到代码上的类名或方法名上(文字会变成超链接样式)。

    • 点击左键,直接跳转。

  2. 键盘操作:Ctrl + B

    • 光标停留在类名或方法名上。

    • 按下 Ctrl + B (Windows/Linux) 或 Command + B (Mac)。

方法2:全局搜索 ​

方法二:全局搜索(想直接看某个类):

如果你当前代码里没有用到 HashMap,但你想专门去看看它的源码:

  1. 按下 Ctrl + N (Windows/Linux) 或 Command + O (Mac)。这是 "Go to Class"(查找类)功能。

    • 或者双击 Shift 打开 "Search Everywhere"。
  2. 输入类名,例如 HashMap。

  3. 关键点: 确保勾选右上角的 "Include non-project items" (包含非项目文件) 或者再次按下快捷键,这样才能搜到 JDK 里的类。

  4. 回车进入。

方法3:从项目结构树查看 ​

方法三:从项目结构树查看(浏览全貌):

如果你想浏览 JDK 到底包含了哪些包(Package):

  1. 打开左侧的 Project 面板。
  2. 拉到最下方,找到 External Libraries (外部库)。
  3. 展开 <1.8> 或 <11> (取决于你的 JDK 版本)。
  4. 展开 rt.jar (JDK 8) 或 java.base (JDK 9+)。
  5. 在这里你可以像翻文件夹一样浏览 java.lang, java.util 等包下的所有源码。

问题:为什么看的是 .class 文件 ​

问题:为什么我看到的是 ".class" 文件:

如果你点进去后,看到 IDEA 顶部提示 "Decompiled .class file, bytecode version...",并且代码里没有注释,变量名可能是 var1, var2 这种奇怪的名字。

原因: IDEA 只找到了编译后的字节码,没有关联到 JDK 的源码压缩包 (src.zip)。

解决方法:

方案 A:点击提示栏的 "Download Sources":

IDEA 通常会在顶部弹出一个黄色条,点击 "Download Sources" 或 "Choose Sources...",它会自动尝试下载。

方案 B:手动关联(一劳永逸):

如果自动下载失败,说明你需要手动指定一下本地的源码包(通常安装 JDK 时都自带了)。

  1. 打开 File -> Project Structure (快捷键 Ctrl+Alt+Shift+S / Cmd+;)。
  2. 选择左侧的 SDKs (或 Platform Settings -> SDKs)。
  3. 选中你当前使用的 JDK 版本。
  4. 点击右侧的 Sourcepath 标签页。
  5. 点击 + 号,找到你 JDK 的安装目录。
  6. 选择目录下的 src.zip 文件(如果是 Linux 可能是 lib/src.zip)。
  7. 点击 OK 应用。

image-20260127180827163

神器技巧 ​

Pro Tips:看源码的神器技巧:

  1. 查看大纲 (Structure): JDK 源码通常很长(String 有 3000 多行),直接看很累。

    • 按下 Alt + 7 (Windows) / Command + 7 (Mac) 打开 Structure 面板。
    • 这里列出了所有的方法和属性,可以直接点击跳转。
  2. 查看类图 (Diagram): 想搞清继承关系?

    • 在类名上右键 -> Diagrams -> Show Diagram。
    • 它会画出这个类的父类、接口实现关系图。
  3. 看具体的实现类 (Implementation): 如果你点击 List 接口的方法,它只会带你到接口定义。你想看 ArrayList 是怎么实现的?

    • 使用 Ctrl + Alt + B (Windows) / Command + Option + B (Mac)。
    • 选择具体的实现类(如 ArrayList)。

您现在可以试着去 IDEA 里按住 Ctrl 点一下 String 类,看看它的 equals 方法是怎么写的(你会发现之前学的 instanceof 和数组比较都在里面)!