JVM基础

JVM基础知识

最近看《深入理解Java虚拟机》学习到了一些JVM基础知识,主要了解了JVM运行时数据区域、GC以及类加载机制,现在来总结一下。

JVM的跨平台性

Java Virtual Machine简称JVM。各个操作系统之间存在着硬件、指令集的差异,而Java虚拟机的出现则屏蔽了这种差异性,我们只需在其中一个平台编写符合Java规范的代码,编译成为字节码,就可以通过不同平台版本的JVM运行这段字节码,实现“一处编译,处处运行”。

运行时数据区域

JVM在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途以及创建和销毁的时间。如下图所示。
运行时数据区域

方法区(Method Area)

方法区用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,它是各个线程共享的内存区域。

程序计数器(Program Counter Register)

这是一块比较小的内存空间,用来存储当前线程所执行的字节码的行号,因此是线程私有的。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

Java虚拟机栈(Java Virtual Machine Stacks)

该区域也是线程私有,生命周期也与线程相同。它描述了Java方法执行的内存模型:每个方法被执行时都会同时创建一个栈帧,栈帧存储局部变量表、操作栈、动态链接、方法出口等信息,线程私有。
在JVM规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,但扩展到无法再申请足够的内存时会抛出OutOfMemoryError异常。

本地方法栈(Native Method Stacks)

这个区域与虚拟机栈比较相似,不过虚拟机栈是为虚拟机执行Java方法服务的,而本地方法栈则是为使用到的操作系统方法服务的。

Java堆(Java Heap)

Java堆是JVM所管理的内存中最大的一块内存区,是所有线程共享的内存区域。它的唯一目的就是存储对象实例,几乎所有的对象实例都在这里分配内存,它是GC所管理的主要区域,也叫“GC堆”。
Java堆中可细分为新生代和老年代,新生代还可以分为Eden、From和To,From和To空间统称Survivor空间。无论是哪个空间,存储的都是对象实例,只不过由于空间的特性不同,采用的垃圾收集算法也不同。

垃圾收集(GC)

在内存管理领域,C和C++既是拥有最高权力的皇帝,同时又是从事最基础工作的劳动人民,因为它既拥有每个对象的生杀大权,又担负着每一个对象生命从开始到终结的维护责任。而Java程序员不需要为每一个new操作去写配对的delete/free代码,因为有垃圾收集器自动对这些内存进行回收。

判断对象是否存活

垃圾收集器在对堆进行回收前,当然得要先确定哪些对象是否还存活,哪些要go die。

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器加一;引用失效时,计数器减一。当计数器为0的时候就是不能再被使用的,这个对象就是要go die了。
这个方法实现比较简单,判定效率也比较高,但是有一个问题,它很难解决对象之间相互引用的问题。因此Java语言中没有选用这一方法来管理内存。

根搜索算法

Java和C#都是使用这一方法来判定对象是否存活的。它的基本思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,此时这个对象就是可回收的。
如下图,object5、object6、object7虽然是有关联的,但是它们到GC Roots是不可达的,所以它们被判定为是可回收的。

GC Roots

垃圾收集算法

复制算法

将内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活的对象复制到另一块,然后把这一块全部清理掉。

复制算法

优点:
内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针即可
缺点:
内存缩小为原来的一半

标记-清除算法(Mark-Sweep)

分为“标记”和“清除”两个阶段。在标记阶段,通过根搜索,标记所有从根节点开始的可达对象;然后在清楚阶段,清楚所有未被标记的对象。

标记-清除算法

缺点:
1.效率问题,无论是标记还是清除过程,效率都不高;
2.空间问题,从上图就可以看出,标记清除后产生了大量不连续的内存碎片,当程序在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-整理算法(Mark-Compact)

分为“标记”和“整理”两个阶段。标记阶段仍和“标记-清除”算法一样,但是后续步骤直接清理可回收对象,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法

优点:
1.解决了内存碎片问题;
2.没有内存碎片,对象创建时内存分配也更快了。
缺点:
还是有一定的效率问题,标记和整理两个过程效率都不高。

