JVM学习小结3(内存溢出,垃圾回收和调优概述)

在这里插入图片描述

JVM内存溢出

堆内存溢出

表层上:堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到每个对象之间有可达路径来防止这些对象被GC清理,当对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。

内部中:新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代;老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。

堆内存溢出常见原因:内存中加载的数据过多,如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。

虚拟机栈/本地方法栈溢出
  • StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,也就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多),就会抛出StackOverflowError。最常见的场景就是方法无限递归调用。
  • OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,若一台机器可供栈使用的内存为512M,假如我们设置每个线程栈的大小为1M,那虚拟机中最多可以创建512个线程,再创建就没有空间了,就报OutOfMemoryError异常了。

总结:在线程较少的时候,某个线程请求深度过大,会报StackOverflow异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),把-Xss的值设置大一些,但一般情况下是代码的问题;在虚拟机产生线程时,无法为该线程申请栈空间了,会报OutOfMemoryError异常,解决这种问题可以适当减小栈的深度,把-Xss的值设置小一些,每个线程占用的空间小了,总空间能容纳更多的线程,但是操作系统对一个进程的线程数有限制,在3000到5000左右。

在jdk1.5之前-Xss默认是256k,jdk1.5之后默认是1M,设置时要根据系统硬性实际情况,谨慎操作。

方法区溢出

方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。

常见原因:常量过多,或代理反射等使用频繁等。

本机直接内存溢出

本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但Java中用到NIO相关操作时(比如ByteBuffer的allocteDirect方法申请的是本机直接内存),也可能会出现内存溢出的异常。

JVM垃圾回收(Garbage Collection)

垃圾回收(Garbage Collection),简称GC,就是通过垃圾收集器把内存中没用的对象清理掉。

涉及到的工作有:

  • 判断对象是否已死
  • 选择垃圾收集算法
  • 选择垃圾收集的时间
  • 选择适当的垃圾收集器清理垃圾

垃圾回收系统是JVM的重要组成部分,垃圾回收器可以对方法区、java堆和直接内存进行回收。其中,堆是垃圾收集器的工作重点。

java中所有的对象空间释放都是隐式的,也就是说,java中没有类似free()或者delete()这样的函数释放指定的内存区域。对于不再使用的垃圾对象,垃圾回收系统会在后台默默工作,默默查找、标识并释放垃圾对象,完成包括java堆、方法区和直接内存中的全自动化管理。

现在默认的回收算法是分代回收算法,对于对象是否可被回收(对象是否已死)使用可达性分析

判断对象是否已死

JVM的内存中,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,内存自然就跟随着回收了,就不需要过多考虑回收的问题。堆区和方法区这两个区域的内存的分配和回收是动态的,也是垃圾收集器所需关注的部分。

判断对象是否已死有引用计数算法和可达性分析算法。

引用计数算法

每个对象实例都有一个引用计数器,当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。每当有一个地方引用它时,计数加1(a = b,则b引用的对象实例的计数器+1),每当有一个地方不再引用它时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集,当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

可达性分析算法

这里引出一个概念,GC Roots,垃圾收集的起点。

在Java, 可作为GC Roots的对象包括:

  • 方法区: 类静态属性引用的对象
  • 方法区: 常量引用的对象
  • 虚拟机栈(本地变量表)中引用的对象
  • 本地方法栈JNI(Native方法)中引用的对象
  1. 当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象
  2. 但这些对象并非必死无疑,当某个对象和GC Roots之间没有引用链相连时,该对象将会被进行一次标记。
  3. 判断如果对象是否覆盖Object的finalize()方法,或者finalize()方法是否被虚拟机调用过。
  4. 若没有覆盖或finalize()已被执行,该对象会被回收。
  5. 若对象覆盖了finalize()方法且该方法还没有被调用,则会执行finalize()方法中的内容。
  6. 若finalize()方法中存在将该对象与GC Roots的引用链连接上的操作,那么该对象则不会被回收。
附加:方法区回收

上面两种都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。

判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。

