专注于 JetBrains IDEA 全家桶,永久激活,教程
持续更新 PyCharm,IDEA,WebStorm,PhpStorm,DataGrip,RubyMine,CLion,AppCode 永久激活教程

Java的万物起源Object

前言

在 Java 的世界中,万物皆为对象。对象是组成 Java 程序的最主要的成分,但是在 Java 语言中,对象的起源是什么呢?就像地球上的万物一样,所有的生命都不是无缘由的出现,所有的生命都是从最原始的微生物一步一步演变而来,而不是从一开始就是存在于地球中。在 Java 世界中也一样,所有的对象都起源于一个最原始的对象,通过这个最原始的对象,可以演变出各种各样的对象出来,而演变而来的对象也具备原始对象的特性。

这个最原始的对象就是今天的主题:Object

或许,每一个 Java 开发者都对这个对象不陌生,每天都在跟它的子孙打交道。但是你是否有认真了解过这个万物起源的对象呢?如果没有的话,今天就跟着我一起来揭开它的真面目。

定义

Object 类是类层次结构中的最高级。每一个类都直接或者间接的继承了这个类,每一个对象包括数组,都具有这个类中的所有方法。

但是这个类中没有成员变量,只有 11 个方法,而这 11 方法在日常开发中是非常重要的,我们可以选择重写这些方法,也可以选择直接使用继承过来的方法。

方法

getClass

该方法的签名是 public final native Class<?> getClass();

通过方法签名可以看到有一个 final 关键字,那么则说明该方法是不可以被重写的;还有一个 native 关键字,说明这个方法是一个本地方法,具体的代码是在 JVM 中由 C/C++ 语言来实现的。

我们先来看官方对它的解释:

Returns the runtime class of this Object. The returned Class object is the object that is locked by static synchronized methods of the represented class.

这里有两句话,我们分开来解释这两句话:

1、 返回这个对象的运行时类型对象。
2、 返回的 Class 对象是该对象所表示的类中被静态同步方法锁定的对象。

我们先来理解第一句,这个比较好理解,我们看一个例子:

class Main{

    public static void main(String[] args){
        test(new ArrayList()); // class java.util.ArrayList
        test(new LinkedList()); // class java.util.LinkedList
    }

    public static void test(List list){
        Class<? extends List> aclass = list.getClass();
        System.out.println(aclass);
    }   
}

这段代码中有一个接受 List 类型参数的 test 方法,该方法打印传入参数的类型。接着我们在 Main 方法中调用了两次 test 方法,分别传入不同的参数,我们可以看到打印的结果是不同。

通过这个现象可以得知,通过 getClass 方法得到的类型是运行时类型,也叫实际类型,而 List 则叫做静态类型,也叫做外观类型,关于类型这方面的知识可以查看《深入理解Java虚拟机 第三版 8.3节》。

现在,我们来理解第二句的意思,我们依然来看一个例子:

class Foo{
    public static synchronized void test(){
        System.out.println("class Foo was synchronized");
    }

    // 等同于
    public static void test(){
        synchronized(Foo.class){
            System.out.println("class Foo was synchronized");
        }
    }
}

class Main{
    new Thread(Foo::test).start(); // thread0
    new Thread(Foo::test).start(); // thread1
}

我们创建了两个线程同时运行 Foo 类中的静态同步方法,学习过并发编程的应该都知道,在 Thread0 调用 test 方法结束之前,thread1 是无法进入该方法的,只能进入等待。因为 Thread0 获取了类对象的锁,也就是 Foo.class 被锁定了。

那么这个例子跟第二句话有什么关系呢?

我相信你已经猜到了,getClass 方法返回的类型对象就是被静态同步方法锁定的对象。

System.out.println(Foo.class == (new Foo()).getClass()); // true

hashCode

该方法的方法签名是 public native int hashCode();

这里可以看到一个 native 关键字,也就是它的默认实现是 JVM 提供的。但是这里没有了 final 关键字,也就是说该方法是可以被重写的。

这个方法的作用是返回一个哈希码,而哈希码经常被用在哈希表中查找指定的槽位。在 Java 语言中是一个比较重要的方法,因为 Java 核心库中的 HashMapCurrentHashMap 等哈希表都依赖该方法返回的值。默认的实现是返回对象内存地址转换而成的整数,由 C/C++ 实现。

由于该方法会影响核心库中部分类的表现,所以我们重写该方法时需要时刻保持注意,否则将会导致难以排查的错误。

