前言
本文是Java基础回炉文集的第二篇,关于文集可通过《Java基础回炉和提升暨文集开篇》了解。
今天这篇文章从类和对象的基本概念出发,一方面,深入探讨了内部类,通过对比的方式介绍了不同类型的内部类的定义、特点、使用场景以及使用时的注意事项,更进一步了解了内部类相关特性的内在原理。另一方面,详细的介绍了类的整个生命周期,彻底弄清楚类从加载到消亡的全过程。
1. 概述
1.1 类和对象的概念
(1)类:类是对某种类型的事物的公共属性和行为的抽取,他并不是实际存在的实体。
(2)对象:对象是类的一个实例,代表现实世界中可以明确标识的一个实体。
可以这么理解:在现实世界中我们会用“高富帅”、“白富美”描述一种群体,这只是一种描述而没有具体的指向。而志玲姐姐是个白富美,她就是个实体例子,也就是对象。
(3)匿名对象:没有引用类型变量指向的对象称作为匿名对象。
- 实例:使用 java类描述一个学生类。
new Student()
,左边并没有引用类型变量指向这个对象; - 匿名对象要注意的事项:
a. 我们一般不会给匿名对象赋予属性值,因为永远无法获取到;
b. 两个匿名对象永远都不可能是同一个对象。new
了两次,地址肯定不一样。 - 匿/名对象好处:简化书写。
- 匿名对象的应用场景:
a. 如果一个对象需要调用一个方法一次的时候,而调用完这个方法之后,该对象就不再使用了,这时候可以使用匿名对象。new Student().study()
;
b. 可以作为实参传入构造函数,装饰者模式中经常用到;
所以,类可以看作是一个模板,而对象则是类的一个实例(类看作是一张图纸,对象则是按照图纸生产的产品)。
1.2 抽象类
(1)什么是抽象类
抽象类是不能实例化的类,用abstract
关键字修饰class
,其目的主要是代码重用。除了不能实例化,形式上和一般的Java
类并没有太大区别,可以有一个或者多个抽象方法(没有方法体,用abstract
修饰),也可以没有抽象方法。抽象类大多用于抽取相关Java
类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。
(2)什么时候用抽象类:
描述一类事物的时候,发现这类事物确实存在着某种行为,但是目前这种行为是不具体的,这时候应该抽取这种行为的声明,而不去实现该种行为,这时候这种行为我们把它称为抽象的行为,这时候应该使用抽象类。具体的行为在其子类中实现。
(3)抽象类要注意的细节:
a. 如果一个方法没有方法体,那么该方法必须使用abstract
修饰。
b. 如果一个类含有抽象方法,那么这个类肯定是一个抽象类或者接口。
c. 抽象类不能创建对象。
d. 抽象类是含有构造方法的。
e. 抽象类可以存在非抽象方法与抽象方法。
f. 抽象类可以不存在抽象方法。
g. 非抽象类继承抽象类的时候,必须要把抽象类中所有抽象方法全部实现。言外之意,抽象类继承抽象类可以不全部实现。
(4)关于abstract
的细节
a. abstract
不能与static
共同修饰一个方法。static
修饰的方法能被类直接调用,而abstract
修饰的方法没有方法体,有冲突。
b. abstract
不能与private
共同修饰一个方法。 abstract
修饰的方法,非抽象类继承抽象类必须实现所有的抽象方法,私有不能被继承
c. abstract
不能以final
关键字共同修饰一个方法。final
修饰的方法不能被重写,这与抽象方法需被子类实现相违背。
1.3 内部类
在类的内部定义的另一个类叫做内部类,又根据内部类的定义的具体位置分为成员内部类和局部内部类。
(1)使用内部类的好处:
- 间接实现多重继承:
/*我们知道,在java中一个类可以多重实现,但不能多重继承。但有时候我们确实是需要实现多重继承,
例如:我们即继承了父亲的行为和特征也继承了母亲的行为和特征。
那么我们有没有方法解决多重继承的问题呢?内部内就提供了一种曲线实现多重继承的方式
*/
public class Father {
public int strong(){
return 9;//强壮指数
}
}
public class Mother {
public int kind(){
return 8;//善良指数
}
}
//子类通过内部类实现多重继承
public class Son {
//继承Father的内部类
class FromFather extends Father{
public int strong(){
return super.strong() + 1;
}
}
//继承Mother的内部类
class FromMother extends Mother{
public int kind(){
return super.kind() - 2;
}
}
public int getStrong(){
return new FromFather().strong();
}
public int getKind(){
return new FromMother().kind();
}
}
-
内部类可以很好的实现隐藏:一般的非内部类,只能用
public
和default
,是不允许有private
与protected
权限的,但内部类可以; -
减少了类文件编译后的产生的字节码文件的大小:(内部类在编译完成后也会产生.class文件,文件名称是:
外部类名称$内部类名称.class
)
(2)使用内部类的缺点:程序结构不清楚
(3)内部类又分为:成员内部类、局部内部类和匿名内部类。
1.3 深入理解内部类
(1)为什么内部类可以访问外部类的成员?
这篇博文《深入理解Java中为什么内部类可以访问外部类的成员》通过反编译内部类的字节码, 说明了内部类是如何访问外部类对象的成员的。
分析之后其实也很简单, 是编译器在背后做了许多工作,主要是通过以下几步做到的:
**编译器自动为内部类添加一个成员变量
final Outer this$0
; **, 这个成员变量的类型和外部类的类型相同, 这个成员变量就是指向外部类对象的引用;(非静态内部类对象有着指向其外部类对象的引用)编译器自动为内部类的构造方法添加一个参数
Outer$Inner(Outer)
; , 参数的类型是外部类的类型, 在构造方法内部使用这个参数为1中添加的成员变量赋值;在调用内部类的构造函数初始化内部类对象时, 会默认传入外部类的引用。
(2)为什么局部内部类(包括匿名内部类)访问了外部类的方法的形参,该变量需要使用final
修饰?
首先要明确:
- 成员函数的形参是也局部变量。
-
并不是所有的局部变量都需要被
final
修饰,只是被内部类引用的局部函数形参才需要被final
修饰。 - 内部类并不是直接调用方法传进来的参数,而是内部类将传进来的参数通过自己的构造器备份到了自己的内部,自己内部的方法调用的实际是自己的属性而不是外部类方法的参数。
这样理解就很容易得出为什么要用
final
了,因为两者从外表看起来是同一个东西,实际上却不是这样,如果内部类改掉了这些参数的值也不可能影响到原参数,然而这样却失去了参数的一致性,因为从编程人员的角度来看他们是同一个东西,如果编程人员在程序设计的时候在内部类中改掉参数的值,但是外部调用的时候又发现值其实没有被改掉,这就让人非常的难以理解和接受,为了避免这种尴尬的问题存在,所以编译器设计人员把内部类能够使用的参数设定为必须是final
来规避这种莫名其妙错误的存在。”
——引自: Java内部类的使用小结 形参为什么要用final
2. 类的生命周期
一个java的源文件,经过编译后生成后缀名为.class的文件,即字节码文件,java虚拟机就识别这种文件,Java程序的生命周期就是class文件从加载到消亡的过程。
2.1 加载
字节码文件并不是本地的可执行程序,当运行Java程序时,首先运行JVM,然后再把字节码文件加载到JVM中运行。
类的加载过程其实就是将字节码文件中的二进制数据读入到内存中,首先Java虚拟机找到需要加载的class文件,并把类的信息加载到“运行时数据区”的方法区中,然后在堆区中(堆在JVM启动时就创建)实例化一个java.lang.Class
对象,用来封装类在方法区内的数据结构,并作为方法区中这个类的信息的入口(每个类都是Class类的实例)。
(1)类加载器
类的加载工作由类加载器来完成,它负责读取.class
文件并转换成java.lang.Class
类的一个实例,加载到内存中,JVM启动时,会形成由三个类加载器组成的初始类加载器层次结构。
BootStrap
(启动类加载器): 加载jdk/jre/lib/rt.jar
(开发的时候使用的核心jar
包);ExtClassLoader
(扩展类加载器):加载jdk/jre/lib/ext/*.jar
(扩展包);AppClassLoader
(应用程序加载器):加载CLASSPATH
中的jar
包和class
文件;自定义Class-loader:继承
ClassLoader
,程序猿自定义编写的加载器。
/*AppClassLoader和ExtClassLoader都继承于URLClassLoader*/
public abstract class ClassLoader
public class SecureClassLoader extends ClassLoader
public class URLClassLoader extends SecureClassLoader
static class AppClassLoader extends URLClassLoader
static class ExtClassLoader extends URLClassLoader
除了启动类加载器Bootstrap ClassLoader
,其他的类加载器都是ClassLoader
的子类。Bootstrap ClassLoader
使用C++写的。
Application ClassLoader
的Parent
是Extension ClassLoader
,而Extension ClassLoader
的Parent
为Bootstrap ClassLoader
。加载一个类时,首先BootStrap
进行寻找,找不到再由Extension ClassLoader
寻找,最后才是Application ClassLoader
。(这里的Parent并不是继承体系,而是委派体系)
(2)类加载器特征
全盘负责
一个类A是由一个类加载器加载的,如果A中关联(继承或包含)到其他的非系统类,那么类B也是由该类加载器加载。-
类加载器双亲委托加载机制
机制:如果一个类加载器收到一个类加载请求,该加载器不会自己去尝试加载这个类,而是把这个请求转交给父类加载器,先委托其父类加载器,如果还有父类加载器就继续委托,直到没有父类加载器为止,最顶层的类加载器(启动类加载器)就需要真正的去加载指定类,如果在其类目录中找不到这个类,继续往下找,找到发起者类加载器为止,若找不到则报ClassNotFound错误。双亲委派的好处:防止有些类被重复加载,有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。
2.2 连接
一般会跟加载阶段和初始化阶段交叉进行,这个阶段的主要任务就是做一些加载后的验证工作以及一些初始化前的准备工作,过程由三部分组成,即验证、准备和解析三步:
(1)验证
当类被加载之后,必须要验证一下这个类是否合法,比如该类是否符合字节码格式规范,变量与方法是不是有重复、数据类型是不是有效,继承与实现是否合乎标准等等。总之,这个阶段的目的就是保证加载的类是能够被jvm所运行。
(2)准备
准备阶段的工作就是为类的静态变量分配内存并设为jvm默认的初值(在方法区分配),对于非静态的变量,则不会为它们分配内存(实例化变量在对象实例化步骤中分配内存)。有一点需要注意,这时候,静态变量的初值为jvm默认的初值,而不是我们在程序中设定的初值。jvm默认的初值如下:
- 八种基本数据类型默认的初始值是0
- 引用类型默认的初始值是
null
- 有
static final
修饰的会直接赋值,例如:static final int x=10;
则默认就是10.
(3)解析
这一阶段的任务就是把常量池中的符号引用转换为直接引用,就是jvm会将所有的类或接口名、字段名、方法名转换为具体的内存地址(通过内存地址才能直接找到符号指向的内容)。
2.3 初始化
这个阶段就是将静态变量(类变量)赋值的过程,即只有static修饰的才能被初始化,执行的顺序就是:父类静态域或静态代码块,然后是子类静态域或者子类静态代码块;(在一个类中的静态内容则按照顺序执行)
2.4 使用
在类的使用过程中依然存在三步:对象实例化、垃圾收集、对象终结。这三个过程也就是对象的生命周期。
(1)对象实例化
- 在堆区分配对象需要的内存
分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量 - 对所有实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值 - 执行实例初始化代码
初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块(非静态语句块)然后是构造方法 - 如果有类似于
Child c = new Child()
形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它.
(2)垃圾收集
当对象不再被引用的时候,就会被虚拟机标上特别的垃圾记号,在堆中等待GC回收
(3)对象的终结:对象被GC回收后,对象就不再存在,对象的生命也就走到了尽头
注意:静态变量和静态代码块是在初始化过程中赋值和执行的,所以它是优先于非静态成员存在于内存中(在new之前,静态变量就已经被赋值,静态代码块就被执行了),当父类和子类的所有静态内容执行完了之后,再执行父类非静态代码块和构造函数,最后执行子类的非静态代码块和构造函数。
2.5 类卸载
即类的生命周期走到了最后一步,程序中不再有该类的引用,也就是说类所会被JV对应的Class对象没有被引用的时候,JVM就会执行垃圾回收,从此生命结束。
3. 对象的生命周期
清楚了类的生命周期,而对象的生命周期则在类的生命周期之中,也就是类的使用阶段。下面两篇博文对相关内容分析的非常到位,值得反复阅读:
这里我将对象的实例化过程总结一下:
当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的实例变量及其从父类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照程序猿的意志进行初始化。
在Java对象初始化过程中,主要涉及三种执行对象初始化的结构,分别是 实例变量初始化、实例代码块初始化 以及 构造函数初始化。
总的来说,类实例化的一般过程是:父类的类构造器<clinit>() -> 子类的类构造器<clinit>() -> 父类的成员变量和实例代码块 -> 父类的构造函数 -> 子类的成员变量和实例代码块 -> 子类的构造函数。
4. 面试相关
面试相关这一小节,我结合自己的面试经历,整理出了关于类和对象的面试题,当然不够全面,可以随时补充。在面试的时候,可以稍微提前准备下。
4.1 类的生命周期,是如何被加载的?
关于类的生命周期,在文中已经分析的非常详细,相信大家在面试的过程中能将整个过程描述出来就没有什么大的问题。
4.2 什么是双亲委派加载,有什么优点?
双亲委派是面试中比较热点的问题,该面试问题的要点是类加载器收到一个类加载请求,该加载器不会自己去尝试加载这个类,而是把这个请求转交给父类加载器,先委托其父类加载器。能回答到问题的精髓,一般没有什么问题。
有时候为了加深对具体机制的映象,我们会去看看源码,关于双亲委派是如何实现的,我们可以从java.lang.ClassLoader中的loadClass(String name)
方法的代码中分析出虚拟机默认采用的双亲委派机制到底是什么模样:
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
//如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else { // 递归终止条件
// 由于启动类加载器无法被Java程序直接引用,因此默认用 null 替代
// parent == null就意味着由启动类加载器尝试加载该类,
// 即通过调用 native方法 findBootstrapClass0(String name)加载
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器不能完成加载请求时,再调用自身的findClass方法进行类加载,
//若加载成功,findClass方法返回的是defineClass方法的返回值
// 注意,若自身也加载不了,会产生ClassNotFoundException异常并向上抛出
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
通过上面的代码分析,我们可以对JVM采用的双亲委派类加载机制有更加深入的认识。
4.3 抽象类和接口有什么区别?
这个问题也是个比较基础,但是容易被问到的问题,接口的总结将在后面的文章中写到,所以大家可以考虑下应该从哪些角度去回答这个问题,我将在后面的文章中回答这个问题。
5. 总结
本文是Java基础回炉文件的第二篇,这篇文章由浅入深的了解了类和对象的相关知识。读完这篇文章的收获应该是:
- 类和对象的基本概念
- 抽象类和内部类的特点
- 类和对象的生命周期
- 类的加载机制