前言
jdk的体系结构示意图如下:
可见最下层的jvm是jre(java runtime environment,运行时环境)的组成部分之一。
当我们编写一段代码并运行时,会执行以下步骤:
以helloworld.java(源代码)为例,我们编写的代码会首先被javac(java编译器)编译为java.class(java字节码)文件,接下来这个class文件就会被扔到jvm中去执行。
java代码拥有跨平台的特性,即我们编写的java文件可以在各大操作系统中运行,而这个特性是依赖jvm实现的。回想一下,当我们在下载jdk的时候,会让我们选择操作系统的版本,同时jvm是属于jdk的一部分,自然就有不同平台的jvm实现。这也就是jvm主要做的事情:从软件层面屏蔽不同操作系统在底层硬件与指令上的区别。
jvm三大组成部分
如图,当我们的java文件被编译器编写为字节码文件(.class文件)后,会被类装载子系统装载到运行时数据区(又称内存模型),最后由字节码执行引擎去执行被装载后的代码。完整的jvm组成部分便由这三部分组成,而其中最为津津乐道的就是内存模型部分。
内存模型
内存模型由以下部分组成:
堆(Heap)
此处存放我们new出来的实例对象。
堆是jvm内存管理模块中最大的一块,主要的gc也是在这一部分执行。
如图,堆内部由年轻代和老年代组成,年轻代又分伊甸区和幸存者区,所占比例如图。
被new出来的对象会优先放在伊甸区,伊甸区如果满了,就会执行minor gc。
gc的底层是由字节码执行引擎后台发起的一个垃圾收集线程。
可达性分析算法
GC Roots根节点:线程栈的本地变量,静态变量、本地方法栈的变量等。
该算法以GC Roots根节点作为起点,从这些节点向下搜索引用对象,找到的对象都是非垃圾对象,其余的对象都是垃圾。
经过可达性分析算法得到伊甸区中的非垃圾对象,会被复制保存到幸存者区,同时分代年龄+1,代表着经历了多少次gc,而剩余在伊甸区的垃圾对象会被gc。
经过一段时间的运行,如果伊甸区又满了,会再次触发gc。gc的对象是整个年轻代的,上一次的非垃圾对象也会被尝试gc。假设上次的非垃圾对象没有被gc,会被移入下一块空着的幸存者区。
以图中的s0、s1为例,如果此时s0存放了一部份非垃圾对象,而伊甸区已满触发gc:
- 伊甸区的非垃圾对象被移入s1
- 同时s0中上一次gc的非垃圾对象再次经历gc,分化出了新的非垃圾对象和垃圾对象,新的非垃圾对象被移入s1
- 伊甸区和s0的两部份的垃圾对象都被gc
- 当伊甸区满时,重复上述步骤,伊甸区和之前gc剩余的非垃圾对象在s0和s1流转
当分代年龄达到15时,会被移入老年代。若老年代已满,会触发full gc,它也是由字节码执行引擎后台执行的。full gc的执行对象是整个堆内存区。
若老年区能回收出空间、给年轻代的非垃圾对象移入时,一切如常;若老年代已经回收不出新的空间供移入时,会出现oom(内存溢出)。
栈(Stack)
每当一个线程开始执行时,jvm就会从此处的栈内存中分配一部分内存给该线程使用,这一部分被分配的内存空间就叫做栈帧内存空间。这部分空间用来存储线程执行过程中产生的局部变量。
举例来说,当我们使用一个方法开辟了一个线程,在这个方法内部的局部变量就会被存储在栈帧内存空间。而我们要归还这部分内存空间也十分简单,在线程执行完之后销毁掉即可。
此处的栈与数据结构中的栈是一样的,都是先进后出的结构,用来存储栈帧。
可是为什么要用栈这样的数据结构,去存储这部分内容呢?
再举一个例子,假设有一个main方法,在其内部调用了compute方法。首先执行的main方法会先开辟栈帧内存空间,而在main方法中执行到调用compute方法的这一步时,compute方法也会在栈中开辟属于自己的内存空间。
public class Math {
public static final initData = 666;
public static User user = new User();
public static void main(String[] args) throw IOException {
Math math = new Math();
math.compute();
System.out.println("end");
}
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
}
此时就有了一个先后关系,compute方法后执行,那么它就后分配内存空间。当他执行完毕了,自然也就不再需要这部分内存空间,于是他的栈帧内存也就先于main方法的被销毁了。
而对应到栈这一结构,我们的分配内存相当于入栈,销毁内存相当于出栈。这一入栈出栈的先进后出原则,与我们方法的嵌套调用的步骤是一致的。
局部变量表和操作数栈
在栈中,会使用一个局部变量表,来存储局部变量。而除了局部变量表,栈还会包含操作数栈。
compute方法的字节码文件反编译结果如图:
当我们创建一个局部变量并赋值时,以compute方法中的int a = 1为例:
- 将int类型常量1、也就是我们要给变量a赋的值1,压入操作数栈(iconst);
- jvm会在局部变量表为局部变量a分配内存空间;
- 将操作数栈中的1出栈,将它放到局部变量a所对应的内存空间(istore)。
此时a这块内存空间所对应的值就是1。
而当我们要使用局部变量时,以compute方法为例:
- 将先前装载到局部变量表的a、b的整数值压入操作数栈(iload)
- 作int类型的加法,从操作数栈的栈顶取出a、b的整数值做加法,并把结果重新压回操作数栈(iadd)
- 把整型值10压入操作数栈(bipush)
- 作int类型的乘法,从操作数栈的栈顶取出两个整数值做乘法,并把结果30重新压回操作数栈(imul)
- 把结果30从操作数栈顶存储到局部变量(istore)
- 从局部变量表获取局部变量,压入操作数栈(iload),并返回该值(ireturn)
由此可见,操作数栈是临时的内存空间,分配给需要使用的数。
注:
在main方法中有一点较为特殊:生成了Math对象指向变量,而不是给某个变量赋值常量。
局部变量表中会生成指向Math对象的引用,而Math对象被存储到专门用于存储变量的堆中,局部变量表中的引用指向的就是堆中的Math对象。
根据局部变量表的引用我们可以找到堆中的对象,此时栈和堆的关系显而易见:栈的线程中包含了指向堆中对象的引用。
当我们使用new关键字创建对象时,实际的对象存储在堆中,对对象的引用存储在栈中。
方法出口
例子中main方法调用了compute方法,可是程序是怎么知道compute方法执行完毕后,需要回到main方法的呢?
这些内容都存到了方法出口,记录的就是执行完毕后的“出口”,即下一步。
程序计数器
每个线程在运行过程中,都会给程序计数器分配内存空间,用于存这个线程即将执行的下一行代码的内存地址(或称代码行号)。
每个线程包含程序计数器的目的显而易见,因为java是多线程的。比如当前线程为a,它的下一行代码的线程为b。此时有有cpu时间片更高的线程c(或称优先级更高)进入时,流程如下:
- 系统通知线程a在执行完后挂起
- 优先级更高的线程c开始运行
- 线程c执行完毕,系统从线程a的程序计数器去获取线程b的地址
- 开始执行线程b
在高优线程插队执行完毕后,之前的线程会恢复,当然它不会从头再来,而是从程序计数器获取下一个代码的内存地址开始执行。
而程序计数器的修改,是由字节码执行引擎完成的。
方法区(元空间)
存储了常量、静态变量、类信息。
在代码示例中,initData和user分别是常量和静态变量。
与线程中新建变量的流程一致,new User( )创建了一个User对象,静态变量user指向了它。
静态变量user会存放在方法区中,User对象会存放在堆中。
此时方法区和堆的关系也出现了:方法区的变量指向堆中的对象。
本地方法栈
以启动线程为例,在使用Thread.start方法时,会使用到一个本地方法start0,它使用native关键字修饰,是使用c++实现的。
不论是使用java还是别的语言实现,本地方法在jvm中被调用时,也需要一部份内存空间去存放数据,而这部份内存空间就是从本地方法栈去获取的。
以main线程为例,如果它运行过程中用到了本地方法,就会从本地方法栈中取出一部份内存空间,即本地方法运行时,是从本地方法栈获得内存的。
在内存模型中,栈、本地方法栈、程序计数器是线程独有的,堆和方法区是线程共享的。
Android虚拟机
在Android中,不使用jvm虚拟机,而是使用Dalvik。
不同的是,java文件编译为字节码文件后,还需要dx命令将其转换为dex文件,才能传入Dalvik虚拟机。