官方也列出了重写该方法时需要遵守的准则:

  • 在应用程序的执行期间,只要对象的 equals 方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode 方法都必须始终返回同一个值。在一个应用程序于另一个应用程序的执行过程中,执行 hashCode 方法所返回的值可以不一致。
  • 如果两个对象根据 equals(Object) 方法比较是相等的,那么调用这个两个对象中的 hashCode 方法都必须产生同样的整数结果。
  • 如果两个对象根据 equals(Object) 方法比较是不相等的,那么调用这两个对象中的 hashCode 方法,则不一定要求 hashCode 方法必须产生不同的结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高哈希表的性能。

equals

该方法的方法签名是 public boolean equals(Object obj)。该方法跟上面的两个方法不一样,它是使用 Java 语言实现的,因为没有 native 关键字。

该方法的作用是比较两个对象是否相等。

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

它默认的实现非常简单,只是简单的比较两个引用是否指向同一个对象。

但是这在我们写代码的使用并不一定符合我们的要求,我们有时候会要求逻辑上的相等,而不是实质上的相等。比如:我们有一个账户类,只要账户 id 相等,我们就认为它们是相等的。所以,我们在重写 equals 方法的时候只需要比较 id 字段是否相等即可,只要 id 相等,我们就认为这两个对象是相等的。

当然,重写 equals 方法的时候没有使用正确的要求去重写,那么也会导致很多严重的错误。

因此,官方也规定了一些重写 equals 方法的准则,我们在重写的时候必须要遵循这些准则:

  • 自反性:对于任何非 null 的引用值 x,x.equals(x) 必须返回 true。
  • 对称性:对于任何非 null 的引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 必须返回true。
  • 传递性:对于任何非 null 的引用值 x、y 和 z,如果 x.equals(y) 返回 true,并且 y.equals(z) 也返回 true,那么 x.equals(z) 也必须返回 true。
  • 一致性:对于任何非 null 的引用值 x 和 y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用 x.equals(y) 就会一致地返回 true,或者一致地返回 false。
  • 对于任何非 null 的引用值 x,x.equals(null) 必须返回 false。
  • 如果重写了 equals 方法,那么也必须重写 hashCode 方法。

如果想要详细了解如何重写 equals 方法请阅读《Effective Java 第三版 第10条建议》。

clone

该方法的方法签名是 protected native Object clone() throws CloneNotSupportedException;

这也是一个本地方法,同时在不支持克隆的时候还会抛出一个 CloneNotSupportedException 异常。最重要的一点是它的访问权限符是 protected,这就导致如果我们想要使用该方法就不得不重写该方法。因为该修饰符最大的使用范围只到不同包的子类,但是我们一般的访问权限都是在不同包下非子类( public )。

原来我一直都将 protected 关键字修饰的方法理解错了。我原本以为不同包的子类指的是只要是它的子类,那么就在任何地方都可以通过子类对象来访问该方法。但实际上并不是,而是只能在子类内部进行访问。如果是在不同包下的非直接子类中,是无法访问 protected 修饰的方法的。

该方法是比较特殊的一个方法,因为我们不能只是单纯的重写该方法就可以克隆出一个对象,我们还需要让类实现一个接口,这个接口就是 Cloneable 接口,实现了这个接口之后,再重写 clone 方法才是合法的,否则就会抛出刚刚说到的 CloneNotSupportedException 异常。

总结一句话就是:如果一个类实现了 Cloneable 接口,Objectclone 方法就返回该对象的逐域拷贝,否则抛出 CloneNotSupportedException 异常。

还需要注意的一点是:clone 方法是通过浅复制创建对象的。也就是说,如果类中存在可变的域,那么原对象与克隆对象将会共享同一个可变的域。

我们来看一个例子:

public class Foo implements Cloneable{
    // 不可变域
    int num;

    // 可变域
    List<Integer> list;

    public Foo(int num, List<Integer> list) {
        this.num = num;
        this.list = list;
    }

    @Override
    public Foo clone(){
        try {
            return (Foo) super.clone(); // 直接调用 Object 中的 clone 方法。
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException();
        }
    }
}

public class Test {
    public static void main(String[] args){
        ArrayList<Integer> list = new ArrayList<>(5);
        list.add(1);
        list.add(2);
        Foo foo = new Foo(1, list);
        Foo clone = foo.clone(); // 克隆一个Foo对象clone
        System.out.println(clone.list.get(0)); // 获取clone中的可变域list中下标为0的值,此时为1 
        list.add(0, 3); // 修改原对象list中下标为0的值为3
        System.out.println(clone.list.get(0)); // 获取clone中的可变域list中下标为0的值,此时为3
    }
}