判断类是否废弃需要同时满足如下条件:

  • 该类所有的实例已经被回收(堆中不存在任何该类的实例)
  • 加载该类的ClassLoader(加载器)已经被回收
  • 该类对应的java.lang.Class对象在任何地方没有被引用(无法通过反射访问该类的方法)
常用垃圾回收算法
标记清除算法(Mark-Sweep)

分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象

缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。

复制算法(Copying)

内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环。

优点:比较标记清除算法,避免了回收造成的内存碎片问题。

缺点:以局部的内存空间牺牲为代价。

标记整理算法(Mark-Compact)

先对可用的对象进行标记,然后所有被标记的对象统一到一起,最后清除可用对象边界以外的内存。

优点:避免了空间的浪费,与内存碎片问题。

缺点:整理时复制有效率成本。

分代收集与分区收集
分代收集

当前主流VM垃圾收集都采用分代收集算法,这种算法会根据对象存活周期的不同将内存划分为几块, 如堆内存种的新生代、老年代,新生代又分为Eden区、From Survivor和To Survivor。这样就可以根据各年代特点分别采用最适当的GC算法。

新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集。年轻代的垃圾回收称为minor GC

老年代中的对象存活率较高且没有额外空间对它进行分配担保,就采用标记清除和标记整理算法来进行回收。不必进行内存复制,而时直接腾出空闲内存。年老代的垃圾回收称为full GC

新生代中的复制算法

由于新生代种98%的对象都是生存周期极短的,因此并不需完全按照1∶1的比例划分新生代空间,而是将新生代划分为一块较大的Eden区和两块较小的Survivor区(HotSpot默认Eden和Survivor的大小比例为8∶1),每次只用Eden和其中一块Survivor。当发生Minor GC时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor上,最后清理掉Eden和刚才用过的Survivor的空间。当Survivor空间不够用(不足以保存尚存活的对象)时,需要依赖老年代进行空间分配担保机制,这部分内存直接进入老年代。

空间分配担保

在执行Minor GC前,VM会首先检查老年代是否有足够的空间存放新生代尚存活对象,由于新生代使用复制收集算法,为了提升内存利用率,只使用了其中一个Survivor作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代,但前提是老年代需要有足够的空间容纳这些存活对象。但存活对象的大小在实际完成GC前是无法明确知道的,因此Minor GC前,VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小,如果条件成立,则进行Minor GC,否则进行Full GC,让老年代腾出更多空间。然而取历次晋升的对象的平均大小也是有一定风险的,如果某次Minor GC存活后的对象突增,远远高于平均值的话。依然可能导致担保失败(Handle Promotion Failure,老年代也无法存放这些对象了),此时就只好在失败后重新发起一次Full GC。

简单来说,这个机制就是为了保证复制算法的空间足够,需要老年代预留足够的空间,若空间不够,则Full GC进行清理。

老年代的标记清除和标记整理
  • 标记清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象。
  • 标记整理算法的标记过程与标记清除算法相同,但后续步骤不再对可回收对象直接清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。
分区收集

算法将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间。在相同条件下,堆空间越大,一次GC耗时就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次GC所产生的停顿。

选择垃圾收集的时间

这里引入两个概念,安全点和安全区。

安全点:从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程(类似于调试代码时的断点)。当垃圾收集时,如果需要暂停当前的用户线程,应该等待这些线程执行到安全点再暂停。理论上,解释器的每条字节码的边界上都可以放一个安全点;实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。

安全区:安全点是相对于运行中的线程来说的,对于如sleep或blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。

垃圾收集器

在这里插入图片描述
新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、CMS、Parallel Old

分区垃圾收集器:G1

Serial

单线程收集器,采用复制算法进行垃圾收集,进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(STW),收集完之后,用户线程继续开始执行。

STW(stop the world):编译代码时为每一个方法注入safepoint(方法中循环结束的点、方法执行结束的点),在暂停应用时,需要等待所有的用户线程进入safepoint,之后暂停所有线程,然后进行垃圾回收。

虽然是单线程收集,但它却简单而高效,在VM管理内存不大的情况下(收集几十M到一两百M的新生代),停顿时间完全可以控制在几十毫秒到一百多毫秒内。

ParNew

ParNew就是一个Serial的多线程版本,其它与Serial并无区别。

