JVM性能优化系列-(2) 垃圾收集器与内存分配策略

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第1张图片

目前已经更新完《Java并发编程》和《Docker教程》,欢迎关注【后端精进之路】,轻松阅读全部文章。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第2张图片

Java并发编程:

  • Java并发编程系列-(1) 并发编程基础
  • Java并发编程系列-(2) 线程的并发工具类
  • Java并发编程系列-(3) 原子操作与CAS
  • Java并发编程系列-(4) 显式锁与AQS
  • Java并发编程系列-(5) Java并发容器
  • Java并发编程系列-(6) Java线程池
  • Java并发编程系列-(7) Java线程安全
  • Java并发编程系列-(8) JMM和底层实现原理
  • Java并发编程系列-(9) JDK 8/9/10中的并发

Docker教程:

  • Docker系列-(1) 原理与基本操作
  • Docker系列-(2) 镜像制作与发布
  • Docker系列-(3) Docker-compose使用与负载均衡

JVM性能优化:

  • JVM性能优化系列-(1) Java内存区域
  • JVM性能优化系列-(2) 垃圾收集器与内存分配策略

2. 垃圾收集器与内存分配策略

垃圾收集(Garbage Collection, GC)是JVM实现里非常重要的一环,JVM成熟的内存动态分配与回收技术使Java(当然还有其他运行在JVM上的语言,如Scala等)程序员在提升开发效率上获得了惊人的便利。理解GC,对于理解JVM和Java语言有着非常重要的作用。并且当我们需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,只有深入理解GC和内存分配,才能对这些“自动化”的技术实施必要的监控和调节。

GC主要需要解决以下三个问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

下面将对这些问题进行一一介绍。

2.1 如何判断对象存活

在堆里存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首要的就是确定这些对象中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。

引用计数算法

引用计数器判断对象是否存活的过程是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是不可能再被使用的。

引用计数算法的实现简单,判定效率也很高,大部分情况下是一个不错的算法。它没有被JVM采用的原因是它很难解决对象之间循环引用的问题。

可达性分析算法

在主流商用程序语言的实现中,都是通过可达性分析(tracing GC)来判定对象是否存活的。

算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是GC Roots 到这个对象不可达)时,则证明此对象时不可用的。用下图来加以说明:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第3张图片

作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

2.2 各种引用

强引用

一般的Object obj = new Object() ,就属于强引用。被强引用关联的对象不会被回收。

软引用

一些有用但是并非必需,用软引用关联的对象,系统将要发生OOM之前,这些对象就会被回收。

下面的例子中,当程序发生OOM之前,尝试去回收软引用所关联的对象,导致后面获取到的值为null。

public class TestSoftRef {
    
    public static class User{
        public int id = 0;
        public String name = "";
        public User(int id, String name) {
            super();
            this.id = id;
            this.name = name;
        }
        @Override
        public String toString() {
            return "User [id=" + id + ", name=" + name + "]";
        }
        
    }
    
    public static void main(String[] args) {

        User u = new User(1,"Vincent");
        SoftReference userSoft = new SoftReference<>(u);
        u = null;//保证new User(1,"Vincent")这个实例只有userSoft在软引用
        
        System.out.println(userSoft.get());
        System.gc();//展示gc的时候,SoftReference不一定会被回收
        System.out.println("AfterGc");
        System.out.println(userSoft.get());//new User(1,"Vincent")没有被回收
        List list = new LinkedList<>();
        
        try {
            for(int i=0;i<100;i++) {
                //User(1,"Vincent")实例一直存在
                System.out.println("********************"+userSoft.get());
                list.add(new byte[1024*1024*1]);
            }
        } catch (Throwable e) {
            //抛出了OOM异常后打印的,User(1,"Vincent")这个实例被回收了
            System.out.println("Throwable********************"+userSoft.get());
        }
        
    }
}

程序输出结果:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第4张图片

弱引用 WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

下面的例子中,发生gc后,弱引用所关联的对象被回收。

public class TestWeakRef {
    public static class User{
        public int id = 0;
        public String name = "";
        public User(int id, String name) {
            super();
            this.id = id;
            this.name = name;
        }
        @Override
        public String toString() {
            return "User [id=" + id + ", name=" + name + "]";
        }
        
    }
    
