Java修炼指南:高频源码解析
上QQ阅读APP看书,第一时间看更新

1.1 JDK中所有类的基类——Object类

首先介绍JDK中所有类的基类——java. lang. Object。

Object类是Java中所有类的父类,所有类默认继承Object。这也就意味着,Object类中的所有公有方法也将被任何类所继承。如果把整个Java类体系看成一棵树,那么Object类毫无疑问就是整棵树的根。

Object类属于java. lang包,此包下的所有类在使用时无须手动导入,系统会在程序编译期间自动导入。Object类是所有类的基类,当一个类没有直接继承某个类时,则默认继承Object类,也就是说任何类都直接或间接继承此类,Object类中能访问的方法在所有类中都可以调用,下面就来分别介绍Object类中的所有方法。

●图1-1 Object类的结构图

首先看一下Object类的结构,如图1-1所示。

1.1.1 为什么java.lang包下的类不需要手动导入

在使用诸如Date类时,需要手动导入import java. util. Date,再比如使用File类时,也需要手动导入import java. io. File。但是在使用Object类、String类和Integer类等时不需要手动导入,而能直接使用,这是为什么呢?

这里先告诉读者一个结论:使用java. lang包下的所有类,都不需要手动导入。

另外介绍一下Java中的两种导包形式:

1)单类型导入(single-type-import),例如import java. util. Date。

2)按需类型导入(type-import-on-demand),例如import java. util.∗。

单类型导入比较好理解,编程所使用的各种工具默认都是按照单类型导入的,需要什么类便导入什么类,这种方式是导入指定的public类或者接口;

按需类型导入,比如import java. util.∗,可能看到后面的 ∗,程序员会以为是导入java. util包下的所有类,其实并不是这样,Java会根据名字知道是按照需求导入,并不是导入整个包下的所有类。

Java编译器会从启动目录(bootstrap)、扩展目录(extension)和用户类路径去定位需要导入的类,而这些目录仅给出了类的顶层目录,编译器的类文件定位方法大致可以理解为如下公式:

顶层路径名 \ 包名 \ 文件名. class = 绝对路径

单类型导入可以导入包名和文件名,所以编译器可以一次性查找定位到所要的类文件。按需类型导入则比较复杂,编译器会把包名和文件名进行排列组合,然后对所有的可能性进行类文件查找定位。例如:

如果文件中使用到了File类,那么编译器会根据如下几个步骤来查找File类:

需要注意,编译器找到java. io. File类之后并不会停止下一步的寻找,而要把所有的可能性都查找完以确定是否有类导入冲突。假设此时的顶层路径有3个,那么编译器就会进行3∗5=15次查找。

如果在查找完成后,编译器发现了两个同名的类,那么就会报错。要先删除用户不用的那个类,然后再编译。

所以可以得出这样的结论:按需类型导入是绝对不会降低Java代码的执行效率的,但会影响到Java代码的编译速度。所以在编码时最好是使用单类型导入,这样不仅能提高编译速度,也能避免命名冲突。

了解Java的两种导包类型后,再回到为什么可以直接使用Object类。上面代码中查找类文件的第③步,编译器会自动导入java. lang包,那么就可以直接使用了。至于原因,因为用得多,提前加载了该包文件,且节省了资源。

1.1.2 类构造器

类构造器是创建Java对象的途径之一,通过new关键字调用构造器不仅可以完成对象的实例化,还能通过构造器对对象进行相应的初始化。一个类必须要有一个构造器,如果没有显示声明,那么系统会默认创造一个无参构造器,在JDK的Object类源码中,是看不到构造器的,系统会自动添加一个无参构造器。可以通过如下方式实现:

1.1.3 equals方法

很多面试题都会问equals方法和 == 运算符的区别,== 运算符用于比较基本类型的值是否相同,或者比较两个对象的引用是否相等,而equals用于比较两个对象是否相等,这样说可能比较宽泛,两个对象如何才是相等的呢?这个标尺该如何界定?

首先来看Object类中的equals方法:

可以看到,在Object类中,== 运算符和equals方法是等价的,都是比较两个对象的引用是否相等,从另一方面来讲,如果两个对象的引用相等,那么这两个对象一定是相等的。对于自定义的一个对象,如果不重写equals方法,那么在比较对象的时候就会调用Object类的equals方法,也就是用 == 运算符比较两个对象。接着来看String类中的重写的equals方法:

String是引用类型,比较时不能比较引用是否相等,而是比较字符串的内容是否相等。所以String类定义两个对象相等的标准是字符串内容都相同。

在Java规范中,对equals方法的使用必须遵循以下几个原则:

1)自反性。对于任何非空引用值x,x. equals(x)都应返回true。

2)对称性。对于任何非空引用值x和y,当且仅当y. equals(x)返回true时,x. equals(y)才应返回true。

3)传递性。对于任何非空引用值x、y和z,如果x. equals(y)返回true,并且y. equals(z)返回true,那么x. equals(z)应返回true。

4)一致性。对于任何非空引用值x和y,多次调用x. equals(y)始终返回true或始终返回false,前提是对象上equals比较中所用的信息没有被修改。

对于任何非空引用值x,x. equals(null)都应返回false。

下面自定义一个Person类,然后重写其equals方法,比较两个Person对象,代码如下所示。

通过重写equals方法,自定义两个对象相等的标尺为Person对象的两个属性都相等,否则两个对象不相等。如果不重写equals方法,那么始终是调用Object类的equals方法,也就是用 == 比较两个对象在栈内存中的引用地址是否相等。

通过Person类的子类Man,也可以重写equals方法,代码如下所示。

通过打印结果可以发现person. equals(Man)得到的结果是true,而Man. equals(person)得到的结果却是false,这显然是不正确的。

问题出现在instanceof关键字上。Man是Person的子类,person instanceof Man结果当然是false。这违反了对称性原则。

实际上用instanceof关键字是达不到对称性的要求的。这里推荐做法是用getClass()方法取代instanceof运算符。getClass()关键字也是Object类中的一个方法,作用是返回一个对象的运行时类,下面详细讲解。

Person类中的equals方法代码如下:

打印结果person. equals(Man)得到的结果是false,Man. equals(person)得到的结果也是false,满足对称性。

注意:

使用getClass要根据情况而定,毕竟定义对象是否相等的标准是由程序员自己定义的。而且使用getClass不符合多态的定义,比如AbstractSet抽象类,它有两个子类TreeSet和HashSet,它们分别使用不同的算法实现查找集合的操作,但无论集合采用哪种方式实现,都需要拥有对两个集合进行比较的功能,如果使用getClass实现equals方法的重写,那么就不能对两个不同子类的对象进行相等的比较。而且集合类比较特殊,其子类不需要自定义相等的概念。

所以对于使用instanceof和getClass()运算符有如下建议:

1)如果子类能够拥有自身相等的概念,则对称性需求将强制采用getClass进行检测。

2)如果有超类决定相等的概念,那么就可以使用instanceof进行检测,这样可以在不同子类的对象之间进行相等的比较。

下面给出一个完美的equals方法的建议:

1)显示参数命名为otherObject,稍后会将它转换成另一个称为other的变量。

2)判断比较的两个对象引用是否相等,如果引用相等则表示是同一个对象。

3)如果otherObject为null,则直接返回false,表示不相等。比较this和otherObject是否是同一个类:如果equals的语义在每个子类中有所改变,就使用getClass检测;如果所有的子类都有统一的定义,那么使用instanceof检测。

4)将otherObject转换成对应的类型变量。

5)最后对对象的属性进行比较。使用 == 比较基本类型,使用equals比较对象。如果都相等则返回true,否则返回false。注意如果是在子类中定义equals,则要包含super. equals(other)。

下面给出Person类中完整的equals方法的代码:

该方法声明相等对象必须具有相同的哈希代码。hashCode也是Object类中的方法,后面会详细讲解。请注意,无论何时重写此方法,通常都必须重写hashCode方法,以遵循hashCode方法的一般约定。