当使用-XX:+UserConcMarkSweepGC来选择CMS作为老年代收集器时,新生代收集器默认就是ParNew,也可以用-XX:+UseParNewGC来指定使用ParNew作为新生代收集器。

Parallel Scavenge

用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量。

吞吐量是CPU执行用户线程的的时间与CPU执行总时间的比值【吞吐量=运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了100分钟,其中垃圾收集花费了1分钟,那吞吐量就是99% 。

例如:垃圾收集器每100秒收集一次,每次停顿10秒,吞吐量=100/(100+10)=91%;垃圾收集器每50秒收集一次,每次停顿7秒,吞吐量=50/(50+7)=88%。

以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimeRatio来精确控制吞吐量。

Serial Old

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

CMS

是一种以最短回收停顿时间为目标的收集器,以“最短用户线程停顿时间”著称。大致分为四个步骤:

  1. 初始标记:标记一下GC Roots能直接关联到的对象,速度较快,需要停止用户线程,单线程执行
  2. 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长,可以和用户线程并发执行
  3. 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短
  4. 并发清除:用标记-清除算法清除垃圾对象,耗时较长

整个过程是和用户线程一起工作,CMS收集器垃圾收集可以看做是和用户线程并发执行的。

缺点:

  • 对CPU资源敏感,默认分配的垃圾收集线程数为(CPU数+3)/4,随着CPU数量下降,占用CPU资源越多,吞吐量越小。
  • 无法处理浮动垃圾,在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当CMS运行时,预留的内存空间无法满足用户线程的需要,就会出现“Concurrent Mode Failure”的错误,这时将会启动后备预案,临时用Serial Old来重新进行老年代的垃圾收集。
Parallel Old

Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力。

分区收集 G1

是jdk9默认的收集器,前面几款收集器收集的范围都是新生代或者老年代,G1进行垃圾收集的范围是整个堆内存,它采用“化整为零”的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在G1收集器中虽然还由新生代和老年代的概念,但它们都是一部分(一个或几个)Region。JVM启动时会自动设置每个Region的大小(1M~32M,必须是2的次幂),最多可以设置2048个区域(即支持的最大堆内存为32M*2048=64G)。

不需要一次就对整个老年代/新生代回收,而是当线程并发寻找可回收的对象时,部分区块包含可回收的对象要比其他区块多很多。虽然在清理这些区块时G1仍然需要暂停应用线程,但可以用相对较少的时间优先回收垃圾较多的Region。这种方式保证了G1可以在有限的时间内获取尽可能高的收集效率。

Remembered Set

G1收集器中, Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用都是使用Remembered Set来避免扫描全堆。G1中每个Region都有一个与之对应的Remembered Set,VM发现程序对Reference类型数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当内存回收时,在GC根节点的枚举范围加入Remembered Set即可保证不对全局堆扫描也不会有遗漏。

常用垃圾收集器组合
  • Serial Young GC + Serial Old GC ,实际上它是全局范围的Full GC,适用于小程序或低配置计算机系统
  • ParNew Young GC + Serial Old GC ,比年轻代多了多线程垃圾回收而已
  • Parallel Young GC + Parallel Old GC,实际上它是全局范围的Full GC,适用于对吞吐量敏感的应用
  • ParNew Young GC + CMS Old GC ,适用于对延时敏感的应用

JVM性能调优

JVM调优工具
  • 系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。

  • 堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。

  • GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。

  • 线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息。

  • 堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。

  • 其他JVM调优工具:Jps(JVM process Status);Jstat(JVM Statistics Monitoring Tool);Jmap(Memory Map for Java);Jinfo;Jconsole;Jvisualvm;Jprofile

JVM调优经验