    public static void main(String[] args) {
        User u = new User(1,"Vincent");
        WeakReference userWeak = new WeakReference<>(u);
        u = null;
        System.out.println(userWeak.get());
        System.gc();
        System.out.println("AfterGc");
        System.out.println(userWeak.get());
        
    }
}

输出结果如下:

Screen Shot 2019-12-19 at 8.56.46 PM.png

虚引用

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。

为一个对象设置虚引用关联的唯一目的,就是能在这个对象被回收时收到一个系统通知

Object obj = new Object();
PhantomReference pf = new PhantomReference(obj);
obj = null; 
 

注意:软引用 SoftReference和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。
例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

2.3 方法区回收

很多人认为方法区没有垃圾回收,Java虚拟机规范中确实说过不要求,而且在方法区中进行垃圾收集的“性价比”较低:在堆中,尤其是新生代,常规应用进行一次垃圾收集可以回收70%~95%的空间,而方法区的效率远低于此。在JDK 1.8中,JVM摒弃了永久代,用元空间来作为方法区的实现,下面介绍的将是元空间的垃圾回收。

元空间的内存管理由元空间虚拟机来完成。先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的C++代码即可完成。在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。

话句话说,只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。

2.4 垃圾收集算法

标记-清除算法(Mark-Sweep)

算法分成“标记”、“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

算法的执行过程如下图所示:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第5张图片

标记-清除算法的不足主要有以下两点:

  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。

  • 效率问题,因为内存碎片的存在,操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。

复制算法(Copying)

将可用内存按容量分成大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

这样做使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半。 复制算法的执行过程如下图所示:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第6张图片

标记-整理算法(Mark-Compact)

根据老年代的特点,标记-整理(Mark-Compact)算法被提出来,主要思想为:此算法的标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。具体示意图如下所示:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第7张图片

分代收集算法(Generational Collection)

当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法,此算法相较于前几种没有什么新的特征,主要思想为:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法:

  • 新生代

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

  • 老年代

在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或“标记-整理”算法来进行回收。

Minor GC与复制算法

现在的商业虚拟机都使用复制算法来回收新生代。新生代的GC又叫“Minor GC”,IBM公司的专门研究表明:新生代中的对象98%是“朝生夕死”的,所以Minor GC非常频繁,一般回收速度也比较快,同时“朝生夕死”的特性也使得Minor GC使用复制算法时不需要按照1:1的比例来划分新生代内存空间。

  • Minor GC过程

事实上,新生代将内存分为一块较大的Eden空间两块较小的Survivor空间(From Survivor和To Survivor),每次Minor GC都使用Eden和From Survivor,当回收时,将Eden和From Survivor中还存活着的对象都一次性地复制到另外一块To Survivor空间上,最后清理掉Eden和刚使用的Survivor空间。一次Minor GC结束的时候,Eden空间和From Survivor空间都是空的,而To Survivor空间里面存储着存活的对象。在下次MinorGC的时候,两个Survivor空间交换他们的标签,现在是空的“From” Survivor标记成为“To”,“To” Survivor标记为“From”。因此,在MinorGC结束的时候,Eden空间是空的,两个Survivor空间中的一个是空的,而另一个存储着存活的对象。

HotSpot虚拟机默认的Eden : Survivor的比例是8 : 1,由于一共有两块Survivor,所以每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的容量会被“浪费”。

  • 分配担保

上文说的98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代内存进行分配担保(Handle Promotion)。如果另外一块Survivor上没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

2.5 HotSpot的算法实现

枚举根节点

  • GC链逐个检查引用,会消耗比较多时间
  • GC停顿,为了保持“一致性”,需要“Stop the world”
  • HotSpot使用一组称为OopMap的数据结构来记录哪些地方存着对象的引用。在类加载过程中,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中会在特定的位置记录下栈和寄存器中哪些位置是引用。

安全点

HotSpot没有为每条指令都生成OopMap,只是在特定位置记录了这些信息,这些位置称为安全点。程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。

安全区域

安全区域是指在一段代码片段之中,引用关系不会发生变化,在这个区域内的任何地方进行GC都是安全的。可以看成是扩展的安全点。

2.6 垃圾收集器

