常用的垃圾回收算法有标记复制,标记清除,标记整理等,在前面的blog中有介绍过。
jvm内存模型与内存错误
Java虚拟机管理的内存将包含以下几个运行时数据区域:程序计数器、方法区,栈区,堆区,本地方法栈。
(1)程序计数器是一块比较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来读取下一条需要执行的字节码指令。每个线程都有一个独立的程序计数器,以保存线程的状态。各线程的计数器之间互不影响,独立存储,我们称这类内存为“线程私有”的内存。如果线程执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行的Native方法,这个计数器值则为空。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
(2)虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法执行的时候会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧从入栈到出栈的过程。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈动态扩展无法申请到足够的内存,就会抛出OutOfMemoryError异常。
注意,HotSpot虚拟机的栈容量是不可以动态扩展的,所以在HotSpot虚拟机上不会由于虚拟机栈无法扩展而导致OOM,但是如果线程申请栈空间时就失败,则还是会出现OOM。 ------深入理解Java虚拟机第三版
(3)本地方法栈与虚拟机栈所发挥的作用是类似的,只不过本地方法区为Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
(4)Java堆一般是虚拟机管理的内存中最大的一块,被所有线程共享的一块内存区域,在虚拟机启动时创建。该内存区存放对象实例。Java堆是垃圾收集器管理的主要区域。从内存回收的角度看,由于现在的GC基本都采用分代收集算法,所以在java堆中还可以细分为:新生代和老年代。从内存分配的角度看,java堆中可能划分出多个线程私有的分配缓存区(Thread Local Allocation Buffer)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
(5)方法区与java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。运行时常量池是方法区的一部分。
类文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放在编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存是会抛出OutOfMemoryError异常。
永久代Perm Space也即是方法区,它是jvm规范的实现。当动态加载的类较多(如jsp页面较多时),容易出现内存溢出。在jdk8中已移除。
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在jdk1.8之后,虚拟机中的方法区的实现发生了改变,原来方法区用永久代(Perm Space)来实现,存放在虚拟机内存区,Jdk1.8之后方法区的实现变为了元空间(MetaSpace),元空间不在虚拟机中,而是直接使用本地内存。
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在jdk1.4中新加入NIO类,引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场合显著提高性能,因为避免了在java堆和native堆中来回复制数据。直接内存的使用见《堆外内存的使用》。
本机直接内存的分配不会受到java堆大小的限制,但是既然是内存肯定会受到本机总内存大小及处理器寻址空间的限制。在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域综合大于物理内存限制。从而导致动态扩展时出现OutOfMemoryError异常。
jvm监控工具
JDK命令行工具
JDK的bin目录下有许多虚拟机监控工具,大部分都异常小巧,因为这些命令行工具大部分都是tools.jar类库的一层薄包装而已。
JKD开发团队选择用java代码来实现这些监控工具是有原因的:当应用环境部署到生产环境后,无论采用直接接触物理服务器还是远程Telnet到服务器上都可能会受到限制。借助tools.jar类库里面的接口,我们可以直接在应用程序中实现功能强大的监控分析性能。命令行监控工具介绍如下:
- jps,显示指定系统内所有的虚拟机进程。
在实际生产环境中,如果jps无法查看,可通过ps命令查看(java程序启动后,会在目录/tmp/hsperfdata_{userName}/下生成几个文件,文件名就是java进程的pid,因此jps列出进程id就是把这个目录下的文件名列一下而已,至于系统参数,则是读取文件中的内容。如果没有权限读取,则jps失效)。 - jstat,用于收集虚拟机各方面的运行数据。
- jinfo,显示虚拟机配置信息。
- jmap,生成虚拟机的内存转储快照。
- jhat,内存转储快照分析工具,可以将堆中的对象以html的形式显示出来,包括对象的数量,大小等等,并支持对象查询语言。
- jstack,显示虚拟机的线程快照。
可视化工具
JDK除了提供命令行工具外,还有两个功能强大的可视化工具,jConsole和jVisualVM。其中后者称为Oracle主力推动的多合一故障处理工具,并且已经从JDK中分离出来称为可独立发展的开源项目。
通常线上环境有比较多的限制,不能使用图形可视化工具。因此熟练掌握命令行工具还是有必要的。比如jstack,可以分析死锁。
jstack -l $pid
会在结果的最后显示死锁:
性能调优与垃圾回收器
在实际项目开发过程中,经常会碰到一些问题,如OutOfMemoryError,内存泄露,线程死锁,java进程CPU消耗过高等等。大部分人都会通过一些虚拟机参数或者命令(比如配置堆内存大小,配置垃圾回收器等)来做简单的调优。
虚拟机参数设置中Xms表示JVM启动时初始化堆内存的大小,Xmx代表JVM分配的堆内存的最大值。Xms设置的值过小,可能会导致应用启动时内存不够,从而应用启动失败。Xmx值过小,可能会导致应用启动后运行一段时间,内存不够用。
实际上性能调优是一个复杂的事情,要想做全面的调优需要对硬件软件各方面进行综合考虑。这里介绍一下垃圾回收器对应用的影响。
垃圾回收分为轻量级垃圾回收(Minor GC,Major GC)和Full GC。从年轻代回收内存被称为Minor GC,从老年代回收内存称为Major GC。而Full GC会在老年代满了之后执行,扫描整个堆(包括年轻代,老年代和永久代),会花费较长时间。尤其当堆内存分配过大时,Full GC可能会执行较长时间,Full GC时程序是停顿的,给用户的感觉就是无响应。
在大多数网站形式的应用中,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象很少。故Full GC基本很少会出现。
垃圾回收器使用
GC的主要回收区域就是年轻代(young gen)、老年代(tenured gen)、持久区(perm gen),在jdk8之后,perm gen消失,被替换成了元空间(Metaspace)。
垃圾收集为了提高效率,采用分代收集的方式,对于不同特点的回收区域使用不同的垃圾收集器。系统正常运行情况回收年轻代是比较频繁的,full gc会触发整个堆p和永久代(如果存在的话)的扫描和回收。垃圾回收器主要有以下几种:
串行垃圾收集器
Serial是JDK1.3之前的垃圾回收器,单线程回收,会有stop the world(STW),即执行GC时暂停所有用户线程。其运行方式是单线程的,适合Client模式的应用,适合单CPU环境。串行垃圾收集器有两种,Serial和SerialOld,一般会搭配使用。新生代使用Serial采取复制算法,老年代使用Serial Old采取标记整理算法,可以通过-XX:+Use SerialGC
开启。
并行垃圾收集器
通过多线程运行垃圾收集的,也会STW,适合Server模式以及多CPU环境。一般会和JDK1.5之后出现的CMS搭配使用。并行垃圾回收器有以下几种:
ParNew:Serial收集器的多线程版本,默认开启的收集器线程和cpu数量一样,运行数量可以通过修改ParallelGCThreads设定,用于新生代收集,使用复制算法。使用-XX:+UseParNewGC
开启,和Serial Old收集器组合进行内存回收。
Parallel Scavenge:关注吞吐量,吞吐量优先(吞吐量=代码运行时间/(代码运行时间+垃圾收集时间))。可以设置最大停顿时间MaxGCPauseMillis和吞吐量大小GCTimeRatio。如果设置了-XX:+UseAdaptiveSizePolicy
参数,则随着GC会动态调整新生代的大小,Eden,Survivor比例等,以提供最合适的停顿时间或者最大的吞吐量。该收集器用于新生代收集,采用复制算法,通过-XX:+UseParallelGC参数启动,Server模式下默认提供了其和Serial Old进行搭配的分代收集方式。
Parallel Old:Parallel Scavenge的老年代版本,通过-XX:+UseParallelOldGC
参数开启,与Parallel Scavenge组合进行内存回收。
CMS
CMS(Concurrent Mark Sweep)基于标记清除算法,用于老年代,其关注点在于减少STW时间,因为CMS是并发运行的,即垃圾收集线程和用户线程同时运行。
CMS最主要是解决了pause time,但是会占用CPU资源,牺牲吞吐量。CMS默认启动的回收线程数是(cpu数量+3)/4,当CPU<4时,会影响用户线程执行,当然现在的服务器性能较强,基本可以不用考虑这个问题。
另外一个缺点就是会产生内存碎片,碎片会给大对象的内存分配造成麻烦,如果老年代的可用的连续空间无法分配时,会触发Full GC。-XX:UseConcMarkSweepGC
参数可以开启CMS,组合年轻代使用ParNew进行垃圾回收(开启CMS后默认使用)。
CMS在两种情况下会触发Full GC,一种是无法再为老生代对象分配内存,另一种情况是jvm判断在并发清理结束前堆就会满了,也即Concurrent Mode Failure(并发模式失败)。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure后的备用收集器使用(Serial Old回收时全程STW)。
CMS的回收分为几个阶段:
-
初始标记:标记一下GC Roots能直接关联到的对象,会STW。
-
并发标记:GC Roots Tracing,从前阶段标记过的对象出发,所有可到达的对象在本阶段标记,可以和用户线程并发执行。
-
并发预处理:仍然是标记对象,对从新生代晋升的对象、新分配到老年代的对象以及在并发标记阶段被修改了的对象进行标记,以减少下一 个阶段重新标记的工作量。
-
重新标记:标记期间产生的对象存活的再次判断,修正对这些对象的标记,执行时间相对并发标记短,会STW。
-
并发清除:清除对象,可以和用户线程并发执行。
-
并发重置。
通过ps -ef | grep java命令查看jvm启动参数,找到gc日志路径,截取其中部分输出如下:
从图中可以清楚地看到CMS回收过程的几个阶段。
CMS相关参数
ParallelGCThreads参数可以设置cms的线程数量。
CMSInitiatingOccupancyFraction
参数设置当老年代空间实用率达到百分比值时进行第一次cms回收,之后的回收时机则由CMS根据历史记录和当前运行情况决定。使用参数 UseCMSInitiatingOccupancyOnly
设置只用指定的回收阈值,而不仅仅是第一次使用。这两个参数一般配合使用,用于降低CMS GC频率或者增加频率、减少时延。
UseCMSCompactAtFullCollection
表示在Full GC时进行内存压缩。首先要明白一个问题:因为使用标记清除算法,所以长时间后会有碎片产生,而碎片多的话会给大对象的分配带来麻烦,当大对象无法分配内存时会触发Full GC,所以内存整理是必须的。
以下几种情况会导致Full GC:System.gc()的调用;老年代空间不足;CMS GC时出现Promotion Failed(年轻代垃圾回收时内存满,新对象进入老年代,然后老年代也满了)和Concurrent mode failure(老年代并发清理结束前用户线程产生的老年代对象导致内存满)。默认情况下UseCMSCompactAtFullCollection
参数是开启的,但内存整理时会STW,所以为了减少时延,一般可以通过CMSFullGCsBeforeCompaction
来配合使用。该参数设置在执行多少次Full GC之后才做一次压缩,默认值为0。
CMSScavengeBeforeRemark
参数设置在CMS重新标记之前启动一次年轻代垃圾回收。目的是减少重新标记的工作量,从而降低时延。
DisableExplicitGC选项禁用显式调用Full GC,ExplicitGCInvokesConcurrent
参数可以设置显式调用GC时使用CMS并发垃圾收集器。
G1
G1(Garbage-First)垃圾收集器是在JDK 7u4版本之后发布的垃圾收集器,并作为jdk9的默认垃圾收集器。
通过-XX:UseG1GC
启动参数即可指定使用G1 GC。从整体来说,G1也是利用多CPU来缩短STW时间,并且是高效的并发垃圾收集器。但是G1不再像上述的垃圾收集器一样需要分代配合不同的垃圾收集器使用,因为G1的垃圾收集区域是分区的。
G1的分代收集和前面的垃圾收集器不同的就是除了有年轻代的Yong FC,全堆扫描的Full GC之外,还有包含所有年轻代及部分老年代的Mixed GC。G1的优势还有可以通过调整参数,指定垃圾收集的最大允许pause time。
Region
传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代,这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:
而G1的各代存储地址是不连续的,每一代都使用了多个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:
G1的region除了年轻代的Eden区和Survivor区,老年代old区,还有一些标识为H的。它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于或等于region一半的对象。
H-obj有如下几个特征:
- H-obj直接分配到了old gen,防止了反复拷贝移动。
- H-obj在global concurrent marking阶段的cleanup 和 full GC阶段回收。
- 在分配H-obj之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。
为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。
一个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
整体比较
从整体上来说,并行收集器吞吐量大,但是时延高。CMS和G1都是当前使用比较多的收集器,比较而言,CMS吞吐量更高一些,而G1的时延更低一些。
调优方法
GC调优的目的有两个:将转移到老年代的对象数量降低到最小;减少Full GC的执行时间。为了达到这两个目的,需要做以下几个事情:
- 减少使用全局对象和大对象。
- 调整新生代的大小到最合适。
- 设置老年代的大小为最合适。
- 选择合适的GC收集器。
当然什么是最合适的,需要根据具体情况来决定。
参考资料
1.《深入理解java虚拟机》
2. https://blog.csdn.net/lijingyao8206/article/details/80566384
3. http://ifeve.com/useful-jvm-flags-part-7-cms-collector/