JVM 原理与优化 (1)

616 阅读17分钟

1、体系结构及生命周期

如图所示,JVM包括类装载器子系统、运行时数据区、执行引擎。类装载器子系统根据给定的权限 的名来装入类型(类或者接口)。执行引擎负责执行那些包含在被装载类的方法中的指令。运行时 数据区包含方法区、堆、Java栈、PC寄存器、本地方法栈。

类装载器子系统:在JVM中负责查找并装载类型的那部分被称为类装载器子系统。JVM中有两种类 装载器:启动类装载器和用户自定义类装载器。类装载器必须严格按照如下顺序进行工作:

1)装载:查找并装载类型的二进制数据。

2)连接:执行验证,准备,以及解析。

验证——确保被导入类型的正确性。

准备——为类变量分配内存,并将其初始化为默认值。

解析——把类型中的符号转换为直接引用。

3)初始化:把类型变量初始化为正确的初始值

方法区:在Java虚拟机中,关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中,类中的类(静态)变量同样存储在方法区中。所有线程共享方法区,因此,它们对方法区的数据访问必须被设计为线程安全的。方法区的大小不必是固定的,虚拟机可以根据应用的需要动态调整,同样方法区也不必是连续的,也可以被垃圾收集。

JVM会在方法区中存储以下类型信息:

这个类型的全限定名; 这个类型的直接超类的全限定名(除非这个类型是java.lang.Object,没有超类); 这个类型是类类型还是接口类型; 这个类型的访问修饰符(public 、abstract或者final的某个子集); 任何直接超类的全限定名的有序列表。

除了上面列出的基本类型信息外,JVM还得为每个被装载的类型存储以下信息:

该类的常量池(常量池就是该类型所用常量的一个有序集合,包括直接常量和对其他类型、字段、方法的符号引用); 字段信息; 除了常量以外的所有类(静态)变量; 一个到Class类的引用。

:一个Java虚拟机实例中只存在一个堆空间,因此所有线程都将共享这个堆。又由于一个Java程序独占一个JVM实例,因而每个Java程序都有它自己的堆空间——它们不会彼此干扰。但是,同一个Java程序的多个线程共享同一个堆空间。

程序计数器:每个线程都有自己的PC(程序计数器)寄存器,它线程启动时创建,大小是一个字长。当执行本地方法时,PC寄存器的内容是下一条将被执行指令的地址;当执行本地方法时,PC寄存器中的值是“undefined”。

Java栈:每当启动一个新的线程时,JVM都会为它分配一个Java栈。Java栈以帧为单位保存线程的运行状态,JVM只会直接对栈执行两种操作:以帧为单位的压栈或出栈。每当调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧,使用这个帧来存储参数、局部变量、中间运算结果等数据。Java栈上的所有数据都是此线程所独有的。

本地方法栈:本地方法本质上是依赖于实现的,JVM实现的设计者们可以自己决定使用怎样的方式来让Java程序调用本地方法。

执行引擎:在Java虚拟机规范中,执行引擎的行为使用指令集来定义,具体内容及实现有待研究。

JVM生命周期

当启动一个Java程序时,一个虚拟机实例就诞生了,当程序关闭退出时,这个虚拟机实例随之消亡。JVM实例通过main()方法来运行一个Java
程序。而这个main()方法必须是共有的(public)、静态的(static)、返回void,并且接收一个字符串数组为参数。Java程序初始类中的
main()方法,将作为改程序初始线程的起点,任何其他线程都是由这个初试线程启动的。JVM内部有两种线程:守护线程与非守护线程。守护
线程通常是由虚拟机自己使用的,比如垃圾回收线程。当该程序所有的非守护线程都终止时,JVM实例将自动退出。

2、JVM内存管理

1)程序计数器,也指pc寄存器

几乎不占有内存。用于取下一条执行的指令。

2)堆 所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。堆被划分为新生代和旧生代,新生代又被进一步划分 为Eden和Survivor区,最后Survivor由FromSpace和ToSpace组成,(也指s0,s1)结构图如下所示:

新生代。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代大小可以由-Xmn来控制, 也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。旧生代用于存放新生代中经过多次垃圾回收仍然存活的对象。