目前为止并没有一个最好的收集器,也没有万能的收集器,通常是根据具体情况选择合适的收集器。

接下来要介绍的收集器如下图所示,7种收集器分别作用于不同的区域,如果两个收集器之间存在连线,就说明可以搭配使用。虚拟机所处的位置,代表是属于新生代收集器还是老年代收集器。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第8张图片

基本概念

1. 并行与并发

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。

2. 吞吐量(Throughput)

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即

吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)

假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

3. Minor GC 和 Full GC

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

Serial/Serial Old 收集器

Serial是一个“单线程”的新生代收集器,使用复制算法,它只会使用一个CPU或者一条收集器线程去完成垃圾收集工作,并且它在垃圾收集时,必须暂停所有其他的工作线程,直到它收集结束。“Stop The World”会在用户不可见的情况下,把用户的工作线程全部停掉。

Serial Old是Serial收集器的老年代版本,同样是一个“单线程”收集器,使用标记-整理算法。这个收集器主要是给Client模式下的虚拟机使用,Server模式下还有两个用途,一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另一个是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

下图是 Serial/Serial Old 收集器运行示意图:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第9张图片

上图中,新生代是Serial收集器采用复制算法老年代是Serial Old收集器采用标记-整理算法。Serial虽然是一个缺点鲜明的收集器,但它依然是虚拟机在Client模式下的默认收集器,它也有优点,比如简单高效(与其他收集器单线程相比),对于单个CPU来说,Serial由于没有线程交互的开销,效率比较高

ParNew 收集器

ParNew收集器是Serial收集器的多线程版本,也是使用复制算法的新生代收集器,它除了使用多条线程进行垃圾收集以外,其他的比如收集器的控制参数、收集算法、Stop-The-World、对象分配规则、回收策略都和Serial收集器完全一样。

下图是 ParNew/Serial Old 收集器运行示意图:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第10张图片

上图中,新生代是ParNew收集器采用复制算法,老年代是Serial Old收集器采用标记-整理算法。ParNew是许多Server模式下虚拟机的首选新生代收集器,因为它能与CMS收集器配合工作。CMS收集器是HotSpot虚拟机中第一个并发的垃圾收集器,CMS第一次实现了让用户线程与垃圾收集线程同时工作。

Parallel Scavenge(ParallerGC)/ Parallel Old 收集器

Parallel Scavenge也是使用复制算法的新生代收集器,并且也是一个并行的多线程收集器。Parallel收集器跟其它收集器关注GC停顿时间不同,它关注的是吞吐量。低停顿时间适合需要与用户交互的程序,而高吞吐量可以高效率的利用CPU时间,能尽快完成运算任务,适合用于后台计算较多而交互较少的任务。

Parallel收集器提供了两个虚拟机参数用以控制吞吐量,-XX:MaxGCPauseMillis参数可以控制垃圾收集的最大停顿时间,-XX:GCTimeRatio参数可以直接设置吞吐量大小。

-XX:MaxGCPauseMillis的值是一个大于0的毫秒数,使用它减小GC停顿时间是牺牲吞吐量和新生代空间换来的,例如系统把新生代调小,收集300M的新生代肯定比500M的快,这也导致垃圾收集发生的更频繁,原来10秒收集一次每次停顿100毫秒,现在5秒收集一次每次停顿70毫秒,停顿时间下降了,但是吞吐量也下降了。

-XX:GCTimeRatio的值是一个0到100的整数,通过它我们告诉JVM吞吐量要达到的目标值,-XX:GCTimeRatio=N指定目标应用程序线程的执行时间(与总的程序执行时间)达到N/(N+1)的目标比值。例如,它的默认值是99,就是说要求应用程序线程在整个执行时间中至少99/100是活动的(GC线程占用其余的1/100),也就是说,应用程序线程应该运行至少99%的总执行时间。

除这两个参数外,还有一个参数-XX:-UseAdaptiveSizePolicy值得关注,这是一个开关参数,当它打开之后,就不需要手工指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据系统的运行情况收集性能监控信息,动态的调整这些参数来提高GC性能,这种调节方式称为GC自适应调节策略。这个参数是默认激活的,自适应行为也是JVM优势之一。

