什么是JVM?
JVM(Java Virtual Machine)意为Java虚拟机。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
使用JVM就是为了支持与操作系统无关,实现跨平台。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。
Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
JVM的内部体系结构被分为三部分,分别是:
顾名思义,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。
一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Javac 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,当它在运行时(不是编译时)首次引用一个类时,它加载、链接并初始化该类文件,并转换成java.lang.Class类的一个实例。
JVM将整个类加载过程划分为了三个步骤:
1.1 加载:
JVM通过类名、类所在的包名通过ClassLoader来完成类的加载,将类.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区中的数据结构。
1.2 链接:
1)校验: 字节码校验器会校验生成的字节码是否正确,如果校验失败,我们会得到校验错误。
2)准备: 为类的静态变量分配内存,并设置其默认初始值
3)解析: 将常量池内的符号引用转换为直接引用的过程(符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是直接指向目标的引用,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的)
1.3 初始化:
这是类加载的最后阶段,执行类中的静态初始化代码、构造器代码以及静态属性的初始化。
JVM会在执行Java程序的过程中把它管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用处,各有各的创建与销毁时间.。
运行时数据区主要包括以下几个运行时数据区:
线程私有区域:程序计数器、Java虚拟机栈、本地方法栈
线程共享区域:堆区、方法区、运行时常量池
2.1 程序计数器(PC寄存器)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有属于自己的PC寄存器来保存当前执行指令的地址,一旦该指令被执行,pc寄存器会被更新至下条指令的地址。
若thread执行Java方法,则PC保存下一条执行指令的地址。若thread执行native方法,这个计数器的值为空。
2.2 Java虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型 ,声明周期与线程相同。 每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。
此区域一共会产生以下两种异常:
2.3 本地方法栈
本地方法栈与虚拟机栈的作用完全一样,他俩的区别无非是本地方法栈为虚拟机使用的Native方法服务,而虚拟机栈为JVM执行的Java方法服务。
一个native方法就是一个Java调用非Java代码的接口。也就是该方法的实现由非Java语言实现,比如用C或C++实现。
2.4 堆区
Java堆(Java Heap)是JVM所管理的最大内存区域。Java堆是所有线程共享的一块区域,在JVM启动时创建。此内存区域存放的都是类的实例对象,几乎所有的对象实例都在这里分配内存。如果在堆中没有足够的内存完成实例分配并且堆也无法再拓展时,将会抛出OOM。
2.5 方法区
主要用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。(在JDK1.7发布的HotSpot中,已经把字符串常量池移除方法区了。)
2.6 常量池
运行时常量池是方法区的一部分。存放的为字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
字面量: 字符串(JDK1.7后移动到堆中) 、final常量、基本数据类型的值。
符号引用: 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
分配给运行时数据区的字节码将由执行引擎执行。执行引擎读取字节码并逐段执行。
垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。
Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。判断对象是否已"死"有如下几种算法:
1.1 引用计数法
引用计数法:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。但引用计数法很难解决对象的循环引用问题,因此在主流的JVM中没有选用引用计数法来管理内存。
看下面的例子:
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test test1 = new Test();
Test test2 = new Test();
test1.instance = test2;
test2.instance = test1;
test1 = null;
test2 = null;
}
}
可以观察到,这两个对象已经不可能再被访问了,但是由于他们相互引用着对方,导致他们的引用计数器永远不可能为0,也就无法通知GC回收他们。
1.2 可达性分析法
在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活。
此算法的核心是:通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,所走过的路径称为"引用链",当一个对象到GC Roots间没有任何引用链相连时(也就是从GC Roots到此对象不可达),证明此对象是不可用的。
如下图所示:
虽然Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
那么此时就引出一个问题,那些对象可以作为GC Roots?
在Java语言中,可作为GC Roots的对象包含下面几种:
1.3 再谈引用
在JDK1.2以前,Java中引用的定义很传统 : 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。
虚引用必须和引用队列联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。
方法区的垃圾回收主要收集两部分内容 : 废弃常量和无用的类。
1.废弃常量
回收废弃常量和回收Java堆中的对象十分类似。以常量池中字面量(直接量)的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池的"abc"常量,也没有在其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个"abc"常量会被系统清理出常量池。
2.无用的类
判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类" :
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
在确定了哪些垃圾可以被回收之后,垃圾收集器要做的就是开始进行垃圾回收,而如何高效的进行垃圾回收,就主要用到以下几种垃圾回收算法。
3.1 标记-清除算法
算法思想:"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
"标记-清除"算法的不足主要有两个 :
3.2 复制算法
算法思想:复制算法是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题以及效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况。
但复制算法也暴露了一个问题,那就是内存一次只能用一半,代价是不是有点大了。
现在主要使用复制算法来回收新生代。
因为在新生代每次都有大量的对象死去,只有少量存活,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的Eden区和两块较小的Survivor区(一个称为From区,另一个称为To区),每次使用Eden和一块Survivor区。当进行回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。
HotSpot实现的复制算法流程如下:
3.3 标记-整理算法
标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。标记-整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。
标记整理算法对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
3.4 分代收集算法
当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。
Minor Gc和Full GC
刚说过JVM如何进行内存回收的,接下来说一下如何进行内存分配。
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发生一次Minor GC。
为了避免为大对象分配内存时,在Eden区以及两个Survivor区之间发生大量的内存复制,就让大对象直接进入老年代。所谓的大对象是指,需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
JVM提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。
既然虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代中。
为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且把对象年龄设为1.对象在Survivor空间中每"熬过"一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将晋升到老年代中。,可以通过参数-XX:MaxTenuringThreshold设置。
为了能更好的适应不同程序的内存状况,JVM并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。