在这个例子中,对原对象中的 list 中的内容进行了修改,克隆对象中的 list 内容也会跟着改变,这是因为他们都是共享同一个 list。它们只是持有的引用不同,但是引用指向的对象却是同一个。

所以我们不能单纯的只是通过 super.clone 来克隆一个对象,这通常并不是我们想要的结果。

对于这种情况,如果我们要使用 clone 方法来克隆对象的话,那么就要自行在重写方法的时候初始化可变域,也就是给它重新赋值。

public class Foo implements Cloneable{

    .....

    @Override
    public Foo clone(){
        try {
            Foo foo =  (Foo) super.clone(); // 直接调用 Object 中的 clone 方法。
            foo.list = new ArrayList<>(); // 重新给可变域赋值
            for(Integer item : list){
                foo.list.add(deepCopy(item)); // 对集合里面的每个元素进行深复制。
            }
            return foo;
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException();
        }
    }
}

对于有可变域的类如果要使用 clone 方法,那么在重写 clone 方法时,就应该要找出所有可变域,然后对这些可变域重新初始化赋值,这样才能达到深复制,否则就会在使用的时候出现毫无意义的结果。

在《Effective Java》中提供了一种较为简单的克隆对象的方法:拷贝构造器/拷贝工厂。

public class Foo {

    ......

    public Foo(Foo foo){
        this.a = foo.a;
        this.list = deepCopy(foo.list);
    }
}

这个方法的好处是:

  • 它们不依赖于某一种很有风险、语言之外的对象创建机制
  • 它们不要求遵守尚未制定好文档的规范
  • 它们不会于 final 域的正常使用发生冲突
  • 它们不会抛出不必要的受检异常
  • 它们不需要进行类型转换。

如果要了解详情,可以查阅《Effective Java 第三版 第13条建议》

toString

该方法的方法签名为 public String toString(),它返回字符串表示的对象。

默认的实现是返回格式化名称:类名 + @ + 十六进制表示的哈希码

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

在官方文档中,它建议返回的结果应该是一个 “ 简约的但信息丰富,并且易于阅读的表达形式 ” 。所以,我们应该始终为每一个 Object 的子类重写该方法。

toString 方法虽然并不像 equalshashCode 那么具有不稳定性,但是它对于程序的调试具有很大的作用,一个可阅读的信息对于程序调试来说,是非常舒适的。

在阿里巴巴的开发规范手册中也指出:对于 POJO,必须要重写 toString 方法,便于开发人员在调试阶段可以获取到里面的详细信息。

在《effective Java》中,也说到:“在实际应用中,toString 方法应该返回对象中包含的所有值得关注的信息”。

在静态工具类中不建议重写 toString 方法,因为静态工具栏不具有成员变量的域,所以没有实际意义的信息可供开发人员使用。

在枚举类中也不建议重写 toString 方法,因为 Java 已经提供了非常完美的方法来打印枚举类中的信息。

但是,在所有子类都共享通用字符串表示法的抽象类中,一定要在抽象类中重写 toString 方法。例如:在大多数集合实现中的 toString 都继承自抽象的集合类。

notify

该方法的方法签名是 public final native void notify();。该方法是一个无法被重写的本地方法。

顾名思义,该方法的作用是发出一个通知。再结合这是在对象上的一个方法,那么就可以说是给对象发出一个通知。那么既然是发出一个通知,那么肯定也有一个等待通知的实体,而这个等待通知的实体就是通过后面会介绍的 wait 方法来生成的。

在官方文档的解释中,该方法的作用是:唤醒一个正在等待该对象监视器锁的线程。

通过这句话我们可以知道的结论是:每一个对象都存在监视器锁,并且该方法的作用是唤醒线程。

也就是说,该方法实际是用在多线程上的。如果有多个线程在等待同一个对象的监视器锁时,调用这个方法就会唤醒任意一个线程,使这个线程有机会获得这个对象的监视器锁。

被唤醒的线程只有在当前线程放弃了监视器锁的情况下才能继续运行。并且被唤醒的线程并不一定就能获取得到这个监视器锁,它还需要与其他任何想要获得这个对象的监视器锁的线程进行竞争。

这个方法也只能唤醒在该对象监视器锁上等待的线程,对于不是因为该对象而陷于等待的线程,是不会唤醒的。

**注意:**该方法并不是在任意地方皆可调用的,只有拥有对象监视器锁的线程才能调用这个方法。