Parallel Old收集器是Parallel Scavenge的老年代版本,使用多线程和标记-整理算法。此收集器在JDK1.6中开始出现,在Parallel Old出现之前,只有Serial Old能够与Parallel Scavenge收集器配合使用。由于Serial Old这种单线程收集器的性能拖累,导致在老年代比较大的场景下,Parallel Scavenge和Serial Old的组合吞吐量甚至还不如ParNew加CMS的组合。而有了Parallel Old收集器之后,Parallel Scavenge与Parallel Old成了名副其实的吞吐量优先的组合,在注重吞吐量和CPU资源敏感的场景下,都可以优先考虑这对组合。

下图是 Parallel Scavenge(ParallerGC)/ Parallel Old 收集器运行示意图:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第11张图片

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是基于标记-清除算法老年代收集器,它以获取最短回收停顿时间为目标。CMS是一款优秀的收集器,特点是并发收集、低停顿,它的运行过程稍微复杂些,分为4个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

4个步骤中只有初始标记、重新标记这两步需要“Stop The World”。初始标记只是标记一下GC Roots能直接关联的对象,速度很快。并发标记是进行GC Roots Tracing的过程,也就是从GC Roots开始进行可达性分析。重新标记则是为了修正并发标记期间因用户线程继续运行而导致标记发生变动的那一部分记录。并发清理当然就是进行清理被标记对象的工作。

下图是 CMS 收集器运行示意图:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第12张图片

整个过程中,并发标记与并发清除过程耗时最长,但它们都可以与用户线程一起工作,所以整体上说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

但是CMS收集器也并不完美,它有以下3个缺点:

  1. CMS收集时对CPU资源非常敏感,并发阶段虽然不会导致用户线程停顿,但是会因为占用CPU资源导致应用程序变慢、总吞吐量变低。
  2. CMS收集器无法处理浮动垃圾(Floating Garbage),可能会产生Full GC。浮动垃圾就是在并发清理阶段,依然在运行的用户线程产生的垃圾。这部分垃圾出现在标记过程之后,CMS无法在当次集中处理它们,只能等下一次GC时清理。
  3. CMS是基于标记-清除算法的收集器,可能会产生大量的空间碎片,从而无法分配大对象而导致Full GC提前产生。

由于存在浮动垃圾,以及用户线程正在运行,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。可以使用-XX:CMSInitialOccupyFraction参数调整默认CMS收集器的启动阈值。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
-XX:+UseCMSCompactAtFullCollection用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入FullGC时都进行碎片整理)。

G1收集器

G1(Garbage-First)收集器是面向服务端应用的垃圾收集器,它被寄予厚望以用来替换CMS收集器。在G1之前的收集器中,收集的范围要么是整个新生代要么就是老年代,而G1不再从物理上区分新生代老年代,G1可以独立管理整个Java堆。它将Java堆划分为多个大小相等的独立区域(Region),虽然还有新生代老年代的概念,但不再是物理隔离的,而都是一部分Region(不需要连续)的集合。

与其他收集器相比,G1收集器的特点有:

  • 并行与并发:G1能充分利用多CPU或者多核心的CPU,来缩短Stop The World的停顿时间。
  • 分代收集:虽然G1收集器可以独立管理整个GC堆,但它能采用不同的方式处理“新对象”和“老对象”,以达到更好的收集效果。
  • 空间整合:G1从整体看是基于标记-整理算法的,从局部看(两个Region之间)是基于复制算法实现的,这两个算法在收集时都不会产生空间碎片,这样就有连续可用的内存用以分配大对象。
  • 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型,可以明确指定一个最大停顿时间(-XX:MaxGCPauseMillis),停顿时间需要不断调优找到一个理想值,过大过小都会拖慢性能。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以避免在整个Java堆中进行全区域的垃圾收集,G1根据各个Region里垃圾堆积的价值大小(回收所获空间大小及所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,这也是Garbage-First名称的由来。

G1收集器的Region如下图所示:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第13张图片

图中的E代表是Eden区,S代表Survivor,O代表Old区,H代表humongous表示巨型对象(大于Region空间的对象)。从图中可以看出各个区域逻辑上并不是连续的,并且一个Region在某一个时刻是Eden,在另一个时刻就可能属于老年代。G1在进行垃圾清理的时候就是将一个Region的对象拷贝到另外一个Region中。

避免全堆扫描:G1中引入了Remembered Set(记忆集)。每个Region中都有一个Remembered Set,记录的是其他Region中的对象引用本Region对象的关系(谁引用了我的对象)。所以在垃圾回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。G1里面还有另外一种数据结构叫Collection Set,Collection Set记录的是GC要收集的Region的集合,Collection Set里的Region可以是任意代的。在GC的时候,对于跨代对象引用,只要扫描对应的Collection Set中的Remembered Set即可。

G1收集器的收集过程如下图所示:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第14张图片

如图所示,G1收集过程有如下几个阶段:

  • 初始标记(Initial Marking):标记一下GC Roots能关联到的对象,需要停顿线程但是耗时短,会停顿用户线程(Stop the World)
  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时长但是可以与用户线程并发执行。
  • 最终标记(Final Marking):修正在并发标记阶段,因用户线程继续运行而导致标记产生变动的那一部分标记记录,这阶段需要停顿用户线程(Stop the World),但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation):会对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划,该阶段也是会停顿用户线程(Stop the World)。

