1、概述
JVM是Java Virtual Machine(Java虚拟机)的缩写,是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。有两个概念和JVM息息相关并且很容易搞混,那就是JRE和JDK。其中JRE(JavaRuntimeEnvironment,Java运行环境),指的是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户运行已开发好的java程序,只要安装JRE即可。而DK(JavaDevelopmentKit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。而JVM是JRE的一部分。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。下面这张图是java程序的一个总执行流程:
2、类加载机制
从上图可以看到,我们写的源程序通过编译后生成的class文件,在运行的时候首先会通过类加载器系统。这边来简要说一下类加载机制。
上面是一个类从最初加载到最后卸载的整个流程。我们对其中几个过程进行梳理。
2.1 加载
加载过程是将class 文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口。这个过程需要类加载器参与。
2.2 链接
链接是将java类的二进制代码合并到JVM的运行状态之中的过程。可以再细分为如下3步:
① 验证:确保加载的类信息符合JVM规范,没有安全方面的问题。
② 准备:正式为变量(static)分配内存并设置类变量默认值,这些内存都将在方法区中进行分配。
③ 解析:虚拟机常量池内的符号引用替换为直接引用的过程。
2.3 初始化
初始化阶段是执行类构造器<clinit>()方法的过程。类构造器<clinit>()方法是由编译器自动收集类中的所有变量的赋值动作和静态语句块(static块)中的语句合并产生的。当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先初始化其父类。虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁同步。当访问一个java类的静态域时,只有真正声明这个域的类才会被初始化。下面是一个例子:
public class Test{
public static void main(String[] args){
System.out.println("运行main");
A testa = new A();
System.out.println("a:"+testa.a);
testa = new A();
}
}
class A{
public static int a = 100;
static{
System.out.println("初始化A静态块");
a = 300;
}
public A(){
System.out.println("创建A对象");
}
}
输出结果如下:
运行main
初始化A静态块
创建A对象
a:300
创建A对象
从上面可以看出,如果类还没加载进来,会先加载类,并初始化static,其中static 加载顺序是在代码中从上到下的顺序来执行的。
2.3.1 类的主动引用
对于类的主动引用操作,则一定会发生类的初始化。下面罗列了一些类的主动引用:
① New一个类的对象。
② 调用类的静态成员(除了final常量)和静态方法。
③ 使用java.lang.reflect包的方法对类进行反射调用。
④ 当虚拟机启动,java 会先启动main方法所在的类。
⑤ 当初始化一个类,如果其父类没有被初始化,则会先初始化他的父类。
2.3.2 类的被动引用
① 当访问一个静态域时,只有真正声明这个域的类才会被初始化,通过子类引用父类的静态变量,不会导致子类初始化。
② 通过数组定义类引用,不会触发此类的初始化。
③ 引用常量不会触发此类的初始化。
2.4 类加载器
加载类必然离不开类加载器,类加载器是将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class 对象,作为方法区的访问入口。关于类加载有一个很有名的机制——双亲委托机制:
这个机制采用代理模式,某个特定的类加载器在接到加载类的请求时,先将代理任务传到最高一辈,加载不了再逐级往下传,直到能加载。这个机制的作用是为了保证Java核心库的类型安全。比如说如果用户自定义了一个String类,在该机制下,类加载器还是会去加载系统自带的String类,而不是加载用户自定义的这个String类。
3、JVM运行时数据区
讲完类加载系统,来说一下JVM的运行时数据区,先看如下图:
运行的程序是内容是放在运行时数据区中的,如上图蓝色那块依次来说明一下:
3.1 堆
保存所有引用数据类型的真实信息(线程共享)。也就是说那些new出来的对象都是放在这块区域的。
3.2 虚拟机栈
基本数据、运算、指向堆内存的引用(线程私有)。在栈里面是由一个个栈帧组成的,每个正在执行的方法对应一个栈帧。当一个方法运行到一半需要调用另一个方法时,就创建一个新的栈帧表示新调用的方法,将原来那个方法压入栈中。当方法运行完毕,栈帧出栈,原来方法处于栈顶接着运行。和栈这一数据结构一样,虚拟机栈里面的栈帧遵循后进先出的原则。
常见的一个错误栈溢出 StackOverflowError 就是由于方法递归层数太多,导致栈空间满了。看如下一个例子:
public class TestDemo{
public static void main(String args[]){
fun();
}
public static void fun(){
fun();
}
}
上面fun()函数无限递归调用自己,最终会造成栈溢出:
3.3 方法区
又叫静态区,跟堆一样,被所有的线程共享。方法区包含所有的class和static变量。方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。同时方法区里面还有一个叫常量池的地方,String的字符串等常量存储就存储在那边。
3.4 程序计数器
一个非常小的内存空间,用来保存程序执行到的位置(线程私有)。下面是一个程序计数器的演示:
public class TestDemo{
public static void main(String args[]){
String str = null;
str.length();
}
}
上面程序会报空指针异常,如下图,在报的这个异常中,有一行日志 at TestDemo,main(TestDemo.java:4) 代表程序运行到TestDemo 中main()函数第四行的时候发生的错误,就是通过程序计数器来记录这个程序运行的位置的。
3.5 本地方法栈
和虚拟机栈类似,不过本地方法栈里面运行的方法不是用java写的,一般是用c或c++写的方法,也有类似栈帧的的概念。
4、内存模型和垃圾回收
4.1 内存模型
JVM对于运行时对于共享数据的部分,即堆和方法区做了一个内存划分的规范。以JDK1.8为分界线,稍微有些不同,先看如下图:
两者变化不大,只是将永久带变成了元空间。一般来说新生代和老年代对应着上一节所讲的堆部分,而永久带或者元空间对应着上一节所说方法区。这边的内存模型和上一节里面的JVM运行时数据区,可以理解为从不同的角度阐述了同一个物理实物。下面说一下这各个部分。
4.1.1 年轻代
所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生 命周期短的对象。年轻代分三个区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
4.1.2 老年代
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
4.1.3 永久带
用于存放静态文件,如Java类、方法等。永久带对垃圾回收没有显著影响,一般不做垃圾回收,在JVM内存中划分空间。
4.1.4 元空间
类似于永久带,不过它是直接使用物理内存而不占用JVM堆内存。
4.2 垃圾回收机制
垃圾回收是JVM中非常重要的部分,也正是由于这一机制的存在,使得Java语言不用像c++一样需要开发者自己去释放内存。而是通过垃圾回收器GC来进行内存的回收释放,下面来看下这个流程是怎么样的:
GC主要处理的是年轻代与老年代的内存清理操作,元空间,永久代一般很少用GC。整个流程图如上图所示总的来说如下流程:
① 当一个新对象产生,需要内存空间,为该对象进行内存空间申请。
② 首先判断伊甸园区是否有有内存空间,有的话直接将新对象保存在伊甸园区。
③ 如果此时伊甸园区内存空间不足,会自动触发MinorGC,将伊甸园区不用的内存空间进行清理,清理之后判断伊甸园区内存空间是否充足,充足的话在伊甸园区分配内存空间。
④ 如果执行MinerGC发现伊甸园区不足,判断存活区,如果存活区有剩余空间,将伊甸园区部分活跃对象保存在存活区,随后继续判断伊甸园区是否充足,如果充足在伊甸园区进行分配。
⑤ 如果此时存活区也没空间了,继续判断老年区,如果老年区空间充足,则将存活区中活跃对象保存到老年代,而后存活区有空余空间,随后伊甸园区将活跃对象保存在存活区之中,在伊甸园区为新对象开辟空间。
⑥ 如果老年代满了,此时将产生MajorGC进行老年代内存清理,进行完全垃圾回收。
⑦ 如果老年代执行MajorGC发现依然无法进行对象保存,此时会进行OOM异常(OutOfMemoryError)。
上面流程就是整个垃圾回收机制流程,总的来说,新创建的对象一般都会在伊甸园区生成,除非这个创建对象太大,那有可能直接在老年区生成。
4.3 垃圾回收算法
4.3.1 BTP和TLAB算法
① BTP:Bump-the-Pointer,该算法的主要特点是跟踪在Eden区保存的最后一个对象,类似栈的形式,每次创建新空间时只要判断最后保存的对象是否有足够空间,可极大提高内存分配速度。
② TLAB:Thread-Local Allocation Buffers BTP不适合多线程,TLAB将Eden区分为多个数据块,每个数据块分别采用BTP进行分配。
这两种算法都在伊甸园区使用,他们的优点在于速度快,由于伊甸园区里面的对象往往是小往往立刻就回收的,很适合这种算法。但这种算法有一个缺点就是当对象回收后会产生许多碎片。对于这个问题就需要下面一种算法来进行弥补了。
4.3.2 复制算法
这个算法在新生代的GC中使用,从根集合扫描出存活对象,并将找到的存活对象复制到一块空的空间中,然后清空伊甸园和前面一块有对象的存活区,这块清空的存活区就变成了空的空间供下次复制。这个算法的优点是能整合出大块连续的空间,缺点就是需要有一块空空间来存放复制后的对象,相对来说比较浪费空间。所以这个算法在存活区中使用,存活区也是相对来说最小的一块区域,两个存活区必定有一个区域是空的。
4.3.2 标记压缩算法
改算法是老年代里面所采用的垃圾回收算法,采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,回收不存活对象所占用的内存空间并且会将其他所存活对象都往左端空闲空间进行移动,并更新引用其对象的指针。这个算法不会产生碎片,也不需要存在一块空的空间,而其缺点就是速度慢。所以比较适合回收频率相对较低的老年区。
5、Android中的JVM
Android中的JVM和传统的JVM有很大的不同,Google公司一开使用的是Dalvik,在2014年6月谷歌I/O大会,Android L 以后,Google将直接删除Dalvik,代替它的是ART。依次来简单介绍一下。
5.1 Dalvik
Dalvik是Google公司自己设计用于Android平台的Java虚拟机。dex格式是专为Android应用设计的一种压缩格式,适合于内存和处理器速度有限的系统。Dalvik允许同时运行多个虚拟机的实例,并且每一个应用作为独立的Linux进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。和传统的JVM不同的是Dalvik指令集是基于寄存器的架构,dex字节码更适合于内存和处理器速度有限的系统。而传统的JVM是基于栈的。相对而言,基于寄存器的Dalvik实现虽然牺牲了一些平台无关性,但是它在代码的执行效率上要更胜一筹。
5.2 ART
在Dalvik的基础上,ART最主要的优化是在应用安装时就预编译字节码到机器语言,而Dalvik下应用每次运行都需要通过即时编译器(JIT)将字节码转换为机器码,即每次都要编译加运行,这虽然会使安装过程比较快,但是会拖慢应用以后每次启动的效率。ART中应用程序执行将更有效率,启动更快。ART占用空间比Dalvik大(字节码变为机器码之后,可能会增加10%-20%)。预编译除了能减少运行时的工作量,还有一个优点就在于预编译可以明显改善电池续航,因为应用程序每次运行时不用重复编译了,从而减少了 CPU 的使用频率,降低了能耗。