而这里有三种方法可以使线程尝试获取对象上的监视器锁:

1、 通过执行对象上的同步实例方法。
2、 通过执行 synchronized 代码块,该代码块中同步的条件是该对象。
3、 通过执行该对象所属类的静态同步方法。

这三种获取对象监视器锁的方式也变相解释了该方法为什么只唤醒了一个线程,却依然会出现竞争的情况。因为在唤醒线程换取锁的同时,有可能存在其他线程通过以上三种方式来尝试获取监视器锁。

可以看一个例子:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        new Thread(() -> {
            main.lock(0);
        }).start();

        new Thread(() -> {
            main.release(1);
        }).start();
    }

    public synchronized void lock(int a){
        System.out.println(a + " is wait");
        try {
            this.wait(); // 挂起线程
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(a + " is release");
    }

    public synchronized void release(int a){
        System.out.println(a + " is notify");
        this.notify(); // 通知线程
        System.out.println(a + " is release");
    }
}

所以,这段代码的最终输出:

61_1.png

因为线程 0 在调用 wait 方法时会被挂起,只有通过 notify 方法进行通知,才会使挂起的线程重新运行。

notifyAll

该方法与 notifyAll 的区别在于,它使唤醒所有等待在对象监视器锁的线程,而不是单独一个线程。

wait

该方法的方法签名为 public final native void wait(long timeout) throws InterruptedException;

这是一个无法被重写的本地方法,并且在挂起线程的期间如果被中断则会抛出一个中断异常。

该方法的作用是:使当前线程挂起,直到另外一个线程调用该对象上的 notifynotifyAll 方法唤醒等待在该对象上的线程,或者使设置的等待时间已到。

**注意:**该方法必须在线程获取了对象的监视器锁的情况才能调用,与 notifynotifyAll 方法是一样的。

获取对象的监视器锁的方式已经在 notify 方法中说明。

当一个线程 T 调用了对象 A 的 wait 方法时,该线程 T 将会放置于等待对象 A 监视器锁的集合中,然后放弃所有对象 A 的同步声明(仅仅是放弃对象 A 的,如果此时还有对象 B 的同步声明,是不会放弃的),有四种方法可以让线程 T 结束休眠状态:

1、 其他线程调用对象 A 的 notify 方法。
2、 其他线程调用对象 A 的 notifyAll 方法。
3、 其他线程中断线程 T。
4、 超过指定的等待时间,如果指定的时间为 0,那么则表示永远不会出现超时的情况(线程只能等待通知和被中断才能继续运行)

当被阻塞的线程 T 被唤醒之后,那么就会从等待对象 A 监视器锁的集合中移除,然后和其他需要获取对象 A 监视器锁的线程进行争夺。如果成功获取到了对象 A 的监视器锁,那么就会进入和执行 wait 方法之前同样的同步状态,并且限制其他线程进入该临界区,当执行完所有的代码之后,就会放弃监视器锁。

除了上述的四种方法可以唤醒线程之外,还存在一种虚假唤醒的情况。虚假唤醒是指线程从发送等待的条件信号中醒来,但是发现并不满足正在等待的条件。

虚假唤醒并不会无缘无故地发生,通常是因为在发出条件变量的信号和等待线程最终运行之间的这段时间之间,存在另一个线程运行并更改了条件。

因为虚假唤醒是实际上有可能发生的,所以我们在等待条件的线程中,应该始终检测条件是否满足再继续执行,比如想下面这样写:

public class Main {

    private Queue<String> queue = new LinkedList<>();