以下是对所有垃圾收集器的总结:

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第15张图片

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第16张图片

常用的垃圾收集器参数

以下是JVM中常用的垃圾收集器参数:

VM参数 描述
-XX:+UseSerialGC 指定Serial收集器+Serial Old收集器组合执行内存回收
-XX:+UseParNewGC 指定ParNew收集器+Serilal Old组合执行内存回收
-XX:+UseParallelGC 指定Parallel收集器+Serial Old收集器组合执行内存回收
-XX:+UseParallelOldGC 指定Parallel收集器+Parallel Old收集器组合执行内存回收
-XX:+UseConcMarkSweepGC 指定CMS收集器+ParNew收集器+Serial Old收集器组合执行内存回收。优先使用ParNew收集器+CMS收集器的组合,当出现ConcurrentMode Fail或者Promotion Failed时,则采用ParNew收集器+Serial Old收集器的组合
-XX:+UseG1GC 指定G1收集器并发、并行执行内存回收
-XX:+PrintGCDetails 打印GC详细信息
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-XX:+PrintTenuringDistribution 在进行GC时打印survivor中的对象年龄分布信息
-Xloggc:$CATALINA_HOME/logs/gc.log 指定输出路径收集日志到日志文件
-XX:NewRatio 新生代与老年代(new/old generation)的大小比例(Ratio). 默认值为 2
-XX:SurvivorRatio eden/survivor 空间大小的比例(Ratio). 默认值为 8
-XX:GCTimeRatio GC时间占总时间的比率,默认值99%,仅在Parallel Scavenge收集器时生效
-XX:MaxGCPauseMills 设置GC最大停顿时间,仅在Parallel Scavenge收集器时生效
-XX:PretensureSizeThreshold 直接晋升到老年代的对象大小,大于这个参数的对象直接在老年代分配
-XX:MaxTenuringThreshold 提升老年代的最大临界值(tenuring threshold). 默认值为 15
-XX:UseAdaptiveSizePolicy 动态调整Java堆中各个区域的大小及进入老年代的年龄
-XX:HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代整个Eden和Survivor中对象都存活的极端情况
-XX:ParallelGCThreads 设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同
-XX:ParallelCMSThreads 设定CMS的线程数量
-XX:ConcGCThreads 并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同
-XX:CMSInitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后触发垃圾收集,默认68%
-XX:+UseCMSCompactAtFullCollection 设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理
-XX:CMSFullGCsBeforeCompaction 设定进行多少次CMS垃圾回收后,进行一次内存压缩
-XX:+CMSClassUnloadingEnabled 允许对类元数据进行回收
-XX:CMSInitiatingPermOccupancyFraction 当永久区占用率达到这一百分比时,启动CMS回收
-XX:UseCMSInitiatingOccupancyOnly 表示只在到达阀值的时候,才进行CMS回收
-XX:InitiatingHeapOccupancyPercent 指定当整个堆使用率达到多少时,触发并发标记周期的执行,默认值是45%
-XX:G1HeapWastePercent 并发标记结束后,会知道有多少空间会被回收,再每次YGC和发生MixedGC之前,会检查垃圾占比是否达到此参数,达到了才会发生MixedGC
-XX:G1ReservePercent 设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10
-XX:G1HeapRegionSize 使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为 1Mb, 最大值为 32Mb

