前言
死磕JVM系列的写了七篇了,基本是按照《深入理解Java虚拟机》的章节顺序写的,但是很明显我跳过几章,比如Class文件结构、虚拟机的字节码执行引擎、编译器和运行期的优化、还有JVM调优的实战,这些内容磕起来真是非常耗时间和耐心,最近牙口又不好,暂时磕不动了,等我换副镶金的假牙,慢慢啃。在买得起金牙之前,决定先换个清淡口味的东西写写,比如spring(目测也是硬茬儿,啃下来肯定得一嘴血),最近的生活让我想起了学生时代,每天都有好多门课在轮流等着强奸我,可惜的是。。没有美丽的女老师。
死磕JVM(一)内存区域 https://blog.csdn.net/u012133048/article/details/85344025
死磕JVM(二)内存模型 https://blog.csdn.net/u012133048/article/details/87886352
死磕JVM(三)内存溢出 https://blog.csdn.net/u012133048/article/details/87891398
死磕JVM(四) 垃圾回收机制 https://blog.csdn.net/u012133048/article/details/85413539
死磕JVM(五)对象的创建 https://blog.csdn.net/u012133048/article/details/87938452
死磕JVM(六) 类加载机制 https://blog.csdn.net/u012133048/article/details/85378148
死磕JVM (七) 锁优化 https://blog.csdn.net/u012133048/article/details/85490843
死磕JVM (八) 总结 https://blog.csdn.net/u012133048/article/details/88069289
虽然跳过了部分章节,但是并不影响我对JVM整体流程的理解,这篇中场休息,就是要好好总结一把JVM的工作流程,如果理解不对的地方,欢迎指正。
解疑
解疑1
首先先来看看比较容易混淆的场景:
- private static String aa = "aa";
- private static final String bb = "bb";
- String cc= "cc";
这三条语句在类初始化的时候有和不同?
相同点:
1、在准备阶段,(JDK1.6)1和2都会分配在方法区的常量池(JDK1.8将常量池转移到了java堆中)中。
不同点:
1、但是在准备阶段,aa赋予的初值为null;而bb被赋予的初值为bb,而且也仅会被赋予这么一次。
2、3不会和1,2同时被初始化,因为这不是一个类变量,类变量只是被static修饰的变量,3会在程序运行到这一步的时候才会被初始化,在常量池中创建“cc”,在占中创建指向cc这个常量的引用。这里并不会对cc赋予一个初值。这也就意味着,在方法中声明变量时,会提示你赋予初值,不然就会报错,但是在类变量中,可以不赋予初值,因为JVM会在准备阶段,对变量赋予初值。这里就有疑问了,为什么JVM对类变量赋予初值,对普通变量却不赋予初值。因为类变量在类加载阶段是分配在方法区中,一个类只会加载一次,因此赋予初值是非常方便的,但是方法中,如果在一个for循环内不断地创建变量,难道JVM要不断的给新的变量赋予初值,这明显是一种低效的做法,因此就强制要求使用者赋予初值。
解疑2
静态方法
与类变量不同,方法(静态方法与实例方法)在内存中只有一份,无论该类有多少个实例,都共用一个方法。
静态方法与实例方法的不同主要有:
静态方法可以直接使用,而实例方法必须在类实例化之后通过对象来调用。
在外部调用静态方法时,可以使用“类名.方法名”或者“对象名.方法名”的形式。
实例方法只能使用这种方式对象名.方法名。
静态方法只允许访问静态成员。而实例方法中可以访问静态成员和实例成员。
静态方法中不能使用this(因为this是与实例相关的)。
为什么会有上述特性??
因为在类加载阶段静态方法的字节码就已经在JVM类加载时就已经存在了(这里的表述我很虚,其实我并不知道在类加载的时候是JVM是怎么处理静态方法的,只知道在初始化的时候运行<client>方法,这个方法是由静态变量和静态代码块组成的,按序执行,但是对于静态方法的处理,以何种形式处理静态方法,还不知道,只知道这货也是跟着类走的),因此在调用静态方法时,不需要this,this指的当前实例,在类加载阶段,压根还没实例,所以静态方法也没有隐形的参数this,直接使用即可,同理在调用静态方法时,也就无法调用非静态方法,因为非静态方法的调用也需要通过实例实现(a.method)。
运行一段程序
这个例子来自http://www.cnblogs.com/dqrcsc/p/4671879.html,这位大神已经分析的非常好了,我就他没涉及到的地方补充补充,如果补充错了。。。。。一定要告诉我!!!!
class Person{
private String name;
private int age;
public Person(int age, String name){
this.age = age;
this.name = name;
}
public void run(){
}
}
interface IStudyable{
public int study(int a, int b);
}
public class Student extends Person implements IStudyable{
private static int cnt=5;
static{
cnt++;
}
private String sid;
public Student(int age, String name, String sid){
super(age,name);
this.sid = sid;
}
public void run(){
System.out.println("run()...");
}
public int study(int a, int b){
int c = 10;
int d = 20;
return a+b*c-d;
}
public static int getCnt(){
return cnt;
}
public static void main(String[] args){
Student s = new Student(23,"dqrcsc","20150723");
s.study(5,6);
Student.getCnt();
s.run();
}
}
1 编译
将上述代码保存为Student.java文件,通过javac命令,将上述上述文件编译成class字节码文件:javac Student。因为文件中有两个类,一个接口,因此会生成3个class文件。
2、执行该class文件
在命令行中输入java Student这个命令,就启动了一个java虚拟机,然后加载Student.class字节码文件到内存,然后运行内存中的字节码指令了。
字节码文件,看似很微不足道的东西,却真正实现了java语言的跨平台。各种不同平台的虚拟机都统一使用这种相同的程序存储格式。更进一步说,jvm运行的是class字节码文件,只要是这种格式的文件就行,所以,实际上jvm并不像我之前想象地那样与java语言紧紧地捆绑在一起。如果非常熟悉字节码的格式要求,可以使用二进制编辑器自己写一个符合要求的字节码文件,然后交给jvm去运行;或者把其他语言编写的源码编译成字节码文件,交给jvm去运行,只要是合法的字节码文件,jvm都会正确地跑起来。所以,它还实现了跨语言……(这里可以得出一个结论,一个编译好的class文件是可以被修改的)
3 类加载过程
上面是一个JVM的基本结构及内存分区的图,有点抽象,有点丑……简单说明下:
JVM中把内存分为直接内存、方法区、Java栈、Java堆、本地方法栈、PC寄存器等。
直接内存:就是原始的内存区
方法区:用于存放类、接口的元数据信息,加载进来的字节码数据都存储在方法区
Java栈:执行引擎运行字节码时的运行时内存区,采用栈帧的形式保存每个方法的调用运行数据
本地方法栈:执行引擎调用本地方法时的运行时内存区
Java堆:运行时数据区,各种对象一般都存储在堆上
PC寄存器:功能如同CPU中的PC寄存器,指示要执行的字节码指令。
JVM的功能模块主要包括类加载器、执行引擎和垃圾回收系统。
类加载器加载Student.class到内存:
1)类加载器会在指定的classpath中找到Student.class这个文件,然后读取字节流中的数据,将其存储在方法区中。
2)会根据Student.class的信息建立一个Class对象,这个对象比较特殊,一般也存放在方法区中,用于作为运行时访问Student类的各种数据的接口。(类加载器加载阶段)
3)必要的验证工作,格式、语义等(验证阶段)
4)为Student中的静态字段分配内存空间,也是在方法区中,并进行零初始化,即数字类型初始化为0,boolean初始化为false,引用类型初始化为null等。(准备阶段)
在Student.java中只有一个静态字段:
private static int cnt=5;
此时,并不会执行赋值为5的操作,而是将其初始化为0。
5)由于已经加载到内存了,所以原来字节码文件中存放的部分方法、字段等的符号引用可以解析为其在内存中的直接引用了,而不一定非要等到真正运行时才进行解析。(解析阶段)
6)在编译阶段,编译器收集所有的静态字段的赋值语句及静态代码块,并按语句出现的顺序拼接出一个类初始化方法<clinit>()。此时,执行引擎会调用这个方法对静态字段进行代码中编写的初始化操作。(初始化阶段)
在Student.java中关于静态字段的赋值及静态代码块有两处:
private static int cnt=5;
static{
cnt++;
}
将按出现顺序拼接,形式如下:
void <clinit>(){
cnt = 5;
cnt++;
}
到这里为止,在方法区中存储的cnt的值就为6。
3.1 加载
通过参数TraceClassLoading 来跟踪类加载的输出信息,java -XX:+TraceClassLoading Student,
查看输出的loadClass.txt文件:
可以看到最先加载的是Object.class这个类,当然了,所有类的父类。
直到第390行才看到自己定义的部分被加载,先是Student实现的接口IStudyable,然后是其父类Person,然后才是Student自身,然后是一个启动类的加载,然后就是找到main()方法,执行了。
通过上述信息我们也能知道,一个类在被加载之前,首先要检查父类是否被加载,并以此类推。也就意味着Object永远都是第一个被加载的类,这里也要有一个问题了,Object是否在加载每个类时都会被加载,很显然,不会,一个类在生命周期内只会被加载一次,也就是说,Object在第一次被加载后,并不会被重复加载。
3.2 执行main方法
执行引擎找到main()这个入口方法,执行其中的字节码指令:
要了解方法的运行,需要先稍微了解下java栈:
JVM中通过java栈,保存方法调用运行的相关信息,每当调用一个方法,会根据该方法的在字节码中的信息为该方法创建栈帧,不同的方法,其栈帧的大小有所不同。栈帧中的内存空间还可以分为3块,分别存放不同的数据:
局部变量表:存放该方法调用者所传入的参数,及在该方法的方法体中创建的局部变量。
操作数栈:用于存放操作数及计算的中间结果等。
其他栈帧信息:如返回地址、当前方法的引用等。
只有当前正在运行的方法的栈帧位于栈顶,当前方法返回,则当前方法对应的栈帧出栈,当前方法的调用者的栈帧变为栈顶;当前方法的方法体中若是调用了其他方法,则为被调用的方法创建栈帧,并将其压入栈顶。
注意:局部变量表及操作数栈的最大深度在编译期间就已经确定了,存储在该方法字节码的Code属性中。
简单查看Student.main()的运行过程:
简单看下main()方法
public static void main(String[] args){
Student s = new Student(23,"dqrcsc","20150723");
s.study(5,6);
Student.getCnt();
s.run();
}
这里我就不和http://www.cnblogs.com/dqrcsc/p/4671879.html这位博主一样,精确到栈帧中去模拟数据的存储,想要了解的同学们,可以去这个帖子看看,这里我就逐句分析运行过程中,JVM的执行流程:
创建栈帧
局部变量表长度为2,slot0存放参数args,slot1存放局部变量Student s,操作数栈最大深度为5。
注意:局部变量表及操作数栈的最大深度在编译期间就已经确定了,存储在该方法字节码的Code属性中。
Student s = new Student(23,"dqrcsc","20150723");
1、首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,必须执行类加载的过程。(这个符号引用描述的是全限定名,通过这个全限定名是可以找到对应的类的)
2、类加载检查后,会为新生对象分配内存,一般的对象都会在堆新生代的eden区被创建,因为新生代使用复制算法的垃圾回收器,所以一般通过指针碰撞的方式为新生对象分配内存
3、为分配好的内存空间都初始化为零值。
4、将单字节常量值23入栈。
将这个常量池中的常量即”dqrcsc”取出,并入栈。
将这个常量池中的常量即”20150723”取出,并入栈。
入栈后的数据结构示意:
5、执行Student的init()方法
<init>()方法,是编译器将调用父类的<init>()的语句、构造代码块、实例字段赋值语句,以及自己编写的构造方法中的语句整合在一起生成的一个方法。保证调用父类的<init>()方法在最开头,自己编写的构造方法语句在最后,而构造代码块及实例字段赋值语句按出现的顺序按序整合到<init>()方法中。
public Student(int age, String name, String sid){
super(age,name);
this.sid = sid;
}
Student.<init>()方法需要4个参数:虽然定义中只显式地定义了传入3个参数,而实际上会隐含传入一个当前对象的引用作为第一个参数,所以四个参数依次为this,age,name,sid。
上面的4条指令刚好把这四个参数的值依次入栈,进行参数传递,然后调用了Student.<init>()方法,会创建该方法的栈帧,并入栈。栈帧中的局部变量表的第0到4个slot分别保存着入栈的那四个参数值。
创建Studet.<init>()方法的栈帧,栈帧数据示意:
6、调用super(age,name)
这一部分和调用Student(第5步)的过程类似,也会在Student.<init>栈帧的上面,新建一个Persen.<init>()的栈帧。在执行完Persen.<init>()后,用于传参的栈顶的3个值被回收了,也就是Persen.<init>()栈帧被回收了。
7、接着执行Student.<init>
执行完Persen.<init>()后,执行this.sid = sid;也就是将sid的传入参数,局部变量表中的“20150723”赋值给0X2222指针所指向的对象的sid字段。return:返回调用方,即main()方法,当前方法栈帧出栈,即栈帧被回收重新回到main()方法
8、执行main()方法
执行s.study(5,6)
public int study(int a, int b){
int c = 10;
int d = 20;
return a+b*c-d;
}
study方法入栈,栈帧中局部变量表分别为abcd,以及还有一个this。
执行完study方法,接着返回执行main()。
3.3 类卸载和GC
可以看到根据上述过程,我们粗粒度的知道了JVM执行的过程,在main()执行完后,在虚拟机栈中,栈帧会被回收,也就意味着在java堆中的对象没有被引用,在触发minor GC或者Full GC时,这些对象就会被回收。