JVM垃圾回收那些事

Java这种VM类跨平台语言比起C++这种传统编译型语言很大的区别之一在于引入了垃圾自动回收机制。自动垃圾回收大大提高了Java程序员的开发效率并且极大地减少了犯错的概率,但终归而言由于无法像C++程序员一样操作内存,于性能而言始终无法与C++相媲美。当然JVM这么多年来也取得了长足的发展,JIT、AOT等技术的引进,甚至依赖于一些运行时优化,Java和C++间的性能差距已经很小了。虽说JVM这些年来一直在发展,内存回收技术也在不停演进,但对程序员来说OOM始终是无法摆脱的梦魇。在JVM技术还没有发展到100%对开发人员透明的情况下,掌握JVM的垃圾回收机制及运行时基本原理是很有必要的。

程序计数器
作用: 记录当前线程所执行到的字节码的行号。字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
意义: JVM的多线程是通过线程轮流切换并分配处理器来实现的,对于我们来说的并行事实上一个处理器也只会执行一条线程中的指令。所以,为了保证各线程指令的安全顺利执行,每条线程都有独立的私有的程序计数器。
存储内容: 当线程中执行的是一个Java方法时,程序计数器中记录的是正在执行的线程的虚拟机字节码指令的地址。 当线程中执行的是一个本地方法时,程序计数器中的值为空。
可能出现异常: 此内存区域是唯一一个在JVM上不会发生内存溢出异常(OutOfMemoryError)的区域。

虚拟机栈
作用: 描述Java方法执行的内存模型。每个方法在执行的同时都会开辟一段内存区域用于存放方法运行时所需的数据,成为栈帧,一个栈帧包含如:局部变量表、操作数栈、动态链接、方法出口等信息。
意义:JVM是基于栈的,所以每个方法从调用到执行结束,就对应着一个栈帧在虚拟机栈中入栈和出栈的整个过程。
存储内容:局部变量表(编译期可知的各种基本数据类型、引用类型和指向一条字节码指令的returnAddress类型)、操作数栈、动态链接、方法出口等信息。
值得注意的是:局部变量表所需的内存空间在编译期间完成分配。在方法运行的阶段是不会改变局部变量表的大小的。
可能出现的异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。如果在动态扩展内存的时候无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈
作用: 为JVM所调用到的Nativa即本地方法服务。
可能出现的异常:和虚拟机栈出现的异常很相像

Java堆
作用: 所有线程共享一块内存区域,在虚拟机开启的时候创建。
意义
1、存储对象实例,更好地分配内存。
2、垃圾回收(GC)。堆是垃圾收集器管理的主要区域。更好地回收内存。
存储内容: 存放对象实例,几乎所有的对象实例都在这里进行分配。堆可以处于物理上不连续的内存空间,只要逻辑上是连续的就可以。
值得注意的是:在JIT编译器等技术的发展下,所有对象都在堆上进行分配已变得不那么绝对。有些对象实例也可以分配在栈中。
可能出现的异常: 实现堆可以是固定大小的,也可以通过设置配置文件设置该为可扩展的。如果堆上没有内存进行分配,并无法进行扩展时,将会抛出OutOfMemoryError异常。

方法区
作用: 用于存储运行时常量池、已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
意义: 对运行时常量池、常量、静态变量等数据做出了规定。
存储内容: 运行时常量池(具有动态性)、已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
可能出现的异常:当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

以上只是JVM标准规范,具体的JVM厂商的实现会有差异,最常用的HotSpot虚拟机对开发人员来说比较关注的有:
1、虚拟机栈
一般来说会触发OutOfMemoryError或StackOverflowError,常用的触发方式有构造函数调用自己,无限递归,或者线程数过多。
-Xss 设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M