2.7 内存分配策略

对象优先在Eden区分配

大多数情况下,对象在新生代的Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,经常出现大对象容易导致内存还有不少空间时,就提前触发GC以获取足够的连续空间来安置它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。缺省为0,表示绝不会直接分配在老年代。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保

新生代中有大量的对象存活,survivor空间不够,当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代.只要老年代的连续空间大于新生代对象的总大小或者历次晋升的平均大小,就进行Minor GC,否则FullGC。

2.8 Full GC的触发条件

对于Minor GC,其触发条件非常简单,当Eden区空间满时,就将触发一次Minor GC。而Full GC则相对复杂,因此本节我们主要介绍Full GC的触发条件。

  • 调用System.gc()

此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存,可通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc()。

  • 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行Full GC后空间仍然不足,则抛出如下错误:
Java.lang.OutOfMemoryError: Java heap space
为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

  • 空间分配担保失败

前文介绍过,使用复制算法的Minor GC需要老年代的内存空间作担保,如果出现了HandlePromotionFailure担保失败,则会触发Full GC。

  • JDK 1.7及以前的永久代空间不足

在JDK 1.7及以前,HotSpot虚拟机中的方法区是用永久代实现的,永久代中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
java.lang.OutOfMemoryError: PermGen space
为避免PermGen占满造成Full GC现象,可采用的方法为增大PermGen空间或转为使用CMS GC。

在JDK 1.8中用元空间替换了永久代作为方法区的实现,元空间是本地内存,因此减少了一种Full GC触发的可能性。

  • Concurrent Mode Failure

执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC),便会报Concurrent Mode Failure错误,并触发Full GC。

2.9 新生代配置实战

关于新生代的配置,主要有下面三种参数:

-XX:NewSize/MaxNewSize : 新生代的size和最大size,该参数优先级最高。
-Xmn(可以看成NewSize= MaxNewSize):新生代的大小,该参数优先级次高。
-XX:NewRatio: 表示比例,例如=2,表示 新生代:老年代 = 1:2,该参数优先级最低。

还有参数:-XX:SurvivorRatio 表示Eden和Survivor的比值,缺省为8,表示 Eden:FromSurvivor:ToSurvivor= 8:1:1

下面举例参数配置进行实战,程序中生成了10个大小为1M的数组,

public class NewSize {

    public static void main(String[] args) {
        int cap = 1*1024*1024;//1M
        byte[] b1 = new byte[cap];
        byte[] b2 = new byte[cap];
        byte[] b3 = new byte[cap];
        byte[] b4 = new byte[cap];
        byte[] b5 = new byte[cap];
        byte[] b6 = new byte[cap];
        byte[] b7 = new byte[cap];
        byte[] b8 = new byte[cap];
        byte[] b9 = new byte[cap];
        byte[] b0 = new byte[cap];
    }
}
  1. -Xms20M -Xmx20M -XX:+PrintGCDetails –Xmn2m -XX:SurvivorRatio=2

没有垃圾回收,数组都在老年代。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第17张图片

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn7m -XX:SurvivorRatio=2

发生了垃圾回收,新生代存了部分数组,老年代也保存了部分数组,发生了晋升现象。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第18张图片

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -Xmn15m -XX:SurvivorRatio=8

新生代可以放下所有的数组,老年代没放。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第19张图片

  1. -Xms20M -Xmx20M -XX:+PrintGCDetails -XX:NewRatio=2

发生了垃圾回收,出现了空间分配担保,而且发生了FullGC。

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第20张图片

2.10 内存泄漏和内存溢出

  • 内存溢出:实实在在的内存空间不足导致;

  • 内存泄漏:该释放的对象没有释放,多见于自己使用容器保存元素的情况下。

下面举例说明,例子中实现了一个基本的栈,注意看出栈的部分,为了帮助GC,当出栈完成后,手动将栈顶的引用清空,有助于后续元素的gc。这里如果不清空,当元素出栈后,栈顶原来的位置还有该元素的引用,所以可能造成无法对已经出栈的元素进行回收,造成内存泄露。

