目录
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.垃圾收集器
参考
Java虚拟机在运行字节码构成的程序时会将内存区域划分成不同的部分,每个区域的数据都有各自的用途和生命周期。Java虚拟机将内存划分为:程序计数器、栈、堆、方法区、虚拟机外内存(也可以称为直接内存,下文使用直接内存)。
程序计数器作为线程当前执行的字节码的行号指示器 ,程序计数器是线程私有的,每个线程都有一个独立的程序计数器。在字节码进行解释执行时是通过改变程序计数器的值来选取下一条需要执行的字节码指令。由于程序计数器是线程私有的,在进行线程切换时,通过程序计数器能指定这个线程的字节码上次执行的位置,线程恢复后可以继续往下执行,保证了线程执行的独立性。
需要注意的是上面提到过,程序计数器是用于解释执行字节码的,对于本地代码或者使用JIT(即时编译器)优化后的机器码不会使用程序计数器来记录当前所执行的本地代码,本地机器码是CPU可直接执行的代码,效率高,就不需要程序计数器来拖后腿了。在执行本地码时程序计数器的值为空。程序计数器不会抛出内存溢出异常。
Java虚拟机栈是线程私有的,它的生命周期与线程相同。在Java虚拟机执行每个方法前都会创建一个栈帧(Stack Frame)的内存区域,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,存储了执行方法所需的局部变量表、操作数栈、动态链接、方法返回等信息。每个方法的执行过程就是一个入栈出栈过程。对应JVM执行引擎来说,只有位于JVM虚拟机栈栈顶的元素才是有效的,叫做当前栈帧。
Java的栈内存还包括本地方法栈,这两种栈的作用非常相似,主要区别就是Java虚拟机栈为解释器解释执行字节码服务,本地方法栈为虚拟机执行本地机器码服务。HotSpot虚拟机中并不区分虚拟机栈和本地方法栈。
栈内存有两种异常:StackOverflowError,线程的栈深度大于虚拟机所允许的深度抛出异常;OutOfMemoryError:栈内存满或者在创建线程是服务器物理内存满无法再给虚拟机分配栈内存空间时抛出此异常。
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的角色在每次垃圾回收后都会进行互换。
方法区是各个线程共享的内存区域,用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。大家可能会产生疑问这不就是HotSpot的永久代么,这个地方比较容易混淆特别说明下。方法区的定义是Java虚拟机所定义的一块内存区域,永久代呢,大家也知道说永久代前要加上是HotSpot虚拟机,这是由于在JDK1.7之前HotSpot虚拟机是通过永久代方式来实现Java虚拟机规范定义的方法区这块内存区域,其他虚拟机可能并不是这样实现的。同时Oracle JDK1.8之后废弃了永久代使用元空间(Metaspace)实现方法区,将类的元数据存储在本地服务器内存中而不是堆内存中。
在方法区中有个运行时常量池的概念。将Java代码编程成Class文件后,Class文件会生成一个常量池,Class文件中的常量池主要存储字面量(就是常量数据)和符号引用(包括三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。程序运行时,在类加载阶段会将Class文件中的常量池数据加载到运行时常量池中,并且在类加载的解析阶段,加载进运行时常量池的符号引用会被替换成直接引用。运行时常量池具有动态性,运行时常量池中的数据并不全部来源于Class文件的常量池,对应在Java程序运行过程中动态生成的类字节码也会存储在运行是常量池中,如动态代理生成的代理类。
Java虚拟机规范规定,方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
直接内存不属于虚拟机运行时数据存储区域的部分,JDK 1.4引入的NIO库类就可以直接使用Java本地库来直接分配直接内存,如通过DirectByteBuffer对象来使用直接内存。在使用I/O操作时直接内存可以提高运行效率,它避免了在Java堆和堆外内存之间的数据拷贝,也可以一定程度上改善GC性能。要使用直接内存也可以动过Unsafe来直接操作堆外内存,需要注意的是这个是不安全的操作,通过Unsafe来使用堆外内存要自己管理内存数据的回收,它是在JVM垃圾回收管控范围之外的法外之地。
Java虚拟机对象的存活判定有两种常用的算法:引用计数算法、可达性分析算法。
引用计数算法会给对象添加一个引用计数器,只要有一个地方引用了这个对象,计数器就加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释放引用,现在就和死锁一样,两对象都在互相等待,导致两对象内存永远无法回收:
现在主流的虚拟机都是使用可达性分析算法来判定对象是否存活。在可达性分析算法中定义了一些可以称为GC Root的对象作为起始点,从这个节点往下的所有节点都是直接或者间接和GC Root节点有引用关系的对象,是一个有向图的数据结构,图中的节点就是对象。从GC Root到达其中任意一个对象节点的路径称为引用链。当一个节点对象没有可到达任何一个GC Root对象的引用链,则说明此对象可以被回收。
在Java中,可以作为GC Root的对象包括下面几个:
在进行垃圾回收时首先要确定哪些对象是可以回收的,HotSpot使用可达性分析算法来判定对象的生命周期。在程序运行过程中存在着大量对象,可作为GC Root的对象和其他对象混在一起,傻傻分不清楚,如果要查找GC Root对象要对所有的对象进行遍历那是完全不可接受的。在进行可达性分析时,无论使用何种垃圾收集器都有需要进行GC停顿,因为如果不进行停顿程序继续运行可能会导致对象引用出现变化,导致分析结果不准确。由于会进行GC停顿可达性分析如果耗时太多也是无法接受的。
HotSpot通过OopMap来存活对象的引用信息,这样虚拟机就可以通过直接扫描OopMap就可以快速并且准确的完成对GC Root的枚举。但是如果对每条能改变OopMap指令的指令都存储一个新的或修改旧的引用信息,一个是会造成OopMap需要大量的空间来存储对应的引用信息(如存储了一些对基本数据引用信息,其实这类引用信息无用的),另一个是会影响虚拟机执行速度,这样GC的成本太高。
HotSpot使用安全点,只有在称为安全点的特定位置才会记录引用信息到OopMap,也就是说在程序运行过程中并非任何时候都可以GC停顿开始垃圾回收,只有程序中所有线程运行到安全点上停顿下来才能进行垃圾回收。这里又会出现个问题,对应安全点的选择要保证能让所有线程都运行到自己安全点的时间间隔不至于太长,导致垃圾回收暂停太久,又不能太密集导致记录引用信息到OopMap太频繁。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,也就是说要在需要长时间执行的地方设置安全点。
但虚拟机要进行GC时如何让所有线程运行到最近的安全点后暂停运行,等待GC开始。一般有两种方式:抢先式中断和主动式中断。
使用主动式中断会有一个问题,就是当线程处于等待状态(如阻塞等待锁资源,休眠等)时,线程没有在运行无法主动响应GC暂停的要求。虚拟机通过定义安全区域来解决这个问题,安全区域就是指在一段代码中,引用关系不会发生变化,在这个区域中的任何地方进行GC操作都是安全的,这也算是一种优化方案,使不一定要等待线程运行到安全点时才开始暂停。线程进入安全区域时会标识自己已经进入安全区域。这样需要进行GC时就不会管处于安全区域的线程。
在介绍垃圾收集器之前要说明下在垃圾收集器中并发和并行的概念:
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收集器执行步骤为:
G1收集器
G1收集器是JDK7新加入垃圾收集器,与其他上述所介绍的垃圾收集器不同,新生代和老年代可同时使用G1作为垃圾收集器。G1收集器将整个Java堆划分为多个大小相等的内存区域(Region)。每个Region都会被标记为某个角色,如eden、survivor、old等。G1可以有计划的避免在整个Java堆中进行全区域垃圾收集,来建立可预测的停顿时间模型,能够让使用者明确指定在定长时间内垃圾收集的时间不能过多少毫秒。以此来保证多CPU、大内存的服务中,在满足高吞吐量的同时,尽可能的满足所指定的垃圾回收时的暂停时间。
G1提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc。
《the java virtual machine specification(Java虚拟机规范)》
《深入理解JVM虚拟机》