Java内存组成&GC算法
@(JAVA)[java]
一、内存组成
(一)Java程序的内存组成
* 简单概述:堆用于存放对象实例;方法区(永久代)用于存放常量、类及方法的字节码、方法传递参数等;栈用于存放操作数栈、基本类型数据等。*
1、Java堆
这是Java程序使用最大内存的部分。Java堆是被所有线程共享的内存区域,在虚拟机启动时创建。此内存用于存放对象实例。
Java堆是GC的主要区域,因此也会被称为GC堆。
Java堆被分为新生代和老生代2部分。
2、方法区(含常量池、永久代)
与Java堆类似,这是所有线程共享的内存区域。它包括常量池、域、方法数据、方法和构造方法的字节码、类、实例、接口初始化时用到的特殊方法。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但它却有一个别名叫Non-Heap。
很多人把方法区称为永久代,本质上二者并不等价,只是HotSpot把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。在hotspot中,永久代被放在堆中,并随full GC时被回收,主要回收常量及类的卸载。
对于HotSpot,已经有规划放弃永久代并逐步使用Native Memory来实现方法区了。目前已经把运行时常量池迁出。
3、栈
(1)Java虚拟机栈
Java虚拟机栈是线程私有的。每个方法在执行的同时都会创建一个Stack Frame,用于存储局部变量表、操作数栈、动态链接、方法出入口等信息。
关于堆和栈的说明:堆用于储存各种对象,而栈的局部变量表是保存基本类型的数据以及对象的指针(非对象自身)
(2)本地方法栈
与上面说的Java虚拟机栈作用非常类似,只是Java虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。
4、程序计数器
程序计数器是线程私有的。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器的值为空。
此区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
5、直接内存
直接内存并不是虚拟机运行时数据区的一部分。
在NIO中,引入了基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
Direct Memory不能像新生代、老生代那样,发现内存不足了就通知JVM进行GC,它是等老生代满了,进行full GC时顺便进行回收的,因此过度使用Direct Memory有可能导致OOM
或者可以通过–XX:MaxDirctMemorySize来调整大小。
(二)各种OOM情形模拟
1、Java堆溢出
我们创建一个List,然后写入大量的对象直接内存溢出:
public class JavaHeapOOMDemo {
public static void main(String[] args) {
List<Integer> list = new LinkedList<Integer>();
Random random = new Random();
while(true){
list.add(new Integer(random.nextInt()));
}
}
}
运行时加上以下JVM参数:
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
指定了最大的堆内存,并在OOM发生时dump出内存中的内容。运行后console显示:
java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid7268.hprof ...
Heap dump file created [37801092 bytes in 0.652 secs]
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at com.lujinhong.commons.java.oom.JavaHeapOOMDemo.main(JavaHeapOOMDemo.java:25)
注意第一行GC overhead limit exceeded,每种OOM都有其相应的内容。
使用IBM HeapAnalyzer分析dump出来的结果
发现有一个List对象占用了98.5%的内存,它包含了498788个Integer对象。
发生堆OOM有2种可能:
* 一是内存真的不够分配所需的对象了,这只能高大堆,或者是优化代码。
* 二是内存泄漏了,某些对象无法被GC回收。
2、方法区(含常量池溢出)
错误现象是
Caused by: java.lang.OutOfMemoryError: PermGen space
即永久出错,但JDK7有了变化。
3、栈溢出
一个常见的情况是递归调用时没有终结条件。
public class JavaVMStackSOF {
public static int add(int a, int b){
return add( a, b);
}
public static void main(String[] args) {
add(1,1);
}
}
运行时出现以下错误:
Exception in thread "main" java.lang.StackOverflowError
at com.lujinhong.commons.java.oom.JavaVMStackSOF.add(JavaVMStackSOF.java:13)
at com.lujinhong.commons.java.oom.JavaVMStackSOF.add(JavaVMStackSOF.java:13)
at com.lujinhong.commons.java.oom.JavaVMStackSOF.add(JavaVMStackSOF.java:13)
at com.lujinhong.commons.java.oom.JavaVMStackSOF.add(JavaVMStackSOF.java:13)
at com.lujinhong.commons.java.oom.JavaVMStackSOF.add(JavaVMStackSOF.java:13)
这是超出了栈允许的最大深度,并不是OOM。
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OOM,此时会报OutOfMemoryError: Unable to create new native thread.
可以通过-Xss来指定这部分内存的大小。
4、直接内存溢出
直接内存容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,则与-Xmx一样。
如果发现OOM之后Dump文件很小,而程序又直接或者间接的使用了NIO,则可以考虑一下是否直接内存溢出了。直接内存溢出时会出现OutOfMemoryError:Direct Buffer memory.这种错误。
5、其它
还有一些其它情况,并不是OOM,但也是资源使用过量,如:
(1)Socket缓冲区
连接过多的话有可能出现IOExecption: Too many open file。此时一般是有一些资源未关闭导致的,如socket, 文件等。一个例子是kafka的producer未使用close(), 而又在不断的新建连接。
二、GC算法
(一)算法基础
当前主流的JVM均使用mark-sweep的算法进行垃圾回收。因此,关于垃圾回收有2个关键的步骤:
* 标记哪些对象已经死亡,即可以回收的。
* 回收被标记的对象。
这部分描述的只是算法的原理,具体实现细节根据JVM的不同而不同。
1、标记:判断对象是否生存的算法
(1)引用次数算法
记录每个对象被多少个其它对象引用,当引用次数变为0时,则这个对象被标记为可回收。这个方法实现简单,高效,但它不能解决对象的循环引用问题:
A.instance = B;
B.instance = A;
上述代码中,A与B是同一个类的对象,它们互相引用。但除了这部分代码以外,它们没有在其余任何地方被引用,因为对应引用来说,它们应该是没用的对象,可以进行回收的。但引用次数算法并不能解决这问题。
(2)可达路径算法
从几个GC root出发,遍历所有对象,当某些对象不能从GC root到达,则认为这个对象已经死掉,可回收。
GC root一般是一些合局的静态成员、表态常量等。
* 关于引用的说明:引用按强弱程度可分为强引用、软引用、弱引用、虚引用。强引用不能被回收,后面3个根据内存的情况以及被标记的次数来决定是否回收。如缓存之类的对象可回收也可不回收的。*
2、清除:垃圾回收算法
当对象被标记为可回收后,下一步就是回收这些对象了(当然会有触发条件,比如内存满了、System.gc()等),下面看一下如何回收。
(1)标记-清除算法
这是最简单的实现方法,就是将内存中标记为可清除的对象删除。这种方法实现简单且高效,但会形成大量的内存碎片,有可能导致大对象无法写入。
(2)标记-复制算法
将内存分成2个部分,每次只使用一个部分。当有垃圾回收需求时,将还生存的对象一次性的全部复制到另一部分内存,原来那部分内存即可直接清空。
这有一个明显的问题是,内存占用多了一倍。
IBM研究发现,新生代的对象符合“朝生夕灭”的特征,即98%以上的对象在第一次GC时就会被回收。因此现在的新生代一般分成3部分: eden, survivor1, survivor2,它们之间的默认比例为8:1:1。
新生代只使用eden及其中一个survivor区,新对象均会在eden区进行分配,当需要GC时,将eden及survivor中还存活的对象一次复制到另一个survivor中。这种方法只浪费了其中10%的内存。
* 此算法是目前主要JVM新生代的主流回收算法 *
(3)标记-整理算法
上述标记-整理算法并不适用于老生代,因为老生代的对象并不符合“朝生夕灭”的特征,而是有很多对象一直存活到程序结束的。而同时为了避免标记-清除算法留下的内存碎片,不再直接清除对象,而是将对象移到内存堆中的前面,整整齐齐的排列。
(二)常用算法
在JDK历史中出现过很多种GC算法,这里只列举出在hostpot中曾经或者现在被广泛使用的算法实现。
1、Serial:串行收集器
(1)应用线程停止工作,Stop-the-word。
(2)单线程进行垃圾回收。
主要包括2种具体的实现:
* Serial收集器:作用于新生代,基于标记-复制算法。
* Serial Old收集器:作用于老生代,基于标记-整理算法。
2、ParNew:并行收集器
(1)应用线程停止工作,Stop-the-word。
(2)多线程进行垃圾回收。
主要包括2咱具体实现:
* ParNew收集器:作用于新生代,基于标记-复制算法。
* Parllel Old收集器:作用于老生代,基于标记-整理算法。
3、Parallel Scanvenge(简称Parallel,也称Throughput)吞吐量优先收集器
(1)可以设置期望GC时间上限,以及GC时间占总时间的比例,以用来控制吞吐量。
(2)仅作用于新生代,对于需要考虑用户交互等待时间的应用,可以考虑使用。
4、ConcurrentMarkSweep
即CMS,下面详细说明。
5、G1
JDK7以后引入的,下面详细说明。
(三)GC组合
以上各种GC算法可以自由组合用于新生代及老生代,JVM参数中提供了常用的组合参数。
1、-XX:+UseSerialGC
新:Serial,老:Serial Old。
2、-XX:+UseParNewGC
新:ParNew,老:Serial Old
3、-XX:+UseParallelGC
新:Parallel ,老:Serial Old
4、-XX:+UseParllelOldGC
新:Parallel, 老:Parallel Old
5、-XX:+UseConcMarkSweepGC
新:ParNew,老:CMS。
说明:当出现Concurrent Mode Failure或者Promotion Failed时,则会切换至ParNew + Serial Old组合。
6、-XX:+UseG1GC
G1收集器并发、并行执行GC。
下面详细说一下目前最常用的几个GC实现。
(四)ParNew
使用-XX:+UseConcMarkSweepGC时,ParNew是默认的YGC方式。
1、重要选项
(1)-XX:MaxTenuringThreshold
GC分代年龄设置,即经过多少次YGC后会被并提升到老生代,缺省值为15。
(2)-XX:UseAdaptiveSizePolicy
动态调整Java堆区各个区域的内存大小,以及GC分代年龄。
(3)-XX:ServivorRation
Eden/Sruvivor的空间比例,默认为8,即8:1:1。
2、日志说明
2016-10-28T20:04:48.613+0800: 10967.643: [GC2016-10-28T20:04:48.613+0800: 10967.643: [ParNew: 860597K->20467K(943744K), 0.0288630 secs] 1120868K->281132K(16672384K), 0.0290730 secs] [Times: user=0.41 sys=0.01, real=0.03 secs]
(1) 2016-10-28T20:04:48.613+0800是GC开始的时间,10967.643是进程启动至今的时间。
(2)[ParNew: 860597K->20467K(943744K), 0.0288630 secs],这是YGC的情况,860597K表示YGC前新生代使用的内存空间,20467K是YGC后新生代的使用的内存空间,943744K是进程为新生代分配的空间。最后一个是指YGC花费的时间。
(3)1120868K->281132K(16672384K), 0.0290730 secs 意思与上面类似,但指的是事个堆的大小情况。上面可以看出YGC时回收了约840M的空间,因此堆被使用的空间也减少了840M左右。
(4) [Times: user=0.41 sys=0.01, real=0.03 secs] 三个时间分别是指用户时间,系统时间以及实际的耗时。
(五)CMS
1、优缺点
先明白一个道理,只是标记阶段是需要STW的,清理阶段是不需要的,因为已经确定了某些对象是没用的,对这些对象进行操作不会影响应用。因此CMS把并发阶段分成了多个步骤,初始标记和最后的重新标记STW,而并发标记和并发预清理可以与用户线程同时运行。
CMS通过细化标记阶段,使得STW时间较短,但它有以下缺点。
(1)Concurrent Mode Failure
需要注意的是,CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好留待下一次GC时再将其清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数以获取更好的性能。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
(2)内存碎片
还有一个缺点,CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费附送一个碎片整理过程,内存整理的过程是无法并发的。空间碎片问题没有了,但停顿时间不得不变长了。虚拟机设计者们还提供了另外一个参数-XX: CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不压缩的Full GC后,跟着来一次带压缩的。
2、CMS的基本流程
(1)初始标记:从根对象节点仅扫描与根节点直接关联的对象并标记,这个过程必须STW,但由于根对象数量有限,所以这个过程很短暂。
(2)并发标记:与用户线程并发执行,这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。这个阶段,应用线程与并发标记线程并发执行,所以应用不会停顿。
(3)并发预清理:与应用线程并发进行。由于上一个阶段执行期间&