Java 的工作方式
- 编写Java源代码。 → “.java文件”
- 编译器对源代码文件进行编译工作,编译过程中,如果源代码编写存在隐患,则会得到编译时异常。
- 如果编译工作通过,则得到一份计算机可执行的字节码文件。→ “.class文件”
- JVM(JAVA虚拟机)对字节码文件进行读取与执行,也就是让我们的代码跑起来。
Java 的内存区域划分
-
程序计数器
1、首先这是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
2、实际上虚拟机在执行我们所编写的代码时,其本质就是将编译后的字节码文件,解析成为一条条计算机能够识别的“指令”进行工作。
3、所以在字节码解释器进行工作时,实际上就是通过改变该计数器的值来选取下一条需要执行的字节码指令(包括分支,循环,跳转,异常处理,线程恢复等)。
4、由此,我们也就可以理解它为什么可以被看做字节码文件执行的行号指示器了。
5、最后,因为Java中的多线程实际是通过,轮流切换并分配CPU执行时间的方式实现。所以为了线程切换后能够恢复到正确的执行位置,每条线程中程序计数器都是独立存在的。它们之间互不影响,独立存储,所以称这类内存区域为“线程私有”的内存。
-
虚拟机栈
1、也就是我们常说的“栈”内存,同样为“线程私有”,生命周期与线程一致。
2、栈内存事实上描述的是Java方法执行的内存模型,每个方法被执行时,就会创建一个栈帧(stack frame)用于存储”局部变量表”,”操作数栈”,”动态链接”,”方法出口”等信息。
3、每一个方法从调用到执行完毕的过程,正是对应着一个栈帧从入栈到出栈的过程。
4、编译期可知的各种”基本数据类型”,”对象引用类型”以及”returnAddress类型”都存放在虚拟机栈中的局部变量表当中。
5、以上种种,最终也归纳出了我们常说的栈内存的特点:
(1)、用以存放局部变量(8种基本数据类型以及对象引用类型)
(2)、其生命周期(从方法被执行,创建栈帧入栈开始,到栈帧出栈,方法结束)是确定的。
(3)、所需的存储空间大小(64位长度的long和double类型占两个局部变量空间,其余的数据类型只占1个)是确定的。
-
本地方法栈
1、与虚拟机栈所发挥的作用非常相似,其区别在于:虚拟机栈为JVM执行Java方法(也就是字节码)服务,而本地方法栈为虚拟机执行Native方法而服务。
-
Java 堆
1、大多数时候,堆内存都是Java虚拟机所管理的内存中最大的一块。
2、与“程序计数器”与“虚拟机栈”的线程私有的特征不同,堆内存被所有线程所共享。
3、所有在程序中创建的对象实例、数组,都要在堆上进行内存分配。实际上,在编码的过程中,最常使用到的正是一个个对象,由此我们也可以理解为什么堆内存是所占开销最大的一块区域。
4、我们通常都知道,Java与其它一些语言不同,我们不用人为的去管理对象的内存开创及销毁。它们有垃圾管理器自动回收,而Java堆正是垃圾管理器管理的主要区域,因此其很多时候也被称为GC堆。
-
方法区
1、与“堆”内存一样,方法区也是被各个线程共享的区域。
2、方法区的主要工作是用于存储虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。
-
运行时常量池
1、该区域是方法区的一部分。
2、一份字节码(.class)文件中,除了存放有类的版本,字段,方法,接口等描述信息(这部分信息在类被装载器装载之后,被加载到方法区中存放)外。还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用(http://hxraid.iteye.com/blog/687660),这部分内容则会在类加载后进入方法区的运行时常量池存放。
3、所以运行时常量池又被称为字节码文件常量池的运行时表现。
4、不过,与字节码文件常量池不同的一个重要特征则是,运行时常量池具备动态性。因为Java当中并不要求常量一定只有在编译期才能产生,在在运行时也可以有新产生的常量进入方法区运行时常量池。
别名现象
Java中的别名现象,通常是指在对象赋值时产生的一种问题。看下面一段代码:
public class Test {
public static void main(String[] args) {
Person p1 = new Person("张三");
Person p2 = new Person("李四");
printName(p1, p2);
p1 = p2;
printName(p1, p2);
p1.setName("王五");
printName(p1, p2);
}
static void printName(Person p1, Person p2) {
System.out.println(p1.getName() + "//" + p2.getName());
}
}
class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Person(String name) {
this.name = name;
}
}
我们来看一下程序打印出的运行结果:
张三//李四
李四//李四
王五//王五
我们来整理一下思路,按照常理来讲,我们可能是这样考虑的:
- 首先,我们创建了两个Person对象,p1名为“张三”,p2名为“李四”。
- 之后我们想要将p2的身份信息赋值给p1,于是我们照做了“p1=p2”。根据打印结果,我们发现我们也达到了我们的目的。
- 但新的问题出现了,这时我们想要为p1再次换上一个全新的身份信息“王五”。却发现p2的身份信息也被改变了。
- 由此也就出现了所谓的“别名现象”,与8种基本数据类型的赋值机制不同。Java中对象的赋值实际上并非是在赋“值”,而是在赋“引用”。也就是所谓的“p1=p2”的赋值操作,实际上只是把指向“堆”当中的p2对象的内存地址的引用,赋给了p1.
- 所以,取名为“别名现象”真是太贴合了。现在就是虽然p1和p2两个不同名字的对象引用,实际上都是指向同一个对象,所以自然改变其中一个的属性,另一个也会受到影响。就如同“李明”,小名又叫做“小明”,但实际上都是指同一个人。李明生病了,小明自然也就生病了。
传值还是传引用?
由上一个谈到的“别名现象”,还可以引申出一个面试中比较常见的更有趣的问题:
“Java的参数传递方式,究竟是传值还是传引用?“。实践出真知,我们来一步步验证一下。
首先,我们来通过基本数据类型的参数传递来验证一下:
public class Test {
public static void main(String[] args) {
int a = 10;
System.out.println("1:" + a);
passValue(a);
System.out.println("3:" + a);
}
static void passValue(int num) {
num = 20;
System.out.println("2:" + num);
}
}
同样首先来查看一下打印结果:
1:10
2:20
3:10
分析一下我们在程序中所做的:
- 首先,定义了一个int型的变量“a”,赋值为10。
- 接着,我们将变量“a”传递给了方法”passValue”。在该方法中,我们将接受到的参数的值改变为20。
- 方法执行结束后,我们打印“a”的值,发现仍然为10。
- 由此,我们发现这里参数传递方式,应该是“传值”无误。
抱着严谨的态度,我们再通过对象引用类型的参数传递来加以验证:
public class Test {
public static void main(String[] args) {
Person p = new Person("张三");
System.out.println("1:" + p.getName());
passValue(p);
System.out.println("3:" + p.getName());
}
static void passValue(Person p1) {
p1.setName("李四");
System.out.println("2:" + p1.getName());
}
}
class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Person(String name) {
this.name = name;
}
}
再次查看程序的打印结果:
1:张三
2:李四
3:李四
这似乎有点奇怪了,我们再来分析一下:
- 首先,我们同样是定义了一个对象数据类型的变量p,设置其name为“张三”。
- 接着,我们将变量“p”作为参数传递给了方法passValue。在该方法中,我们将接收道的参数的name值改变为了李四。打印结果也得到了验证。
- 但问题在于,通过上一个基本数据类型的参数传递方式验证,我们得到的结论是“传值”传递。那么,这里我们再次打印变量“p”的name值时,就应该仍然为“张三”才对。然后结果却并不如此。
- 根据该用例的验证结果,似乎参数的传递方式似乎又应该为“传引用”才对。
那么,我们是不是可以得出这样一种结论:
在Java中,对于基本数据类型的参数传递,采取的是“传值”的方式,而对于对象数据类型的参数传递,则采取的是“传引用”的方式。
似乎通过之前对于Java内存区域的分配特点,以及“别名现象”提到的“对象赋值”的情况。这样的结论似乎站得住脚。
因为作为局部变量的基本数据类型都在“虚拟机栈中”以值的方式存放着;而因为Java中对象赋值,采取的是传递“指向对象内存地址”的引用的方式。所以它们分别对应着“传值”与“传引用”的方式。
理论上,这样似乎并没有错,但下面一种情况似乎又让人凌乱了,修改上一个例子的代码如下:
public class Test {
public static void main(String[] args) {
Person p = new Person("张三");
System.out.println("1:" + p.getName());
passValue(p);
System.out.println("3:" + p.getName());
}
static void passValue(Person p1) {
Person p2 = new Person("李四");
p1 = p2;
System.out.println("2:" + p1.getName());
}
}
class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Person(String name) {
this.name = name;
}
}
我们再来查看程序的打印结果:
1:张三
2:李四
3:张三
我靠,说好的“传引用”呢?感觉不会再爱了。得了,还是再来分析下到底什么情况吧:
- 因为有上一个例子的分析,我们废话少说。问题就出在:在passValue方法中,我们创建了一个新的对象p2,name设置为“李四”。
- 接着,我们将引用p2赋值给了我们接收到的参数p。接着,从打印结果,我们也发现我们接收到的对象引用p所指向的对象的name值的确被改变了。
- 但回过头当我们再次打印用来传递的参数p的值时候,发现,其name值仍然为“张三”,并没有改变。
其实,出现这样的问题,只要对我们谈到的所谓的“别名现象”的原理理解清楚了的话,也不难理解:
- 我们说到了,Java中的对象赋值实际采用的是将指向“堆内存”中的对象的内存地址的引用进行赋值的操作。
- 所以,在所谓的对象参数传递的“传引用”的方式中,实际上也是将这个对象的内存地址进行“拷贝”而传递。
- 那么,以上面第二个例子来说,我们将引用“p”作为参数传递给了方法“passValue”。所以passValue方法中使用到的引用“p1”也就是和参数”p”指向了同一块内存区域。所以针对于p1所作的操作,实际也作用到了p。
- 而在最后一个例子中,在passValue方法接收到参数后,“p1”与“p”同样是指向同一个对象的。而在经过“p1 = p2;”的执行过后,实际上就是将引用“p2”所指向的对象的内存地址赋值给了“p1”.所以“p1”现在就和“p2”指向同一个对象了。它们之间的改变会互相影响,而“p”则是指向原本的对象,自然不会受到改变。
由此,我们可以得出结论,对于Java中的参数传递方式:
- 对于基本数据类型来说,是进行“传值”传递。
- 而对于对象来说,与其说是“传引用”,更准确来说,不如说是在传“引用所指向的对象在堆当中的内存地址”。
- 而所谓的地址实际上也一串二进制数字。这也就是为什么网上有关于这个问题得出的结论是“Java只有一种参数传递方式,那就是传值传递”的原因了。
for 循环的工作机制
Java中的for循环(包括while及do-while语句)通常都用于程序中的迭代运算。
相比之下,for循环的迭代机制更加复杂一点,弄清其细节,也是加深和巩固Java基础的不错的办法。
for循环语句的标准格式如下:
for( initialization; Boolean-Expression; Step-Operation )
statement;
也就是说,一个for循环表达式通常由initialization(初始化表达式),Boolean-Expression(布尔表达式)以及Step-Operation(步进运算)构成。
那么,顾名思义:
- 初始化表达式 是在for循环开始之前,用于初始化循环变量值的表达式。它只会在循环开始的时候被执行一次。
- 布尔表达式 则是在每次迭代运算开始之前,都会先执行该表达式,得到一个boolean类型的返回结果。只有当该结果的值为true时,才会继续循环;反之则结束循环。
- 步进运算 通过其名字就可以想象到:它是在一次循环执行完毕过后执行的运算,用以推进下一步的迭代工作。
通过概念了解了for循环的工作机制过后,我们通过一点代码来加以验证,加深印象。
/*
* 程序输出结果为:
*
* 初始化表达式执行
* 布尔表达式执行
* 执行循环...
* 步进运算
* 布尔表达式执行
* 执行循环...
* 步进运算
* 布尔表达式执行
* 执行循环...
* 步进运算
* 布尔表达式执行
*/
public class Test {
private static int i = -1000;
public static void main(String[] args) {
for (i = initialization(); booleanExpression(i, 3); stepOperation()) {
System.out.println("执行循环...");
}
}
private static int initialization() {
System.out.println("初始化表达式执行");
return 0;
}
private static boolean booleanExpression(int value, int target) {
System.out.println("布尔表达式执行");
return value < target;
}
private static void stepOperation() {
System.out.println("步进运算");
i++;
}
}
也许这样还不够生动,那么我们再以网上流传的一道面试题来加以分析,以加深理解。
/*
What is the result?
A. ABDCBDCB
B. ABCDABCD
C. Compilation fails.
D. An exception is thrown at runtime.
*/
public class Demo {
static boolean foo(char c) {
System.out.print(c);
return true;
}
public static void main(String[] args) {
int i = 0;
for (foo(‘A‘); foo(‘B‘) && (i < 2); foo(‘C‘)) {
i++;
foo(‘D‘);
}
}
}
我们已经了解了for循环的运算机制,相信只要静下心加以分析,很容易就能得到结果。
- 首先,不用多加考虑,for循环开始之前,初始化表达式”foo(‘A’)”一定会得到执行。于是我们首先得到输出结果”A”。
- 初始化表达式执行结束后,每次循环开始之前会先执行布尔表达式进行判断,于是得到输出结果”B”
- 布尔表达式运算的结果为true。于是循环体得到执行,将得到输出”D”。
- 首次循环执行完毕,于是将执行步进运算。那么,得到输出”C”。
- 在下一次的循环开始之前,仍然要进行布尔表达式的运算判断,那么再次得到输出”B”。
- 同理,再次执行循环,得到输出”D”。
- 相同的,再次执行步进运算,得到输出”C”。
- 你已经觉得很烦了,很显然的,当然仍然是在循环执行之前,执行布尔表达式的运算,于是得到输出”B”。但这个时候,变量”i”在经过两次for循环的迭代运算后,值变为了2;于是布尔表达式当中的”i<2”的结果为false。那么,最终的结果则是该for循环表达式,执行到此则结束了。
- 其实关于Java中for循环的机制,最容易混淆的也就是这一点。因为很多时候,我们会想当然的觉得,当循环体第二次被执行i++,i已经等于2后,是否循环就会结束了,从而忽略了再一次的步进运算及布尔表达式。从而得到结果:ABDCBD.
所以,最终我们得到的该段程序的输出结果则应该是:ABDCBDCB,故选择答案A。
break与continue的区别
简单的归纳来说:
- break与continue语句都可以用于控制循环的流程。
- break语句用于强行退出当前整个循环。
- continue语句则用于结束此次迭代运算,然后退回循环起始处,开始下一次循环。
- 而对于for循环来说,continue语句实际就是让此次迭代运算在该处结束,直接进入步进运算,然后再次执行布尔表达式运算,开始下次的循环执行。
通过代码我们依旧可以更加形象的对这两种流程控制语句的特点加以理解。
假设我们现在希望有1-10000的数进行循环打印,我们希望每打印9个数进行换行,而不打印第十个数。当打印到100的时候结束。
那么,通过break和continue就可以很容易的完成这样的需求:
/*
1 2 3 4 5 6 7 8 9
11 12 13 14 15 16 17 18 19
21 22 23 24 25 26 27 28 29
31 32 33 34 35 36 37 38 39
41 42 43 44 45 46 47 48 49
51 52 53 54 55 56 57 58 59
61 62 63 64 65 66 67 68 69
71 72 73 74 75 76 77 78 79
81 82 83 84 85 86 87 88 89
91 92 93 94 95 96 97 98 99
*/
public static void main(String[] args) {
for (int i = 1; i < 10000; i++) {
if (i == 100)
break;
if (i % 10 == 0) {
System.out.print("\n");
continue;
}
System.out.print(i + "\t");
}
}
方法重载(OverLoading)
- 以《Thinking in Java》当中的说法为例,任何设计语言都具备的一项重要特性就是对名字的运用。
- 想来的确如此,对于面向对象来说。我们把所有现实世界的问题都抽象成为一个个对象。而对象所包含的无非就是其属性以及其行为。
- 这个时候问题就来了,以我们自身而言,对于同样一种行为,可能我们针对于现实世界的不同情况,会衍生出不同的处理方式。正所谓”条条大路通罗马”。
- 那么对应于编程世界而言,也就是说,同一种方法(行为)可能会存在多种不同的处理方式。那么,我们难道要为同一种行为的不同处理方式去想出多个方法名吗?
- 而另一个问题则是,为了保证每一个对象的正确创建与初始化工作,Java沿袭了C++,提供了构造器。
而为了解决“所取的任何名字都可能与类的某个成员方法冲突以及调用构造器是编译器的责任,所以必须让编译器知道应该调用哪个方法”的问题,Java中构造器要求与类名保持一致。
那么,问题则出现在:假设我们希望存在多种方式去构造一个类的对象呢?
- 针对于以上情况,则带来了这里所说的:方法重载(OverLoading)。
重载的概念
对于方法重载,简单的归纳来说:
- 是指类中的方法使用相同的方法命名,但通过参数列表(使用不同的参数类型、参数顺序、参数个数)的不同来加以区分。
- 重载 是Java中多态的其中一种表现形式。
而对于重载的使用,值得注意的则是以下几点:
避免通过参数顺序进行重载
- 虽然通过参数顺序的不同,也能够对方法进行重载。但考虑到代码的可维护性来说,尽量别这么做。
而实际上,通过这种方式进行重载,通常也没有太多的实际意义。
首先:
class Person {
Person(String name, int age) {
}
Person(int age, String name) {
}
}
对于这样的重载方式,是否并没有什么意义?
其次,还可能存在这样的情况:严格来说,它实际上已经并不能算是通过参数顺序来对方法重载的情况,因为它已经代表的是带有不同意义的参数。
假设这样一种情况:有一个订购某种商品的接口,提供了两种购货方式:
- 一种是单次购买服务,提供商品名称,和购买件数。
- 一种则包月购买,是提供商品名称,和购买的月数。
private void getGoods(String name,int num){
}
private void getGoods(int months,String name){
}
为了实现重构,可能就会出现如上方式。但这样的接口在使用中,是不是特别容易造成混淆和困惑呢?
涉及基本数据类型的重载
因为我们知道Java中基本类型的数据存在着自动提升转换的情况,所以对于涉及基本类型的方法重载,也就需要注意一下,以免在使用中造成混淆。
首先我们来看一段代码:
public class Test {
static void printX(char a) {
System.out.println("print char " + a);
}
static void printX(byte a) {
System.out.println("print byte " + a);
}
static void printX(short a) {
System.out.println("print short " + a);
}
static void printX(int a) {
System.out.println("print int " + a);
}
static void printX(long a) {
System.out.println("print long " + a);
}
static void printX(float a) {
System.out.println("print float " + a);
}
static void printX(double a) {
System.out.println("print double " + a);
}
public static void main(String[] args) {
byte a = 10;
printX(a);
short b = 10;
printX(b);
int c = 10;
printX(c);
long d = 10;
printX(d);
float e = 10.1f;
printX(e);
double f = 10.1;
printX(f);
char x = ‘x‘;
printX(x);
}
}
查看以上程序的输出结果如下:
print byte 10
print short 10
print int 10
print long 10
print float 10.1
print double 10.1
print char x
接着,我们修改程序,将上面参数类型为 byte,short,int,float,char的五个重载方法注释掉,再次运行程序:
print long 10
print long 10
print long 10
print long 10
print double 10.100000381469727
print double 10.1
print long 120
到此,我们可以发现:
- 当变量的类型显式的声明为与方法参数的类型一致时,则会进行准确的重载调用。
- 而与基本数据类型的自动转换相同,当将“较小”的数据类型传递给参数类型“较大”的方法时,也会进行一次数据类型的自动提升。
- 正如上面的例子中,byte,short,int的数据都提升为了long。float提升为了double。而对于特殊的char,如果没有找到恰好接受char型的方法参数,则会被自动的提升为int,如果也没有接受int型的方法,则会被提升为更高的整数类型long。
- 而与基本类型“高精度”转为“低精度”需要强制转换也相同的是:如果实际传入的参数类型大于重载声明的参数类型,则必须将实际参数进行显示的强制类型转换,否则将出现编译错误。
返回类型不能作为重载的标示
虽然以如下方式,看上去能够清楚的分别出这是两个不同的方法,但实际上并不能这样做。
void method(){
}
int method(){
return 0;
}
这是因为,如果通过这样的方式来实现方法重载,那么如果你在代码中通过”method();”这样的方式来调用该方法时,虚拟机该如何判断你究竟想要调用其中哪个方法呢?
所以记住,方法的返回类型并不能作为方法重载的标识。
浅析垃圾回收器工作方式
我们已经知道,通常在Java当中无需我们人为的去对对象的生命周期做管理。
这是因为Java实现了一个名叫“垃圾回收器”的东西,用于管理和释放那些已经不被使用的对象。
那么,垃圾回收器究竟是如何工作的呢?
- 通常的实现方式来说,每个对象在内存中会有一个引用计数器。即用来统计在当前的内存当中有多少个引用联系到该对象。
当有新的引用关联到该对象,则引用计数+1;当引用离开或者被设置为null后,则引用计数-1。
垃圾回收器会在存放有全部对象的列表上进行遍历,当发现引用计数为0的对象,则会进行清理。 - 但“引用计数器”却存在着一种缺陷,如果对象之间存在着交叉引用,则会出现“对象应该被销毁,但引用计数却不为0”的情况。所以这种实现方式,很少真正被用在Java虚拟机上。
- 所谓的“对象之间交叉引用”,顾名思义就是两个对象之间存在着互相引用,形成了交叉。类似于:
public class Test {
public static void main(String[] args) {
A a = new A();
B b = new B();
a.b = b;
b.a = a;
// some code...
}
}
class A{
B b;
}
class B{
A a;
}
在上面的代码中我们可以看到,当某个方法执行结束后,实际上我们对对象a和b的使用已经结束了。
但是因为它们互相之间形成了交叉引用,导致两个对象的引用计数都难以归零,从而 难以被垃圾回收器清理。
- 所以,在一些更快的模式中,垃圾回收器并非使用引用计数模式。而是采用:寻找“活”的对象!即寻找那些,一定能最终追溯到其存活在Java虚拟机栈或者静态存储区里的引用。
- 这种工作方式的本质就是,从虚拟机栈和静态存储区开始,遍历所有的引用,从而发现所有“活”的对象引用。对于发现的所有引用,则跟踪到它们所关联的对象,进而使该对象所包含的所有引用,如此反复。直到”根源于虚拟机栈和静态存储区的引用”所形成的网络被全部访问为止。
- 由此,虚拟机则只需将这些“活”的对象以外的对象(死对象^ _ ^)进行清理则可。对于这种方式,虚拟机通常采用一种名叫“停止-复制(stop and copy)”的方式进行清理,即先暂停程序的运行,然后将当前堆里的存活对象复制到新堆,没有被复制到的则都是“垃圾”;当对象被搬到新堆后,再进行空间的分配,对向引用的修正工作。
- 对于垃圾回收器的工作方式的理解来说,最难的可能就是如何寻找“活”的对象。其实也不难理解,只要我们对Java虚拟机有一定的认识。
举例来说,我们已经知道,Java中一个方法的执行,实际上就是一个“栈帧”在虚拟机栈上的入栈到出栈的过程。
Java中的局部变量(包括各种基本类型数据以及对象引用)都被存放在,这个对应的栈帧当中。
那么,我们所谓的“活”的概念,就是指这个对象仍旧在程序当中被使用。对应来说,也就是指这个对象的引用仍处于栈帧的”局部变量表“中,也就是位于虚拟机栈内。
那么,当一个栈帧出栈,即一个方法执行完毕,该引用也出栈了。所以说,如果能在虚拟站中查找到的引用,其指向的对象自然也就是活着的了。 - 通过分析过后,我们再对我们上面谈到的交叉引用所带来的回收问题,则可以有所收获了。
在我们上面说到的例子当中,假设我们使用引用计数器的方式,虽然在方法结束过后,对象引用a和b出栈了。但是由于a和b两个引用所指向的类“A”和“B”的对象其内部却分别交叉持有了对方对象,导致虽然虽然方法已经执行结束,堆中的两个对象却迟迟无法得到回收。相反,通过寻找“活”的对象的方式,则可以很好避免这种问题。 - 最后,需要记得的是,垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身!
类的初始化顺序
对类的初始化顺序加以分析以前,我们先要了解一个Java类通常都包含什么部分,接着才是这些部分的初始化先后顺序。
那么,一个Java类所包含的模块无非就是:
- 静态成员(static)和非静态成员。
- 而一个类的组成通常包含:属性(常/变量)、构造器、代码块、以及方法(函数)
而我们所说的类的初始化工作,也就是我们使用”new”来构建一个对象时,所进行的工作。
对于一个类当中的这么多部分,其初始化的先后顺序是怎么样的呢?
1.首先进行静态成员的初始化工作。即静态属性和静态代码块(也叫静态初始化子句)的初始化。静态属性和静态代码块之间的先后顺序由在代码中定义的先后顺序决定。
2.静态部分的初始化完成后,则接着进行成员属性及构造代码块(也叫实例初始化子句)的初始化工作。同理,成员属性和构造代码快的先后执行顺序由代码定义的先后顺序决定。
3.当以上的部分执行完毕过后,才轮到类的构造器执行。
4.而关于构造的初始化顺序,需要留意的则是,如果类之间存在继承关系,则父类的初始化先于子类执行。这也正常,因为子类是依赖于父类存在的。
我们通过一段代码加以验证:
class A {
private int m = memberFieldInit();
{
System.out.println("class A constructor statement");
}
A() {
System.out.println("class A constructor");
}
private int memberFieldInit() {
System.out.println("class A memberFieldInit");
return 200;
}
}
public class B extends A {
private static int s = staticFieldInit();
private int m = memberFieldInit();
static {
System.out.println("static statement");
}
{
System.out.println("class B constructor statement");
}
private static int staticFieldInit() {
System.out.println("staticFieldInit");
return 100;
}
private int memberFieldInit() {
System.out.println("class B memberFieldInit");
return 200;
}
B() {
System.out.println("class B constructor");
}
public static void main(String[] args) {
System.out.println("main方法执行..");
new B();
}
}
查看一下程序的打印结果:
staticFieldInit
static statement
main方法执行..
class A memberFieldInit
class A constructor statement
class A constructor
class B memberFieldInit
class B constructor statement
class B constructor
从程序的输出结果,正验证了我们上面的结论。而唯一值得留意的是:我们发现类的静态部分的初始化,在所谓的程序的“入口”main方法之前,就已经得以执行。
其实仍然很好理解,因为我们已经观察到main方法自身是一个静态方法。也就是说,在这里可能引用到该类的其它静态属性,所以静态部分的初始化工作自然要在其之前完成。
最后,我们对类的初始化顺序进行一次更加深入和详细的归纳:
- 当首次创建一个类的对象,或者该类的静态静态方法或静态域被访问时。Java解释器会查找路径,找到该类的.class文件。
- 接着,类装载器会对该.class文件进行装载,在这个过程中,所有类的静态有关的初始化工作都将被执行。并且,只会执行这一次!
- 当使用new关键字,创建类的对象的时候,虚拟机会先在堆内存中分配足够的内存空间。
- 这块存储空间首先会被清零,这也就是为什么对象的所有属性都会首先被初始化为一个默认值(如数字为0,引用为null等)。
- 直到这时,才会执行所有字段定义处的显示初始化工作。
- 最后,才会进行到类的构造器的执行。
final的冷门小知识
final是Java众多关键字当中的一个,它的作用是:
- 修饰基本数据类型时,用于声明该数据的值不能被改变。也就是常说的声明一个常量。
- 修饰对象数据类型时,比较值得注意。当一个对象引用被声明为final,实际是指该引用不能再被赋为新的对象,而本身初始赋值的对象的内容实际上还是可以被改变的。
- 修饰一个方法的时候,代表该方法不允许被重写(override)。
- 修饰一个类时,代表该类不允许被继承(扩展)。
相信对Java熟练运用的人,都知道以上的特性。所以,下面我们来看几个比较冷门的,关于final关键字的小知识。
-
编译时常量 与 运行时常量
关于这两个玩意儿,其实很有意思。因为当我们菜鸟时期,经常会造成这样的误解,那就是混淆了final域与常量的概念,将它们盲目的划上了等号。
不知道大家有没有这样的体验,总之我在最初很长的一段时间内,我都一直认为是,被final关键字修饰的域,就是所谓的“常量”。
事实上,这么理解似乎也没有什么不对。但引出对此的疑问是在《Thinging in Java》当中看到类似这样的话:
被设置为final的域,也并不是绝对就能在编译时知道它的值。
当时我就惊呆了,因为对于常量,我一直都以为是下面这样的“public static final int X = 10;”
这你妹的还能编译时不知道值?逗我呢?然后,经过一段经验积累和深入了解之后,才知道:
其实,“常量”也有“编译时常量”和“运行时常量”之分,而关于final域,也还有blank final field这一说(下面会说到)。
首先我们来说“编译时常量”,因为实际上这也是我们写代码的过程中,最常用到的常量的定义和使用方式。
对于编译时常量来说,我想应该理解为:被final关键字修饰的,并且在字段定义处显示的指定了初始值的域! 更为准确。
要深入理解这个概念,我们首先要说到“字节码文件常量池”这个东西。
在Java当中,一个基本数据类型的域,被声明为final并且在字段定义处已经明确的进行了初始化的情况下:
编译器将在程序的编译时期就能够明确的知道这个域的值,那么这个域在编译工作结束后,就会进入到生成的.class文件中的“字节码文件常量池“当中。
所以,这也是为什么这种类型的数据也被称为“编译时常量”。使用编译时常量的最大优点就是:
编译器可以在程序中,将该值带入到任何可能使用到该常量的表达式当中。也就是说,可以在编译时执行计算表达式。从而在一定程序上减低了程序运行时的负担。
接下来呢,我们还知道:对于JVM的内存区域划分,在方法区之中,有一块叫做“运行时常量池”的区域。
注意了,《深入Java虚拟机》里已经说到,这块内存区域的特点之一就是:
比”字节码文件常量池”拥有更高的动态性,因为常量并不是一定在编译期产生,也可能发生在运行期。
这也就接下来引出来了,与我们说到的“编译时常量”对应的“运行时常量”。
我们以一个例子来看一下,常量可能怎么样在运行时产生:
import java.util.Random;
public class Test {
private Random random;
private final int x = random.nextInt();
Test() {
random = new Random();
}
}
这里,我们用final修饰了字段x。但是呢,x的值在程序编译期是无从得知的。
它会在程序运行时,通过对象random的实例方法随机产生。这也就所谓的“运行时常量”。
而需要分清楚的概念则是:“运行时常量”是指,在程序运行时产生(进行初始化值)的常量,而并非存放在“运行时常量池”的常量。
这是因为,“字节码文件常量池”当中的常量,当进入运行期,类被装载后也会进入到“运行时常量池”当中进行存放。
-
一个既是static又是final的域只会占据一段不能改变的存储空间
这个特点看着高大上,其实也很容易理解:
我们说,被static修饰的域,只会在类装载器对类进行装载时,进行一次初始化工作;
同时,一个类的static域,是被其所有对象所共享的(存放在方法区(静态存储区)当中)。
结合这两个特点,可以总结出:一个类的static域,无论其在程序中,生成多少对象,该域在内存中都只有一份,并且只进行一次初始化。
接着,final关键字修饰域的特点在于,该域被初始化后,值就不允许改变了。
于是,二者结合 = 仅进行一次初始化 + 初始化值后不允许改变 + 内存中独此一份 = 一段不变的存储空间。
-
有趣的空白final
个人觉得这是一个初学时如果理解不深入,就很容易被坑的东西(面试时可能会问到吧)。
从书面概念上来说,空白final(blank final)是指:被声明为final,但又未给定初始值的域。
那么,根据概念来说,应该就是下面这样声明的域?
public class Test {
private final int x;
}
但以上代码,我们却会得到一个编译时异常信息:
the blank final field x may not have been initialized
根据提示的异常信息我们发现:空白final域可能未被初始化?
我们再瞄了一眼关于空白final的概念定义,你他妈的逗我是吧?
原来,与其说是未给初始值,不如说是未在字段定义处给定任何初始值。
也就是说,初始化值的工作还是得做,只不过允许你换种方式而已。
对于上面的例子而言,有两种方式来空白final域“x”进行初始化工作:
public class Test {
private final int x;
//方式 1
{
x = 10;
}
//方式 2
Test(){
x = 20;
}
}
也就是说,要么通过构造代码快(实例化初始子句),要么通过构造器对该域进行初始化。
也就是说,绕了半天,对于空白final域,你还是必须再其构造器之前对其进行初始化。
你可以再对上面例子中的x加上static关键字试试,会发现又得到编译时异常。
这个时候,如果你了解Java对于类的初始化顺序机制的话,也就能想到剩下的路了:在静态代码块内对其进行初始化、
到了这个时候,你可能和我一样,觉得空白final域这玩意确实很坑爹,到底要它何用?
其实,其最大的用处也就是实现这种需求:希望某个类某个域可以根据不同对象含有不同的值,但值却又保持恒定不变。
public class Test {
private final int x;
Test(int x) {
this.x = x;
}
}
向上转型与动态绑定
多态是面向对象的三大特性之一,其作用是消除类型之间的耦合关系。
Java当中,继承是多态的表现形式之一;而动态绑定则是通过继承实现多态的原理。所以我们当然有兴趣来看看这两个东西说的是什么。
向上转型这个名词就是基于继承而产生的,通俗的说:将子类型转换为父类型的过程,就叫做向上转型。
毕竟由子到父的转换过程,是向上一代进行转换的过程。或者说,在继承树的画法中,父(基)类是在上的。
Java中继承的特性,保证了这种转型过程是安全的,因为子类总是具有(包含父类)比父类更宽泛的接口定义。
所以在向上转型的过程中,最多就是丢失子类添加的特有接口,但至少会保证它们的共同接口。
那么,回到正题。由继承的向上转型的特性而带来的多态,在代码中的表现形式是什么呢?
最简单的向上转型,通过一行代码就可以得以体现,假设有基类A,子类B继承自A:
A a = new B();
在这里,我们创建了类B的对象,但却将该对象赋值给了类型为A的对象引用a。
这也就是多态最基本的一种表现形式:父类型的对象引用指向其子类型的对象。
我们需要思考的是,这样做带来的好处是什么?
很显然,同理的这样的特点,让我们可以将一个方法的参数类型设置为基类型,而该参数同时就具备接受该基类型所有子类型的能力。
通过代码,能够更形象的理解这样做给编写代码带来的好处。
class Soccer {
void play() {
System.out.println("踢足球");
}
}
class BasketBall {
void play(){
System.out.println("打篮球");
}
}
public class Test {
void takeExercises(Soccer s){
s.play();
}
void takeExercises(BasketBall b){
b.play();
}
}
我们在代码中提供了不同的运动方式进行锻炼,实际上上面的代码也展现了多态。
但这里的多态,我们是通过方法的重载来完成的。在代码如此之少的情况下,还难以看出什么。
但试想一下,随着时间的推移,慢慢的“棒球,乒乓球,长跑,瑜伽”等等锻炼方式都被加入进来了?
我们需要怎么做呢?只能一个一个的添加新的接口。这样做带来的显而易见的弊端就是:类型耦合带来的代码冗余。
通过继承“向上转型”的特点,就可以改变这种情况:
class Sports {
void play(){
System.out.println("做运动");
}
}
class Soccer extends Sports{
void play() {
System.out.println("踢足球");
}
}
class BasketBall extends Sports{
void play(){
System.out.println("打篮球");
}
}
public class Test {
static void takeExercises(Sports s){
s.play();
}
public static void main(String[] args) {
takeExercises(new Soccer());
takeExercises(new BasketBall());
}
}
这里,我们不再为不同的运动方式一一编写接口,而是使用统一的参数类型Sports。
这样做的好处是,任何Sports的子类型,都可以作为该接口的参数类型。因为它们会在传递的过程,被自动的向上转型。
下面,就来到了我们说到的另一个问题:动态绑定。
试想一下,如果仅仅是完成向上转型的动作,那么还不足以达到我们的目的。
我们想要的效果是,在子类型被向上转型,被参数类型接受后,它却能够以自身的特定方式去完成要做的事。
而之所以Java中,能够完成这样神奇的事情,就源自于动态绑定机制。
要了解动态绑定,我们需要先了解几个其它的概念。
首先什么是绑定?将一个方法调用与一个方法主体关联起来叫做绑定。正如上面的例子当中:
- s.play(); 就叫做一个方法调用。
- s.play();位于takeExercises的方法主体当中。
而接下来要说到的,就是传统编程语言(面向过程)当中的绑定方式,叫做前期绑定。
也就是说,绑定过程是在程序执行前,也就是编译期就完成的。
很容易想到的,就是这种绑定方式的前提是,需要“方法调用”的那个方法是能够被明确的。
这在我们上面的例子中,显然是不行的。因为我们在编写程序的时候,也不知道究竟调用的是Sports,还是Soccer亦或BasketBall等其他类型的方法。
所以,这种情况之所以在Java中能够实现,正是因为与“前期绑定”对应的“动态绑定(后期绑定,运行时绑定)”。
顾名思义,也就是这种绑定过程,是在程序的运行期间,通过参数实际类型来进行动态的绑定的。
Java中,除了static方法与被修饰为final的方法都是动态绑定的。
很容易理解,向上转型这种机制是通过继承,基于对象来完成的,而static方法与对象无关,只联系于类。
而被final修饰的方法则是切断了“继承”这条道路,简单的说,就是在声明:这个方法是唯一的,它不可能被覆写。
所以,常可以看到比较讨厌的带官腔的说法就是:合理的使用我们上面描述的关于继承带来的特点,可以降低耦合,提高程序的扩展性。
实际上,换句话说,也就是这样做可以将可能改变的事物与不变的事物分离开来。
就像上面的例子中,takeExercises被定义后,我们就不再改变。而不断运动类型在不断新增,但这种改变却不会影响到我们之前定义的takeExercises方法。
还有一种比较特殊的情况,就是覆写私有方法。我们可以借这种情况来简单了解一下Java的前期绑定:
public class Sports {
private void play(){
System.out.println("做运动");
}
public static void main(String[] args) {
Sports s = new Soccer();
s.play();
}
}
class Soccer extends Sports{
public void play() {
System.out.println("踢足球");
}
}
最终,我们会发现该段程序的运行结果是:做运动。而之所以出现这样的情况,是因为:
Java当中的private方法实际上都是被隐式的声明为final的,而final修饰的方法我们说过是唯一的,所以编译器在编译期就可以确定,从而完成绑定。
所以,当通过Sports类型的对象引用区调用play方法,在程序运行时已经不会再浪费精力去做动态绑定的类型查找了。
也就是说,在上面的例子中,子类型中的play方法事实上已经不是所谓的方法覆写了,它可以被看做属于子类的一个全新的方法。
从而,我们可以记住,所有的覆写都是针对于非私有的方法进行的。
毕竟方法的覆写是基于继承的,继承要求子类与父类保有共有接口,那么父类的私有方法既然都不能被子类访问,何谈共有,那又谈什么覆写呢?
说到这里的时候,我们对于继承所带来的多态的实现,向上转型,及动态绑定都有了了解。
我们现在最好奇的就是,Java里,对于方法绑定的机制,其实现原理究竟是怎么样的?我们来分析一下这个过程:
- 首先,我们已经知道了,所有的static和final方法都是在编译期就能够被确定的,它们会在编译期就完成绑定。
- 接着,当我们在代码中调用某个类的实例方法的时候,虚拟机则会在该对象引用的声明类型以及其超类的方法表中进行查找工作。找出与调用的实例方法的方法名对应的,被声明为“public”的方法,这就是初步得到的所有可能被执行的候选方法。
- 接着,将对调用的实例方法的参数列表(参数个数、类型、顺序)进行判断,如果在查找到的候选方法中,有一个完全符合的,则会对该方法完成绑定。
- 如果没有找到一个完全符合的方法,那么就找一个“将就”的。所谓将就,就是通过将参数类型进行自动转型之后再进行匹配。例如调用了如下代码:short x = 128;f(x);。如果没有查找到f(short x)方法,那么就寻找一个类似f(int x)或f(long x)的方法。
- 假如找到了多个“将就”的方法,则选择与传入的实际参数类型最为接近的那个一个。例如上面的f(int x)和f(long x),在传入的参数类型为short时,就会选择精度更为接近的f(int x)。这种情况通常都是针对于基本数据参数类型。
- 最后,对于对象参数类型如果存在以下3个条件:1、有继承2、有重写3、父类引用指向子类对象的时候。更简单的说,也就是当参数存在着类型转换的可能时。那么,当程序运行时,则一定会选择:对象引用所指向的实际对象所属类型中,最合适的方法。