JVM内存模型和垃圾回收

目录

 

1.JVM内存划分

1.1 程序计数器

1.2 栈

1.3 堆

1.4 方法区

1.5 直接内存

2.垃圾收集

2.1 对象生命周期判定

2.1.1 引用计数算法

2.1.2 可达性分析算法

2.2 垃圾收集算法

2.3 HotSpot虚拟机垃圾回收实现

3.垃圾收集器

参考


1.JVM内存划分

    Java虚拟机在运行字节码构成的程序时会将内存区域划分成不同的部分,每个区域的数据都有各自的用途和生命周期。Java虚拟机将内存划分为:程序计数器、栈、堆、方法区、虚拟机外内存(也可以称为直接内存,下文使用直接内存)。

1.1 程序计数器

    程序计数器作为线程当前执行的字节码的行号指示器 ,程序计数器是线程私有的,每个线程都有一个独立的程序计数器。在字节码进行解释执行时是通过改变程序计数器的值来选取下一条需要执行的字节码指令。由于程序计数器是线程私有的,在进行线程切换时,通过程序计数器能指定这个线程的字节码上次执行的位置,线程恢复后可以继续往下执行,保证了线程执行的独立性。

    需要注意的是上面提到过,程序计数器是用于解释执行字节码的,对于本地代码或者使用JIT(即时编译器)优化后的机器码不会使用程序计数器来记录当前所执行的本地代码,本地机器码是CPU可直接执行的代码,效率高,就不需要程序计数器来拖后腿了。在执行本地码时程序计数器的值为空。程序计数器不会抛出内存溢出异常。

1.2 栈

    Java虚拟机栈是线程私有的,它的生命周期与线程相同。在Java虚拟机执行每个方法前都会创建一个栈帧(Stack Frame)的内存区域,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,存储了执行方法所需的局部变量表、操作数栈、动态链接、方法返回等信息。每个方法的执行过程就是一个入栈出栈过程。对应JVM执行引擎来说,只有位于JVM虚拟机栈栈顶的元素才是有效的,叫做当前栈帧。

  • 局部变量表:由一组存储空间构成,用于存放方法所需的参数或方法内定义的局部变量。局部变量可以存储boolean、byte、char、short、int、float、reference和returnAddress这八种类型的数据。reference表示对象的引用,returnAddress指向了一条字节码指令的地址。
  • 操作数栈:是一个后入先出栈,虚拟机在执行方法时,会将操作所需要的数据和指令压入操作数栈,指令执行时会把指令和其所需数据弹出操作数栈,后将执行结果压入操作数栈。如此往复进行入栈/出栈操作,直到方法执行完毕。
  • 动态连接:当一个方法需要调用其他方法时,会将对这些方法的符号引用转化为其实际内存地址空间的直接引用。
  • 方法返回:方法返回就是方法执行完毕或者遇到异常退出,把当前栈帧出栈,恢复上层方法的栈帧。无论是正常还是异常的返回都表示当前执行的方法结束了,进入上层的方法。

    Java的栈内存还包括本地方法栈,这两种栈的作用非常相似,主要区别就是Java虚拟机栈为解释器解释执行字节码服务,本地方法栈为虚拟机执行本地机器码服务。HotSpot虚拟机中并不区分虚拟机栈和本地方法栈。

    栈内存有两种异常:StackOverflowError,线程的栈深度大于虚拟机所允许的深度抛出异常;OutOfMemoryError:栈内存满或者在创建线程是服务器物理内存满无法再给虚拟机分配栈内存空间时抛出此异常。

1.3 堆

    Java堆是所有线程共享的内存区域,用于存储对象实例,所有对象以及数组都需要在堆上分配内存区域(JVM通过JIT优化后的部分对象数据除外)。Java堆可以划分为新生代(年轻代)和老年代。 当堆内存满并且进行Full GC后也无足够的空间进行对象分配时会抛出OutOfMemoryError异常。

    新生代又可以细分为Eden、From Survivor、To Survivor。新生代内存空间默认的分配比例为8(Eden):1(From Survivor):1(To Survivor)。给新建对象分配的内存是Eden区域的空间。新生代的垃圾回收算法一般使用复制-清除算法,当对新生代进行Minor GC时,会将Eden和From Survivor中还存活的并且没有进入老年代的对象复制到To Survivor中,然后清空Eden和From Survivor。两个Survivor空间是在进行垃圾回收时使用的,From Survivor用于存储上次垃圾回收存活的并且还不够资格进入老年代的对象,To Survivor是空的用于下次进行垃圾回收时存活的又无法进入老年代的对象要复制并存储的地方,实际上From Survivor和To Survivor的角色在每次垃圾回收后都会进行互换。