public class Stack {
    
    public  Object[] elements;
    private int size = 0;//指示器,指示当前栈顶的位置

    private static final int Cap = 16;

    public Stack() {
        elements = new Object[Cap];
    }

    //入栈
    public void push(Object e){
        elements[size] = e;
        size++;
    }

    //出栈
    public Object pop(){
        size = size-1;
        Object o = elements[size];
        elements[size] = null;//help gc
        return o;
    }
    
    public static void main(String[] args) {
        Stack stack = new Stack();
        Object o = new Object();
        System.out.println("o="+o);
        stack.push(o);
        Object o1 =  stack.pop();
        System.out.println("o1="+o1);
        
        System.out.println(stack.elements[0]);
    }
}

2.11 浅堆和深堆

浅堆 :(Shallow Heap)是指一个对象所消耗的内存。例如,在32位系统中,一个对象引用会占据4个字节,一个int类型会占据4个字节,long型变量会占据8个字节,每个对象头需要占用8个字节。

深堆 :这个对象被GC回收后,可以真实释放的内存大小,也就是只能通过对象被直接或间接访问到的所有对象的集合。通俗地说,就是指仅被对象所持有的对象的集合。

举例:对象A引用了C和D,对象B引用了C和E。那么对象A的浅堆大小只是A本身,不含C和D,而A的实际大小为A、C、D三者之和。而A的深堆大小为A与D之和,由于对象C还可以通过对象B访问到,因此不在对象A的深堆范围内。

2.12 jdk工具

jps

列出当前机器上正在运行的虚拟机进程
-p:仅仅显示VM 标示,不显示jar,class, main参数等信息.
-m:输出主函数传入的参数. 下的hello 就是在执行程序时从命令行输入的参数
-l: 输出应用程序主类完整package名称或jar完整名称.
-v: 列出jvm参数, -Xms20m -Xmx50m是启动程序指定的jvm参数

jstat

是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。

假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:jstat-gc 2764 250 20

常用参数:
-class (类加载器)
-compiler (JIT)
-gc (GC堆状态)
-gccapacity (各区大小)
-gccause (最近一次GC统计和原因)
-gcnew (新区统计)
-gcnewcapacity (新区大小)
-gcold (老区统计)
-gcoldcapacity (老区大小)
-gcpermcapacity (永久区大小)
-gcutil (GC统计汇总)
-printcompilation (HotSpot编译统计)

jinfo

查看和修改虚拟机的参数jinfo –sysprops 可以查看由System.getProperties()取得的参数
jinfo –flag 未被显式指定的参数的系统默认值
jinfo –flags(注意s)显示虚拟机的参数
jinfo –flag +[参数] 可以增加参数,但是仅限于由java -XX:+PrintFlagsFinal –version查询出来且为manageable的参数
jinfo –flag -[参数] 可以去除参数
Thread.getAllStackTraces();

jmap

用于生成堆转储快照(一般称为heapdump或dump文件)。jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。和jinfo命令一样,jmap有不少功能在Windows平台下都是受限的,除了生成dump文件的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统都提供之外,其余选项都只能在Linux/Solaris下使用。
jmap -dump:live,format=b,file=heap.bin
Sun JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。

jhat

jhat dump文件名
后屏幕显示“Server is ready.”的提示后,用户在浏览器中键入http://localhost:7000/就可以访问详情.

jstack

(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。
在代码中可以用java.lang.Thread类的getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码就完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈。

jconsole

Java提供的GUI监视与管理平台。

visualvm

和jconsole类似,但是通过插件扩展,可以具备远优于jconsole的可视化功能。


参考:

  • http://www.cellei.com/blog/2018/04251
  • https://crowhawk.github.io/2017/08/15/jvm_3/
  • https://meandni.com/2019/01/11/jvm_note2/
  • https://juejin.im/post/5d7ba549e51d453b5e465bd4#heading-13

    本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,立刻获取最新文章和价值2000元的BATJ精品面试课程

JVM性能优化系列-(2) 垃圾收集器与内存分配策略_第21张图片

你可能感兴趣的:(JVM性能优化系列-(2) 垃圾收集器与内存分配策略)