2、方法区
触发OutOfMemoryError的方式可能是第三方类库依赖树过大,或者是大量的JSP,也可能是使用了CGLib等字节码技术的框架生成了大量的动态类。
对HotSpot,很长一段时间由于把分代回收技术就用到了方式区,所以方法区可以近似看作是HotSpot的永久代。
在JDK8后,已经放弃永久代PermGen,改用无空间MetaSpace了。
原来的XX:PermSize和XX:MaxPermSize也分别改为了-XX:MetaspaceSize和-XX:MaxMetaspaceSize
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

3、堆
相比栈和方法区,堆对开发人员来说会更加熟悉,这里也是最容易产生OOM的地方。
如果让你来实现某对象是否可回收,通常你会想到两个方案:
1、引用计数法,对象被引用一次,计数器加1,这种方法没办法解决对象互相引用的情况,如A、B都可回收,但因为互相引用了对方,所以计数器都是1,却回收不了。
2、根搜索算法,以不可回收的对象作为GC Roots,没有引用关系的被回收,有引用关系的看关系强度及当前的堆内存的紧张情况决定。在Java里面有强引用、软引用、弱引用、虚引用,这四种引用强度依次逐步减弱。现代JVM基本采用该算法。

宏观上采用根搜索算法已经定了,但微观上又怎么回收每个对象呢?

1、标记-清除算法
标记清除算法是最基础的收集算法,其他收集算法都是基于这种思想。标记清除算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,标记完成之后统一清除对象。
它的主要缺点:
①.标记和清除过程效率不高 。
②.标记清除之后会产生大量不连续的内存碎片。

2、复制算法
它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次理掉。这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可,实现简单,运行高效。适用于只有少量对象存活的情况。
主要缺点:内存缩小为原来的一半。

3、标记-整理算法
标记操作和“标记-清除”算法一致,后续操作不只是直接清理对象,而是在清理无用对象完成后让所有存活的对象都向一端移动,并更新引用其对象的指针。适用于多数对象存活的情况。
主要缺点:在标记-清除的基础上还需进行对象的移动,成本相对较高,好处则是不会产生内存碎片

在Java中大部分的对象,在生成后马上就会变成垃圾。不同生命周期的对象可以采取不同的收集方式。
所以当前商业虚拟机基本上都是采用
分代垃圾回收算法
堆空间分为年轻代与老年代
年轻代又分为Eden区,两个Survivor区:From区与To区
对年轻代的垃圾回收效率要远高于年老代,对新生代,使用复制算法;对年老代直接使用标记-整理算法。

一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

无论是年轻代的Minor GC,还是年老代的Major GC,甚至是整个堆空间的Full GC,都会引起Stop The World,停止所有应用程序的线程。想想你妈妈在打扫卫生的时候,你是不是要停止往地上扔垃圾?但是如果长时间不让你扔垃圾,可能又会严重影响你的生活,地不能不扫,垃圾又不能不扔,怎么平衡呢?下面介绍具体的垃圾收集器的实现。

Serial收集器
串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法、老年代标记-整理;垃圾收集的过程中会Stop The World(服务暂停)。通俗地说就是你妈妈扫地的时候,你不要扔垃圾,扫完你继续扔。
参数控制:-XX:+UseSerialGC 串行收集器

ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,利用计算机的多核提高收集速度。新生代并行,老年代串行;新生代复制算法、老年代标记-整理。通俗地说就是你妈妈找了几个帮手一起扫地,扫地期间你不要扔垃圾,扫完你继续扔。
参数控制:-XX:+UseParNewGC ParNew收集器
-XX:ParallelGCThreads 限制线程数量

Parallel收集器
Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-整理。通俗地说就是你还可以和打扫卫生的妈妈她们定好每次打扫时长,过短可能会导致扫不干净哦!
参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

Parallel Old 收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供
参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)。通俗地说就是她们先让你出去,然后进来你房间给所有垃圾做上标记(这个过程很短),然后你继续边扔垃圾她们边扫(这个过程挺长),然后她们又让你出去,给前面没标记到的新垃圾做上标记(这个过程也很短),最后又让你回来继续你继续扔垃圾,她们清理掉已经做了标记的垃圾。目标是让你离开房间的时间尽可能的短。
优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量
参数控制:-XX:+UseConcMarkSweepGC 使用CMS收集器
-XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