结合JVM调优工具等对JVM配置进行合理的调整。当老年代内存过小时可能引起频繁Full GC,当内存过大时Full GC时间会特别长。物理内存一定的情况下,新生代设置越大,老年代就越小,Full GC频率就越高,但Full GC时间越短;相反新生代设置越小,老年代就越大,Full GC频率就越低,但每次Full GC消耗的时间越大。

  • 新生代和老年代空间的大小:由于Full GC的成本远比Minor GC的成本大,所以新生代尽量设置大一些,让对象在新生代多存活一段时间,每次Minor GC都要尽可能多的收集垃圾对象,防止或延迟对象进入老年代的机会,以减少应用程序发生Full GC的频率
  • 堆内存的大小:稳定的堆大小是对垃圾回收有利的,将-Xms和-Xmx的大小一致。堆大小默认为-Xms指定的大小,空闲堆内存小于40%时,JVM会扩大堆到-Xmx指定的大小;空闲堆内存大于70%时,JVM会减小堆到-Xms指定的大小。如果在Full GC后满足不了内存需求会动态调整,这个阶段比较耗费资源
  • 老年代收集器的选择:老年代使用CMS回收器,因为CMS的并行收集速度很快,减少Full GC的次数和停顿
  • 大对象的处理:将大对象直接分配到老年代,保持新生代对象的结构的完整性,以提高GC效率, 以通过-XX:PretenureSizeThreshold设置进入老年代的阀值
  • 逻辑操作方面:
    1. 避免同时加载大量数据,如一次从数据库中取出大量数据,或者一次从Excel中读取大量记录,可以分批读取,用完尽快清空引用
    2. 避免长时间等待外部资源(数据库、网络、设备资源等)的情况,缩小对象的生命周期,避免进入老年代,如果不能及时返回结果可以适当采用异步处理的方式等
    3. 避免创建过大的对象及数组:过大的对象或数组在新生代没有足够空间容纳时会直接进入老年代,如果是短命的大对象,会提前出发Full GC
    4. 当集合中有对象的引用,这些对象使用完之后要尽快把集合中的引用清空,这些无用对象尽快回收避免进入老年代
JVM排查案例

问题排查案例可参考:

JVM服务问题排查 https://blog.csdn.net/jacin1/article/details/44837595

次让人难以忘怀的排查频繁Full GC过程 http://caogen81.iteye.com/blog/1513345

线上FullGC频繁的排查 https://blog.csdn.net/wilsonpeng3/article/details/70064336/

【JVM】线上应用故障排查 https://www.cnblogs.com/Dhouse/p/7839810.html

一次JVM中FullGC问题排查过程 http://iamzhongyong.iteye.com/blog/1830265

JVM内存溢出导致的CPU过高问题排查案例 https://blog.csdn.net/nielinqi520/article/details/78455614

一个java内存泄漏的排查案例 https://blog.csdn.net/aasgis6u/article/details/54928744

常用JVM参数

参数说明实例
-Xms初始堆大小,默认物理内存的1/64-Xms512M
-Xmx最大堆大小,默认物理内存的1/4-Xms2G
-Xmn新生代内存大小,官方推荐为整个堆的3/8-Xmn512M
-Xss线程堆栈大小,jdk1.5及之后默认1M,之前默认256k-Xss512k
-XX:NewRatio=n设置新生代和年老代的比值。为3,表示年轻代与年老代比值为1:3-XX:NewRatio=3
-XX:SurvivorRatio=n年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8-XX:SurvivorRatio=8
-XX:PermSize=n永久代初始值,默认为物理内存的1/64-XX:PermSize=128M
-XX:MaxPermSize=n永久代最大值,默认为物理内存的1/4-XX:MaxPermSize=256M
-verbose:class在控制台打印类加载信息
-verbose:gc在控制台打印垃圾回收日志
-XX:+PrintGC打印GC日志,内容简单
-XX:+PrintGCDetails打印GC日志,内容详细
-XX:+PrintGCDateStamps在GC日志中添加时间戳
-Xloggc:filename指定gc日志路径-Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC年轻代设置串行收集器Serial
-XX:+UseParallelGC年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。-XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n设置Parallel Scavenge回收的最大时间(毫秒)-XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)-XX:GCTimeRatio=19
-XX:+UseParallelOldGC设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC设置老年代并发收集器CMS
-XX:+CMSIncrementalMode设置CMS收集器为增量模式,适用于单CPU情况。

参考博文:

【1】:JVM知识点精华汇总

【2】:JVM知识点汇总

【3】:JVM知识点(全,一篇搞定)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值