Java语言中,Object类是所有类的祖先,因此,Object类的特性就成为了Java语言中所有类的特性。Java语言之所以要给所有类都定义一个共同的祖先,就是为了让Java世界万物归于一统,这样的话,所有的对象都能被看作同一种事物。例如程序员定义一个数组,并且希望这个数组中可以存放任何类的对象,那么,程序员只要把这个数组定义成Object类型就可以。此外,程序员定义一个方法,并且希望任何类的对象都可以充当这个方法的参数,那么只需要把这个方法的参数定义为Object类型就可以。假如说Java语言中的类没有一个共同祖先,就无法用最简单的方式把所有对象都看成同一种事物进行统一管理。此外还需强调: Java语言中任何一个数组也都是一个对象,因此Object不仅仅是普通类的祖先,它也是数组的祖先。这也意味着Object类型的数组当中不仅可以存放普通对象,还可以存放另外一个数组。
由于Java语言中所有的类都有Object这个共同祖先,这样就很容易为所有的类都定义出公有的属性和方法,只需要把这些属性和方法都定义在Object类当中就可以。到目前为止,Object类当中总共定义了11个可以被子孙类继承方法,本节将详细讲解这些方法的用途和使用方式。
11.6.1 getClass()方法
Object类中定义的getClass()方法能够获得一个对象的真实类型。Java语言用Class这个类表示对象的类型。需要注意:Class是Java语言中一个类的名称,它的首字母是大写的C,与表示类的class关键字意义不同。getClass()方法的返回值类型就是Class,如果希望获得一个对象所属类的名称,可以调用Class类的getName()方法,这个方法能够返回一个字符串形式的类名称。
通过getClass()方法的返回值还可以判断两个对象是不是同一类型,程序员只需要用==直接比较两个对象getClass()方法的返回值是否相等即可。下面的【例11_19】展示了getClass()方法的作用。
【例11_19 getClass()方法的使用】
Exam11_19.java
class Animal{ }
class Dog extends Animal{ }
class Cat extends Animal{ }
public class Exam11_19 {
public static void main(String[] args) {
Animal a1 = new Dog();
Animal a2 = new Cat();
Dog a3 = new Dog();
Cat a4 = new Cat();
System.out.println("a1的类型:"+a1.getClass().getName());
System.out.println("a2的类型:"+a2.getClass().getName());
System.out.println("a1与a2属于同一类型:"+(a1.getClass()==a2.getClass()));
System.out.println("a1与a3属于同一类型:"+(a1.getClass()==a3.getClass()));
}
}
为方便读者阅读程序,本例把Animal、Dog和Cat这三个类也定义到了Exam11_19.java这个文件中。【例11_19】的运行结果如图11-17所示。
图11-17【例11_19】运行结果
从图11-17可以看到:虽然a1和a2这两个引用的类型是Animal,但通过getClass()方法仍然能够获得它们所指向对象的真实类型是Dog和Cat,此外, a1和a3这两个引用的类型并不相同,但通过getClass()方法也能够判断出它们所指向的对象类型是相同的。
11.6.2 toString()方法
Object类中所定义的toString()方法可以使得对象被转换成字符串,转换所得到的成字符分为三部分,第一部分是对象所属类的名称,第二部分是一个@符号,第三部分是hashCode()方法的返回值,这个返回值是一个整数,并且经过Integer类的toHexString()方法的转换,最终以十六进制字符串的形式展现出来。
Object类中所定义的toString()方法所返回的字符串并没有把对象中的有用信息显示出来,因此如果必要的话,可以重写toString()方法。下面的【例11_20】展示了toString()方法的执行效果。
【例11_20 toString()方法的使用】
Exam11_20.java
class Rectangle{
private double width;//矩形的宽度
private double height;//矩形的高度
public Rectangle(double width, double height){
this.width = width;
this.height = height;
}
@Override
public String toString(){
String str = "此矩形宽为"+width+"高为"+height;
return str;
}
}
class Triangle{
private double length;//三角形底边长
private double height;//三角形的高
public Triangle(double length, double height){
this.length = length;
this.height = height;
}
}
public class Exam11_20 {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(2.0,3.0);
Triangle triangle = new Triangle(3.8,4.5);
System.out.println(rectangle);
System.out.println(rectangle.toString());
System.out.println(triangle);
System.out.println(triangle.toString());
}
}
在Exam11_20.java这个源文件中定义了3个类,分别是Rectangle、Triangle和Exam11_20。其中Rectangle代表矩形,并且这个类重写了toString()方法。Triangle代表三角形,但它没有重写toString()方法。【例11_20】的运行结果如图11-18所示。
图11-18【例11_20】运行结果
从图11-18可以看出:直接打印一个对象和打印这个对象的toString()方法返回值的效果是完全相同的,这是因为在执行println()方法时会调用toString()方法。此外还可以看出:在没有被重写的情况下,toString()方法并不能返回对象的各项属性值。
11.6.3 equals()方法
equals()方法是读者已经非常熟悉的一个方法,Object类定义equals()方法的宗旨是希望通过这个方法判断两个对象是否相同。但对象是否相同的判断标准并不统一,例如:有两张面值相同的钞票,有人认为它们具有同样的购买力,所以它们是相同的,也有人认为这两张钞票的编号不同,所以它们是不相同的。正因为判断标准不统一,所以Object类中所定义的equals()方法没有采用很复杂的判断方式,只是简单的用==判断了一下两个对象是否为同一个对象,而每个类如果希望建立自身的判断标准就需要重写equals()方法。假如有一个代表水杯的Cup类,程序员认为如果两个水杯的容积相同,这两个水杯就是相同的,可以按如下方式重写equals()方法。
class Cup{
double volume;//水杯的容积
@Override
public boolean equals(Object o){
Cup cup = (Cup)o;
return this. volume ==cup. volume;
}
}
由于Cup类的equals()方法重写了Object类中的equals()方法,因此Cup类中的equals()方法的参数也必须与Object类中的equals()方法保持一致,也就是说参数类型必须也是Object。但重写的equals()方法是要比较两个Cup类对象的volume属性(容积)是否相同,所以还需要在比较之前把参数强制转换为Cup类。但如果给方法传递的参数不是Cup类或它的子类对象,完成转换时就会出现ClassCastException异常,这个异常表示类型转换错误。为了避免这个异常的出现并且使判断更加合理,通常需要在强制转换和对象比较之前先做一些判断工作。通常情况下重写equals()方法之前要做以下判断:
- 判断参数对象是不是当前对象自身,如果是自身直接返回true。
- 判断参数对象是否为空,一般情况下参数为空直接返回false。
- 判断参数对象是不是某种类型,因为如果参数对象不是某种类型,与当前对象做比较可能没有意义,例如把代表水杯的Cup类对象和代表汽车的Car类对象做比较就没有意义。
- 判断参数对象与当前对象是否类型完全相同,因为对象类型不相同的情况下做比较可能也是没有意义的。
以上几项中,第3项和第4项不易区分,此处举例讲解。我们知道:狗是一种动物,但狗也分很多品种,假如当前对象是一只狗,可以认为第3项判断操作就是判断参数对象是不是也是一只狗。而第4项判断操作就是判断当前对象和参数对象是否都是同一品种的狗。此外还需说明:重写equals()方法时是否要把以上判断操作都执行一遍,是要看实际的业务需求,如果业务不需做某些判断,在编写代码时就可以不用写相应的判断语句。下面的代码给展示了Cup类重写equals()方法的合理操作过程。
class Cup{
double volume;//水杯的容积
@Override
public boolean equals(Object o){
if(this==o){//判断参数对象是不是当前对象自身
return true;
}
if(o==null){//判断参数对象是否为空
return false;
}
if(!(o instanceof Cup)){//判断参数对象是否属于Cup类
return false;
}
if(this.getClass()!=o.getClass()){//判断当前对象是否与参数对象属于同一类型
return false;
}
Cup cup = (Cup)o;
return this. volume ==cup. volume;
}
}
以上代码在比较两个对象之前做了很多判断,只有经过缜密的判断才能让对象的比较更加合理。
11.6.4 hashCode()方法
在11.6.2小节曾经讲过:toString()方法中调用了hashCode()方法。hashCode()方法定义在Object类当中,如果查看这个方法的源代码可以发现:hashCode()方法没有定义方法体,但它的前面并不是abstract关键字,而是native关键字。native意为“当地”或“本地”,把这个关键字添加到方法的前面,就表示这个方法是一个本地方法。Java语言在被设计的一开始,就被定位成一种无法直接操控计算机底层资源的语言。如果在程序中希望操作计算机底层资源,可以用C或C++来完成。程序员可以先用C或C++编写好一些操作底层资源的代码,然后把这些写好的代码经过编译打包成可调用的程序,这种可调用的程序在专业上被称为“动态链接库”。Java语言就可以通过一种叫做JNI的技术来调用到这些动态链接库,实际上也就是调用到那些用C或C++编写的程序。这种被native关键字所修饰的本地方法就是指在执行的过程中调用了动态链接库的方法。Java基础类库中的类所调用的动态链接库都来自于JDK,因此只要正确的安装了JDK,并且按照要求配置好环境变量就能调用到动态链接库。如果程序员自己定义了本地方法则需要同时实现动态链接库。
hashCode()方法没有参数,它运行会返回一个int型数据,这个数据被称为“哈希码”或“哈希值”。 因此,hashCode()方法的作用就是产生一个哈希码。为使读者更深入了解哈希码的产生过程,必须先说明:Java虚拟机并不是一个产品,而是一套标准和规范,任何个人和公司都可以根据这套标准发布自己的虚拟机产品。不同的虚拟机产品对hashCode()方法的实现算法也不同。虽然各种虚拟机产品在计算哈希码时采用算法并不完全相同,但大多数虚拟机产品在计算哈希码的时候都会以对象的存储地址作为一个重要的参数来完成整个计算的过程。因此有很多人都误以为hashCode()方法的返回值是对象的内存地址,但真实情况并非如此。
在Java语言中,有一种专门存放对象的容器叫做集合。有些集合在存放对象时要求两个相同的对象不能重复存入其中,因此要把一个对象放入到这种集合当中时就要先判断一下集合中是否已经有了一个与它相同的对象。多数读者都会想到用对象的equals()方法进行判断。11.6.3小节曾经讲过:很多类在继承了Object的equals()方法之后都要进行重写。大部分类重写equals()方法的过程就是把两个对象的各个属性值都进行一番比较,所以equals()方法的执行过程都比较长,如果集合当中已经有了几万个对象,比较的过程就会非常浪费时间。假设本来把一个对象放入到集合中只需要1微秒的时间,但是却往往需要用1万微秒的时间来判断这个集合中是不是有一个相同的对象。
为了解决这个问题,这种不允许相同对象重复出现的集合采用了一种应对机制,那就是用两轮比较来完成判断。具体做法是:刚创建出的集合中还没有任何对象,当第一个对象存入集合时不需要做任何比较,直接就把这个对象存入集合中。在存入对象的同时,调用对象的hashCode()方法得到它的哈希码,然后把这个哈希码存放到一张数据表中。这张保存了哈希码的数据表被称为“哈希码表”。当第二个对象到来的时候,就需要与集合中已经存在的那个对象比较一下是否相同。之前已经在集合的哈希码表中已经保存了第一个对象的哈希码,因此集合会用第二个对象的哈希码与第一个对象的哈希码比较一下,如果不相同,就把第二个对象存放到集合中,并且同时把第二个对象的哈希码也保存到哈希码表中。如果第二个对象的哈希码与第一个对象的哈希码完全相同,这种情况下并不会直接把第二个对象挡在集合之外,而是调用第二个对象equals()方法与第一个对象进行比较,这就是第二轮比较。如果equals()方法判断出两个对象不同,那么第二个对象仍然可以进入到集合中,但是,如果equals()方法判定两个对象相同,则不允许第二个对象进入到集合中。以此类推,第三个对象到来的时候,也是先判断它的哈希码是否与前两个对象的哈希码是否相同,如果都不相同,则直接进入集合,并且在哈希码表中保存它的哈希码,否则再调用equals()方法判断两个对象是否相同,不相同则允许对象进入集合,相同则不允许进入。这种两轮判断的机制节约时间提高效率的原理是:首先用哈希码是否相同来判断对象能不能进入集合,这个过程仅仅是比较一下两个整数是否相同,而equals()方法往往是把对象的多个属性值逐一做一番比较,所以哈希码的比较要比equals()方法的执行快很多。集合只有在发现新加入的对象与集合中已有对象的哈希码相同的情况下,才启动equals()方法进行第二轮比较。这样就大大减少了equals()方法的执行次数,因此从整体上加快了判断的速度。
hashCode()方法所产生的哈希码不仅仅能够用来对两个对象进行比较,它还能够决定对象被存放在集合中的位置。这就好比是某公司要召开全体员工大会,开会之前规定:A部门的人都坐到第一排,B部门的员工都坐到第二排,以此类推。当一个员工来到会场时,就根据自己的部门找到自己应该坐的位置,而集合在存放对象时,也是根据哈希码值来决定每个对象被存放到集合的什么位置。当然这只是一个简单的比喻而已,真实情况下集合会用一套严谨的算法来完成对象的存放操作。
此处需要强调:并不是每种类型的集合都不允相同的对象重复进入其中,也不是每种集合都按哈希码来决定对象在集合中的位置。不同种类的集合会有不同存放对象的策略。关于这方面的知识将会在集合相关的章节中详细讲解。
实际上,native方法也可以被重写,例如表示字符串的String类就重写了hashCode()方法。那么,在什么情况下需要重写hashCode()方法呢?一般来讲,如果程序员定义了一个类,并且明确知道这个类的对象会被存入到集合当中,这种情况下就需要重写hashCode()方法。重写hashCode()方法时需要注意以下几个原则:
- 重写hashCode()方法时,方法最终的返回值都不应该是一个负数。
- 同一个对象的hashCode()方法无论执行多少次,它的返回值都应该是一个固定不变的int型数值。如果使用随机数来当作hashCode()方法的返回值会影响到对象在集合中的存取操作。
11.6.5 clone()方法
clone一词意为“克隆”,clone()方法可以完成当前对象的复制操作。这个方法是一个被native关键字修饰的本地方法,此外它还是一个被protected关键字修饰的受保护的方法,这意味着在程序员无法从外部直接调用到这个方法,但这个方法会被所有的类继承下来,因此通常情况下只能在类当中定义一个方法,然后在这个方法当中再调用自身的clone()方法完成对象的复制。需要注意:一个类只有在实现了Cloneable接口的情况下才能调用clone()方法完成对象的复制。Cloneable接口中并没有定义抽象方法,一个类实现这个接口只是为了表明它允许自己的对象被复制。clone()方法的定义代码用throws关键字声明了CloneNotSupportedException,这个异常表示不支持复制,程序员需要在调用clone()方法时处理这个异常。实际上,一个类只要实现了Cloneable接口,那么在调用clone()方法时就不会抛出这个异常。下面的【例11_21】展示了clone()方法复制对象的效果。
【例11_21对象的复制】
Person.java
public class Person implements Cloneable{
StringBuffer name ;//姓名
int age;//年龄
public Person(String name,int age){
this.name = new StringBuffer(name);
this.age = age;
}
public Object cloneSelf() throws CloneNotSupportedException {
Object o = clone();
Person p = (Person)o;
StringBuffer name = new StringBuffer(this.name);//为复制版对象单独创建StringBuffer对象
p.name = name;//以新创建的StringBuffer对象作为复制版对象的name属性
return p;
}
}
Exam11_21.java
public class Exam11_21 {
public static void main(String[] args) {
Person p1 = new Person("张三",20);
Person p2 = null;
try {
p2 = (Person)p1.cloneSelf();//复制对象
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
System.out.println("原对象的姓名:"+p1.name+",原对象的年龄:"+p1.age);
System.out.println("被复制对象的姓名:"+p2.name+",被复制对象的年龄:"+p2.age);
System.out.println("p1与p2是否为同一对象:"+(p1==p2));
p2.name.append("丰");//修改被复制对象的姓名
System.out.println("原对象的姓名:"+p1.name);
}
}
【例11_21】中包含两个类,分别是Person和Exam11_21。由于clone()方法是一个受保护的方法,其他类无法直接调用到它,所以要Person类中要定义一个cloneSelf()方法,由这个方法完成对clone()方法的调用。clone()方法的返回值类型为Object,因此Person类中定义的cloneSelf()方法的返回值类型也要定义成Object。Exam11_21类的main()方法中调用cloneSelf()方法完成对象的复制。【例11_21】的运行结果如图11-19所示。
图11-19【例11_21】运行结果
通过图11-19可以看出:被复制的p2对象与原p1对象的各个属性值都完全相同,但通过==又能判断出它们并不是同一对象,这说明clone()方法真正完成了对象的复制,而不是简单的用两个不同的引用指向了同一个对象。但是,当修改了p2对象的name属性值后,p1对象的name属性值也发生了改变,这是因为clone()方法完成的是“浅复制”。浅复制在复制对象属性时只是复制了基础数据类型的值以及对象的地址,下面的图11-20展示了浅复制的基本原理。
图11-20浅复制原理
从图11-20可以看出:p1和p2是两个独立的对象,并且p2全盘复制了p1的内容。p1和p2的name属性本质上是两个引用,引用当中保存了表示姓名的StringBuffer类对象的内存地址。由于p2全盘克隆了p1的内容,因此p1和p2的name属性所保存的内存地址也完全相同,它们都指向了内存中同一个StringBuffer对象。当通过p2的name属性找到这个StringBuffer对象,并且对这个对象做修改的时候,实际上也是修改了p1的name属性所指向的那个StringBuffer对象,因为它们原本就是同一个对象。这就是为什么程序中把p2的name属性修改为“张三丰”后,p1的name属性也会发生变化的原因。
浅复制有一定的缺陷,主要表现在原版对象和复制版对象要共享一些数据,这导致两个对象不能实现真正独立。如果希望原版对象和复制版对象能够实现真正的独立,就必须完成“深复制”。深复制的效果如图11-21所示。
图11-21深复制原理
从图11-21可以看出:原版对象和被复制的对象各自拥有一个独立的StringBuffer对象作为name属性。当修改p1对象的name属性时,p2对象不会受到任何影响。
由于Object类的clone()方法只能实现浅复制,所以实现深复制还需要程序员自己写代码完成。实现深复制的基本思路也很简单,首先观察一个类当中那些属性是引用类型的数据。然后在已经完成浅复制的基础上,再给那个被克隆出来的对象创建单独的引用数据类型属性。以下这个重新定义的cloneSelf()方法展示了如何实现深复制。
public Object cloneSelf() throws CloneNotSupportedException {
Object o = clone();
Person p = (Person)o;
StringBuffer name = new StringBuffer(this.name);//为复制版对象单独创建StringBuffer对象
p.name = name;//以新创建的StringBuffer对象作为复制版对象的name属性
return p;
}
重新定义的cloneSelf()方法为复制版对象单独创建了一个StringBuffer对象作为name属性,这样原本对象和复制版对象就不再共用同一个StringBuffer对象作为各自的name属性。各位读者可以按以上代码修改Person类中的cloneSelf()方法并重新运行【例11_21】以观察深复制的执行效果。
实际开发过程中,如果类的属性属于不可变类的属性可以不用考虑深复制的问题,例如String类型的属性就不必考虑深复制,其原因是:不可变类的对象充当属性,对象不能被修改,因此如果要修改属性,一定会用一个新对象给属性赋值。假如有两个对象分别叫做a和b,它们共享一个String类型的name属性。如果一定要修改a对象的name属性,就必须新创建一个新的String类对象,然后由a的name属性指向那个新的String对象,而此时b对象的name属性仍然指向那个旧String对象,b不会因为a修改了name属性而使自身的name属性受到影响。
至此,本书已经讲解了Object类常用的5个方法。实际上Object类中还定义了finalize()、wait()、notify()、notifyAll()这些方法。finalize()方法是在回收内存垃圾前自动执行,但由于内存垃圾的回收工作是由垃圾回收器完成,程序员无法控制垃圾的回收时机,因此也无法控制finalize()方法的执行时机,所以从JDK9开始finalize()方法被标记为不被提倡使用的方法,本书也不再对这个方法展开讲述。而wait()、notify()、notifyAll()这三个方法都是与多线程相关的方法,本书将在多线程相关章节中讲解这些方法的使用。
本文字版教程还配有更详细的视频讲解,小伙伴们可以点击这里观看。