G1收集器
G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  1. 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  2. 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
    上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
    G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。
    通俗地说就是因为你的房间太大了,除了用CMS算法外,还给你的房间分了区域,针对不同热点区域(比如办公桌下面最容易脏)重点清理以降低卡顿时间。

常见配置汇总
堆设置
-Xms :初始堆大小
-Xmx :最大堆大小
-XX:NewSize=n :设置年轻代大小
-XX:NewRatio=n: 设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n :年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
-XX:MaxMetaspaceSize :设置元空间大小(JDK8或以上版本)
收集器设置
-XX:+UseSerialGC :设置串行收集器
-XX:+UseParallelGC :设置并行收集器
-XX:+UseParalledlOldGC :设置并行年老代收集器
-XX:+UseConcMarkSweepGC :设置并发收集器
垃圾回收统计信息
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename
并行收集器设置
-XX:ParallelGCThreads=n :设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n :设置并行收集最大暂停时间
-XX:GCTimeRatio=n :设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
并发收集器设置
-XX:+CMSIncrementalMode :设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n :设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

JDK提供的实用工具
jinfo:观察运行中的java程序的运行环境参数:参数包括Java System属性和JVM命令行参数,java class path等信息。命令格式:jinfo 进程pid
jps:用来显示本地的java进程,可以查看本地运行着几个java程序,并显示他们的进程号。命令格式:jps 或 jps 远程服务ip地址 (默认端口1099)
jstat:一个极强的监视VM内存工具。可以用来监视VM内存内的各种堆和非堆的大小及其内存使用量。
jstack:可以观察到jvm中当前所有线程的运行情况和线程当前状态。, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。命令格式:jstack 进程pid
当程序出现死锁的时候,使用命令:jstack 进程ID > jstack.log,然后在jstack.log文件中,搜索关键字“BLOCKED”,定位到引起死锁的地方。
jmap:观察运行中的jvm物理内存的占用情况(如:产生哪些对象,及其数量)。命令格式:jmap [option] pid
option参数如下:
-heap:打印jvm heap的情况
-histo:打印jvm heap的直方图。其输出信息包括类名,对象数量,对象占用大小。
-histo:live :同上,但是只答应存活对象的情况
-permstat:打印permanent generation heap情况
使用jmap进行 heap dump的例子: jmap -dump:format=b,file=
打印内存统计图:jmap -histo:live
结果中每行显示了当前堆中每种类类型的信息,包含被分配的实例个数及其消耗的字节数。选项“live”,表示只统计存活的对象
需要注意的是,jmap不是运行分析工具,在生成统计图时JVM可能会暂停,因此当生成统计图时需要确认这种暂停对程序是可接受的。
jconsole:一个java GUI监视工具,可以以图表化的形式显示各种数据。并可通过远程连接监视远程的服务器VM。
VisualVM:一款免费的,集成了多个 JDK 命令行工具的可视化工具,它能为您提供强大的分析能力,对 Java 应用程序做性能分析和调优。这些功能包括生成和分析海量数据、跟踪内存泄漏、监控垃圾回收器、执行内存和 CPU 分析,同时它还支持在 MBeans 上进行浏览和操作。

注意:jconsole和VisualVM由于需要GUI,一般无法直接在线上服务器运行。但相比前面几个工具,它们会更加直观。建议先在线上使用前面几个工具组合查找出原因,如果仍觉得分析有困难,则可考虑dump回本地分析。

总结:JVM常用的垃圾回收算法,总能在现实生活中找到应用例子,只要你在生活中勤于观察,善于总结。

posted @ 2018-05-14 00:39  彭彭(moext.com)  阅读(176)  评论(0编辑  收藏  举报