大白话讲解JVM调优(基础篇)

原创不易,喜欢的话点个赞支持一下吧

1.概述

在开始之前先来说下咱们JVM调优主要是调的啥?毫无疑问,调优就是调的咱们JVM运行时内存大小+gc垃圾回收细节,要想调整JVM运行时内存大小,需要我们知道JVM内存划分知识,要想调整gc垃圾回收的一些细节,需要我们知道一些垃圾回收器工作原理,以及它们使用的垃圾回收算法,还有知道垃圾回收的一个流程,这样才能根据自己项目的具体情况来调整,这里有个调优原则就是能在年轻代回收掉的不要留到老年代,减少Full GC 次数,如果这个原则里面的一些概念不懂的话也没关系,下面我们都会有介绍,接下来我们先来介绍下调优前的一些必备知识点,后面的篇章会介绍具体的调优实战。

2.涉及的知识点

2.1 JVM内存划分

我们先来看下下面这个JVM运行时内存划分图
在这里插入图片描述
我们来介绍下各个区域都是干啥的

  1. 堆(heap):这块区域主要存放对象的,比如说我们程序中new User()对象它就会被分配到这块区域,这块区域是共享的,也就是所有线程都可以访问该区域的对象,堆也是垃圾回收主要区域。

  2. java虚拟机栈 (vm stack): 这块区域存放我们线程运行时的一些数据,它是每个线程私有的,可以理解为一个线程就对应的着一个java虚拟机栈,它里面就是一个个的栈帧组成的,一个方法就是一个栈帧,栈帧里面有局部变量表,操作数栈,常量池的引用,方法返回地址等,比如下面这段代码:

public static void main(String[] args) {
        int a=1;
        int b=2;
        int c= a+b;
        System.out.println(c);
        c=0;
}

这段代码就是计算a+b 为c,然后调用println方法打印c的值。
这里面 a,b,c 就在局部变量表中,在算c的时候就是使用的操作数栈算的,然后调用println方法打印c其实就是往java 虚拟机栈 压入一个println 方法的栈帧。
在这里插入图片描述
println栈帧中会保存着一个返回地址(也就是方法返回地址),当println方法执行完成后,会进行弹栈,然后程序跳到返回地址位置,也就是c=0 位置继续执行。

  1. 本地方法栈(native stack):它与java虚拟机栈功能差不多,只不过java虚拟机栈是运行的java方法,而本地方法栈主要是执行的native方法。在hotspot虚拟机中将本地方法栈与java虚拟机栈合二为一。
  2. 元数据(Metaspace):这个是java8 的新概念,以前叫方法区,主要是存放着我们的class类信息,我们常说的类加载器其实就是将class文件加载到这个区域,这个区域还保存着我们定义的常量,也就是常量池。
  3. 程序计数器(Program Counter Register):它也叫做pc寄存器,每个线程一个程序计数器,它保存着当前线程执行的指令地址,当执行的是java 方法的时候,程序计数器保存的是当前需要执行的指令地址,当执行的是native 方法的时候,程序计数器保存的就是undefined。

2.2 回收算法

2.2.1 垃圾回收的介绍

在介绍垃圾回收算法之前,我们要解释下什么是垃圾回收,哪些内存需要回收,怎样的算是垃圾这三个问题

1.什么是垃圾回收

先来说下什么是垃圾回收,比如说我们在代码新起的线程中new 了以User对象,看下面这段代码

public static void main(String[] args) {
  new Thread(new Runnable() {
    @Override
    public void run() {
      User user = new User();
      //do something
    }
  }).start();

  //do something
  //.........
}
private static class  User{
  private String name;
  private int age;
}

然后它就会在堆(heap)区中为这个User对象划分一块内存,当这个线程执行完成之后,这个User对象就用不到了,就成了垃圾,你把它占用的这块内存释放掉就是垃圾回收。说白了垃圾回收就是释放那些用不着的对象(这个用不着的对象就是我们平常说的垃圾)占用的内存。

2.哪些内存需要回收(哪些内存会产生垃圾)

我们在2.1小节中介绍了JVM运行时的内存划分,

3.怎样辨别垃圾

最简单的解释,就是一个对象没有地方用着了就算是垃圾。怎样判断一个对象有没有被用着了呢? 其实最早的时候就是给某个对象引用计数器,有一个地方引用它,计数器就+1,不引用了就-1,这样做有个好处就是快,垃圾回收的时候只需要判断一下它这个引用计数器是不是0就可以了,但是有个问题就是那种相互引用的对象没法回收,比如说A对象 里面有个成员是B对象,B对象里面有个成员是A对象,然后A,B对象的引用计数器都是1,但是实际上已经没有地方用到这两个对象,还无法被回收掉,这个引用计数的方法就是引用计数算法。