1.4 方法区

    方法区是各个线程共享的内存区域,用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。大家可能会产生疑问这不就是HotSpot的永久代么,这个地方比较容易混淆特别说明下。方法区的定义是Java虚拟机所定义的一块内存区域,永久代呢,大家也知道说永久代前要加上是HotSpot虚拟机,这是由于在JDK1.7之前HotSpot虚拟机是通过永久代方式来实现Java虚拟机规范定义的方法区这块内存区域,其他虚拟机可能并不是这样实现的。同时Oracle JDK1.8之后废弃了永久代使用元空间(Metaspace)实现方法区,将类的元数据存储在本地服务器内存中而不是堆内存中。

    在方法区中有个运行时常量池的概念。将Java代码编程成Class文件后,Class文件会生成一个常量池,Class文件中的常量池主要存储字面量(就是常量数据)和符号引用(包括三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。程序运行时,在类加载阶段会将Class文件中的常量池数据加载到运行时常量池中,并且在类加载的解析阶段,加载进运行时常量池的符号引用会被替换成直接引用。运行时常量池具有动态性,运行时常量池中的数据并不全部来源于Class文件的常量池,对应在Java程序运行过程中动态生成的类字节码也会存储在运行是常量池中,如动态代理生成的代理类。

    Java虚拟机规范规定,方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.5 直接内存

    直接内存不属于虚拟机运行时数据存储区域的部分,JDK 1.4引入的NIO库类就可以直接使用Java本地库来直接分配直接内存,如通过DirectByteBuffer对象来使用直接内存。在使用I/O操作时直接内存可以提高运行效率,它避免了在Java堆和堆外内存之间的数据拷贝,也可以一定程度上改善GC性能。要使用直接内存也可以动过Unsafe来直接操作堆外内存,需要注意的是这个是不安全的操作,通过Unsafe来使用堆外内存要自己管理内存数据的回收,它是在JVM垃圾回收管控范围之外的法外之地。

2.垃圾收集

2.1 对象生命周期判定

    Java虚拟机对象的存活判定有两种常用的算法:引用计数算法、可达性分析算法。

2.1.1 引用计数算法

    引用计数算法会给对象添加一个引用计数器,只要有一个地方引用了这个对象,计数器就加1,当引用失效时减1。引用计数器为0就表示对象没有被引用,可以进行回收。这个算法实现相对可达性分析算法简单,判定效率也更高,但是有个比较难解决的问题,当对象之间互相循环引用这种情况很难解决,会造成内存对象无法回收。

    举个栗子:如下代码创建了类A、类B的对象,并赋值给a,b,此时类A对象和类B的引用计数器都为1,然后a中属性pb引用了引用了b,此时类B对象的引用计数器值为2;b的属性pa引用了a,此时类A对象的引用计数器的值为也为2。当将a、b都赋值为null时,按写代码的程序员的意思是类A对象和类B对象已经完成使命,可以升天了。但是此时类A对象和类B对象的引用计数器值都是1,虚拟机表示这无法回收,就这样类A对象在等待类B对象中的pa释放引用,类B对象也在等待类A对象中的pb释放引用,现在就和死锁一样,两对象都在互相等待,导致两对象内存永远无法回收:

2.1.2 可达性分析算法

    现在主流的虚拟机都是使用可达性分析算法来判定对象是否存活。在可达性分析算法中定义了一些可以称为GC Root的对象作为起始点,从这个节点往下的所有节点都是直接或者间接和GC Root节点有引用关系的对象,是一个有向图的数据结构,图中的节点就是对象。从GC Root到达其中任意一个对象节点的路径称为引用链。当一个节点对象没有可到达任何一个GC Root对象的引用链,则说明此对象可以被回收。

    在Java中,可以作为GC Root的对象包括下面几个:

  • 所有当前活跃线程栈帧中所引用的堆中的对象(局部变量和方法参数);
  • 静态数据结构中所引用的堆中的对象;
  • 作为常量引用的对象;
  • 由系统类加载所加载的对象。

2.2 垃圾收集算法

  • 标记-清除算法:此算法分为标记和清除两个阶段。在标记阶段会标记出所有要回收的对象,标记完成后统一回收所有被标记的对象。这个算法的效率较低,并且使用此算法垃圾回收后会导致大量内存区域不连续的内存碎片出现。
  • 复制算法:将一块内存区域划分成两块不同的区域,只使用其中一块,单这块内存区域满了后就将还存活的对象复制到另一块内存区域中,然后清空这块以使用的内存区域的数据。这个算法的实现简单运行高效,但是会对内存空间的利用率照成一定程度的浪费。大部分的Java虚拟机都使用这种算法来回收新生代,由于新生代中大部分对象都是存活时间短的对象,每次垃圾回收要进行复制的存活对象较小,复制效率高,用于存储每次垃圾回收后还存活的对象所需要的内存空间也需要很大。
  • 标记-整理算法:其标记过程和标记-清除算法一样,只是在回收垃圾时不时直接对可回收对象进行清理,而是让所有还存活的对象都向一端移动,还存活对象都集中在一块完整的内存区域中,然后清理这块内存区域之外内存数据。这个算法能有效的避免内存碎片的产生,但是效率比标记-清除算法还低。
  • 分代收集算法:根据对象的生命周期的不同,划分为不同的几块内存区域,分别存储在不同的内存区域中,一般在java虚拟机中的应用是将堆拆分为新生代和老年代。这样可以根据拥有不同生命周期的对象使用特定的垃圾收集器进行垃圾回收。

2.3 HotSpot虚拟机垃圾回收实现

    在进行垃圾回收时首先要确定哪些对象是可以回收的,HotSpot使用可达性分析算法来判定对象的生命周期。在程序运行过程中存在着大量对象,可作为GC Root的对象和其他对象混在一起,傻傻分不清楚,如果要查找GC Root对象要对所有的对象进行遍历那是完全不可接受的。在进行可达性分析时,无论使用何种垃圾收集器都有需要进行GC停顿,因为如果不进行停顿程序继续运行可能会导致对象引用出现变化,导致分析结果不准确。由于会进行GC停顿可达性分析如果耗时太多也是无法接受的。

    HotSpot通过OopMap来存活对象的引用信息,这样虚拟机就可以通过直接扫描OopMap就可以快速并且准确的完成对GC Root的枚举。但是如果对每条能改变OopMap指令的指令都存储一个新的或修改旧的引用信息,一个是会造成OopMap需要大量的空间来存储对应的引用信息(如存储了一些对基本数据引用信息,其实这类引用信息无用的),另一个是会影响虚拟机执行速度,这样GC的成本太高。

    HotSpot使用安全点,只有在称为安全点的特定位置才会记录引用信息到OopMap,也就是说在程序运行过程中并非任何时候都可以GC停顿开始垃圾回收,只有程序中所有线程运行到安全点上停顿下来才能进行垃圾回收。这里又会出现个问题,对应安全点的选择要保证能让所有线程都运行到自己安全点的时间间隔不至于太长,导致垃圾回收暂停太久,又不能太密集导致记录引用信息到OopMap太频繁。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,也就是说要在需要长时间执行的地方设置安全点。

    但虚拟机要进行GC时如何让所有线程运行到最近的安全点后暂停运行,等待GC开始。一般有两种方式:抢先式中断和主动式中断。

  • 抢先式中断:要进行GC时,首先把所有线程全部中断,然后查看线程暂停的位置,如果线程没有在安全点上就恢复线程,让线程跑到安全点再次暂停。这种方式效率太低,频繁进行线程中断和恢复也比较消耗CPU资源,现在虚拟机都不要这种方式。
  • 主动中断:进行GC时,不会对线程直接操作来中断线程,只是设置一个标志,每个线程会主动去轮询这个标志,发现为中断标志就主动挂起。线程进行轮询这个标志的地点为安全点和一些需要分配内存地址的地方,如创建对象前。

    使用主动式中断会有一个问题,就是当线程处于等待状态(如阻塞等待锁资源,休眠等)时,线程没有在运行无法主动响应GC暂停的要求。虚拟机通过定义安全区域来解决这个问题,安全区域就是指在一段代码中,引用关系不会发生变化,在这个区域中的任何地方进行GC操作都是安全的,这也算是一种优化方案,使不一定要等待线程运行到安全点时才开始暂停。线程进入安全区域时会标识自己已经进入安全区域。这样需要进行GC时就不会管处于安全区域的线程。

3.垃圾收集器

    在介绍垃圾收集器之前要说明下在垃圾收集器中并发和并行的概念:

  • 并发:是指来垃圾收集过程中,垃圾回收的运行线程和用户的运行线程同时在执行。
  • 并行:使用多条线程进行垃圾回收工作,用户的运行线程还是需要暂停等待。

    GC类别:

  • Minor GC:表示新生代垃圾回收
  • Major GC:表示老年代垃圾回收
  • Full GC:堆回收,包含新生代和老年代

Serial收集器

    Serial收集器使用复制算法作为新生代的垃圾收集器,使用单线程进行垃圾回收。Serial收集器在回收过程中会暂停程序的运行。Serial收集器实现简单,对于运行在单核CPU上的程序是比较好的新生代垃圾收集器,可以在最大程度上利用CPU时间。

ParNew收集器

    ParNew收集器使用多个线程进行垃圾回收,也是作为使用复制算法的新生代垃圾收集器,可以作为Serial收集器的多线程版本。ParNew收集器在单核CPU下性能比Serial收集器差,多核环境下作为新生代垃圾收集器的较好选择。

Parallel Scavenge收集器

    Parallel Scavenge收集器也是使用复制算法多线程并行的新生代垃圾收集器。与ParNew收集器不同的是,Parallel Scavenge是吞吐量优先,为了达到尽可能可控制的吞吐量而设计。高吞吐量可以更加高效的利用CPU,适合对垃圾收集暂停时间不太敏感的后台运算服务。

Serial Old收集器

     Serial Old收集器是Serial收集器的老年代版本,使用标记-整理算法的单线程收集器。

Parallel Old收集器

    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。

CMS收集器

    CMS作为以最短回收停顿时间为目标的老年代收集器,使用标记-清除算法。标记-清除算法,会导致大量内存碎片的产生。内存碎片过多后会导致老年代没有足够大的内存空间分配给新进入老年代的对象,从而触发Full GC对内存碎片进行整理,内存整理过程中无法并发的运行程序线程,使停顿时间变长。为了解决这个问题虚拟机提供了-XX:CMSFullGCsBeforeCompaction配置,使用这个配置可以定了在执行多少次不进行压缩整理的Full GC后,下一次Full GC会对老年代内存进行压缩整理。

    CMS收集器执行步骤为:

  1. 初始标记:仅仅对GC Root和其直接关联的对象进行标记,这个过程会暂停程序运行,这个标记过程非常快,停止时间短;
  2. 并发标记:遍历初始标记所标记的GC Root,对这些GC Roots进行追踪,从而找出所有存活的对象。并发标记阶段应用程序线程和并发标记的线程并发执行,不会暂停程序;
  3. 重新标记:在并发标记过程中,由于程序继续运行而导致引用标记发生变化,重新标记会对这些变化进行修正,这个过程会暂停程序运行;
  4. 并发清理:清理垃圾对象,这个过程和应用程序线程并发执行。

G1收集器

    G1收集器是JDK7新加入垃圾收集器,与其他上述所介绍的垃圾收集器不同,新生代和老年代可同时使用G1作为垃圾收集器。G1收集器将整个Java堆划分为多个大小相等的内存区域(Region)。每个Region都会被标记为某个角色,如eden、survivor、old等。G1可以有计划的避免在整个Java堆中进行全区域垃圾收集,来建立可预测的停顿时间模型,能够让使用者明确指定在定长时间内垃圾收集的时间不能过多少毫秒。以此来保证多CPU、大内存的服务中,在满足高吞吐量的同时,尽可能的满足所指定的垃圾回收时的暂停时间。

    G1提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc。

  • young gc:对新生代进行GC,当所有eden region被耗尽时,就会触发一次young gc。执行young gc时会将还存活对象复制到survivor region或者晋升到old region中;
  • mixed gc:当老年代的空间old region太满时,为了堆内存中所有的old region被耗尽,虚拟机会触发一个mixed gc。这个gc会进行young gc的同时回收一部分old region的内存,注意这里说的是部分,通过只是回收部分old region来达到控制垃圾回收暂停时间的目的;
  • full gc:如果内存对象分配太快来不及进行mixed gc,导致所有old region都被使用掉了,就会触发full gc。

参考

《the java virtual machine specification(Java虚拟机规范)》

《深入理解JVM虚拟机》

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值