面试题:JVM与G1要点总结
一.Java内存区域
1.运行时数据区的介绍
2.站在线程的角度看Java内存区域
3.深入分析堆和栈的区别
4.方法的出入栈和栈上分配、逃逸分析及TLAB
5.虚拟机中的对象创建步骤
6.对象的内存布局
1.运行时数据区的介绍
运行时数据区的类型:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区(运行时常量池)、直接内存。
程序计数器:当前线程所执行的字节码的行号指示器,占用内存空间小,线程私有,各线程间独立存储,互不影响。Java虚拟机规范中程序计数器不会出现OOM情况。
Java虚拟机栈:是线程私有的,其生命周期和线程同生共死。每个方法在开始执行时,Java虚拟机会同步创建一个栈帧用于存储:局部变量表、操作数栈、动态链接、方法出口等信息。每个方法的执行,对应着栈帧在虚拟机栈中入栈和出栈的过程。这也是Java方法执行的线程内存模型,可用-Xss进行虚拟机栈大小的设置,默认为1M。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存也会抛出OutOfMemoryError异常。注意:HotSpot虚拟机的栈容量是不能动态扩展的。
本地方法栈:作用和Java栈一样,本地方法栈保存的是native方法的信息。当一个JVM创建的线程调用native方法后,JVM不再为其创建栈帧。JVM只会简单地动态链接并直接调用native方法。本地方法栈也会在栈深度溢出或者栈扩展失败时,分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆:Java中几乎所有对象实例都在堆上分配内存,因为部分对象由于逃逸分析、栈上分配、标量替换而不在堆上分配的。Java堆是Java开发者需要重点关注的一块区域,因为涉及到内存的分配(new关键字、反射)与回收(回收算法、收集器)。
如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(TLAB)。在Java堆中的这些线程私有的分配缓冲区(TLAB)可以提升对象分配效率,而将Java堆细分的目的就是为了更好地回收内存,或者更快地分配内存。
当前主流的Java虚拟机对Java堆都支持可动态扩展。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。-Xms:堆的最小值,-Xmx:堆的最大值,-Xmn:新生代的大小。
方法区:方法区和Java堆一样,是共享的区域,所有线程都可以共享这一区域。方法区主要存放:类信息、常量、静态变量和JIT编译后的代码缓存等。在JVM启动的时候,方法区就被创建为固定大小或可动态扩容的区域。方法区在逻辑上属于堆的一部分,但一些简单的实现不会进行GC回收。因而方法区可看作是独立于Java堆的一块空间。
方法区的垃圾回收主要包含两部分内容:废弃的常量、不再使用的类。判断一个常量是否废弃:没有任何地方引用该常量。判断一个类是否不再使用的条件如下:该类所有的实例都已被回收,堆中不存在该类及其子类的实例、加载该类的类加载器已被回收,通常该条件很难达成、该类对应的java.lang.class对象没有在任何地方被引用(如反射)。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常需要JVM具备卸载类的能力,保证不会对方法区造成过大内存压力。
运行时常量池:运行时常量池是方法区的一部分,运行时常量池用于存放编译期生成的各种字面量和符号引用。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table)。常量池表用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行常量池中。
2.站在线程的角度看Java内存区域
一个线程在一个时刻只能运行一个方法,只能运行一行代码,所以一个线程只需要一个本地方法栈,只需要一个程序计数器。
虚拟机栈、本地方法栈、程序计数器是和线程同生共死的。Java堆、方法区是和Java进程同生共死的。GC是不发生在栈上的,GC只发生在堆和方法区上。一个线程调用本地方法的话,会开辟一个虚拟机栈和本地方法栈。
3.深入分析堆和栈的区别
(1)堆和栈在功能上的区别
栈内存会以栈帧的方式存储方法调用的过程和所使用的变量。方法调用过程中使用的变量包括:基本数据类型变量和对象的引用变量。其内存分配在栈上,里面的变量出了作用域就会自动释放。堆内存用来存储Java中的对象。无论是成员变量、局部变量、还是类变量,这些变量指向的对象都是存储在堆内存的。
(2)堆和栈是线程独享还是线程共享
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见。栈内存可理解成线程的私有内存,堆内存中的对象对所有线程可见,堆内存中的对象可被所有线程访问。
(3)堆和栈的空间大小对比
栈的内存要远远小于堆内存,栈的深度是有限制的,可能发生StackOverFlowError问题。
(4)堆中创建出来的类的对象不包含类的成员方法
Java堆是用来存放动态产生的数据,比如new出来的对象,注意创建出来的对象只包含属于各自的成员变量,并不包括成员方法。因为同一个类的对象拥有各自的成员变量,存储在堆中,但是它们共享该类的方法,并不是每创建一个对象就复制成员方法一次。
4.方法的出入栈和栈上分配、逃逸分析及TLAB
(1)方法会打包成栈帧
一个栈帧至少要包含局部变量表、操作数栈和帧数据区。执行任何一个方法时,方法会打包成一个栈帧。
(2)栈上分配
几乎所有的对象都是在堆上分配的,栈上分配就是虚拟机提供的一种优化技术。其基本思想是将线程私有的对象打散分配在栈上,而不分配在堆上。这样的好处是对象跟着方法调用自行销毁,不需要进行垃圾回收,从而提高性能。
(3)逃逸分析
栈上分配需要的技术基础:逃逸分析。逃逸分析的目的是判断对象的作用域是否会逃逸出方法体。注意:任何可以在多个线程间共享的对象,一定都属于逃逸对象。
(4)线程本地分配缓冲TLAB
TLAB全称是ThreadLocalAllocBuffer,线程本地分配缓冲。创建对象是在堆上分配的,需要在堆上申请指定大小内存的。一个堆一块区域,线程A进来分配区域a,线程B进来分配区域b。
如果有大量线程同时申请堆上的内存,为避免两个线程申请内存时不会申请同一块内存,需要对申请进行加锁。加锁不仅在并发编程时会有,虚拟机在实现时同样要考虑并发而加锁;如果不加锁,就有可能两个线程同时分配到同一块内存,导致数据错乱。
我们经常会new一个对象出来,所以内存分配是一个非常频繁的动作。因此内存分配时也就需要频繁加锁,而频繁加锁就会影响性能。一旦加锁,这种动作就会变成串行的模式,对性能影响很大。
所以TLAB的作用就是:它会事先在堆里面为每个线程分配一块私有内存,在线程A中new出的对象只在线程A的私有内存上进行分配。
所以TLAB的好处就是:由于线程的堆内存事前分配好了,因此同时分配时就不存在竞争了。从而大大提高了分配的效率,当私有内存用完了再重新申请继续使用。不过要注意的是,重新申请堆内存的动作还是需要保证原子性的。
TLAB涉及到线程私有,每个线程在new对象时会在私有内存上分配内存。尽管线程A在私有内存区域a位置拥有一块私有内存并在上面分配了对象,但是这些对象对所有线程都是可见并可用的。也就是说这些A线程的对象在分配的时候只能在a区域分配而已,B线程、C线程也是可以看见它们并使用它们的。
5.虚拟机中的对象创建步骤
Java程序几乎无时无刻都有对象被创建出来,虚拟机碰到一个new关键字时是如何创建对象的呢?
步骤一:进行类加载
步骤二:为对象分配内存
步骤三:为分配的内存空间初始化零值
步骤四:设置对象的对象头
步骤五:对象初始化
步骤一:进行类加载
首先检查new指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否被加载、解析和初始化过。如果没有,则执行相应的类加载过程。
步骤二:为对象分配内存
类加载完成后,虚拟机就要为这个新生对象分配内存,也就是需要把一块确定大小的内存从Java堆中划分出来。
如果Java堆中的内存是绝对规整的,所有用过的内存都放一边,空闲的内存放另一边,并且中间放一个指针作为已用内存和空闲内存的分界点的指示器,分配内存时就把该指针向空闲空间那边移动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法进行指针碰撞了,此时虚拟机就必须要维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效。当使用CMS这种基于清除算法的收集器时,理论上采用空闲列表来分配内存,但实际为了分配更快也加入指针碰撞。
除如何划分可用空间外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为。即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的。可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS+失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存TLAB。
开启使用TLAB本地线程分配缓冲(Thread Local Allocation Buffer)时,在线程初始化时会申请一块指定大小的内存,只给当前线程使用。这样每个线程都单独拥有一个Buffer,如果要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况。当Buffer容量不够的时候,再重新从Eden区域申请一块内存继续使用。
TLAB的目的是在为新对象分配内存内存时,让每个Java应用线程用自己专属的分配指针来分配内存,减少同步开销。TLAB只是让每个线程拥有私有的分配指针,创建的对象还是线程共享的。当一个TLAB用完了(分配指针top撞上end了),那么就重新申请一个TLAB。
步骤三:为分配的内存空间初始化零值
内存分配完成后,虚拟机就需要将分配到的内存空间都初始化为零值。这一步操作保证了对象的实例字段在代码中可以不赋初始值就直接使用,也就是程序能访问到这些字段的数据类型所对应的零值。
步骤四:设置对象的对象头
内存空间初始化零值后,虚拟机就要对对象进行必要的设置,需要在对象的对象头中设置这些内容:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
步骤五:对象初始化
设置完对象的对象头信息后,从虚拟机视角来看,一个新的对象已产生。但从Java程序视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,执行new指令后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
6.对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头的第一部分是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。
对象头的第二部分是类型指针,即对象指向它的类的元数据的指针,JVM虚拟机可以通过这个指针确定这个对象是哪个类的实例。
对象头的第三部分是对齐填充,它仅仅起着占位符的作用。因为HotSpot的自动内存管理系统要求对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。
二.垃圾回收机制
1.如何判断对象存活
2.各种引用介绍
3.垃圾收集的算法
4.垃圾收集器的设计
5.Serial和Serial Old收集器
6.ParNew收集器
7.Parallel Scavenge/Parallel Old收集器
8.Concurrent Mark Sweep(CMS)收集器
1.如何判断对象存活
(1)引用计数算法
给对象添加一个引用计数器,每当一个地方引用它时就将计数器加1,当引用失效时就将计数器减1,任何时刻计数器为0的对象都不再被使用。这种算法简单,但是有个致命的缺点,就是不能用于相互引用的情况。优点:快、方便、实现简单。缺点:对象相互引用时,很难判断对象是否应被回收。PHP、Python的垃圾回收就是使用了引用计数算法,Java的垃圾回收使用的是可达性分析。
(2)可达性分析算法
通过一系列称为"GC Roots"的根对象作为起始点集,根据引用关系从这些节点往下搜索,搜索走过的路径称为引用链(Reference Chain)。当一个对象到"GC Roots"之间不存在任何引用链的时候,就表示这个对象不可达,不可用了。
在Java中,可作为GC Roots的对象包括:
一.在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各线程栈帧对应的方法中用到的参数、局部变量、临时变量
二.方法区中静态属性引用的对象,比如Java类的引用类型的静态变量
三.方法区中常量引用的对象,比如字符串常量池(StringTable)里的引用
四.本地方法栈中引用的对象
五.Java虚拟机内部的引用对象,比如基本数据类型对应的Class、系统类加载器、一些常驻的异常对象如NullPointException和OutOfMemoryError等
六.被同步锁(Synchronized)持有的对象
七.反映Java虚拟机内部情况的对象,比如JMXBean、JVMTI中的回调、本地代码缓存等
从"GC Roots"出发到达不了的那些对象都是可以被回收的。这里从"GC Roots"出发的引用链中的"引用"包括4种引用:强引用、软引用、弱引用、虚引用。
2.各种引用介绍
(1)强引用
一般Object obj = new Object()就属于强引用。
(2)软引用(SoftReference)
一些有用但并非必需的对象,可以使用软引用进行关联。在系统将要发生OOM之前,这些软引用对象才会被回收。如果这些软引用对象被回收后内存还不够,才会发出OOM异常。
(3)弱引用(WeakReference)
弱引用对象是一些有用(程度比软引用更低)但是并非必需的对象。弱引用关联的对象,只能生存到下一次GC之前。WeakHashMap就用到了弱引用。GC发生时,不管内存够不够,弱引用都会被回收。
(4)虚引用(PhantomReference)
也叫幽灵引用,最弱的。一个对象是否存在虚引用对它的生存完全不构成任何影响,同时通过虚引用也没法拿到一个对象的实例。虚引用唯一的作用就是被垃圾回收的时候会收到一个通知。
注意:软引用SoftReference和弱引用WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。另外日常编程中不要手动调用"System.gc();"。
(5)软引用和弱引用的应用场景
场景一:二级缓存可以用弱引用WeakReference
场景二:构建图片缓存可以用软引用SoftReference
一个程序需要处理用户提供的图片。如果将所有图片读入内存,这样虽然能很快打开图片,但内存使用巨大。而且一些使用较少的图片会浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用少,但一些经常使用的图片每次打开都要访问磁盘速度慢。这时就可以用软引用构建缓存。
很多系统的缓存功能都符合这样的场景:当内存空间还足够时,能够保留在内存中。如果内存空间在进行GC后仍然非常紧张,那就可以抛弃这些对象。
3.垃圾收集的算法
(1)标记-清除算法(Mark-Sweep)
首先标记出所有要回收的对象,标记完成后统一回收所有被标记的对象。也可以先标记存活的对象,标记完成后统一回收未被标记的对象。
缺点一:执行效率不稳定
如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时就必须要进行大量的标记和清除动作。这会导致标记和清除两个过程的执行效率都随对象数量增长而降低。
缺点二:内存空间的碎片化问题
标记、清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致:在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾回收。
(2)标记-复制算法(Copying)
策略一:半区复制策略
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,该策略会产生大量的内存间复制开销。但对于多数对象都是可回收的情况,需要复制的就是占少数的存活对象。
由于每次都是针对整个半区进行内存回收,所以内存分配时就不用考虑有内存碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,浪费50%的内存空间。
策略二:更优化的半区复制策略
HotSpot虚拟机的Serial、ParNew等新生代收集器,就是采用这种策略的。具体就是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden区和其中一块Survivor区。
进行垃圾收集时,将Eden和Survivor中存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden区和已使用过的那块Survivor区的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8 : 1,也就是每次新生代中可用内存空间为整个新生代空间的90%,只有一个Survivor空间(即10%的新生代空间)是会被浪费掉的。此外,当Survivor空间不足以容纳一次Young GC之后存活的对象,就需要依赖其他内存区域(大多数是老年代)进行分配担保。
标记-复制算法总结:标记-复制算法在对象存活率较高时要进行较多的复制操作,效率会降低。更关键的是,如果不想浪费50%的空间,就要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。所以在老年代一般不推荐选用这种算法。
(3)标记-整理算法(Mark-Compact)
首先标记出所有需要回收的对象。在标记完成后,后续步骤不是直接对可回收对象进行清理。而是让所有存活的对象向一端移动,然后直接清理掉端边界外的内存。
标记-清除算法和标记-整理算法的本质差异在于:前者是一种非移动式的回收算法,而后者是移动式的回收算法。
(4)是否移动回收后的存活对象分析
情况一:如果移动存活对象
尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会负担极大,而且这种对象移动操作必须全程暂停用户应用程序才能进行(STW)。
情况二:如果不移动存活对象
比如像标记-清除算法那样不考虑移动和整理存活对象的话,则空间碎片化问题只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,这个环节增加额外负担将影响应用程序的吞吐量。
可见是否移动回收后的存活对象都存在弊端:移动则内存回收时更复杂,不移动则内存分配时更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间更短甚至不需要停顿。但从整个应用程序的吞吐量来看,移动对象会更划算。因为不移动对象虽然会使得收集器的效率提升了,但因为内存分配和访问相比垃圾收集频率高得多,这部分的耗时增加后,总吞吐量仍然是下降的。HotSpot虚拟机里关注吞吐量的Parallel Old收集器是基于标记-整理算法,而关注延迟的CMS收集器则是基于标记-清除算法。
(5)CMS收集器如何处理空间碎片过多
和稀泥式不在内存分配和访问上增加太大额外负担,具体做法是:让虚拟机平时采用标记-清除算法,暂时容忍内存碎片的存在。直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
4.垃圾收集器的设计
(1)分代收集理论的3个假说
弱分代假说:绝大多数对象都朝生夕灭
强分代假说:熬过多次GC过程的对象越难消亡
跨代引用假说:跨代引用相对于同代引用来说仅占极少数
(2)垃圾收集器的设计原则
假说一和二奠定了垃圾收集器的设计原则。收集器首先应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。其中对象的年龄就是对象熬过垃圾收集过程的次数。
如果一个区域中大都是朝生夕灭的对象,则应以最低代价回收大量空间,即每次回收只须关注如何保留少量存活对象而非标记大量被回收的对象。如果一个区域中大都是难以消亡的对象,则应该以较低的频率回收这个区域,同时兼顾垃圾收集的时间开销和内存利用的效率。
现在商用Java虚拟机,一般把Java堆划分为新生代和老年代两个区域。新生代中,每次垃圾收集时都发现有大批对象死去,而新生代每次回收后存活的少量对象将会逐步晋升到老年代中存放。
(3)新生代和老年代出现跨代引用的处理
分代收集并非简单划分一下内存区域这么容易,至少存在一个明显的问题:对象不是孤立的,对象之间会存在跨代引用。
假如要进行一次只局限于新生代区域内的收集(Young GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有的对象来确保可达性分析结果的正确性。遍历整个老年代所有对象的方案虽然理论可行,但无疑会为内存回收带来了很大的性能负担。
根据假说三,不应为少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在哪些跨代引用。只需在新生代上建立一个全局的数据结构(该结构被称为"记忆集"),记忆集会把老年代划分成若干小块,标识出那块内存会存在跨代引用。此后当发生Young GC时,包含了跨代引用的小块内存里的对象会被加入到GC Roots进行扫描。
(4)分代收集算法
当前商业虚拟机的垃圾收集都采用分代收集算法,这种算法就是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
专门研究表明,新生代中的对象98%是朝生夕死的。所以并不需要按照1 : 1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor空间,回收时将Eden和Survivor中存活的对象一次性复制到另一块Survivor,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8 : 1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活。那就选标记-复制算法,只需付出少量存活对象的复制成本就能完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除算法或者标记-整理算法来进行回收。
新生代的回收:Young GC(只发生在新生代的垃圾收集)。老年代的回收:Full GC(发生在整个Java堆和方法区的垃圾收集)。新生代不断会有少量对象进入老年代,当老年代快填满时会发生Full GC。
垃圾回收器新生代列表:
垃圾回收器老年代列表:
5.Serial和Serial Old收集器
单线程,适合单CPU或CPU核心数少的服务器。Serial收集器依然是HotSpot运行在客户端模式下的默认新生代收集器。它有优于其他收集器的地方,即简单而高效(与其他收集器的单线程相比)。对于内存资源受限的环境,Serial是所有收集器里内存消耗最小的。对于单核处理器或处理器核心数较少的环境来说,Serial由于没有线程交互开销,专心做GC而获得最高的单线程收集效率。Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
6.ParNew收集器
ParNew收集器是Serial收集器的多线程并行版本。和Serial收集器相比,基本没区别(回收策略、算法),唯一的区别就是:多线程,适合于多CPU的,停顿时间比Serial少。
和Parallel Scavenge收集器相比,它关注的是尽可能缩短垃圾收集时用户线程的停顿时间,也就是关注停顿时间。停顿时间短的收集器适合用户交互的程序,以便于提高用户体验。
除了Serial收集器外,目前只有ParNew能与CMS收集器配合工作。自JDK9开始,ParNew+CMS就不再是官方推荐的Server下的解决方案。官方希望被G1取代,甚至取消ParNew+Serial Old和Serial+CMS的支持。
7.Parallel Scavenge/Parallel Old收集器
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是尽可能地达到一个可控制的吞吐量。
所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。虚拟机总共运行100分钟,其中垃圾收集花了1分钟,那吞吐量就是99%。
停顿时间越短越适合需要与用户交互或需要保证服务响应质量的程序,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,关注高吞吐量主要适合在后台运算而不需要太多交互的任务。
8.Concurrent Mark Sweep(CMS)收集器
(1)CMS的阶段(初始标记 + 并发标记 + 重新标记 + 并发清除)
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器可以让系统停顿时间最短,给用户带来较好的体验。从名字Mark Sweep可看出,CMS收集器是基于标记-清除算法实现的。它的运作过程相对于前面几种收集器来说更复杂,整个过程分4个阶段。
阶段一:初始标记
用户程序短暂暂停,仅标记GC Roots能直接关联到的对象,速度很快。
阶段二:并发标记
和用户程序同时进行,进行GC RootsTracing。
阶段三:重新标记
用户程序短暂暂停,为修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记长,但远比并发标记的时间短。
阶段四:并发清除
在耗时最长的并发标记和并发清除阶段,GC线程都与用户线程一起工作,所以从总体上看,CMS收集器的内存回收过程与用户线程一起并发执行。
CMS收集器的主要优点:并发收集、低停顿。CMS收集器有三个明显缺点:资源敏感、浮动垃圾、内存碎片。
(2)CMS的缺点(资源敏感 + 浮动垃圾 + 内存碎片)
缺点1:资源敏感
在并发阶段,它虽然不会导致用户线程停顿,但却因为占用一部分线程,也就是处理器的计算能力,而导致应用程序变慢,降低总吞吐。CMS默认启动的回收线程数:(CPU核数 + 3) / 4。如果CPU核心数在4个以上,那么并发回收时,GC线程数只占不超过25%的CPU运算资源,并且会随着CPU核心数量的增加而下降。
如果CPU核心数不足4个,CMS对用户程序的影响就可能就变得很大。比如应用原来的CPU负载很高,还要分一半的运算能力去处理GC线程,那么就可能导致用户程序的执行速度忽然大幅降低。
缺点2:浮动垃圾
由于并发清理时用户线程还在运行,所以还会有新的垃圾不断产生。这一部分垃圾出现在标记过程后,这些垃圾就称为浮动垃圾。CMS无法在当次收集中处理掉它们,只好等下次GC时再清理掉。同时用户的线程还在运行,要给用户线程留下运行的内存空间。
参数-XX:CMSInitiatingOccupancyFraction。由于浮动垃圾和需要留内存给运行着的用户线程,因此CMS不能像其他收集器那样等老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
早期JDK默认下,CMS收集器当老年代使用了68%的空间后就会被激活。如果在应用中老年代增长不是太快,可以适当调高该CMS的激活阈值,以便降低内存回收次数从而获取更好的性能。
在JDK1.6中,CMS收集器的启动阈值已经提升至92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure,这时虚拟机将启动后备预案,临时启用Serial Old收集器进行老年代的GC,这样停顿时间就很长了。
如果-XX:CMSInitiatingOccupancyFraction设置得太高,就会很容易导致出现大量Concurrent Mode Failure,性能反而降低。
缺点3:内存碎片
由于CMS使用标记-清除算法,因此会产生大量空间碎片。空间碎片过多时,分配大对象就会出现问题而提前触发Full GC。
参数-XX:+UseCMSCompactAtFullCollection。CMS提供这个开关参数(默认开启)便是为了解决内存碎片问题,用于在CMS顶不住要进行Full GC时开启内存碎片的合并整理过程。内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间更长。
这个参数用于设置执行多少次不压缩Full GC后,跟着来一次带压缩的。默认值0,表示每次进入Full GC时都进行碎片整理。
三.类和类加载相关
1.类的生命周期
2.类加载的全过程
3.类加载器
4.双亲委派模型
5.类加载器和双亲委派机制总结
1.类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止。类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
2.类加载的全过程
Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这5个阶段所执行的具体动作:
(1)加载阶段
首先代码中包含main()方法的主类会在JVM进程启动后被加载到内存,然后JVM进程会开始执行main()方法中的代码,接着遇到使用别的类就会从对应的".class"字节码文件加载该类到内存。
(2)验证阶段
这一步会根据虚拟机规范,校验加载的".class"文件内容,是否符合规范。假如".class"文件被篡改,里面的字节码不符合规范,JVM是没法执行的。
(3)准备阶段
这是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下。
首先,此时进行内存分配的只是类变量(被static修饰),不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值"通常情况"下是数据类型的零值。假设一个类变量的定义为:public static int value=123,那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法。而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
假设上面类变量value的定义变为:public static final int value=123。编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
(4)解析阶段
这是虚拟机将常量池内的符号引用替换为直接引用的过程,比如有:类或接口的解析、字段解析、方法解析、接口方法解析等。验证、准备、解析这三个阶段里,最关键的其实就是准备阶段。准备阶段会给加载进来的类分配好内存空间,以及也会对类变量分配好内存空间,并设置默认初始值。
(5)类初始化阶段
这是类加载过程的最后一步。在前面的类加载过程中:除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其余动作完全由虚拟机主导和控制。
到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,变量已经赋过一次系统要求的初始值。而在初始化阶段,则根据初始化方法去初始化类变量和其他资源,或者说初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作,以及静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
<clinit>()方法对于类或接口来说并不是必需的。如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可不为这个类生成<clinit>()方法。
JVM会保证一个类的<clinit>()方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。所以如果在一个类的<clinit>()方法中有耗时很长的操作,那么就可能造成多个线程阻塞。
什么时候会初始化一个类,一般来说有以下时机:
时机一.使用new关键字实例化类的对象
此时会触发类的加载到初始化的全过程,把类准备好,然后实例化对象。
时机二.包含main()方法的主类必须马上初始化
时机三.如果初始化一个类时发现父类还没初始化,则必须先初始化其父类
3.类加载器
(1)类加载器的定义及其用途
类加载的5个阶段中:除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
一.类加载器的定义
类加载的阶段通过一个类的全限定名来获取描述该类的二进制字节流,这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称为类加载器(Class Loader)。
二.类加载器的用途
类加载器的用途有:热加载、代码保护和加解密、类层次划分、OSGi等。
(2)类加载器与类是否相等
对于任意一个类,都必须由加载它的类加载器和这个类本身,一同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义。否则即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
(3)如何重写类的加载方法
在自定义ClassLoader的子类时,常见的会有两种做法。一种是重写loadClass()方法,另一种是重写findClass()方法。
其实这两种方法本质上差不多,毕竟loadClass()也会调用findClass(),但是从逻辑上讲最好不要直接修改loadClass()的内部逻辑,建议只在findClass()里重写自定义类的加载方法。
loadClass()这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此最好在双亲委托模型框架内进行小范围改动,不破坏原有的结构,同时也避免了重写loadClass()方法的过程中必须写双亲委托的重复代码。从代码的复用性来看,不直接修改loadClass()方法始终是比较好的选择。
4.双亲委派模型
(1)JVM角度的两种类加载器
从Java虚拟机的角度来讲,只存在两种不同的类加载器。第一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分。第二种是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
(2)开发者角度的三层类加载器
站在Java开发人员的角度来看,类加载器应该划分得更细致些。Java一直保持着三层类加载器、双亲委派的类加载架构。三层类加载器如下所示:
(3)双亲委派模型各个类加载器之间的关系
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系(委托)来复用父加载器的代码。
(4)双亲委派模型的工作过程
如果一个类加载器收到了类加载请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都如此。
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(即搜索不到所需的类),才会让子加载器尝试自己去完成加载。
(5)双亲委派模型的好处
使用双亲委派模型来组织类加载器之间的关系,有个显而易见的好处是:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类java.lang.Object,它存放在rt.jar之中。无论哪个类加载器要加载这个类,最终都委派给启动类加载器进行加载。因此Object类在程序的各种类加载器环境中都是同一个类。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话。如果用户自己编写了一个java.lang.Object类并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,应用程序将会变得一片混乱。
(6)双亲委派模型的实现
双亲委派模型的实现就放在loadClass()方法里,所以自定义的类加载器最好不要覆盖loadClass(),而是覆盖findClass()。
//代码逻辑为:
//先检查请求加载的类是否已经被加载过, 若没有则调用父加载器的loadClass()方法;
//若父加载器为空则默认使用启动类加载器作为父加载器;
//假如父加载器加载失败, 抛出ClassNotFoundException异常, 才调用自己的findClass()方法尝试进行加载
protected Class<?> loadClass(String name, boolean resolve) throwsClassNotFoundException {synchronized (getClassLoadingLock(name)) {
//首先检查请求的类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {try {
if (parent != null) {
c = parent.loadClass(name, false);} else {c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {//如果父类加载器抛出ClassNotFoundException异常, 说明父类加载器无法完成加载请求}
if (c == null) {//在父类加载器无法加载时, 再调用本身的findClass方法来进行类加载long t1 = System.nanoTime();
c = findClass(name);}}
if (resolve) { resolveClass(c);}
returnc;}
}
(7)双亲委派模型被破坏的情形
双亲委派很好解决了各个类加载器协作时基础类的一致性问题,越基础的类由越上层的加载器进行加载。
基础类型之所以被称为基础,是因为它们总是作为被用户代码继承、调用的API,但如果有基础类又要调用回用户的代码,该如何处理。
为了解决该困境,Java引入了线程上下文类加载器。线程上下文类加载器可通过Thread.setContextClassLoader()方法设置。如果创建线程时还未设置,它将会从父线程中继承一个。如果在应用程序的全局范围内都没有设置过的话,那这个线程上下文类加载器默认是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可使用它去加载所需的SPI服务代码。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,这种行为已经违背了双亲委派模型的一般性原则。
Java中涉及SPI的加载基本上都是采用线程上下文加载器来完成,例如:JNDI、JDBC、JCE、JAXB和JBI等。
5.类加载器和双亲委派机制总结
Java里有如下这些几种类加载器:
(1)启动类加载器
这个类加载器主要负责加载Java安装目录("lib"目录)下的类。如果要在一个机器上运行写好的Java系统,都得先装一下JDK。那么在Java安装目录下,就有一个"lib"目录。"lib"目录里就有Java最核心的一些类库,支撑写好的Java系统的运行。JVM一旦启动,就会使用启动类加载器去加载"lib"目录中的核心类库。
(2)扩展类加载器
这个类加载器主要负责加载Java安装目录("lib\ext"目录)下的类。这个类加载器其实也是类似的,就是在Java安装目录下,有一个"lib\ext"目录。该目录有一些类需要使用这个类加载器来加载,以支撑Java系统的运行。那么JVM一旦启动,也得在Java安装目录下加载"lib\ext"目录中的类。
(3)应用程序类加载器
这个类加载器就是负责加载"ClassPath"环境变量所指定的路径中的类。其实可以理解为去加载我们写好的Java代码,这个类加载器就负责加载我们写好的那些类到内存里。
(4)自定义类加载器
除了上面几种外,还可以自定义类加载器,根据需求加载我们的类。
(5)双亲委派机制
JVM的类加载器是有亲子层级结构的:启动类加载器在最上层,扩展类加载器在第二层,第三层是应用程序类加载器,最后一层是自定义类加载器。
然后基于这个亲子层级结构,就有一个双亲委派的机制。当我们的应用程序类加载器需要加载一个类的时候,它会委派给自己的父类加载器去加载,最终传导到启动类加载器去加载。但是如果父类加载器在自己负责加载的范围内,没找到这个类,那么父类加载器就会下推加载权利给自己的子类加载器去进行加载。
比如JVM现在需要加载ReplicaManager类,此时应用程序类加载器会找其父类加载器(扩展类加载器)帮忙加载该类。然后扩展类加载器也会找其父类加载器(启动类加载器)帮忙加载该类。启动类加载器在"lib"下没找到该类,就会下推加载权利给扩展类加载器。扩展类加载器在"lib/ext"下也没找到该类,则让应用程序类加载器加载。然后应用程序类加载器在其负责范围内,比如由系统打包成的jar包中,发现了ReplicaManager类,于是它就会把这个类加载到内存里去。
这就是所谓的双亲委派模型:先找父亲去加载,加载不了再由儿子加载,这样就可以避免多层级的类加载器重复加载某些类。否则不同层级的类加载器加载高层级类加载器的类时就会出现重复加载。
(6)问题
为什么要一级一级的往上找,能否直接从顶层类加载器开始往下找?
答:双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己尝试加载这个类。而是把该请求委派给父类加载器去完成,每一个层次的类加载器都如此。因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父加载器己无法完成加载请求时,子加载器才会尝试去完成加载。
这样做的一个显而易见的好处就是:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,可保证例如Object类在程序的各种类加载器环境中都是同一个类。否则用户编写一个名为java.lang.Object类,并放在程序的ClassPath中,那么系统就会出现多个不同的Object类,当然还双亲委派机制可以避免重复加载同一个类。
从java.lang.ClassLoader的loadClass()方法就可以知道:如果从顶层开始找,就要将parent换成child + 对child硬编码,才能找到。也就是说需要改动loadClass()方法,需要把parent换成child + 硬编码,硬编码也要先从下往上逐层获得整个父子路径才能直接从顶层往下找到。
loadClass()方法的逻辑如下:先检查请求加载的类是否已被加载过。若没有则调用父加载器的loadClass(),若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器加载失败则抛异常,才调用自己的findClass()尝试进行加载。
四.JVM实战总结
1.每日百万交易的支付系统的压力
2.JVM内存中的对象何时会被垃圾回收
3.JVM垃圾回收的原理核心流程
4.CMS是如何工作的
5.CMS会产生什么问题
6.哪些情况下对象会进入老年代
7.G1垃圾回收器的工作原理
8.G1分代回收原理—性能为何比传统GC好
9.使用G1垃圾回收器时应如何设置参数
10.如何基于G1垃圾回收器优化性能
11.使用jstat了解线上系统的JVM运行状况
12.线上FGC的几种案例
13.CPU负载过高的原因
14.JVM运行原理和GC原理总结
15.JVM性能优化的思路和步骤
1.每日百万交易的支付系统的压力
(1)支付系统每秒要处理多少笔支付订单
假设每天100万个支付订单。那么一般用户交易行为都会发生在每天的高峰期,比如中午或者晚上。假设每天高峰期大概是3个小时,将100万平均分配到3个小时里。那么大概每秒100笔订单,所以就以每秒100笔订单来进行计算。假设支付系统部署3台机器,则每台机器实际上每秒大概处理30笔订单。
(2)每个支付订单处理要耗时多久
如果用户发起一次支付请求:那么支付需要在JVM中创建一个支付订单对象,填充进数据。然后把这个支付订单写入数据库,以及可能处理一些其他事情。
假设一次支付请求的处理包含一个支付订单的创建,大概需要1秒时间,那么每台机器一秒钟会接收到30笔支付订单的请求。然后会在JVM新生代里创建30个支付订单的对象,进行写库等处理。接着1秒后这30个支付订单就处理完毕,此时栈帧中对这些支付订单对象的引用就被回收了。然后这些订单对象在JVM的新生代里就是没被引用的垃圾对象了。接着下一秒会继续来处理30个支付订单,重复这个步骤。
(3)每个支付订单大概需要多大的内存空间
可以直接根据支付订单类中的实例变量的类型来计算。比如在支付订单类中:一个Integer类型的变量数据4字节,一个Long类型的变量数据是8字节,还有别的类型的变量数据占据多少字节等,这样就可以计算出每个支付订单对象大致占多少字节了。
一般像支付订单这种核心类,可以按20个实例变量来计算。然后大概一个订单对象也就一两百字节,可以算它大一点。比如一个支付订单对象占据500字节的内存空间,也不到1K。
(4)每秒发起的支付请求对内存的占用
假设有3台机器,每秒钟处理30笔支付订单的请求。那么在这1秒内,肯定有方法栈帧里的局部变量在引用这些支付订单对象。那么30个支付订单,大概占据的内存空间是30 * 500字节 = 15000字节。大概15K左右,其实是非常小的。
(5)让支付系统运行起来进行分析
现在已经把整个系统运行的关键环节的数据都分析清楚了:每秒30个支付请求,每秒创建30个支付对象,每秒占15K的内存空间。接着1秒过后,这30个对象就没有被引用了,成为新生代里的垃圾。下一秒请求过来,系统继续创建30个支付对象放入新生代里,然后新生代里的对象就会持续累积增加。
直到有一刻,发现可能新生代里都有几十万个对象。此时占据了几百M的内存空间,可能新生代空间就快满了。然后就会触发Young GC,把新生代里的垃圾对象都给回收掉。从而腾出内存空间,可以继续在内存里分配新的对象。
这就是该支付系统在创建订单环节的JVM运行模型。
(6)对完整的支付系统内存占用需要进行预估
前面的分析都是基于一个核心业务流程中的一个支付订单对象来分析的,但那其实那只是整个支付系统的一个小部分而已。真实的支付系统在线上运行时,肯定会每秒创建大量其他对象。所以可以结合这个访问压力以及核心对象的内存占据,大致估算一下整个支付系统每秒钟大致会占据多少内存空间。
如果要估算的话,其实可以把上述的计算结果扩大10到20倍。即每秒除了在内存里创建支付订单对象,还会创建其他数十种对象。假设一台机器每秒创建100个500字节的支付订单对象,扩大20倍后,那么每秒创建出的被栈内存的局部变量引用的对象,大概占1M内存空间。然后下一秒对新请求,继续创建1M对象放入新生代,一秒后又变成垃圾。循环多次后,新生代里垃圾太多,就会触发Young GC回收掉这些垃圾。这就是一个支付系统在JVM层面的内存使用模型。
(7)可以考虑采用4核8G的机器来部署支付系统
此时JVM进程至少可以给4G以上内存,新生代至少可分配2G内存空间。这样就可以做到即便新生代每秒消耗1M左右的内存,也要将近半小时到1小时才会让新生代触发YGC,大大降低了GC频率。
举个例子:机器采用4核8G,-Xms和-Xmx设置为3G,给整个堆内存3G内存空间。-Xmn设置为2G,给新生代2G内存空间。而且假设业务量如果更大,则可以考虑不只部署3台机器,可以考虑横向扩展部署5台机器或者10台机器,这样每台机器处理的请求更少对JVM的压力更小。
(8)总结
从一个日百万交易的支付系统出发,部署3台机器的场景下。每秒钟每台机器需要处理多少笔订单,每笔订单要耗时多久处理。每笔订单的核心对象每秒钟会对JVM占据多大内存空间,根据单个核心对象横向扩展预估整个系统每秒需要占据多大内存空间。接着根据上述数据模型推算出:在不同的机器配置之下,新生代大致会有多大的内存空间。然后在不同的新生代大小下,多久会触发一次Young GC。
为了避免频繁的GC,那么应该选用什么样的机器配置。部署多少台机器,设置JVM堆内存、新生代分别多大的内存空间。根据这套配置,就可以推算出来整个系统的运行模型了。每秒钟创建多少对象在新生代,然后1秒之后成为垃圾。大概系统运行多久,新生代会触发一次GC,频率有多高。
2.JVM内存中的对象何时会被垃圾回收
在JVM规范中,局部变量就是可以作为GC Roots的。一个对象只要被局部变量引用,就说明它有一个GC Roots,不能被回收。静态变量也可以看做是一种GC Roots。只要一个对象被GC Roots引用了,就不会去回收它。因此一句话总结就是:只要对象被方法的局部变量、类的静态变量给引用了,就不会回收它们。
强引用就是最普通的代码,一个变量引用一个对象。只要是强引用的类型,那么垃圾回收的时候绝对不会去回收这个对象。正常情况下垃圾回收是不会回收软引用对象的。但如果垃圾回收后,发现内存空间不够存放新对象,内存都快溢出了,就会把这些软引用对象给回收掉,哪怕它被变量引用着。但是因为它是软引用,所以还是要回收。弱引用就与没有引用类似,如果发生垃圾回收,就会回收这个对象。
3.JVM垃圾回收的原理核心流程
问题一:什么时候会尝试触发YGC
当新生代的Eden区和其中一个Survivor区空间不足时,就会触发YGC。
问题二:YGC前如何检查老年代大小(涉及步骤)
步骤1:先判断新生代中所有对象的大小是否小于老年代的可用区域。如果是则触发YGC,如果否则继续进行下面2中的判断。
步骤2:如果设置了-XX:HandlePromotionFailure参数,那么进入步骤3。如果没有设置-XX:HandlePromotionFailure参数,那么就触发FGC。
步骤3:判断YGC历次进入老年代的平均大小是否小于老年代可用区域。如果是则触发YGC,如果否则触发FGC。
问题三:什么情况下YGC前会提前触发FGC
(新生代现有存活对象 > 老年代剩余内存情况) + 未设置空间担保
(新生代现有存活对象 > 老年代剩余内存情况) + (设置了空间担保 + 但担保失败)
问题四:FGC的算法是什么
标记整理算法(但是CMS是标记清理再整理,FGC包含CMS)。老年代对象存活时间较长,复制算法不太适合且老年代区域不再细分。标记清除算法会产生内存碎片,标记整理算法则可以规避碎片。
问题五:YGC过后可能对应哪几种情况
情况1:存活对象所占空间 < S区域内存大小,那么存活的对象进入Survivor区。
情况2:S区域内存大小 < 存活对象所占空间 < 老年代可用大小,那么存活的对象直接进入老年代。
情况3:(存活对象大小 > S区大小) & (存活对象大小 > 老年代可用大小),那么会触发FGC,老年代腾出空间后,再进行YGC。如果腾出空间后还不能存放存活对象,则会导致OOM。OOM也就是堆内存空间不足、堆内存溢出。
问题六:哪些情况下YGC后的对象会进入老年代
情况1:S区域内存大小 < 存活对象所占空间 < 老年代可用大小。
情况2:经过XX:MaxTenuringThreshold次YGC的,默认最大是15次。
情况3:对象动态年龄判断机制。年龄1 + 年龄2 + 年龄n的对象,大小总和超过了Survivor区的50%,此时就会把年龄为n及以上的对象都放入老年代。
4.CMS是如何工作的
(1)新生代垃圾回收总结
新生代的垃圾回收是通过标记-复制算法来实现的,我们最希望的是:新对象都在新生代的Eden区分配内存。然后每次垃圾回收后,存活对象都进入Survivor区。然后下一次垃圾回收后的存活对象都进入另外一个Survivor区。这样几乎很少对象会进入老年代,几乎不会触发老年代的垃圾回收。
但是理想很丰满,现实是在写代码时,很少会考虑垃圾回收。都是不停写代码然后上线部署,很少考虑所写代码对垃圾回收的影响。最多有经验的工程师在系统上线前,通过前面案例介绍的方法:估算一下系统的内存压力以及垃圾回收的运行模型,然后合理设置一下内存各个区域大小,尽量避免太多对象进入到老年代。
实际中,线上系统很可能因各种各样原因导致很多对象进入老年代,然后频繁触发老年代的Full GC。之前介绍的案例就演示过这种情况,比如Survivor区太小,容纳不了每次YGC后的存活对象,从而导致对象频繁进入老年代,最后频繁触发老年代Full GC。类似的情况其实很多,所以不能过于理想化的期待永远没有老年代GC,还是要对老年代的垃圾回收器如何进行回收有一个充分的了解和认识。
(2)CMS垃圾回收的基本原理
一般老年代选择的垃圾回收器是CMS,它采用的是标记-清理算法。就是先标记出哪些对象是垃圾对象,然后就把这些垃圾对象清理掉。
现假设因老年代可用内存小于历次YGC后升入老年代对象的平均大小,判断出YGC有风险,于是就提前触发FGC回收老年代的垃圾对象。或者一次YGC后对象太多,都要升入老年代但空间不足,于是触发FGC。总之就是要进行FGC,此时的标记-清理算法会如下处理:首先通过追踪GC Roots,看看各个对象是否被GC Roots给引用了。如果是的话,那就是存活对象,否则就是垃圾对象。接着将垃圾对象都标记出来,然后再一次性把垃圾对象都回收掉。这种标记-清理算法最大的问题,其实就是会造成很多内存碎片。
(3)如果Stop the World然后垃圾回收会如何
假如要先STW,再采用标记-清理算法去回收垃圾,那会有什么问题?如果停止一切工作线程,然后慢慢去执行标记-清理算法,会导致系统卡死时间过长,很多响应无法处理。所以CMS垃圾回收器采取的是:垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。
(4)如何实现JVM垃圾回收的同时让应用也工作
CMS在执行一次垃圾回收的过程共分为4阶段:
阶段一:初始标记
阶段二:并发标记
阶段三:重新标记
阶段四:并发清理
CMS进行垃圾回收时会先进入初始标记阶段:这个阶段会让系统的工作线程全部停止,进入Stop the World状态。所谓初始标记,就是标记出所有GC Roots直接引用的对象。方法的局部变量和类的静态变量是GC Roots,类的实例变量不是GC Roots。初始标记阶段虽然会造成STW暂停一切工作线程,但其实影响不大。因为它的速度很快,仅仅标记GC Roots直接引用的那些对象而已。
接着是并发标记阶段,该阶段系统线程可继续运行创建新对象:在并发标记运行期间,可能会创建新的存活对象,也可能会让部分存活对象失去引用变成垃圾对象。在这个过程中,垃圾回收线程会尽可能对已有的对象进行GC Roots追踪。在进行并发标记的这个过程中,系统程序会不停的工作。此时系统程序可能会创建出各种新的对象,部分对象可能成为垃圾。并发标记阶段会对老年代所有对象进行GC Roots追踪,其实是最耗时的,因为需要追踪所有对象是否从根源上被GC Roots引用了。但是这个最耗时的阶段,并发标记线程是和系统程序并发运行的,所以并发标记阶段不会对系统运行造成太大影响。
接着会进入重新标记阶段:由于在并发标记阶段里,一边是JVM在标记存活对象和垃圾对象,一边是系统程序在不停运行创建新对象让老对象变成垃圾。所以并发标记阶段结束后,会有很多存活对象和垃圾对象没被标记出来。于是在重新标记阶段需要让系统程序停下来,再次进入STW。重新标记在并发标记阶段新创建的存活对象,以及失去引用的垃圾对象。重新标记阶段的速度是很快的,因为只是对并发标记阶段中系统程序运行变动过的少数对象进行标记。
接着恢复运行系统程序,进入并发清理阶段:这个阶段会让系统程序并发运行,然后CMS垃圾回收器会清理掉之前标记为垃圾的对象。这个并发清理阶段其实是很耗时的,因为需要进行对象的清理。但是它也会跟系统程序并发运行,所以其实也不影响系统程序的执行。
(5)对CMS的垃圾回收机制进行性能分析
从CMS的垃圾回收机制可以发现,它已经尽可能的进行了性能优化了。
因为最耗时的:一是并发标记阶段对老年代全部对象追踪GC Roots,标记可回收对象。二是并发清理阶段对各种垃圾对象先清除后整理。但由于并发标记阶段和并发清理清理,都是和系统程序并发执行的,所以基本上这两个最耗时的阶段对性能影响不大。
虽然初始标记阶段和重新标记阶段需要Stop the World,但是这两个阶段都是简单的标记而已,所以速度非常快,所以基本上这两个STW的阶段对系统运行响应也不大。
(6)CMS的基本工作原理总结
为了避免长时间Stop the World,CMS采用了4个阶段来垃圾回收。其中初始标记和重新标记耗时很短,虽然会导致STW,但是影响不大。然后并发标记和并发清理耗时最长,但可以和系统的工作线程并发运行。所以并发标记和并发清理两个阶段对系统也没太大影响,这就是CMS的基本工作原理。
5.CMS会产生什么问题
(1)并发回收垃圾导致CPU资源紧张
CMS垃圾回收器有一个最大的问题:虽然能在垃圾回收的同时让应用程序也同时运行,但是在并发标记和并发清理两个最耗时的阶段,垃圾回收线程和应用程序工作线程同时工作,会导致有限的CPU资源被垃圾回收线程占用了一部分。
并发标记时要对GC Roots进行深度追踪,看所有对象里有多少是存活的。但因老年代里存活对象比较多,该过程又追踪大量对象,所以耗时较高。并发清理时要把垃圾对象从各种随机的内存位置清理掉,也是很耗时的。所以在并发标记和并发清理这两阶段,CMS的垃圾回收线程会特别耗费CPU。
CMS默认启动的垃圾回收线程的数量是:(CPU核数 + 3) / 4,下面用最普通的2核4G机器来计算一下。假设是2核CPU,本来CPU资源就有限,结果CMS还需要"(2 + 3) / 4 = 1"个垃圾回收线程,占用宝贵的1个CPU。所以CMS这个并发垃圾回收的机制,最大的问题就是会消耗CPU资源。
(2)Concurrent Mode Failure问题
一.什么是浮动垃圾
在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象。但这个阶段系统一直在运行,随着系统运行可能有些对象进入老年代。同时这些对象很快又失去引用变成垃圾对象,这种对象就是浮动垃圾。
比如有一些垃圾对象(新的)就是在并发清理期间,先被系统分配在新生代,然后触发一次YGC,一些对象进入了老年代,短时间内又没被引用了。这种对象,就是老年代的浮动垃圾。浮动垃圾在本次的并发清理阶段中,由于没有被标记,所以不能被回收,需要等到下一次GC执行到并发清理阶段时才能进行回收。
二.CMS垃圾回收的触发时机与预留空间
为了保证在CMS垃圾回收期间,能让一些对象可以进入老年代,JVM会给老年代预留一些空间。
CMS垃圾回收的一个触发时机就是:当老年代内存占用达到一定比例,就自动执行FGC。这个比例是由-XX:CMSInitiatingOccupancyFaction参数控制的,这个参数可以用来设置老年代占用达到多少比例时就触发CMS垃圾回收。
-XX:CMSInitiatingOccupancyFaction参数在JDK 1.6里默认的值是92%,也就是如果老年代占用了92%的空间,就会自动进行CMS垃圾回收。此时会预留8%的空间,这样在CMS并发回收期间,可让系统程序把一些新对象放入老年代中。
三.如果CMS垃圾回收期间,要放入老年代的对象已大于可用内存空间
这时就会发生Concurrent Mode Failure,即并发垃圾回收失败了。CMS一边回收,系统程序一边把对象放入老年代,内存不够了。此时就会自动用Serial Old替代CMS,直接强行对系统程序STW。重新进行长时间GC Roots追踪,标记全部垃圾对象,不允许新对象产生。最后再一次性把垃圾对象都回收掉,完成后再恢复系统程序。
所以在实践中:老年代占用多少比例时触发CMS垃圾回收,要设置合理。让CMS在并发清理期间,可以预留出足够的老年代空间来存放新对象,从而避免Concurrent Mode Failure问题。
(3)内存碎片问题
老年代的CMS垃圾回收器会采用"标记-清理"算法:每次都是标记出垃圾对象,然后一次性回收,这样会产生大量内存碎片。内存碎片太多会导致对象进入老年代时找不到连续内存空间,触发FGC。所以CMS不能只用标记-清理算法,因太多内存碎片会导致频繁FGC。
"-XX:+UseCMSCompactAtFullCollection"这个CMS的参数,默认是打开的。意思是在FGC后要再次进行STW,停止工作线程,然后进行碎片整理。碎片整理就是把存活对象移动到一起,空出大片连续内存空间,避免内存碎片。
"-XX:CMSFullGCsBeforeCompaction"这个CMS的参数,意思是执行多少次FGC后再执行一次内存碎片整理的工作。该参数值默认是0,意思是每次Full GC后都会进行一次内存整理。
(4)为什么老年代的FGC要比新生代的YGC慢
为什么老年代的FGC要比新生代的YGC慢很多倍,一般在10倍以上?其实原因很简单,下面分析一下它们的执行过程。
一.新生代Young GC执行速度很快
Young GC时首先从GC Roots出发就可以追踪哪些对象是存活的了。由于新生代存活对象很少,这个速度会很快,不需要追踪多少对象。然后直接把存活对象放入Survivor中,接着再一次性回收Eden和之前使用的Survivor。
二.CMS的Full GC执行速度很慢
首先在并发标记阶段,需要去追踪所有存活对象。老年代存活对象很多,这个过程就会很慢。其次在并发清理阶段,不是一次性回收一大片内存,而是要找到分散的垃圾对象,速度也很慢。最后在完成Full GC后,还得执行一次内存碎片整理,把大量的存活对象给移动到一起,空出连续内存空间,这个过程还得Stop the World,就更慢了。
此外万一并发清理期间,剩余内存空间不足以存放要进入老年代的对象,还会引发Concurrent Mode Failure问题,还得用Serial Old垃圾回收器,先进行Stop the World,再重新来一遍标记清理的过程,这就更耗时了。所以,老年代的垃圾回收比新生代的垃圾回收慢。
(6)触发老年代GC的时机总结
时机一:老年代可用内存 < 新生代全部对象大小 + 没开启空间担保,触发FGC。所以一般都会打开空间担保参数-XX:-HandlePromotionFailure。
时机二:老年代可用内存 < 历次YGC后进入老年代的对象平均大小,触发FGC。
时机三:新生代YGC存活对象 > S区(需进入老年代) + 老年代内存不足,触发FGC。
时机四:参数-XX:CMSInitiatingOccupancyFaction可以设置CMS垃圾回收时的预留空间比例。进行YGC前的检查时,如果发现老年代可用内存大于历次新生代GC后进入老年代的对象平均大小,但老年代已使用的内存超过了这个参数指定的比例,就会触发FGC。
6.哪些情况下对象会进入老年代
情况一:大对象直接分配到老年代
情况二:YGC后对象的年龄到了15
情况三:YGC后存活对象大小大于Survivor区大小
情况四:动态年龄规则触发
情况五:YGC前检查发现没有配置空间担保参数
情况六:YGC前有配置空间担保参数 + 老年代可用内存小于历次晋升平均内存
情况七:老年代中已经被使用的内存空间达到了-XX:CMSInitiatingOccupancyFaction设置的比例
7.G1垃圾回收器的工作原理
(1)ParNew + CMS的组合有哪些痛点
Stop the World是ParNew + CMS组合最大的问题。无论是新生代GC还是老年代GC,都会或多或少产生STW现象,这对系统的运行是有一定影响的。
所以JVM对垃圾回收器的优化,都是朝减少STW的目标去做的。在这个基础之上,就诞生了G1垃圾回收器。G1垃圾回收器可以提供比ParNew + CMS组合更好的垃圾回收性能。
(2)G1垃圾回收器介绍
G1垃圾回收器可以同时回收新生代和老年代的对象,不需要两个垃圾回收器配合起来运作,它自己就能搞定所有的垃圾回收。G1的一大特点就是把Java堆内存拆分为多个大小相等的Region。然后G1也会有新生代和老年代,但是只是逻辑上的概念。也就是说,某些Region属于新生代,某些Reigon属于老年代。
G1的另一特点,就是可以设置每次垃圾回收时的最大停顿时间,以及指定在一个长度为M毫秒的时间片段内,垃圾回收时间不超N毫秒。比如可指定,希望G1在垃圾回收时保证:在1小时内由G1垃圾回收导致系统停顿时间,不超过1分钟。
(3)G1如何实现垃圾回收的停顿时间是可控的
如果G1要做到这一点,就必须要追踪每个Region里的回收价值。什么是回收价值?即G1必须搞清楚每个Region里有多少垃圾对象。如果对一个Region进行垃圾回收,会耗费多长时间,可回收多少垃圾?
G1的核心设计是:G1可以让我们设定垃圾回收对系统的影响,G1会把内存拆分为大量的小Region,G1会追踪每个Region中可以回收的对象大小和预估时间,G1在垃圾回收时会尽量把垃圾回收对系统影响控制在指定时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象。
(4)Region可能属于新生代也可能属于老年代
在G1中,每一个Region可能属于新生代,也可能属于老年代。刚开始一个Region可能谁都不属于,然后接着就被分配给了新生代。然后这个Region会被放入很多属于新生代的对象,接着触发了垃圾回收,需要回收这个Region。然后下一次这个Region可能又被分配给了老年代,用来存放老年代需要长期存活的的对象。所以在G1的内存模型中,一个Region会属于新生代也会属于老年代。于是就没有所谓新生代给多少内存,老年代给多少内存这一说法。新生代和老年代各自的内存区域是不停变动的,由G1自己去控制。
(5)总结
这里介绍了G1垃圾回收器的设计思想:包括Region划分、Region动态变成新生代或老年代,Region的按需分配。当触发G1垃圾回收时,可以根据设定的预期的系统停顿时间,来选择最少回收时间和最多回收对象的Region进行垃圾回收。保证GC对系统停顿的影响在可控范围内,同时尽可能回收最多对象。
8.G1分代回收原理—性能为何比传统GC好
(1)G1垃圾回收器的设计思想
G1垃圾回收器设计的思想:把内存拆分为很多Region,然后新生代和老年代各自对应一些Region。回收时尽可能挑选停顿时间最短以及回收对象最多的Region,尽量保证达到指定的垃圾回收系统停顿时间。
(2)如何设定G1对应的内存大小
如果JVM启动时发现了指定使用G1垃圾回收器,那么默认情况下G1会自动用堆大小除以2048得出每个Region的大小。每个Region的大小范围是1M~32M,且必须是2的倍数。如果堆大小是4G = 4096M,除以2048,每个Region的大小就是2M。当然也可以通过-XX:G1HeapRegionSize参数来手动指定Region大小。
需要注意的是:按照默认值计算,G1可以管理的最大内存为2048 * 32M = 64G。假设设置xms=32G,xmx=128G。由于Region的大小最小是1M,最大是32M,而且要是2的倍数。那么初始化时按2048个Region计算,得出每个Region分区大小为32M。然后分区个数动态变化范围从1024个到4096个。
系统刚开始运行时,默认新生代对堆内存的占比是5%。也就是占据200M左右的内存,对应大概是100个Region。这可以通过-XX:G1NewSizePercent来设置新生代初始占比,但通常维持默认值即可。
因为在系统运行中,JVM会不停地给新生代增加更多的Region。但新生代占比最多不超60%,可通过-XX:G1MaxNewSizePercent设置。而且一旦Region进行了垃圾回收,新生代的Region数量就会减少。
(3)新生代Region还会分Eden区和Survivor区
G1虽然把内存划分为很多的Region,但还是有新生代、老年代的区分,而且新生代里同样有Eden和Survivor的划分。所以前面介绍的很多原理在G1中都还是适用的。比如参数-XX:SurvivorRatio=8,系统刚开始运行时有100个Region。此时新生代中有80个Region是Eden区,20个Region是两个Survivor区。
所以在G1中还是有Eden和Survivor的,它们会占据不同数量的Region。然后随着对象不停地在新生代分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。
(4)G1的新生代垃圾回收
既然G1的新生代有Eden和Survivor之分,那么垃圾回收的机制也类似。当不停往新生代Eden的Region放对象,G1会不停给新生代加入Region。直到新生代占据堆大小的最大比例60%,一旦新生代大小达到了设定的占据堆内存大小的最大比例60%。比如2048个Region中有1200个Region都是属于新生代的了,里面的Eden占了1000个Region,每个Survivor占了100个Region,而且Eden中的Region都占满了对象,这时就会触发新生代GC。G1就会使用复制算法来进行垃圾回收,进入Stop the World状态。然后把Eden对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象。
G1的新生代垃圾回收过程和ParNew是有区别的。因为G1可以设定GC停顿时间,执行GC时最多会让系统停顿某个时间。可以通过-XX:MaxGCPauseMills参数来设定,默认值是200ms。G1会追踪每个Region,然后GC时根据回收各Region需要多少时间、以及可回收多少对象,来选择回收其中一部分Region。从而保证GC时的停顿时间控制在指定范围内,并尽可能多地去回收对象。
(5)对象什么时候进入老年代
在G1的内存模型下,新生代和老年代各自都会占据一定的Region。如果按照默认新生代最多只能占据堆内存2048个Region的60%的Region来推算,老年代最多可以占据40%的Region,大概就是800个左右的Region。
那么对象何时候会从新生代进入老年代?和ParNew几乎一样,还是以下几个条件:
条件一:对象在新生代躲过多次YGC,达到参数-XX:MaxTenuringThreshold设置的年龄
条件二:动态年龄判定规则,比如年龄为1岁、2岁、3岁、4岁的对象大小总和超过了Survivor的50%,此时Survivor区还有5岁+的对象,那么4岁及以上的对象就会全部进入老年代
条件三:新生代回收后存活的对象在Survivor区的Region都放不下了
(6)大对象Region
G1提供专门的Region存放大对象,不让大对象进入老年代的Region。G1中大对象的判定规则就是一个大对象超过了一个Region大小的50%。比如按照上面算的,每个Region是2M。那么只要一个大对象超过了1M,就会被放入大对象专门的Region中,而且一个大对象如果太大,可能会横跨多个Region来存放。
堆内存里哪些Region会用来存放大对象?60%的Region给新生代,40%的Region给老年代,那还有哪些Region给大对象?其实在G1里,新生代和老年代的Region是不停的动态变化的。比如新生代现占1200个Region,但一次GC后里面1000个Region空了。此时这1000个Region就可以不属于新生代,可用部分Region放大对象,所以大对象既不属于新生代也不属于老年代。
既然大对象既不属于新生代也不属于老年代,那何时会触发垃圾回收?其实在新生代、老年代回收时,会顺带着大对象Region一起回收,这其实就是在G1内存模型下对大对象的分配和回收策略。
(7)总结
这里介绍了G1的内存模型和分配规则,包括:
规则一:每个Region多大(1-32M)
规则二:新生代包含多少Region(60%)
规则三:新生代动态增加Region(初始5% -> 60%)
规则四:G1中仍然存在Eden和Survivor两个区域
规则五:新生代占60%且满了触发新生代垃圾回收
规则六:G1新生代垃圾回收使用的复制算法
规则七:G1特有的预设GC停顿时间功能
规则八:对象进入老年代(15岁+动态年龄+S区不足)
规则九:大对象的独立Region存放和回收
(8)问题
从新生代的垃圾回收来看,G1相比ParNew的优点:
优点一:停顿时间可以预设
优点二:大对象不再进入老年代
优点三:对象进入老年代的情况少很多
优点四:同样内存大小,Eden和Survivor都大很多
优点五:ParNew的GC需要停止系统程序,但G1的新生代GC可以不用停止
9.使用G1垃圾回收器时应如何设置参数
(1)G1的动态内存管理策略总结
G1的动态内存管理策略:根据情况动态地把Region分配给新生代(Eden+S区)、老年代和大对象。但是新生代和老年代会有一个各自的最大占比,新生代占比最大60%,老年代占比最大40%。然后在新生代的Eden满的时候,触发新生代垃圾回收。
G1新生代的垃圾回收还是采用了复制算法。只是会考虑预设GC停顿时间,保证垃圾回收的停顿时间不超预设时间。因此会挑选一些回收价值比较高的Region来进行垃圾回收。
然后G1新生代垃圾回收和ParNew一样:如果一些对象在新生代熬过一定次数GC,或触发了动态年龄判定规则,或GC后的存活对象在Survivor放不下,都会让对象进入老年代中。所以G1中的新生代对象还是会因为各种情况而慢慢地进入老年代的。
G1对大对象的处理则与ParNew不一样:G1的大对象会进入单独的大对象Region,不再进入老年代。
(2)何时触发新生代 + 老年代的混合垃圾回收
-XX:InitiatingHeapOccupancyPercent是G1的参数,默认值是45%。意思是如果老年代占据了堆内存的45%的Region时,就会尝试触发新生代 + 老年代一起回收的混合回收。比如按照默认情况下的堆内存有2048个Region:如果老年代占据了其中45%的Region,就会开始触发混合回收。
(3)G1混合垃圾回收的过程
G1:初始标记-并发标记-最终标记-混合回收
CMS:初始标记-并发标记-重新标记-并发清除
首先进入初始标记阶段:这个阶段需要STW,标记GC Roots直接引用的对象,这个过程是很快的。
然后会进入并发标记阶段:这个阶段会允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象。这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象。但这个阶段可以跟系统程序并发运行,所以对系统程序影响不太大。而且JVM也会记录在并发标记阶段对对象进行的修改,比如哪个对象被新建了,哪个对象失去了引用。
接着会进入最终标记阶段:这个阶段会STW禁止系统程序运行,但会根据并发标记时的记录,最终标记出哪些对象存活、哪些对象回收。
最后进入混合回收阶段:这个阶段首先会进行如下计算:老年代中各Region的存活对象数量、存活对象占比,还有执行垃圾回收的预期性能和效率。接着会Stop The World停止系统程序,选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在指定的范围内。比如老年代此时有1000个Region都满了:但是根据预定目标,本次垃圾回收可能只能停顿200毫秒。那么通过之前计算得知,可能回收其中800个Region刚好需要200ms。于是就只回收那800个Region,把GC停顿时间控制在指定范围内。
10.如何基于G1垃圾回收器优化性能
(1)G1的运行原理总结
G1会根据预设的GC停顿时间,给新生代分配一些Region。然后到一定程度才触发GC,并且把GC停顿时间控制在预设范围内,尽量避免一次性回收过多Region导致GC停顿时间超出预期。
(2)新生代GC如何优化
垃圾回收器是一代比一代先进的,虽然内部实现机制越来越复杂,但是优化却越来越简单。比如对于G1:
首先给整个JVM的堆区域足够的内存,比如给JVM超过5G的内存,其中堆内存有4G的内存。
接着合理设置-XX:MaxGCPauseMills参数。如果这个参数设置太小了:那么说明每次GC停顿时间可能特别短。此时G1可能在发现几十个Region占满时,就要开始触发新生代GC。从而导致新生代GC频率特别频繁。比如如果设置每次停顿30毫秒,那么可能会每30秒触发一次新生代GC。如果这个参数设置过大了:那么G1会允许不停地在新生代分配新对象。然后积累很多对象,再一次性回收几百个Region。此时可能一次GC停顿时间就会达到几百毫秒,但是GC的频率很低。比如30分钟才触发一次新生代GC,但每次停顿500毫秒。
所以预期的GC停顿时间到底如何设置,需要结合系统压测工具、GC日志、内存分析工具来进行考虑,尽量别让系统的GC频率太高,同时每次GC停顿时间也别太长。
(3)Mixed GC如何优化
一.频繁触发Mixed GC的关键
新生代对象进入老年代的几个条件是:YGC后存活对象太多没法放入S区 + 对象年龄太大 + 动态年龄判定规则。
Mixed GC的触发条件是:老年代在堆内存里占比超过45%。在新生代对象进入老年代的几个条件其中比较关键的就是:新生代GC后存活对象太多无法放入Survivor区和动态年龄判定规则,因为这两个条件可能让很多对象快速进入老年代。一旦老年代达到占用堆内存45%的阈值,那么就会频繁触发Mixed GC。
所以Mixed GC本身很复杂,很多参数可以优化。但是优化Mixed GC的核心不是优化它的参数,而是和前面分析的一样。尽量避免对象过快进入老年代,避免频繁触发Mixed GC,就能实现优化。
二.合理设置-XX:MaxGCPauseMills避免频繁触发Mixed GC
由于G1和ParNew + CMS的组合是不同的,那应该如何来优化参数呢?其实核心的还是-XX:MaxGCPauseMills这个参数。
如果-XX:MaxGCPauseMills参数设置的值很大,导致系统运行很久,新生代都占用堆内存的60%时才触发新生代GC。那么存活下来的对象可能就会很多,导致Survivor区放不下那么多对象。于是这些存活下来的对象就会全部进入老年代,或者存活下来的对象比较多,达到S区的50%,触发动态年龄判定规则,那么也会导致下次新生代GC的存活对象进入老年代。
所以核心还是在于调节-XX:MaxGCPauseMills这个参数的值。在保证新生代GC不太频繁的同时,还得考虑每次GC后有多少存活对象。避免存活对象太多快速进入老年代,频繁触发Mixed GC。
11.使用jstat了解线上系统的JVM运行状况
(1)新生代对象增长的速率
需要了解JVM的第一个信息就是:随着系统运行,每秒会在新生代的Eden区分配多少对象。要获取该信息,只需要在Linux机器上运行命令:jstat -gc PID 1000 10,该命令意思:每隔1秒更新最新jstat统计信息,一共执行10次jstat统计。通过这行命令可以灵活地对线上机器以固定频率输出统计信息。从而观察出每隔一段时间,JVM中Eden区的对象占用变化。
比如执行这行命令后:第一秒显示出Eden区使用了200M内存,第二秒显示出Eden区使用了205M内存,第三秒显示出Eden区使用了209M内存,以此类推。此时我们就可以推断出来,这个系统大概每秒钟会新增5M左右的对象。而且这里可以根据自己系统的情况灵活多变地使用,如果系统负载很低,则不一定每秒进行统计,可以每分或每10分来统计,以此查看系统每隔1分钟或者10分钟大概增长多少对象。
此外,系统一般有高峰和日常两种状态,比如系统高峰期用户很多,我们应该在系统高峰期去用上述命令看看高峰期的对象增长速率,然后再在非高峰的日常时间段内看看对象的增长速率,这样就可以了解清楚系统的高峰和日常时间段内的对象增长速率了。
(2)Young GC的触发频率和每次耗时
接着需要了解JVM的第二个信息是:大概多久会触发一次YGC,以及每次YGC的耗时。其实多久触发一次YGC是很容易推测出来的,因为系统高峰和日常时的对象增长速率都知道了,根据对象增长速率和Eden区大小,就可以推测出:高峰期多久发生一次YGC,日常期多久发生一次YGC。
比如Eden区有800M内存:如果发现高峰期每秒新增5M对象,那么大概3分钟会触发一次YGC。如果发现日常期每秒新增0.5M对象,那么大概半小时才触发一次YGC。
那么每次Young GC的平均耗时呢?jstat会展示迄今为止系统已经发生了多少次YGC以及这些YGC的总耗时。比如系统运行24小时后共发生了260次YGC,总耗时为20s。那么平均下来每次YGC大概耗时几十毫秒,我们由此可以大概知道每次YGC时会导致系统停顿几十毫秒。
(3)每次Young GC后有多少对象进入老年代
接着要了解JVM的第三个信息是:每次YGC后有多少存活对象,即有多少对象会进入老年代。
其实每次YGC过后有多少对象会存活下来,只能大致推测出来。假设已经推算出高峰期多久会发生一次YGC,比如3分钟会有一次YGC。那么此时就可以执行下述jstat命令:jstat -gc PID 180000 10。这就相当于让JVM每隔三分钟执行一次统计,连续执行10次。观察每隔三分钟发生一次YGC时,Eden、Survivor、老年代的对象变化。
正常来说:Eden区肯定会在几乎放满后又变得很少对象,比如800M只使用几十M。Survivor区肯定会放入一些存活对象,老年代可能会增长一些对象。所以这时观察的关键,就是观察老年代的对象增长速率。
正常来说:老年代不太可能不停快速增长的,因为普通系统没那么多长期存活对象。如果每次YGC后,老年代对象都要增长几十M,则可能存活对象太多了。存活对象太多可能会导致放入S区后触发动态年龄判定规则进入老年代,存活对象太多也可能导致S区放不下,大部分存活对象需要进入老年代。
如果老年代每次在YGC过后就新增几百K或几M的对象,这个还算正常。但如果老年代对象快速过快增长,那一定是不正常的。所以通过上述观察策略,就可以知道每次YGC后有多少对象是存活的,也就是Survivor区里增长的 + 老年代增长的对象,就是存活的对象。通过jstat -gc也可以知道老年代对象的增长速率,比如每隔3分钟一次YGC,每次会有50M对象进入老年代,于是老年代对象的增长速率就是每隔3分钟增长50M。
(4)Full GC的触发时机和耗时
只要知道老年代对象的增长速率,那么Full GC的触发时机就很清晰了。比如老年代有800M,每3分钟新增50M,则每1小时就会触发一次FGC。根据jstat输出的系统运行迄今为止的FGC次数以及总耗时,就能计算出每次FGC耗时。比如一共执行了10次FGC,共耗时30s,那么每次FGC大概耗费3s左右。
12.线上FGC的几种案例
(1)如何优化每秒十万QPS的社交APP的JVM性能(增加S区大小 + 优化内存碎片)
(2)如何对垂直电商APP后台系统的FGC进行深度优化(定制JVM参数模版)
(3)不合理设置JVM参数可能导致频繁FGC(优化反射的软引用被每次YGC回收)
(4)线上系统每天数十次FGC导致频繁卡顿的优化(大对象问题)
(5)电商大促活动下严重FGC导致系统直接卡死的优化(System.gc()导致)
13.CPU负载过高的原因
(1)机器CPU负载过高有两个原因
原因一:在系统里创建了大量线程,这些线程同时并发运行,且工作负载都很重,过多的线程同时并发运行就会导致机器CPU负载过高。
原因二:机器上运行的JVM在频繁FGC,FGC是非常耗费CPU资源的,它也是一个非常重负载的过程。
(2)频繁FGC会导致的两个现象
现象一:系统可能时不时因为FGC的STW而卡顿。
现象二:机器的CPU负载很高。
(3)排查CPU负载过高的原因
知道CPU负载过高的两个原因后,就很容易进行排查了,这时候完全可以使用排除法来做。首先看一下JVM FGC的频率,通过jstat或监控平台可以很容易看到现在FGC的频率。如果FGC频率过高,就是FGC引起的CPU负载过高。如果FGC频率正常,就是系统创建了过多线程并发执行负载很重的任务。
所以当时直接通过监控平台就可以看到:JVM的FGC频率变得极为频繁,几乎是每分钟都有一次FGC。每分钟一次FGC,一次至少耗时几百毫秒,可见这个系统性能很糟糕。
(4)排查频繁FGC的问题
出现频繁FGC一般有三个可能:
可能一:内存分配不合理或高并发,导致对象频繁进入老年代,引发频繁FGC。
可能二:存在内存泄漏,即内存里驻留了大量对象塞满了老年代且无法回收,导致稍微有一些对象进入老年代就会引发FGC。
可能三:Metaspace里的类太多,触发了FGC。
当然如果上述三个原因都不存在,但是还是有频繁FGC,也许就是工程师错误的执行System.gc()导致的了。但这个一般很少见,而且JVM参数中可以禁止这种显式触发的GC。
一般排查频繁FGC,核心利器就是jstat了。当时使用jstat分析了一下线上系统的情况,发现并不存在内存分配不合理导致对象频繁进入老年代的问题,而且永久代的内存使用也很正常,所以排除掉了上述三个原因中的两个。那么接下来考虑最后一个原因:老年代里是不是驻留了大量的对象。是的,当时系统就是这个问题。
通过jstat可以明显发现老年代驻留了大量的对象,几乎快塞满了。所以年轻代稍微有一些对象进入老年代,就会很容易触发FGC。而且FGC后还回收不了老年代里大量的对象,只能回收一小部分而已。所以老年代里驻留了大量本不应该存在的对象,才导致频繁触发FG
接下来就是要想办法找到这些对象了,前面介绍过jmap + jhat的组合来分析内存里的大对象,接下来介绍另外一个常用的强有力的工具MAT。jhat适合快速的去分析一下内存快照,但是功能上不是太强大,所以一般会使用比较强大的而且也特别常用的内存分析工具MAT。
(5)基于MAT来进行内存泄漏分析
使用MAT打开一个内存快照后,MAT上有一个工具栏,里面有一个按钮。这个按钮的英文是:Leak Suspects,就是内存泄漏的分析。
接着MAT会分析选择的内存快照,尝试找出导致内存泄漏的一批对象。这时可以看到它会显示出一个大的饼图,展示哪些对象占用内存过大。这时直接会看到某种自己系统创建的对象占用量过大,这种对象的实例多达数十万个,占用了老年代一大半的内存空间。
接着就可以找开发工程师去排查这个系统的代码问题了,为什么会创建那么多对象,且始终回收不掉?这就是典型的内存泄漏,即系统创建了大量的对象占用了内存,很多对象不再使用但又无法回收。
后来找出了原因:就是系统里做了一个JVM本地缓存,把很多数据都加载到内存里缓存,然后提供查询服务时会直接从本地内存里进行查询。但因为没有限制本地缓存大小,且没有使用LRU算法定期淘汰缓存数据。最终导致缓存在内存里的对象越来越多,最后造成了内存泄漏。
解决问题很简单:只要使用如Ehcache等缓存框架即可,它会固定最多缓存多少个对象,以及定期淘汰一些不常访问的缓存,以便新数据可以进入缓存中。
14.JVM运行原理和GC原理总结
(1)JVM和YGC的运行原理
首先必须要明白,JVM是如何运行起来的。
JVM的内存区域划分:最核心的就是:新生代、老年代、Metaspace(永久代),其中新生代又分成了Eden区和2个Survivor区,默认比例是8 : 1 : 1。
系统程序会不停在新生代Eden区创建各种对象:系统程序会不停运行,运行时会不停在新生代的Eden区中创建各种对象。
方法运行完毕,其局部变量引用的对象可被回收:一般创建对象都是在各种方法里执行的,一旦方法运行完毕,方法局部变量引用的那些对象就会成为Eden区里的垃圾对象可被回收。
随着不断创建对象,Eden区就会逐步被占满:这时可能Eden区里的对象大多数都是垃圾对象,一旦Eden区被占满后,就会触发一次YGC。首先从GC Roots(方法局部变量、类静态变量)开始追踪,标记存活对象。然后用复制算法把存活对象放入第一个Survivor区中,也就是S0区。
接着新生代垃圾回收器就会回收掉Eden区里剩余的全部垃圾对象:在整个新生代垃圾回收的过程中全程会进入STW状态。也就是暂停系统工作线程,系统代码全部停止运行,不允许创建新对象。这样才能让新生代垃圾回收器专心工作,找出存活对象然后回收垃圾对象。一旦新生代垃圾回收全部完毕,存活对象都进入了Survivor区域。然后Eden区都清空了,那么YGC就会执行完毕。此时系统程序恢复工作,继续在Eden区里创建对象。
下一次如果Eden区又满了,就会再次触发YGC:把Eden区和S0区里的存活对象转移到S1区里去,然后直接清空掉Eden区和S0区中的垃圾对象。当然这个过程中系统程序是禁止工作的,处于Stop the World状态。
负责YGC的垃圾回收器有很多种,常用的是ParNew垃圾回收器。它的核心执行原理就如上所述,只不过ParNew运行时是基于多线程并发执行垃圾回收的,以上就是最基本的JVM和YGC的运行原理。
(2)对象什么时候进入老年代
导致对象会进入老年代区域中的情况如下:
情况一:对象在新生代里躲过15次垃圾回收,年龄太大要进入老年代。
情况二:对象太大超过了一定的阈值,直接进入老年代,不经过新生代。
情况三:YGC后存活对象太多导致S区放不下,存活对象会进入老年代。
情况四:可能几次YGC过后,Surviovr区域中的对象占用超50%的内存,此时如果年龄1+年龄2+年龄N的对象总和超过了Survivor区域的50%,那么年龄N及以上的对象都进入老年代,即动态年龄判定规则。
对象进入老年代的情况说明:
说明一:躲过15次YGC的对象毕竟是少数。
说明二:大对象一般在特殊情况下会有。
说明三:加载大量数据长时间处理及高并发,才容易导致存活对象过多。
对于这些情况,都会导致对象进入老年代中,老年代对象会越来越多。
(3)老年代的GC是如何触发的
一旦老年代对象过多,就可能会触发FGC。FGC必然会带着Old GC,也就是针对老年代的GC,而且FGC一般也会跟着一次YGC,也会触发一次永久代GC。
触发FGC的几个条件如下:
条件一:可以设置老年代内存使用阈值,有一个JVM参数可以控制。老年代内存使用达到阈值就会触发FGC,一般建议调大一些,如92%。
条件二:在执行YGC前,如果发现老年代可用空间小于历次YGC后升入老年代的平均对象大小。那么就会在YGC前触发FGC,先回收掉老年代一批对象,再执行YGC。
条件三:在执行YGC后,如果YGC过后的存活对象太多,Survivor区放不下,要放入老年代。但是此时老年代也放不下,就会触发FGC,回收老年代一批对象,然后再把这些年轻代的存活对象放入老年代。
触发FGC几个比较核心的条件就是这几个,总结起来就是:老年代一旦快要满了,空间不够了,必然要进行FGC垃圾回收。
老年代的垃圾回收通常建议使用CMS垃圾回收器。此外老年代GC的速度是很慢的,少则几百毫秒,多则几秒。所以一旦FGC很频繁,就会导致系统性能很差。因为频繁FGC会频繁停止系统工作线程,导致系统一直有卡顿的现象。而且频繁FGC还会导致机器CPU负载过高,导致机器性能下降。
所以优化JVM的核心就是减少FGC的频率。
(4)正常情况下系统的GC频率
正常YGC频率是几分钟或几十分钟一次,一次耗时几毫秒到几十毫秒。正常FGC频率是几十分钟一次或几小时一次,一次耗时大概几百毫秒。
所以如果观察线上系统就是这个性能表现,基本上问题都不太大。实际线上系统很多时候会遇到一些JVM性能问题:比如FGC过于频繁,每次耗时很多,此时就需要进行优化了。
(5)CPU负载高原因总结
CPU负载高的两个原因:
原因一:系统里创建了大量线程并发执行。
原因二:JVM在执行频繁的FGC。
(6)FGC频繁的原因总结
频繁FGC问题的三个可能:
可能一:内存分配不合理或高并发,导致对象频繁进入老年代,引发频繁FGC。
可能二:存在内存泄漏,就是内存里驻留了大量对象塞满了老年代且无法回收。
可能三:Metaspace里的类太多,触发了FGC。
15.JVM性能优化的思路和步骤
(1)一个新系统开发完毕后应如何设置JVM参数
一个新系统开发完毕后,到底该如何预估及合理设置JVM参数呢?毕竟直接用默认的JVM参数部署上线再观察,是非常的不靠谱的,而很多公司其实也没有所谓的JVM参数模板。
步骤一:估算新系统每秒占用多少内存,每秒多少次请求、每次请求创建多少对象、每个对象大概多大、每秒使用多少内存空间。
步骤二:估算Eden区大概多长时间会占满
步骤三:估算多长时间会发生一次YGC
步骤四:估算YGC时有多少对象存活而升入老年代
步骤五:估算老年代对象的增长速率 + 多久会FGC
通过一连串估算就能合理分配新生代、老年代、Eden、Survivor空间。原则就是:让YGC后存活对象远小于S区,避免对象频繁进入老年代触发FGC。
最理想的状态就是:系统几乎不发生FGC,老年代应该就是稳定占用一定的空间。就是那些长期存活的对象在躲过15次YGC后升入老年代占用的,然后平时主要就是几分钟发生一次YGC,耗时几毫秒。
(2)在压测之后合理调整JVM参数
任何一个新系统上线都得进行压测,在模拟线上压力的场景下,用jstat等工具去观察JVM的运行指标:
指标一:Eden区的对象增长速率多快
指标二:YGC频率多高
指标三:一次YGC多长耗时
指标四:YGC过后多少对象存活
指标五:老年代的对象增长速率多高
指标六:FGC频率多高
指标七:一次FGC耗时多少
压测时可以完全精准的通过jstat观察出上述JVM运行指标,然后就可以优化JVM的内存分配:尽量避免对象频繁进入老年代,尽量让系统只有YGC。
(3)线上系统的监控和优化
系统上线后,务必要进行一定的监控。一般通过Zabbix等工具来监控机器和JVM的运行,频繁FGC就要告警。没这些工具,就在机器上运行jstat,把监控信息写入文件,定时查看。
一旦发现频繁FGC的情况就要进行优化,优化的核心思路是类似的:通过jstat分析出来系统的JVM运行指标,找到FGC的核心问题。然后优化一下JVM的参数,尽量让对象别进入老年代,减少FGC的频率。
(4)线上频繁Full GC的几种表现
一旦系统发生频繁Full GC,可能会看到:
表现一:机器CPU负载过高
表现二:频繁FGC报警
表现三:系统无法处理请求或者处理过慢
所以一旦发生上述几个情况,第一时间应该想到是不是发生了频繁FGC。
(5)频繁FGC的几种常见原因
频繁FGC的常见原因有下面几个:
原因一:系统承载高并发请求,或者处理数据量过大,导致YGC很频繁。如果每次YGC后存活对象太多,内存分配不合理,Survivor区过小,必然会导致对象频繁进入老年代,频繁触发FGC。
原因二:系统一次性加载过多数据进内存,创建出来很多大对象。导致频繁有大对象进入老年代,必然频繁触发FGC。
原因三:系统发生了内存泄漏,莫名其妙创建大量的对象,始终无法回收。大量的对象一直占用在老年代里,必然频繁触发FGC。
原因四:Metaspace因加载类过多触发FGC。
原因五:误调用System.gc()触发FGC。
其实常见的频繁FGC原因无非就上述那几种,所以在线上处理FGC时,可以就从这几个角度入手使用jstat分析。
如果jstat分析发现FGC原因是第一种:新生代升入老年代多且频繁,但老年代并没有大量对象一直无法回收。那么就合理分配内存,调大Survivor区即可。
如果jstat分析发现是第二种或第三种原因:也就是老年代一直有大量对象无法回收,新生代升入老年代的对象不多。那么就dump出内存快照,用MAT工具进行分析,找出占用过多的对象。通过分析对象的引用和线程执行堆栈,找到导致那么多对象的那块代码,接着优化代码即可。
如果jstat分析发现内存使用不多但频繁触发FGC,必然是第四第五种,此时进行对应优化即可。
16.OOM的原因
(1)可能发生OOM的区域有三块
第一块是存放类信息的Metaspace区域
第二块是每个线程的虚拟机栈内存
第三块是堆内存空间
(2)Metaspace如何因类太多而发生内存溢出
Metaspace区域发生内存溢出的原理是:Metaspace满了之后先FGC -> 发现回收不了足够空间就OOM。
两种常见的触发Metaspace内存溢出原因是:默认JVM参数导致Metaspace区域过小 + CGLIB等动态生成类过多。
因此只要合理分配Metaspace区域的内存大小,避免无限制地动态生成类,一般Metaspace区域都是比较安全的,不会触发OOM内存溢出。
(3)无限制调用方法如何让线程的栈内存溢出
栈内存溢出的原因和场景:原因是大量的栈帧会消耗完线程的栈内存 + 场景是方法无限递归调用。所以只要避免代码出现无限方法递归,一般就能避免栈内存溢出。
(4)对象太多导致堆内存实在放不下而内存溢出
发生堆内存溢出的原因:有限的内存中放了过多对象,而且大多都是存活的,此时即使FGC后还是有大部分对象存活,要继续放入更多对象已经不可能,只能引发内存溢出。
发生内存溢出有几种场景:
场景一:系统承载高并发请求,因为请求量过大导致大量对象都是存活的。此时无法继续往堆内存里放入新的对象了,就会引发OOM系统崩溃。
场景二:系统有内存泄漏,创建了很多对象,结果对象都是存活的没法回收。由于不能及时取消对它们的引用,导致触发FGC后还是无法回收。此时只能引发内存溢出,因为老年代已经放不下更多的对象了。
场景三:代码问题创建的对象占用了大量内存,且该方法一直在长时间运行。这样导致占用大量内存的对象一直不释放。
因此引发堆内存OOM的原因可能是:系统负载过高、存在内存泄漏、创建大量对象长时间运行,不过OOM一般是由代码写得差或设计缺陷引发的。
(5)如何在JVM内存溢出时自动dump内存快照
JVM如果知道自己将要发生OOM了,那么此时完全可以让它做点事情。比如可以让JVM在OOM时dump一份内存快照,事后只要分析这个内存快照,就可以知道是哪些对象导致OOM的了。为此,需要在JVM的启动参数中加入如下参数:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/usr/local/app/oom
第一个参数的意思是:在OOM时自动进行dump内存快照。第二个参数的意思是:把内存快照放到哪里。只要加入了这两个参数,可以事后再获取OOM时的内存快照进行分析。
(6)OOM内存溢出排查总结
情形一:Metaspace区域溢出
通过异常信息可以直接定位出是Metaspace区域发生异常,然后分析GC日志就可以知道Metaspace发生溢出的全过程,接着再使用MAT分析内存快照,就知道是哪个类太多导致异常。
情形二:栈内存溢出
首先从异常日志中就能知道是栈内存溢出。
然后从异常日志中可以找到对应的报错方法。
知道哪个方法后,就可以到代码中定位问题。
情形三:堆内存溢出
堆内存溢出问题的分析和定位:
一是加入自动导出内存快照的参数
二是到线上看一下日志文件里的报错
如果是堆溢出,则用MAT分析内存快照。
MAT分析的时候一些顺序和技巧:
一.首先看占用内存最多的对象是谁
二.然后分析那个线程的调用栈
三.接着看哪个方法引发内存溢出
四.最后优化代码即可
(7)MAT使用技巧总结
一.首先通过Histogram界面找占用大的对象
二.然后进入dominator_tree界面找对应的线程
三.接着进入thread_overview界面找线程对应的方法调用栈
(8)接口假死的两者情况
为什么要先用top看机器资源?因为如果服务出现无法调用接口假死的情况,首先要考虑的是两种原因。
第一种原因:这个服务可能使用了大量的内存,内存始终无法释放,导致频繁FGC。也许每秒都执行一次FGC,结果每次都回收不了,最终导致频繁FGC。频繁FGC又会频繁Stop the World,所以接口调用时就会出现频繁假死。
第二种原因:可能是这台机器的CPU负载太高了,也许是某个进程耗尽了CPU资源。CPU资源耗尽会导致这个服务的线程始终无法得到CPU资源去执行。没有CPU资源去执行也就无法响应接口调用请求,也会出现频繁假死。
因此针对服务假死的问题,先通过top命令查看,就知道关键点了。
(9)JVM出现内存溢出的三种原因
原因一是并发太高,大量并发创建过多的对象,导致系统直接崩溃了。
原因二是内存泄漏,有很多对象都在内存里,无论如何GC都回收不掉。
原因三是代码问题,导致某种情况下加载了大量数据,创建了大量对象。