我们再来了解下另一个辨别垃圾的方法,可达性分析算法,就是从一个点开始,然后顺着往下找,沿途的对象就是有用的,剩下的就是无用的,这个点就是GCRoot。

这个GCRoot都有哪些呢?

  1. 虚拟机栈方法中引用的对象(栈帧的局部变量表)
  2. 静态成员引用的对象
  3. 常量引用的对象
  4. 本地方法栈中(Native方法)引用的对象

我们举个例子(虚拟机栈方法中引用的对象) :

public class GCTest {
    public static void main(String[] args) {
        createUser();
        try {
            TimeUnit.SECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    private static void createUser() {
        User user = new User();
    }
}
class User{
    private String name;
    private String des;
}

main方法中,先调用了createUser方法,创建了User对象,我们用图来表示下:
在这里插入图片描述
这时候createUser 的user变量就是个GCRoot,顺着它往下找就能找到在堆中的User对象,这时候User对象是个有用的对象,一旦createUser执行完,也就是createUser栈帧弹栈,这个User对象没有GCRoot能够到达它了,他就成了无用的,也就是我们常说的垃圾。
在这里插入图片描述
再来个静态成员引用的例子:

public class GCTest {
    private static User user =new User();
}
class User{
    private String name;
    private String des;
}

我们知道,static修饰的成员属于类成员,在这里也就是GCTest.class的成员,那么GCTest.class存在哪呢,我们在介绍2.1JVM运行时内存划分的时候说过元数据区域是存放class对象的,GCTest.class 就是存放在这。
在这里插入图片描述
这个静态成员user就是个GCRoot,user引用着User对象,如果User对象里面还引用着对象A,A引用着对象B…

那么这一串都属于有用对象,而且这个静态成员引用的对象一般不会被回收,元数据区这块回收的条件非常苛刻,等着我们在介绍永久代回收的时候讲到。

常量引用的对象已经native栈引用的对象也同理,这里就不展开介绍了。

2.2.2 标记清除

标记清除算法分为先根据GCRoot找出存活的对象,并进行标记,标记完成后,扫描整个空间,然后对未标记的对象进行回收。
在这里插入图片描述
由于user 引用着User对象,bytes引用着byte[]数组,所以这两个对象被标记。标记完成后就会扫描整个空间,然后回收未标记的对象
在这里插入图片描述
由于直接回收未标记的对象,会导致内存碎片的产生,为什么这么说呢,如果直接回收的对象占空间很小,然后程序新创建的对象占用的空间比较大,就没法重复利用这块小内存,如果这种小内存非常多的话,就会浪费很多内存。

2.2.3 标记整理

标记整理也分两部分,先是标记,根据GCRoot找出活着的对象并标记
在这里插入图片描述
然后就是将这些活着的对象靠左边的空闲空间移动,并更新对应的指针,并清除未被标记的对象。
在这里插入图片描述
我们可以看到它跟标记清除算法差不多,但是在清除之前多了一步整理的工作。这个整理的工作增加了成本,但是解决了标记清除算法的内存碎片问题

2.2.4 复制算法

复制算法,是将内存分为两块,一块内存存放创建的对象,一块内存空着,回收的时候,根据GCRoot找到活着的对象,然后将它们复制到那块空着的内存上,之后把存放创建对象那块内存清了。之后分配对象在存活对象的那块内存上分配。这样子解决了内存碎片问题,同时效率能够提升,唯一不好的地方就是费内存。

2.2.5 分代回收

我们先来看下这个图
在这里插入图片描述
通过这个JVM运行时数据区域图我们可以看到,堆区被分成了年轻代与老年代两部分,通过字面意思可以猜到,年轻代主要就是存放新创建的对象的,然后老年代主要是存放一些老对象的,我们还可以看到这个我们这个元数据区域变成了永久代,这个永久代好像是存放永久存活的对象的。我们再看下下面这个图:
在这里插入图片描述
可以看到年轻代被划分成了Eden, From ,to三个区域,这三个区域默认大小比例是8:1:1 , 这三个区域是怎么回事呢,这里其实是个复制算法的优化版,前面我们说了复制算法会浪费内存,然后这里对它进行了优化,当我们在代码中执行new User(创建对象)的时候,JVM会帮我们在Eden申请一块内存存放User对象,当Eden和From满了的时候,就会触发年轻代的垃圾回收,这时候会根据GCRoot找到存活的对象,然后将这些存活的对象复制到to区域,然后清除Eden与From区域,当有新建对象的时候,会被分配到Eden或to区域,等着Eden与to区域满了的时候,还是根据GCRoot找出存活的对象,然后复制到From区域,最后将Eden与to清空。我们可以看到From与to这两块内存总有一块内存会空着,也就是不使用,这样浪费一小块内存,从而达到高效率的收益。

大家有没有想下三个区域默认大小比例为啥是8:1:1呢?

因为,我们创建的对象大多数都是朝生夕死的,所以在每次GC的时候使用一小块内存来存放活着的对象,来个官方一点的说法:

IBM论文里说据他们统计95%的对象朝生夕死一样存活时间极短,为了保险默认实际使用了90%。

关于具体的垃圾回收流程我们在优化篇详细讲解。

2.3 回收器

2.3.1 回收器介绍

垃圾回收器说白了就是直接进行垃圾回收的程序,不同的垃圾回收器会采用不同的回收算法与不同的回收方式,我们下面介绍下hotspot里面的回收器
在这里插入图片描述
我们可以看到,垃圾回收器根据工作空间被分为了三类,一类是工作在年轻代的:ParNew,Serial,Parallel Scavenge ,还有工作在工作在老年代的:CMS,Serial Old,Parallel Old。最后就是作用在整个堆的G1回收器。这里就拿出两个服务端常用的垃圾回收器介绍下

2.3.2 ParNew

ParNew 是个工作在年轻代的垃圾回收器,它是采用的复制算法来回收的,咱们上面 “2.2.5 分代回收”小节介绍的那个年轻代回收流程就是它的。

下面我们来介绍它的一些执行原理:

当程序在创建一个新对象在向新生代申请内存的时候,如果Eden与From内存剩余放不下这个对象,就会触发YoungGC,这时候如果使用的是ParNew作为年轻代垃圾回收器,它会先停掉所有用户线程,然后开启多个垃圾回收线程(默认CPU核心数),根据GCRoot找出所有存活的对象,然后把这些对象复制到to这块内存上,接着把Eden与From这两块内存清掉。这里面有个暂停所有用户线程的动作就是“Stop The World”,其实Serial 与它的工作流程差不多,只不过回收的时候没有采用并行收集,而是使用的单线程。
在这里插入图片描述

2.3.3 CMS收集器

上面介绍完了年轻代的ParNew回收器,接下来我们介绍下服务端老年代常用的一款回收器CMS,这个CMS(Concurrent Mark Sweep)从名字上可以看出,并发标记清除,是一种获取最短回收停顿时间为目标的回收器,也就它能够缩短Stop The World的时间,它采用标记清除算法,我们来看下执行原理:

当YoungGC完成之后,to放不下存活的对象,这些活着的对象就会被划到老年代,当然还有对象很多进入老年代的途径,这里就不一一陈述了,这个后面会有详细介绍,久而久之,老年代也会被这些对象塞满,当老年代放不下的时候,就会触发Full GC,这时候就轮到CMS回收器出场了,它的工作流程分为四步,首先是初始标记,这个初始标记用户线程会全部停止,也就是Stop The World,不过它执行速度很快,标记下GCRoot能够直接到达的对象,完成后,用户线程恢复,这时候它会进入第二步,也就是并发标记,与用户线程一块工作,一起抢占CPU资源,这一步它会找出存活对象,完成后,进入第三步,重新标记阶段,这个阶段用户线程全部停止,也就是Stop The World,然后多线程并行工作,主要是为了修正并发标记期间用户程序继续运行而导致标记发生变化那部分对象的标记记录,重新标记完成后,用户线程恢复,进入第四步,并发清除,与用户线程一起抢占CPU资源,进行垃圾回收工作,好了,到这CMS工作就完成了。我们在缕下CMS那四步

  1. 初识标记: Stop The World ,标记GCRoot能够直接到达的对象
  2. 并发标记: 与用户线程一起工作,找出存活的对象。
  3. 重新标记: Stop The World , 修正并发标记阶段用户程序运行带来的标记变化那部分对象。
  4. 并发清除:与用户线程一起工作,并发回收。

由于CMS采用的是标记清除算法,会带来内存碎片的问题,还有并发标记与并发清除阶段与用户线程一起工作,一起抢占CPU资源,会带CPU资源紧张的问题,我个人觉得占用CPU也比Stop The World的要强,还有在并发清除的时候,与用户线程一起工作,这时候用户线程可能会产生新的垃圾,但是这些新产生的垃圾这次是没法回收了,只能等到下次回收了,这些新垃圾就叫做浮动垃圾,好了,我们再来总结下CMS带产生的问题:

  1. 使用标记清除算法,会带来内存碎片问题
  2. 并发标记与并发清除阶段与用户线程抢占CPU资源,带来CPU资源紧张的问题
  3. 并发清除阶段产生的新垃圾没法回收,产生浮动垃圾。
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页