3)栈

每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、数和中间结果。

4)本地方法栈

用于支持native方法的执行,存储了每个native方法调用的状态

5)方法区

存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用永久代(PermanetGeneration)来存放方法区, (在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,但是在其他类型的虚拟机中,没有永久代的概念)可通过-XX:PermSize 和-XX:MaxPermSize来指定最小值和最大值。

6)Java内存泄露和内存溢出

内存泄漏:分配出去的内存回收不了

内存溢出:指系统内存不够用了

7)Java类加载机制

JVM将类加载过程划分为三个步骤:装载、链接和初始化。

装载(Load):装载过程负责找到二进制字节码并加载至JVM中,JVM通过类的全限定名(com.bluedavy.HelloWorld)及类加载器 (ClassLoaderA实例)完成类的加载;

链接(Link):链接过程负责对二进制字节码的格式进行校验、初始化装载类中的静态变量及解析类中调用的接口、类;

初始化(Initialize):执行类中的静态初始化代码、构造器代码及静态属性的初始化。

3、GC详解

1) 垃圾收集的意义

在C++中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在Java中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。JVM的一个系统级线程会自动释放该内存块。垃圾收集意味着程序不再需要的对象是"无用信息",这些信息将被丢弃。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用。事实上,除了释放没用的对象,垃圾收集也可以清除内存记录碎片。由于创建对象和垃圾收集器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM将整理出的内存分配给新的对象。

  垃圾收集能自动释放内存空间,减轻编程的负担。这使Java 虚拟机具有一些优点。首先,它能使编程效率提高。在没有垃圾收集机制的时候,可能要花许多时间来解决一个难懂的存储器问题。在用Java语言编程的时候,靠垃圾收集机制可大大缩短时间。其次是它保护程序的完整性, 垃圾收集是Java语言安全性策略的一个重要部份。

  垃圾收集的一个潜在的缺点是它的开销影响程序性能。Java虚拟机必须追踪运行程序中有用的对象,而且最终释放没用的对象。这一个过程需要花费处理器的时间。其次垃圾收集算法的不完备性,早先采用的某些垃圾收集算法就不能保证100%收集到所有的废弃内存。当然随着垃圾收集算法的不断改进以及软硬件运行效率的不断提升,这些问题都可以迎刃而解。

  一般来说,Java开发人员可以不重视JVM中堆内存的分配和垃圾处理收集,但是,充分理解Java的这一特性可以让我们更有效地利用资源。同时要注意finalize()方法是Java的缺省机制,有时为确保对象资源的明确释放,可以编写自己的finalize方法。

2) 垃圾收集算法

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

标记-清除算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记过程其实就是根搜索算法判断对象是否存活。该算法主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如下图所示:

2.2 复制算法(Coping):

复制算法是把内存分成大小相等的两块,每次使用其中一块,当垃圾回收的时候,把存活的对象复制到另一块上,然后把这块内存整个清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,实现简单,运行高效。这种方法适用于短生存期的对象,持续复制长生存期的对象则导致效率降低。复制算法的执行过程如下图所示:

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

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。标记-整理算法是一种老年代的回收算法,该算法与标记-清除算法的标记过程一样,但是之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,其性价比比较高。该算法示意图如下图所示:

2.4分代收集算法:

根据垃圾回收对象的特性,不同阶段最优的方式是使用合适的算法用于本阶段的垃圾回收,分代算法即是基于这种思想,它将内存区间根据对象的特点分成几块,根据每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率。一般把java堆分为新生代和老年代,新生代采用复制算法,老年代采用标记-整理算法。

新生代(Young Generation):用于存放新创建的对象,采用复制回收方法,如果在s0和s1之间复制一定次数后,转移到年老代中。这里的垃圾回收叫做minor GC;

年老代(Old Generation):这些对象垃圾回收的频率较低,采用的标记整理方法,这里的垃圾回收叫做 major GC。

永久代(Permanent Generation):存放Java本身的一些数据,当类不再使用时,也会被回收。

这里可以详细的说一下新生代复制回收的算法流程:

在新生代中,分为三个区:Eden, from survivor, to survior。