    public static void main(String[] args) throws InterruptedException {
        Main main = new Main();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                main.product(String.valueOf(i));
            }
        }).start();

        new Thread(() -> {
            try {
                main.custom();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }

    public synchronized void custom() throws InterruptedException {
        while (queue.size() == 0){
            this.wait(); // 重点在这句,这里应该使用一个while循环来判断是否可以进行消费
        }
        String remove = queue.remove();
        System.out.println(remove);
    }

    public synchronized void product(String mission){
        queue.add(mission);
        this.notify();
    }
}

我们有一个生产者和消费者模型,在消费者这一端,在队列中没有任务的时候,我们需要挂起线程,等待任务到来的通知。我们现在有一种情况就是,消费者线程被唤醒了,但是在获取监视器锁的之前,已经被其他线程把这个任务给消费了,线程任务队列中是为空的,此时其他线程放弃了监视器锁,消费者线程获取了锁,然后开始执行代码,如果我们不使用 while 循环来判断任务队列中是否满足条件,那么就会出现 remove 操作的失败。如果我们假如了 while 循环判断,那么线程每次被唤醒,都会重新检测一次队列是否满足要求,如果不满足要求,则继续挂起线程,等待通知。

而这也是虚假唤醒的典型解决方法,通过一个循环来判断线程是否满足等待的条件,从而决定是否可以继续运行。

假如有 10 个线程都在一个条件等待队列中,如果使用的唤醒方法是 notifyAll,那么当发出一个信号时,只有一个可以竞争获取得到锁,所以剩下的 9 个线程都可能是虚假唤醒。

虚假唤醒发生在等待条件变量发生信号的情况下,线程确实被唤醒了,但是被唤醒之后,因为其他线程发生了一些动作影响了条件变量,导致该条件变量已经不符合要求,所以线程虽然被唤醒了,但是却不能做出正确的动作,需要再次被挂起。

finalize

该方法的方法签名是 protected void finalize() throws Throwable { }

它和 clone 方法相似,都是 protected 权限访问修饰符修饰的,也就是说如果你要使用这个方法,那么你就必须重写这个方法,然后将权限访问修饰符提高为 public

finalize 方法在垃圾收集器回收该对象前会调用该方法,该方法的作用是处理系统资源和其他资源的清理。我们也可以在该方法中给该对象关联上一个强引用,这样这个对象就可以逃过这一次的垃圾回收,但是不建议这么做。Java 程序语言也不保证一定会执行该函数,因为如果程序员在该方法重写的过程中,直接造成了一个死循环或者执行一个长时间的任务,那么将会造成性能的大幅度降低。

finalize 方法在整个程序运行期间只会被执行一次。

public class Finalize {

    private static Finalize temp = null;

    public static void main(String[] args) throws InterruptedException {
        Finalize finalize = new Finalize();
        finalize = null;
        System.gc(); // 提示 JVM 进行gc
        Thread.sleep(10); // 确保gc一定会发生
        if(temp != null){ // 检查对象是否死亡
            System.out.println("Yes, I'm still alive");
        } else {
            System.out.println("No, I'm dead");
        }
        temp = null;
        System.gc(); // 提示 JVM 进行gc 
        Thread.sleep(10); // 确保gc一定会发生
        if(temp != null){
            System.out.println("Yes, I'm still alive");
        } else {
            System.out.println("No, I'm dead");
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("execute finalize method");
        temp = this;
    }
}

61_2.png

最后打印的结果如上图所示,第一次 gc 的时候,对象被拯救了回来。第二次 gc 的时候,对象就不会再被拯救了,而是直接被垃圾收集器回收。所以 finalize 方法在对象生命周期期间,只会执行一次。

该方法引发的任何异常,该异常都不会被捕获,该方法的终结过程也会终止,造成对象被破坏的状态。被破坏的对象又很可能导致部分资源无法被回收,造成浪费。

应该尽量避免使用该方法:

1、 finalize 方法不一定会被调用,因为 Java 的垃圾收集器特性就决定它不一定会被调用。
2、 finalize 和垃圾收集器的运行本身就会消耗资源,也许会导致程序的暂时停止。
3、 就算 finalize 函数被调用,它被调用的实际充满了不确定性,因为程序中的其他线程的优先级远高于执行 finalize 函数线程的优先级。
4、 该方法引发的任何异常,该异常都不会被捕获,该方法的终结过程也会终止。

尾声

作为 Java 万物起源的 Object 看似简单,其实内部充满了各种各样的细节,我们应该对它充分掌握,才能理解 Java 中对象的基本功能和特性。

参考

1、 《Effective Java 第三版》
2、 《深入理解 Java 虚拟机 第三版》
3、 Why does pthread_cond_wait have spurious wakeups?
4、 Do spurious wakeups in Java actually happen?

文章永久链接:https://tech.souyunku.com/38007

未经允许不得转载:搜云库技术团队 » Java的万物起源Object

JetBrains 全家桶,激活、破解、教程

提供 JetBrains 全家桶激活码、注册码、破解补丁下载及详细激活教程,支持 IntelliJ IDEA、PyCharm、WebStorm 等工具的永久激活。无论是破解教程,还是最新激活码,均可免费获得,帮助开发者解决常见激活问题,确保轻松破解并快速使用 JetBrains 软件。获取免费的破解补丁和激活码,快速解决激活难题,全面覆盖 2024/2025 版本!

联系我们联系我们