目录
一、序言
JavaSE这一系列的文章不会包含那些过于简单的知识点,只会包含所有需要注意的地方以及需要特别掌握的知识点。原意是自己想把之前学过的所有东西都复习一遍,为了方便以后的复习决定还是写文章里。
二、基本数据类型
稍微提及一下基本数据类型的字节长度以及代表范围,如下所示:
基本数据类型 | 字节长度 | 范围 |
---|---|---|
byte | 1 | [, ] |
short | 2 | [, ] |
int | 4 | [, ] |
long | 8 | [, ] |
float | 4 | 32位IEEE 754单精度范围 |
double | 8 | 64位IEEE 754双精度范围 |
char | 2 | Unicode [0,65535] |
boolean | 1 | [true, false] |
需要注意的是Java中的char类型是2个字节长度,因为Java中的编码是Unicode16的,因此一个char是16位的二进制位,所以占2个字节。而在标准的C/C++中,char类型是占一个字节的。
类型提升
在基本数据类型的相关知识点中我觉得最需要注意的就是类型提升的部分。在讲类型提升之前,先讲一个基础案例:
public class Demo01 {
public static void main(String[] args) {
byte b = 3;
b = b + 4;
}
}
在输入完第②行代码后,会发现编译器提示错误信息,如下所示:
翻译过来就是类型不匹配。为什么会有这样的错误提示?因为Java默认数字是int类型,但最需要了解的是赋值语句的右边的表达式运算完毕后,编译器会判断表达式的结果是否在byte(即左边变量的类型)范围之内,而b是一个变量,编译器无法判断byte类型的b能否容纳表达式的结果。
如果一定要将值赋给byte类型,那么就得进行显式类型转换(即强制类型转换),但在这里又需要注意每一个类型都有自己的范围,会有丢失问题产生。如下所示:
public class Demo01 {
public static void main(String[] args) {
byte b = 3;
b = (byte) (b + 200);
System.out.println(b);
}
}
在做数字的运算时,Java编译器会隐式地将不同的类型进行转换,而编译器根据以下优先级对表达式的数据类型进行自动提升:
所有的byte、short、char的值将被提升到int型,而如果操作数中有long型,则就提升到long型。如果遇到了float和double亦是如此。对以下代码进行反编译,可以看出编译器内部帮我们做了一个隐式的提升。
public class Demo01 {
public static void main(String[] args) {
int c = 6;
long l = 3L;
l = c + l;
}
}
反汇编结果如下:
int c = 6;
long l = 3L;
l = (long)c + l;
三、运算符
运算符这一章我觉得最需要记忆和理解的地方只有优先级和自增自减。每一个运算符的优先级以高到低如下所示:
运算符 | 结合性 |
---|---|
[ ] . ( ) (方法调用) | 从左向右 |
++ -- ! ~ + - | 从右向左 |
* / % | 从左向右 |
+ - | 从左向右 |
<< >> >>> | 从左向右 |
< <= > >= instanceof | 从左向右 |
== != | 从左向右 |
& | 从左向右 |
^ | 从左向右 |
| | 从左向右 |
&& | 从左向右 |
|| | 从左向右 |
?: | 从右向左 |
= | 从右向左 |
说到自增自减,也不外乎就是前后的问题。用法是不需要讲的,最主要是理解其原理。以自增为例,先附上一段代码:
int a = 3, b;
b = a++;
System.out.println("b=" + b);
结果如下所示:
很多书都说后自增就是运算完后再自身加1,那从结果来说,也好像看上去是先将a的值赋给了b然后a再自增。实际上原理并不是这样,记忆可以这么记忆。
需要先知道的一点是:赋值语句是将等号右边运算完的结果赋值给左边。那么因为a在前,根据后自增的描述,a的值需要先去参与其他运算,那么就会在内存当中开辟一个临时区域,先把a的值临时记录下来,以便作其他运算。当右边运算完毕后,将右边赋给左边,正常应该是a的值赋过去,那么因此把预存的原先的a的值赋给了b。
那么我们再来看一段代码:
public class Demo01 {
public static void main(String[] args) {
int i = 3;
i = i++;
System.out.println("i = " + i);
}
}
会发现结果为3,这个结果就很好地证明了上述所说的原理。
四、面向对象基础
在这里先稍微提及一下JVM规范中的五大Java内存区域的名称:方法区、虚拟机栈、本地方法栈、堆、程序计数器。如果以后有时间的话再写一下跟JVM有关的文章。
①对象的内存体现
这里以主函数中的局部变量Person p = new Person()为例,画一个示意图如下所示:
当类在内存加载完毕后,JVM调用该类的主函数方法(即main方法)。main方法入栈后,main方法里有一个局部变量p。根据右边运算完后赋值给左边,则通过new Person()在堆内存中开辟一个空间,里面会有name,age属性。这里必须要知道的是,堆内存对象中的变量的特点是会默认初始化值,即name=null, age=0。当默认初始化后,将地址赋值给p。p就会通过地址寻找堆内中的对象,完成进一步的初始化。
这里提一句:其实所谓的变量本质上是一段连续内存空间的别名。
那么到底进一步的初始化又是什么呢?就是通过构造函数进行下一步初始化。所谓的构造函数就是构建构造的对象时调用的函数,其作用就是给对象初始化。对于构造函数现阶段只需要知道:一个类中如果没有定义构造函数,那么该类中会有一个默认的无参构造函数。如果在类中定义了指定的构造函数,那么默认构造函数就消失了。
那么接下来继续画一幅过程图,其如下所示:
当默认初始化后,会进一步初始化。这进一步的初始化就是指构造函数进栈将参数完成赋值,然后将参数的值赋给了属性。在这里面有个细节:当这个对象要进内存准备初始化的话,对象构造函数加载进来以后,在构造函数内有一个this指向了唯一的对象,根据this来确认哪个对象需要初始化。即this是所在函数的所属对象的引用。
②static关键字
static关键字是用于修饰类中的成员,其具备以下特点:修饰的成员随着类的加载而加载、被所有对象共享、可以直接被类名调用。那么到底没有static修饰的成员变量和static修饰的静态成员变量有什么区别呢?
第一、成员变量随着对象的创建而存在,随着对象的回收而释放;静态变量随着类的加载而加载,随着类的消失而消失。
第二、成员变量只能被对象调用;而静态变量不仅可以被对象调用,还可以被类名调用。
第三、成员变量也称为实例变量;而静态变量称为类变量。
第四、成员变量数据存储在堆内存的对象中;而静态变量数据存储在方法区。
既然讲到了static那就把涉及到的内存区域用图表示一下,先附上一段演示代码:
class Person {
private String name;
private int age;
public static String country = "CHINA";
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public static void show() {
System.out.println(Person.country);
}
}
class Demo01 {
public static void main(String[] args) {
Person.show();
}
}
演示图如下所示:
当我们运行(即cmd>Java ClassName)Demo1时,Demo1就会进入内存当中。其中的static修饰的main方法就会加载到静态区,接着由JVM调用main方法。因此main方法入栈,Person类进入内存,其中的static修饰的成员变量以及成员函数就会加载至静态区。接下来的步骤就省略描述了。
③对象的继承性
什么是继承在这里不多于概述。对于面向对象思想中,继承分为两种:单继承和多继承。单继承就是一个子类只能有一个父类,多继承就是一个子类可以有多个直接父类。这里必须知道的是,Java中并不允许多继承,即不允许一个子类有多个直接父类。
为什么在Java中会有这样的限定?这里以一段代码举例:
class A {
void show() {
System.out.println("a");
}
}
class B {
void show() {
System.out.println("b");
}
}
Class C extends A, B {
}
从代码上看,C继承A和B两个父类,但是A和B都拥有一个相同名的方法show,那么到底该调用哪个类的方法呢?这就是Java规定不允许直接多继承的原因,其会产生调用不确定性。至于Java是如何体现面向对象思想中的多继承,在下面将会提到。
需要注意的地方是:仅当类与类之间存在着所属关系的时候才定义继承。
既然说到继承,就得说子父类成员的特点。
构造函数
首先从构造函数说起,子类的构造函数中第一行有一个默认的隐式语句:super()。那么为什么在子类实例化的时候需要访问父类的无参构造函数呢?因为继承,子类继承父类,获取到父类中的属性,因此在使用父类的内容前,需要看父类是如何对自己的内容进行初始化的。简而言之就是,先将父类的成员初始化后再初始化子类对象。
如何验证这一特点?我们可以分别创建一个父类和子类,父类和子类都有无参构造方法,让子类继承父类,创建子类实例,结果会发现父类中的无参构造方法也运行了。代码以及结果如下所示:
class Fu {
Fu() {
System.out.println("Fu constructor run");
}
}
class Zi extends Fu{
Zi() {
System.out.println("Zi constructor run");
}
}
class Demo1 {
public static void main(String[] args) {
Zi z = new Zi();
}
}
如果父类中没有定义无参构造函数,那么子类的构造函数必须用super指明要调用父类的哪个构造函数。同时,如果子类构造函数中如果使用了this调用了自身本类的构造函数时,那么super就没有了。因为super和this只能定义在构造函数的第一行。
在先前的内存图中已经知道了对象创建的过程,那么当出现了继承时对象的创建过程又是什么?即子类的属性显式初始化什么时候完成的?这里以一段代码为例:
class Fu {
Fu() {
// 注释的部分都是编译器内会隐式调用的部分
// super();
show();
// return;
}
void show() {
System.out.println("Fu show");
}
}
class Zi extends Fu{
int num = 8;
/*构造代码块*/
{
System.out.println("Constructor ..." + num);
num = 9;
}
Zi() {
super();
System.out.println("Zi Constructor run ..." + num);
return;
}
void show() {
System.out.println("Zi show ..." + num);
}
}
class Demo1 {
public static void main(String[] args) {
Zi z = new Zi();
z.show();
}
}
演示结果如下所示:
从结果上发现,在父类构造结束以后,才能初始化子类自己的值。这里附上一个内存区图来理解这个特点:
当执行Demo1时,子类进入内存,并且在子类中有一个super指向父类空间。特别注意一下,super不能代表一个父类对象,因为内存中只有子类,没有调父类对象,只能代表父类加载进来的所属空间。而父类中的成员已经随着子类的建立已经存储在子类的空间当中。因此当完成了默认初始化后,调用子类的构造方法,再去调用父类的构造方法。其中在父类构造方法中的show方法中的this是代表子类对象的引用!因此才会显示子类的show方法的输出结果。
总结一下一个对象实例化的过程(以Person p = new Person()为例):
1.JVM读取指定路径下的Person.class字节码文件并加载进内存,如果有直接父类,则会先加载Person的父类。
2.在堆内存中开辟空间,分配地址,并且在对象空间中,对对象的属性进行默认初始化。再调用对应的构造函数进行初始化。
3.在构造函数中,第一行会先调用父类中的构造函数进行初始化。父类初始化完毕后,再对子类的属性进行显式初始化。
4.显式初始化完毕后,对构造代码块初始化。(这一步在上面的演示结果已经得出,没有写明,反编译字节码文件亦可证明)
5.在完成以上步骤后,再进行子类构造函数的特定初始化。当初始化完毕后,将地址值赋给引用变量。
成员变量
对于成员变量,子类有该成员变量则不需要再找父类。直接上代码:
class Fu {
int num = 4;
}
class Zi extends Fu {
int num=5;
void show() {
System.out.println(num);
}
}
结果如下所示:
成员函数
对于子父类的成员函数,我觉得也就这个需要提一下:当子类覆盖父类方法时,必须要满足子类的权限大于等于父类权限。这里直接用代码演示,如下所示:
class Fu {
int num = 4;
// public protected
public void show() {
System.out.println("num = " + num);
}
}
class Zi extends Fu {
int num=5;
void show() {
System.out.println("num = " + num);
}
}
class Demo01 {
public static void main(String[] args) {
Zi z = new Zi();
z.show();
}
}
结果如下所示:
从结果可知,其以public>protected>默认权限的规范来约束继承。
④抽象类
对于抽象类,需要知道其特点、相关的细节以及使用的方式即可。先来说抽象类的特点:
1.方法只有声明没有实现时,该方法就是抽象方法,需要被abstract修饰
2.抽象方法必须定义在抽象类中,该类必须也被abstract修饰
3.抽象类不可以被实例化(调用抽象方法没意义,无方法体)
4.抽象类必须有其子类覆盖了所有的抽象方法后,该子类才可以被实例化,否则这个子类依旧是抽象类
需要知道的几个细节:抽象类方法可以不定义抽象方法,但是方法只有方法体没有内容。抽象关键字abstract不能和private、static以及final关键字共存。
⑤接口
当一个抽象类中的方法都是抽象的时候,这时就可以将抽象类用另一种形式定义和表达,即接口interface。需要明确的一点是,接口并不是类,接口是一个标准,是一个多个方法特征的集合。
需要注意的两个点就是在接口中成员属性都是常量,即通过public static final修饰属性。在接口中成员函数都是抽象的,即通过abstract修饰方法。
在JDK8以前,不能在接口中提供方法的实现。而在JDK8,接口引入了新功能:默认方法和静态方法。如果想在接口中编写方法实现,那么就需要用“default”关键字来修饰方法。但其实会发现这样又重复了早期的一个现象,子类可以看到父类的具体实现的现象,并且会增加代码的重复性以及降低安全性。那么在JDK9中为了解决JDK8的问题,接口又引进了private修饰符在接口中编写私有方法,这样就无法从接口访问或者继承私有方法到另一个接口或类中。
⑥多态
所谓的多态,就是同一个种类有不同的表现,比如说猫科类有猫和豹。那其实在Java中多态指的是同一个行为具有多个不同表现形式的能力。
多态如果要分的话其实可以分为编译时多态和运行时多态,我们常说的“多态”指的是运行时多态(下文简称多态)。编译时多态是指在编译时就已经确定了指向的具体对象以及具体的实现方法,而运行时多态则是需要在运行的时候才能确定。方法重载就是编译时多态的一种体现。
回到重点(运行时多态),多态的前提是必须要有关系(即继承或实现)并且还要有方法重写,最重要的一点是父类的引用指向子类对象。接下来以代码演示多态效果:
public class Animal {
String name = "Animal";
public void eat() {
System.out.println("动物吃饭");
}
public void sleep() {
System.out.println("动物睡觉");
}
}
public class Dog extends Animal {
String name = "Dog";
@Override
public void eat() {
System.out.println("狗吃饭");
}
@Overrid
public void sleep() {
System.out.println("狗睡觉");
}
public void watchDog() {
System.out.println("狗看门");
}
}
public class Demo02 {
public static void main(String[] args) {
// 父类引用指向子类对象 -->向上转型
Animal a = new Dog();
a.eat();
a.sleep();
// The method wachtDog() is undefined for the type Animal
// a.wachtDog();
// 要想使用Dog类的独特方法就得向下转型
Dog d = (Dog)a;
d.watchDog();
System.out.println("name = " + a.name);
}
}
结果如下所示:
我们会发现在打印名字属性的时候得到的结果却并不是和狗有关的值,这就是多态的表现。
既然讲到多态,我们还需要了解什么是向上转型以及向下转型。以一段代码为例:
public class Fu {
public int num = 10;
public void show() {
System.out.println("Father的show");
}
}
public class Zi extends Fu {
public int num = 20;
public void show() {
System.out.println("Zi的show");
}
public void method() {
System.out.println("Zi独特方法");
}
}
public class Demo01 {
public static void main(String[] args) {
Fu f = new Zi();
f.show();
System.out.println("num = " + f.num);
Zi z = (Zi)f;
z.method();
}
}
那么其实所谓的向上转型就是父类引用指向子类对象,作用是为了限制对特有功能的访问。直白地说就是父类引用无法调用子类独有的方法。那向下转型就是将指向子类对象的父类引用赋给子类引用,作用是为了能够使用子类独有的方法。
但是在实验的过程中,我们会发现父类不能直接向下转型到子类,这又是为什么呢?其实原因很简单,父类所有的可继承的成员属性和成员方法都可以在其指向的子类对象里找到,但是子类引用自己独有的属性和方法在其所属的父类不一定都能完全找到。
为了加深理解,用内存图表示向上转型以及向下转型。
解析:首先类加载机制加载Demo01类(图中未画),主方法进栈即①。到创建对象时加载Fu类和Zi类在内存中即②③,在堆内开辟一个空间存放Zi类对象即④,在堆内的对象空间中还有一个附加空间代表Fu类空间,进行两步初始化。初始化完毕后,右边运算完毕,将空间所在的地址赋给局部变量f。这里需要注意的是,f指向的是super表示的父类空间。当遇到强制类型转换时,将z指向了this代表的子类空间。当调用f.show()时就会运行子类的show方法(子类方法覆盖了父类的方法)。当show方法出栈后,从super指向的空间里获取num变量,得到父类的成员变量。向下转型后的运用同理。这就是向上转型和向下转型的内存表现。
从内存表现不难发现,运行时获取成员变量是父类的成员变量,而调用的非静态方法是调用子类的非静态方法。
因此就有了以下三句话:
1.成员变量:在编译时,判断引用型变量所属的类中是否有调用的成员变量,有则编译通过,没有则编译失败;而在运行时,判断引用型变量所属的类中是否有调用的成员变量。
2.非静态成员函数:判断引用型变量所属的类中是否有调用的函数,有则编译通过,没有则编译失败;而在运行时,判断对象所属的类中是否有调用的函数。
3.静态函数:编译运行时都判断引用型变量所属的类中是否有调用的静态方法。
总结:多态中成员变量编译和运行看赋值语句左边;非静态成员函数编译看左边,运行看右边;静态函数编译运行都看左边。
⑦内部类
内部类的内容有需要大多理解的地方,先讲其定义吧。内部类意如其名,将一个类定义在另一个类里面,对里面的那个类就称为内部类。内部类一般可以分为三种:成员内部类、局部内部类、匿名内部类。
在讲第一种内部类前,先来讲一下内部类的特点:
1.内部类可以访问外部类的成员(包括私有),而外部类要访问内部类就必须要建立内部类对象。
2.内部类可以被权限修饰符修饰。
接下来开始分别讲述四种内部类。
成员内部类
所谓的成员内部类,就是相当于外部类的成员。那么接下来用代码演示:
package it.hch.revision;
class Outer {
private int num = 5;
/* 内部类 */
/*private*/public class Inner {
private int num = 10;
void show() {
System.out.println("Inner show run ..." + num);
// 要想访问外部类的同名属性或同名方法就得使用 外部类.this.成员变量
System.out.println("Outer.num = " + Outer.this.num);
}
}
public Inner getInnerInstance() {
return new Inner();
}
public void method() {
getInnerInstance().show();
}
}
public class Demo02 {
public static void main(String[] args) {
Outer out = new Outer();
// 两种创建成员内部类对象的方法
Outer.Inner inner = out.new Inner();
Outer.Inner inner1 = out.getInnerInstance();
inner.show();
}
}
结果如下所示:
从结果来看可以很好地体现内部类的两个特点。权限符限定了内部类的访问位置,如private表示只能在外部类中使用,也即等同于一个类的私有成员。再比如protected修饰,则只能在同一个包下或者继承外部类的情况下访问内部类。
最需要注意的是,如果使用static修饰内部类,那么外部类一加载至内存,内部类也就随之加载至内存。也就是说,这个内部类相当于一个外部类。这就代表了我们可以直接使用内部类的构造方法创建对象,即如下所示:
Outer.Inner inner = new Outer.Inner();
如果使用static修饰内部类的成员方法,首先第一个需要注意的地方是内部类也要必须是static修饰。第二,当有了静态修饰后,就不需要再创建一个内部类对象去调用方法,可以直接使用类名.方法名的方式去调用,即如下所示:
Outer.Inner.show();
局部内部类
所谓的局部内部类就是定义在一个方法或者一个作用域里面的类,与成员内部类的只在于局部内部类的访问仅限于方法内或者该作用域内。这里附上代码演示(这里我们用返回类对象的方式):
class Outer {
int num = 3;
public Object method() {
final int x = 9;
class Inner {
void show() {
System.out.println("show ..." + x);
}
}
Object in = new Inner();
((Inner) in).show();
return in;
}
}
public class Demo02 {
public static void main(String[] args) {
Outer out = new Outer();
Object obj = out.method();
}
}
结果如下所示:
需要注意的一点是:Object是所有对象的父类,因此由method方法获取的内部类也就是Object的子类,那么就有了多态的两个前提。但是却少了重写方法的前提。因此如果直接用obj调用show方法是会编译报错的,因为父类并没有show方法。
但是这里更需要注意的一点是:从内部类访问局部变量,该局部变量必须声明为final类型。如果不声明为final类型,则会报以下错误:
这里说明一下为什么必须是final类型。先前说过,当我们调用方法的时候,方法会入栈。那么当我们调用method方法时,method方法就会进栈,并且内存当中就多了一个局部变量x。之后加载Inner类,执行Object in = new Inner()语句时完成初始化操作。那么当method方法执行完毕后,就会弹栈。弹栈了,局部变量x就不存在了。当我们再次访问x的时候就无法得到具体的值。因此,必须将局部变量声明为final类型,即常量。
多说一点吧,通过反编译我们可以发现,当变量的值如果在编译期间就可以确定,那么编译器就会自动地在局部内部类的常量池中添加一个内容相等的字面量,这样就可以访问这个字面量而不是访问方法中的局部变量。但是如果变量的值在编译期间无法确定(例如传递形参),那么编译器就会通过带参构造器对变量进行初始化赋值。无论是哪种情况,都必须限定final,让值能够保留以及不会造成数据不一致的问题。
匿名内部类
匿名内部类,本质上就是一个匿名子类对象。它与前两者的区别在于:内部类必须继承一个外部类或者实现一个接口。
演示代码如下所示:
abstract class Demo {
abstract void show1();
abstract void show2();
}
class Outer {
int num = 3;
public void method() {
new Demo() {
void show1() {
System.out.println("show1 ..." + num);
}
@Override
void show2() {
System.out.println("show2 ..." + num);
}
}.show1();
}
}
public class Demo02 {
public static void main(String[] args) {
Outer out = new Outer();
out.method();
}
}
结果如下所示:
从代码上看,假设内部类需要重写的方法过多,不仅冗长又难以维护。因此匿名内部类大多数的用法都是用于编写事件监听的代码。
演示了这么多,还有一个问题需要提出:内部类为什么能够访问到外部类?
通过反编译前面的代码,得到以下代码(反编译也可以使用javap -c命令):
final Outer this$0;
void show()
{
System.out.println("show ...9");
}
Outer$1Inner()
{
this$0 = Outer.this;
super();
}
编译器会自动地为内部类添加了一个指向外部类对象的引用,并且在内部类的构造方法中让这个引用指向外部类对象。注意,是指向外部类对象。如果没有创建外部类的对象,则无法对 Outter this&0 引用进行初始化赋值,也就没有内部类什么事了。这就是内部类能够肆意访问外部类的原因。
⑧Object类
默认情况下,Java中的每一个类的父类,换句话说就是Java中的顶级类。Object类中提供了很多方法,这里暂时只着重讲解其中三个方法,即hashCode()、equals()、getClass()。
在讲equals()方法前,我们先讲一下判断两个对象是否相等的代码,其如下所示:
Person p1 = new Person("张三");
Person p2 = new Person("张三");
System.out.println(p1 == p2); // ->false
当我们查看Object类中的源码,其中有一个equals方法,如下所示:
public boolean equals(Object obj) {
return (this == obj);
}
方法内部实际上也是一个this == obj,没有什么不一样的地方。也不难发现,Object中的equals方法也是直接判断当前对象和传参的obj对象是否是同一引用对象,即是否都指向同一内存空间。如果都指向同一内存空间则返回true,反之则返回false。
那么这样看上去这个方法就非常的鸡肋了,尽管看起来鸡肋但是这样可以增加类的功能性以及实际编码的灵活性。实际上使用的时候我们都是通过重写该方法来实现自己的两个对象比较,比如说String类中重写了该方法来判断内容是否相等。比如说判断用户之间是否一致,只需要判断uId是否相同即可。
看上去重写该方法即可,实际上并不是这样。我们还需要重写hashCode()方法,接下来就开始讲解何为hashCode()方法。
查看源码,得到方法如下所示:
public native int hashCode();
首先先提一下native表示该方法为本地方法,即该方法由底层语言实现,无需关注。所谓的这个方法就是返回一个整型数值,代表了调用该方法的对象的哈希码值。
那么到底这个哈希码值和equals方法有什么关系呢?其实hashCode()方法也是用于对比两个对象是否相等一致(其实这个方法还有另一个作用,现阶段不讲,等到讲集合的时候会再提一次),看起来这两个方法功能一样,但其实在效率上有不同。一般而言,重写的equals方法比较复杂,而利用哈希码值判断则效率高,但是哈希码值并不是那么的可靠。因为不同对象产生的hashCode也会一样,这是生成公式造成的。
从上面我们可以得出,一个严格的数学逻辑:
'''
两个对象相等 <=> equals()相等 => hashCode()相等
'''
从这个数学逻辑,我们就可以优先使用hashCode来比较两个对象是否相同,如果连hashCode都不同,那么对象肯定不同。反之,若hashCode相同,则再用更复杂的equals()方法判断两个对象是否相同。
知道了关系后,那又为什么要重写hashCode方法呢?其实很简单,在Object类中的hashCode()方法只是返回对象的内存地址经过处理后的结构。这样的返回其实并不能达到我们想要的结果,所以我们重写自己类的hashCode()方法。
以后还会讲hashCode,现在只需要知道重写equals方法就必须也要重写hashCode方法。
⑨Class类
在讲Class类之前,我们先来看一下Object类方法中的getClass,即
翻译过来的意思就是返回某个类已经实例化的当前对象的运行时类,那么到底什么是运行时类呢?
在Java程序运行时,JVM会识别所有对象和类的信息,即所谓的RTTI(RunTime Type Information)。而保存这些类型信息的类是Class类。而当装载类时,即类进内存时,Class类型的对象就会自动创建。而所谓的运行时类即为Class类。
其实说到底这个Class类也是一种类,其中包含的内容则是创建的类的内容。需要知道的是,这个Class类并没有公有的构造函数,即它的对象只能由JVM去实例化。除去当类进入内存时JVM自行创建相应的Class对象,还可以通过类加载器中的defineClass方法生相应的Class对象。
那么到底我们该如何获取Class对象呢?在这之前,需要知道的是,类加载器中的defineClass是将字节码加载到JVM里,然后在堆里生成一个新的Class对象,然后再返回相应的Class对象。因此除了这个方法以外,其余的方法都是建立在某个类相应的Class对象已经在堆生成的情况下直接获取的。
查看Class类的源码,不难发现,其有几个forName方法来获取相应的Class对象。实际上我们只需要关注这一个方法即可
public static Class<?> forName(String className)
throws ClassNotFoundException {
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
除了这个方法以外还有用Object类自带的getClass()方法也可以返回当前对象的相应的Class类和字面常量。
三种方法的例子如下所示:
public static void main(String[] args) {
try {
// 1 需要注意这里必须包含类名
Class c = Class.forName("it.revision.Person");
// 2
Person p = new Person();
Class c1 = p.getClass();
// 3
Class c2 = Person.class;
System.out.println(c == c1 && c1 == c2);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
结果如下所示:
那么到底获取这个Class类到底有什么用呢?这里暂时只讲利用这个Class类来生成相应类的实例。在Class类的源码可以看到有以下方法:
public T newInstance();
我们可以通过这个方法来生相应类的实例,T是泛型表示法,如果没有指定是什么类型则默认返回一个Object。演示代码如下所示:
public static void main(String[] args) {
try {
Class c = Class.forName("it.hch.revision.Person");
Object obj = c.newInstance();
Person p = (Person)obj;
p.setName("zhangsan");
System.out.println("默认情况:" + p);
Class<Person> c1 = Person.class;
Person p1 = c1.newInstance();
p1.setName("lisi");
System.out.println("指定类型:" + p1);
} catch (Exception e) {
e.printStackTrace();
}
}
结果如下所示:
⑩异常类
异常是导致程序中断运行的一种指令流,简单来说就是在程序运行时发生的不正常情况。那么在Java中通过类的形式来描述各种不同的不正常情况,而这些不正常情况都有一个共同的父类Throwable。接下来我们来看一下Java中异常类层次划分。
异常类结构
从上图可以看到,Exception和Error都继承Throwable类。对于Error而言,其代表了程序自身无法处理的错误。换句话说,就是程序在正常运行时,产生了不太可能发生的情况,比如OutOfMemoryError之类的错误。
而对于Exception,其代表了程序自身可以处理的异常。换句话说,就是程序在正常运行时,能够预料到的意外情况,并且可能会被捕获加以处理。在Java中异常分为可检查异常(checked exceptions)和非检查异常(unchecked exceptions)。
那么这两者异常的区别在哪里?所谓的可检查异常即为在源代码中必须显式地对异常进行捕获处理,换句话来说,就是编译器会检查此类异常,如果不处理这类异常就无法通过编译;而非检查异常即为运行时异常,换句话来说就是编译器不会强制要求检查该类异常。也就是说,可以根据具体的需求判断是否需要捕获并且处理这类异常。
这里需要注意:NoClassDefFoundError和ClassNotFoundException的区别。
NoClassDefFoundError产生的原因在于没有找到类的定义,也就是说通过了编译期,运行的时候却找不到了,就会产生这个错误;而ClassNotFoundException则是因为Java支持使用Class.forName方法来动态地加载类,任意一个类的类名如果被作为参数传递给这个方法都将导致该类被加载到JVM内存中,如果这个类在类路径中没有被找到,那么此时就会在运行时抛出ClassNotFoundException异常。
异常处理机制
在Java中异常处理机制分为抛出异常和捕捉异常。抛出异常就是当在执行方法的过程中发生了异常,则就生成代表该异常的对象并停止当前运行,最后将这个异常对象提交给JRE。而捕捉异常就是方法抛出异常后,寻找相应的异常处理器来处理该异常,其实就是Throw early, catch late原则。对于抛出异常和捕获异常都有相应的语句来实现,我们先来看捕获异常的语句的语法格式:
try {
语句1;语句2;
} catch (Exception e) {
异常处理语句;
}
我们称try块的部分为监控区域,意思就是可能会发生异常的代码的部分。如果在运行过程中出现了异常,就会创建相应的异常对象,然后将异常抛出监控区域去匹配catch子句的异常类来捕获异常,最后运行相应的异常处理代码。那么怎么样才算是匹配的异常类呢?其实就是抛出的异常对象属于异常类亦或者是属于异常类的子类,那么都算生成的异常对象与捕获的异常类相匹配。
接下来分别以几段代码为例来阐述捕获异常中涉及的一些规则,第一段如下所示:
public class Demo01 {
public static void main(String[] args) {
int a = 5;
int b = 0;
method(a, b);
}
public static void method(int a, int b) {
try {
System.out.println("a/b = " + a / b);
} catch (ArithmeticException e) {
System.out.println("除数不能为0");
} finally {
System.out.println(a);
System.out.println("finally块执行");
}
}
}
当去除捕获异常的代码块,只剩下try块中的代码,会发现ArithmeticException异常就是属于一个非检查异常(即运行时异常)。当输入的除数不为0时,也就是说没有异常,也就没有捕获异常什么事了,但是finally块中的代码依旧会执行。因此会看到如下结果:
5
finally块执行
那么这里我们就有一个问题了,假如在finally块中又有异常抛出的时候,finally块中的代码是否会执行?比如说如下所示的代码:
public static void method(int a, int b) {
try {
System.out.println("a/b = " + a / b);
} catch (ArithmeticException e) {
System.out.println("除数不能为0");
} finally {
System.out.println("finally块执行start");
System.out.println("a/b = " + a / b);
System.out.println("finally块执行end");
}
}
运行结果如下所示:
从结果可以看出,当finally块也有异常抛出的时候,抛出异常的那一瞬间也是停止了当前的运行,而后续的代码都没有被执行。也就是说finally块中的代码并没有执行完毕。
那接着再来看一段代码;
public static int method1(int a, int b) {
try {
System.out.println("a/b = " + a / b);
return a;
} catch (ArithmeticException e) {
System.out.println("除数不能为0");
return b;
} finally {
System.out.println("finally块执行start");
System.out.println("finally块执行end");
// return b + 10;
}
}
分别在try、catch以及finally块中加入了return语句,查看其最终结果,如下所示:
取消finally块中的注释后再运行最后一行结果则为10。
从结果我们不难发现,当try...catch块中有return语句时,会在return语句结束之前,触发finally块的代码。并且从结果来看,finally块的return语句覆盖了前面出现的return语句。
接下来将返回值改为引用类型,代码如下所示:
public static Person method2(Person p) {
try {
System.out.println("p.age = " + p.getAge() );
return p;
} finally {
p.setAge(20);
}
}
public static void main(String[] args) {
Person p = new Person("zhangSan", 18);
System.out.println("p.age = " + method2(p).getAge());
}
结果如下所示:
通过这几段代码以及结果可以总结出以下几个规则:
1. try块用于捕获异常,后续可以跟零至多个catch块。
2. 当没有catch块时,try块后必须跟finally块
3. 前面出现return语句时,会先执行finally块,再执行return语句。尽量不要在try块中使用return、break等语句。
4. 在finally块中不要出现return语句或者对返回的值进行修改
这里还得再额外多加两条规则,在实际开发中,不要在catch子句中统一捕获Exception类,并且也不要将异常生吞(即忽略异常处理),也就是说不应该出现以下代码:
try {
//监控区域
} catch (Exception e) {
//不做任何事
}
最后需要知道的是try-catch代码段会产生额外的性能的开销,也就是说会影响JVM对代码进行优化,所以尽量不要在try块中包含过多无需检查异常的代码。并且需要知道的是在Java中只要每实例化一个Exception,都会对当时的栈进行快照,也就是说,如果发生频繁,就会导致开销过于庞大。因此如果没有必要的话,尽量不要使用异常。
后话
'''
这些内容只是我觉得比较重要的部分,还有一些可能没有想到的 希望能补充
原本想将Class类放到反射机制的时候再讲 想了想
这个内容更可以方便理解面向对象 还是往前放在这一章
'''