当触发minor GC时,会先把Eden中存活的对象复制到to Survivor中;

然后再看from survivor,如果次数达到年老代的标准,就复制到年老代中;如果没有达到则复制到to survivor中,如果to survivor满了,则复制到年老代中。

然后调换from survivor 和 to survivor的名字,保证每次to survivor都是空的等待对象复制到那里的

3) 常见的垃圾收集器

    下面一张图是HotSpot虚拟机包含的所有收集器

3.1 Serial收集器:

这个收集器是一个单线程收集器,使用复制收集算法,收集时会暂停所有工作线程,直到收集结束,虚拟机运行在Client模式时的默认新生代收集器。优点是:简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器没有现成交互的开销,做垃圾收集可以获得最高的单线程收集效率。如下图:

 3.2 ParNew收集器:

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括算法、STW、对象分配规则、回收策略等都与Serial收集器一样。ParNew收集器是许多运行在server模式下的虚拟机中首选的新生代收集器,一个重要原因是在除了serial收集器外,目前只有它能与CMS收集器配合使用。ParNew收集器在单CPU环境中不比Serial效果好,甚至可能更差,两个CPU也不一定跑的过,但随着CPU数量的增加,性能会逐步增加。ParNew收集器的工作过程如下:

 3.3 Parallel Scavenge 收集器:

ParallelScavenge 收集器是一个新生代收集器,它是使用复制算法的并行多线程的收集器。

ParallelScavenge的特点是它的关注点与其他收集器不同,CMS等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码时间与CPU总消耗时间的比值。吞吐量=运行用户代码时间/运行用户代码时间+垃圾收集时间。

高吞吐量和停顿时间短的策略相比,主要强调高效率地利用CPU时间,任务更快完成,适用于后台运算而不需要太多交互的任务;而后者强调用户交互体验。

 3.4  Serial Old收集器:

单线程收集器,是Serial收集器老年代版本,使用“标记-整理”算法,主要用在client模式下,如果在Server模式下,它主要有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge 收集器搭配使用;另一用途是作为CMS收集器的后备预案,在并发手机发生CMF时使用。

3.5 Parallel Old 收集器:

Parallel Old是ParallelScavenge收集器的老年代版本,使用多线程和“标记-整理”算法。Parallel Old收集器的工作过程如下图:

3.6  CMS收集器:

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:

(1)初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快。

(2)并发标记(CMS concurrent mark):进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。

(3)重新标记(CMS remark):修正并发标记期间因用户程序继续运行而导致标记发生改变的那一部分对象的标记记录。

(4)并发清除(CMS concurrent sweep)

其中初始标记和重新标记两个阶段仍然需要Stop-The-World,整个过程中耗时最长的并发标记和并发清除过程中收集器都可以和用户线程一起工作。所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美,器主要有三个显著缺点:

(1)CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。

(2)CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。

(3)最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。

3.7 G1收集器:

Parallel Old是ParallelScavenge收集器的老年代版本,使用多线程和“标记-整理”算法。Parallel Old收集器的工作过程如下图:

G1收集器是一款面向服务端应用的垃圾收集器,用于替换CMS收集器。与其他GC收集器相比,G1具有以下几个特点:

(1)并行与并发:充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间,在收集过程中用并发的方式让Java线程继续执行。

(2)分代收集:仍然有分代的概念,不需要其他收集器配合能独立管理整个GC堆,能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的就对象以获得更好的收集效果。

(3)空间整合:G1从整体看,是基于“标记-整理”算法实现的,从局部(两个Region之间)看是基于“复制”算法的。在运行期间不会产生内存碎片,有利于程序长时间运行分配大对象时不会因为无法找到连续内存而提前出发下一次GC。

(4)可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型

G1收集器运作大致可以分为以下几个步骤:

(1)初始标记:只标记GC Roots能直接关联到的对象,并且修改TAMS(Next Topat Mark Start)值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。此阶段需要停顿用户线程。

(2)并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象;耗时较长,可与用户线程并发执行。

(3)最终标记:修正在并发标记期间有变动的标记记录,这阶段需要停顿线程,可以并行执行。

(4)筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间制定回收计划,进行垃圾回收。