1.1.4 getClass方法

上一小节在介绍equals方法时,介绍如果equals的语义在每个子类中有所改变,那么使用getClass检测,为什么这样说呢?

getClass()在Object类中代码如下,作用是返回对象的运行时类。

这是一个用native关键字修饰的方法。

这里读者要知道用native修饰的方法是由操作系统帮忙实现的,该方法的作用是返回一个对象的运行时类,通过这个类对象可以获取该运行时类的相关属性和方法。也就是Java中的反射,各种通用的框架都是利用反射来实现的,这里不做详细的描述。

getClass方法返回的是一个对象的运行时类对象,这该怎么理解呢?Java中还有一种方法,通过类名. class获取这个类的类对象,这两种方法的区别如下:

父类Parent. class

子类Son. class

测试:

打印结果:

结论:class是一个类的属性,能获取该类编译时的类对象,而getClass()是一个类的方法,它是获取该类运行时的类对象。

需要注意,虽然Object类中getClass()方法声明是:public final native Class<?>getClass();返回的是一个Class<?>,但是如下方法也能通过编译:

即类型为T的变量getClass方法的返回值类型其实是Class<?extends T>,而非getClass方法声明中的Class<?>。

官方文档中也有相关说明。

1.1.5 hashCode方法

1. hashCode()是什么?

hashCode()方法和equals()方法的作用其实一样,在Java里都是用来对比两个对象是否相等一致。

hashCode在Object类中定义代码如下所示。

这也是一个用native声明的本地方法,作用是返回对象的哈希码,是int类型的数值。

那么这个方法存在的意义是什么呢?

在Java中有几种集合类,比如List、Set和Map,List集合存放的元素一般是有序可重复的,Set存放的元素则是无序不可重复的,而Map集合存放的是键值对。

前面写到判断一个元素是否相等可以通过equals方法,每增加一个元素,就通过equals方法判断集合中的每一个元素是否重复,但是如果集合中有10000个元素了,当新加入一个元素时,那就需要进行10000次equals方法的调用,这显然效率很低。

于是,Java的集合设计者就采用了哈希表来实现。哈希算法也称为散列算法,是将数据依特定算法产生的结果直接指定到一个地址上。这个结果就是由hashCode方法产生的。这样一来,当集合要添加新的元素时,先调用这个元素的hashCode方法,就能定位到它应该放置的物理位置上。具体示意图如图1-2所示。

●图1-2 hashCode存储示意图

这里有A、B、C、D四个对象,分别通过hashCode方法产生了三个值,注意A和B对象调用hashCode产生的值是相同的,即A. hashCode()=B. hashCode()=0x001,发生了哈希冲突,这时候由于最先插入了A,在插入B的时候,发现B是要插入A所在的位置,而A已经插入了,这时候就通过调用equals方法判断A和B是否相同,如果相同就不插入B,如果不同则将B插入A后面的位置。所以对于hashCode方法有如下要求。

2. hashCode要求

1)在程序运行时期间,只要对象的(字段的)变化不会影响equals方法的决策结果,那么,在此期间无论调用多少次hashCode,都必须返回同一个散列码。

2)通过equals调用返回true的两个对象的hashCode一定相同。

3)通过equasl返回false的两个对象的散列码不需要不同,也就是通过hashCode方法的返回值允许出现相同的情况。

因此,得到如下推论:

● 若两个对象相等,其hashCode一定相同;

● 若两个对象不相等,其hashCode有可能相同;

● 若hashCode相同的两个对象,则不一定相等;

● 若hashCode不相同的两个对象,则一定不相等。

这4个推论通过图1-2可以更好地理解。可能会有人疑问,对于不能重复的集合,为什么不直接通过hashCode对于每个元素都产生唯一的值,如果重复就是相同的值,这样不就不需要调用equals方法来判断是否相同了吗?