分代收集算法(Generational Collection)

根据对象存活周期的不同将内存分为几块,像Java堆就是分为新生代和老年代,然后根据各个年代的特点采用适当的收集算法。新生代采用复制算法,老年代采用“标记-清除”或“标记-整理”算法。

分代收集算法

垃圾收集器

收集算法是内存回收的方法论,而垃圾收集器是内存回收的具体实现。

垃圾收集器

Serial(串行)收集器

这种收集器是最基本、历史最悠久的收集器,它是一个单线程的收集器,使用复制算法,简单高效,Serial收集器由于没有线程交互的开销,专心做垃圾收集可以获得很高的单线程收集效率。它在进行垃圾收集时,必须暂停其它所有的工作线程,直到收集结束(“Stop The World”)。这就有点难受……想象一下你玩手机的时候隔一段时间就卡一卡,还卡很久……

ParNew

实际上这个收集器就是Serial收集器的多线程版本,它除了多线程收集之外,其它与Serial没多大区别。这是第一款真正意义的并发收集器,它实现了让垃圾收集线程与用户线程同时工作。
ParNew收集器在单CPU的环境中不会比Serial收集器有更好的效果,而且由于存在线程交互的开销,该收集器可能回避Serial收集器表现更差。但是,随着可以使用的CPU数量的增加,它的表现它对于GC时系统资源的利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用-XX:ParallerGCThreads参数设置。

Parallel(并行) Scavenge 收集器

这个收集器是一个并行的多线程新生代收集器,也是使用复制算法。它的关注点与其它收集器不同,其它收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量(吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间))。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis以及直接设置吞吐量大小的 -XX:GCTTimeRatio参数。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

  • 此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge与Parallel Old组合。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。
从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的,它的运作过程分为四个步骤:

  1. 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  2. 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
  3. 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  4. 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器

优点:
并发收集、低停顿
缺点
1.对CPU资源非常敏感
2.无法处理浮动垃圾
3.标记清楚算法导致产生较多空间碎片

G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:

  • 并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
  • 空间整合 G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  • 可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

总的来说,这七种垃圾收集器的对比如下表。

收集器 串行、并行or并发 算法 目标 适用场景
Serial 串行 复制 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 复制 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 复制 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 标记-整理+复制 响应速度优先 面向服务端应用,将来替换CMS

类加载机制

类加载的过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个结点。其中验证、准备和解析三个部分统称为连接。

类加载过程

加载

在加载阶段,虚拟机需要完成三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

验证

这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。大致上会完成四个阶段的校验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。首先这时候进行内存分配的仅包括类变量而不包括实例变量,其次这里所说的初始值通常情况下是数据类型的零值,假设一个类变量定义为:

1
public static int v = 321;

那么变量v在准备阶段过后的初始值是0而不是321。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

初始化

初始化阶段是类加载过程的最后一步,到初始化阶段,才真正开始执行类中定义的Java程序代码。

类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块被称为“类加载器”。

类加载器与类的唯一性

类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性。通俗的说,JVM中两个类是否“相等”,首先就必须是同一个类加载器加载的,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要类加载器不同,那么这两个类必定是不相等的。

这里的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

双亲委派模型

站在Java虚拟机的角度讲,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader)和所有其他的类加载器。启动类加载器使用C++实现,是虚拟机自身一部分,而其他的类加载器都由Java实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。
站在Java开发人员的角度来看,类加载器可以分为以下三种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader) 这个类加载器负责将存放在<JAVA_HOME>lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是被虚拟机识别的类库加载到虚拟机内存中。该加载器无法被Java程序直接引用
  • 扩展类加载器(Extension ClassLoader) 这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器
  • 应用程序类加载器(Application ClassLoader) 这个类加载器由sun.misc.Launcher@AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器

双亲委派模型

上图所展示的类加载器之间的层次关系就称为双亲委派模型。它要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。使用组合(Composition)关系来复用父加载器。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。