实际上对于元素不是很多的情况下,直接通过hashCode产生唯一的索引值,通过这个索引值不仅能直接找到元素,而且还能判断是否相同。比如数据库存储的数据,ID是有序排列的,通过ID直接找到某个元素,如果新插入的元素ID已经有了,那就表示是重复数据,这是很完美的办法。但现实是存储的元素很难有这样的ID关键字,也就很难实现这种hashCode的算法,另外该方法产生的hashCode码非常大,这会超过Java所能表示的范围,很占内存空间,所以也是不予考虑的。

3. hashCode编写指导

1)不同对象的hash码应该尽量不同,避免hash冲突,也就是算法获得的元素要尽量均匀分布。

2)hash值是一个int类型,在Java中占用4个字节,也就是232 ,要避免溢出。

JDK中的Integer类、Float类、String类等都重写了hashCode方法,自定义对象也可以参考这些类来写。

接着看一下JDK String类的hashCode源码如下所示。

再次提醒读者,对于Map集合,可以选取Java中的基本类型,还有引用类型String作为key,因为它们都按照规范重写了equals方法和hashCode方法。但是如果用自定义对象作为key,那么一定要重写equals方法和hashCode方法,不然会产生错误。下面说一下:如何正确使用hashCode()和equals()。

4. hashCode()和equals()使用的注意事项

1)对于需要大量并且快速的对比,如果都用equals()去实现显然效率太低,所以解决方式是:每当需要对比的时候,首先用hashCode()去对比,如果hashCode()结果不一样,则表示这两个对象肯定不相等(也就是不必再用equals()去对比了),如果hashCode()相同,此时再对比equals(),如果equals()也相同,则表示这两个对象真正相同,这样既能大大提升了效率也保证了对比结果的正确。具体流程如图1-3所示。

●图1-3 hashCode与equals使用流程

2)大量的并且快速的对象对比一般使用hash容器。比如HashSet、HashMap、HashTable等,比如HashSet里要求对象不能重复,则其内部必然要对添加进去的每个对象进行对比,而对比规则就是先使用hashCode()方法,如果hashCode()结果相同,再用equals()验证,如果hashCode()结果不同,则肯定不同,这样对比的效率就很高了。

3)hashCode()和equals()都是基本类Object里的方法,和equals()一样,Object里hashCode()只返回当前对象的地址,如果当相同的一个类中新建两个对象时,由于它们在内存里的地址不同,则它们的hashCode()不同,所以这显然不是想要的结果,所以必须重写自定义类的hashCode()方法,即在hashCode()里面返回唯一的一个hash值,代码如下:

由于标识这个类的是通过其内部的变量num和name,所以就要根据它们返回一个hash值,作为这个类唯一的hash值。

所以如果想把编写的对象放进hashSet,并且发挥hashSet的特性(即不包含一样的对象),就要重写自定义类的hashCode()和equals()方法。像String、Integer等这种类内部都已经重写了这两个方法。

如果只想对比两个对象是否一致,则只重写一个equals(),然后用equals()去对比即可。

1.1.6 toString方法

该方法在JDK中的源码如下所示。

getClass(). getName()是返回对象的全类名(包含包名),Integer. toHexString(hashCode())是以十六进制无符号整数形式返回此哈希码的字符串表示形式。

打印某个对象时,默认是调用toString方法,比如System. out. println(person)等价于System. out. println(person. toString())。

1.1.7 notify()/notifyAll()/wait()

notify()/notifyAll()/wait()方法是用于多线程之间的通信方法,关于多线程后面会详细描述,这里就不进行讲解了。

1.1.8 finalize方法

该方法用于垃圾回收,一般由JVM自动调用,一般不需要程序员去手动调用该方法。

1.1.9 registerNatives方法

该方法在Object类中定义,代码如下所示。

这是一个本地方法,一个类定义了本地方法后,想要调用操作系统的实现,必须还要装载本地库,但是在Object. class类中具有很多本地方法,却没有看到本地库的载入代码。而且这个方法是用private关键字声明的,在类外面根本调用不了。这个方法的类似源码,代码如下所示。

上面的代码表明,静态代码块是一个类在初始化过程中必定会执行的内容,所以在类加载的时候会执行该方法,并通过该方法来注册本地方法。