大家好,我是oldou,这次文章介绍的是关于JVM的相关知识,学习来源还是观看B站狂神的视频学习、同时在网上查找了很多资料进行整理【文末有参考地址】,毕竟网上的学习资料很多很多,当然要好好的利用起来进行学习,文章内容可能整理得不是特别好的那种,但是看完绝对是有收获的,如果文中有不对的地方还请各位指正,在此感激不尽,如果本文对你有所帮助,希望点赞支持一下哈,谢谢各位!【关于JVM的整体图我整理好之后会发出一个链接】

目录

前言

  • 请你谈谈对JVM的理解?Java8虚拟机和之前的变化更新有什么不一样?
  • 什么是OOM?什么是栈溢出StackOverFlowError?怎么分析?
  • JVM的常用调优参数有哪些?
  • 内存快站如何抓取?怎么分析Dump文件?
  • 谈谈你对JVM中的类加载器的认识

一问到这些问题,说实话没学过JVM的同学一般都会大皱眉头,然后默默地…
在这里插入图片描述
不过也别灰心,遇见不会的就说明我们还有进步的空间,毕竟现在不会不代表我们以后不会,不会的我们可以去学,所以我们要努力不断的学习新的技术、新的知识,因为人生本来就需要不断的学习,下面我们开始进入正文吧。

JVM的初识(了解即可)

定义
JVM就是java虚拟机,它是一个虚构出来的计算机,可在实际的计算机上模拟各种计算机的功能。JVM有自己完善的硬件结构,例如处理器、堆栈和寄存器等,还具有相应的指令系统。

作用

  • JVM是java字节码执行的引擎,还能优化java字节码,使之转化成效率更高的机器指令。
  • JVM中类的装载是由类加载器和它的子类来实现的,类加载是java运行时一个重要的系统组件,负责在运行时查找和装入类文件的类。
  • 不同的平台对应着不同的JVM,在执行字节码(class文件)时,JVM负责将每一条要执行的字节码送给解释器,解释器再将其翻译成特定平台换将的机器指令并执行,这样就实现了跨平台运行。

工作原理
JVM在整个JDK中处于最底层,负责与操作系统的交互。操作系统装入jvm是通过JDK中的java.exe来实现的,具体步骤如下:

  • a、创建JVM装载环境和配置;
  • b、装载jvm.dll;
  • c、初始化jvm.dll;
  • d、调用JNIEnv实例装载并处理class类;
  • e、运行java程序

JVM的体系结构(掌握)

完整图
在这里插入图片描述

简略图
在这里插入图片描述

当我们运行一个Java代码的时候,会按照上图步骤依次进行,下面简单解释上图中的JVM运行时数据区域:

  • 1、程序计数器:指向当前线程正在执行的字节码的地址,行号。线程私有,无GC

  • 2、Java栈:存储当前线程运行方法所需要的数据,指令,返回地址。线程私有,无GC

  • 3、本地方法栈:与Java栈相同,不同的是它存的是本地方法的数据。

  • 4、方法区:存储类信息(字段方法的字节码,部分方法的构造器),常量、静态变量,JIT(即时编译的信息)。线程共享,无GC,非堆区;(java.lang.OutOfMemoryError:PermGen space)。

  • 5、堆-heap:存储类实例,一个JVM实例只有一个堆内存,线程共享,需要GC。

  • 6、JNI【Java Native Interface】(Java本地方法接口)
    凡是带了native关键字的方法就会进入到本地方法栈,其他的就是进入Java栈;

  • 7、 Navite Interface 本地接口
    本地接口的作用就是融合不同的编程语言为Java所用,它的初衷就是融合C/C++程序,Java刚诞生的时候是C/C++横行的时候,那个时候想要立足就必须由调用C/C++的程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法就是再Nativa Method Stack中登记native方法,再(Execution Engine)执行引擎执行的时候加载Native Libraies。
    目前该方法的使用越来越少了,除非是与硬件相关的应用,使用Java玩嵌入式等等。由于限制的异构领域间通信很发达,可以使用Socket通信等等。

  • 8、Native Method Stack 本地方法栈
    它的具体做法就是Native Method Stack中登记native方法,在(Execution Engine)执行引擎执行的时候加载Native Libraies【本地库】。

各个版本之间的区别

  • JDK1.6以及之前:有永久代,字符串常量池和运行时常量池都在方法区;
  • JDK1.7:有永久代,但已经逐步“去永久代”,字符串常量池移到堆中,运行时常量池还在方法区中(永久带);
  • JDK1.8之后:无永久代,字符串常量池在堆中,运行时常量池在元空间;

类加载器(Class Loader)

类加载器的作用

类加载器,顾名思义就是用来加载类的,但是它的作用不仅仅用于加载类,因为对于任意一个类都需要加载它的类加载器和这个类本身以此确立它在Java虚拟机中的唯一性,而每一个类加载器都拥有一个独立的类名称空间。

说直白点,类加载器的作用就是比较两个类是否“相等”,只有它们是由同一个类加载器加载的时候才有意义,对于同一个类,如果由不同的类加载器加载,那么它们必然不想等。(相等包括Class对象的equals方法、isAssignableFrom()方法、isInstance()方法返回的结果,也包括用instanceof关键词判断的情况)。

类加载器的类别

(1)BootstrapClassLoader(启动类加载器,又名根加载器)
C++编写,用于加载Java核心库 java.*(例如:java.lang.*),构造ExtClassLoader(扩展类加载器)AppClassLoader(系统类加载器)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许程序员直接通过引用进行操作。

(2)ExtClassLoader(标准扩展类加载器)
该类加载器由Java编写,用于加载扩展类库,主要负责加载【jre/lib/ext】目录下的一些扩展的jar,例如classpath中的jrejavax.*或者java.ext.dir指定位置的类,开发者可以直接使用扩展类加载器。

(3)AppClassLoader(系统类加载器)
Java编写,主要负责加载应用程序的主函数类,加载程序所在的目录,例如user.dir所在的位置的class

(4)CustomClassLoader(用户自定义类加载器)
Java编写,用户自定义的类加载器,可加载指定路径的class文件。

开发者角度的类加载器位置
在这里插入图片描述

  • 根类加载器,加载位于/jre/lib目录中的或者被参数-Xbootclasspath所指定的目录下的核心Java类库。此类加载器是Java虚拟机的一部分,使用native代码(C++)编写。如图所示,rt.jar这个jar包就是Bootstrap根类加载器负责加载的,其中包含了java各种核心的类如java.langjava.iojava.utiljava.sql

  • 扩展类加载器,加载位于/jre/lib/ext目录中的或者java.ext.dirs系统变量所指定的目录下的拓展类库。此加载器由sun.misc.Launcher$ExtClassLoader实现。

  • 系统类加载器,加载用户路径(ClassPath)上所指定的类库。此加载器由sun.misc.Launcher$AppClassLoader实现。

类加载器之间的关系

在这里插入图片描述
图中的层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的根类加载器以外,其余的类加载器都应该有自己的父类加载器(一般不是以继承实现,而是使用组合关系来复用父加载器的代码)。
如果一个类收到类加载请求,它首先请求父类加载器去加载这个类,只有当父类加载器无法完成加载时(其目录搜索范围内没找到需要的类),子类加载器才会自己去加载

类加载的过程图

在这里插入图片描述

双亲委派机制

什么是双亲委派机制?

当某个类加载器需要加载某个.class文件的时候,这个类加载器会首先将这个任务委托给它的上级类加载器(父级加载器),递归这个操作,如果上级的类加载器没有进行加载,那么这个类加载器才会自己去加载这个.class文件。

源码分析

【我们在java.lang包中首先找到ClassLoader,然后打开ClassLoader类,Ctrl+F搜索loadClass方法,下面为该方法的源码】

public Class<?> loadClass(String name) throws ClassNotFoundException {
    return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先检查这个class是否已经被加载过了
                Class<?> c = findLoadedClass(name);
        //如果 c==null就表示该class没有被加载
                if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 如果有父类的加载器就将该class委托父类加载器进行加载
                             if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    //如果父类的加载器为空,则说明递归到bootStrapClassloader根加载器了
                                     //bootStrapClassloader比较特殊,无法通过get获取
                                     c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {}
            //如果 c==null就表示该class在父加载器那边没有被加载
                      if (c == null) {
                //如果bootstrapClassLoader 仍然没有加载过,则会一层一层的递归回来,并且尝试自己去加载这个class
                long t1 = System.nanoTime();
                c = findClass(name);
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

这里需要注意的一点就是long t0 = System.nanoTime();这个的源码public static native long nanoTime();中的native使用这个关键字声明的方法表示告知JVM调用,该方法在外部定义,可能使用C/C++去实现了,这个关键字详细的解释可以百度去查一下,这里不做过多的介绍。下面使用流程图来解释一下源码中的流程。

委派机制的流程图

【这里我画的那张图太大了不好截图,引用了别人的图,图地址我放在文末了。后面我会将我自己画的整个JVM的图分享出来。】
在这里插入图片描述
从上图中我们就更容易理解了,当一个.class这样的文件要被加载时。不考虑我们自定义类加载器的话,首先就会在AppClassLoader中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理会先检查自己是否已经加载过,如果没有再往上。注意这个过程,知道到达Bootstrap classLoader之前,都是没有哪个加载器自己选择加载的。如果父加载器无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException

双亲委派机制的作用

  • 保证数据安全,能够防止重复加载同一个.class文件。通过往父类加载器去委托,如果已经加载过了那么就不用再加载一遍;
  • 保证核心 .class不能被篡改。通过委托方式,不会去篡改核心 .class,即使篡改了也不会去加载,即使加载也不会是同一个 .class对象了。不同的加载器加载同一个 .class也不是同一个 Class对象。这样保证了 Class执行安全。

沙箱安全机制

什么是沙箱?

Java安全模型的核心就是Java沙箱(sandbox),那么什么是沙箱呢?沙箱就是限制程序运行的环境,沙箱机制就是Java代码限定JVM虚拟机特定的运行范围中,并且严格限制代码对本地系统资源的访问,通过这样的措施来保证对代码的有效隔离,以防止对本地系统造成破坏。
沙箱主要限制系统资源的访问,那系统资源包括哪些呢?----CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也会不一样。但所有的Java程序运行都可以指定沙箱,可以定制安全策略。

Java中的安全模型

在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱 (Sandbox) 机制。如下图所示
在这里插入图片描述
但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示
在这里插入图片描述
在 Java1.2 版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示
在这里插入图片描述
当前最新的安全机制实现,则引入了域 (Domain) 的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域 (Protected Domain),对应不一样的权限 (Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示
在这里插入图片描述
以上提到的都是基本的 Java 安全模型概念,在应用开发中还有一些关于安全的复杂用法,其中最常用到的 API 就是doPrivilegeddoPrivileged方法能够使一段受信任代码获得更大的权限,甚至比调用它的应用程序还要多,可做到临时访问更多的资源。有时候这是非常必要的,可以应付一些特殊的应用场景。例如,应用程序可能无法直接访问某些系统资源,但这样的应用程序必须得到这些资源才能够完成功能。

组成沙箱的基本组件

(1) 字节码校验器(bytecode verifler:确保Java类文件遵循Java语言规范。这样可以帮助到Java程序实现内存保护。但并不是所有的类都会经过字节码校验,比如核心类。

(2)类加载器(Class Loader:其中类加载器在以下三个方面对Java沙箱起作用

  • 它防止恶意代码去干涉善意代码;(使用了双亲委派机制)
  • 它守护了被信任的类库边界;
  • 它将代码归入到保护域,确定了代码可以进行哪些操作。
    虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。

类装载器采用的机制是双亲委派模式

  • 1、从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;
  • 2、由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。

(3)存取控制器(access controller):存取控制器可以控制和讯API对操作系统的存取权限,而这个控制的策略设定可以由用于指定。
(4)安全管理器(security manager):是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。
(5)软件安全包(security package):Java.security下的类和扩展包下的类,运行用户为自己的应用增加新的安全也行,包括以下:

  • 安全提供者
  • 消息摘要
  • 数字签名
  • 加密
  • 鉴别

Native关键字

我们在源码中经常看见Native这个关键字,例如我们最常用的线程方法,

public static void main(String[] args) {

    new Thread(()->{

    },"myThread").start();
}

我们点进去这个start()方法,在该源码中我们发现这个start0()方法

public synchronized void start() {

    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {

        }
    }
}

我们找到start0()的定义发现竟然是private native void start0();这样的,很惊奇,这个native干了啥呢?仅仅就是这样声明了一下就完事了,它到底是弄了什么操作呢?是不是有很多的问号,没关系我们来学习一下。、

首先来看一下这个图【注意标红的地方】:
在这里插入图片描述

  • 凡是使用了native关键字修饰的,就说明Java的作用范围已经达不到了,这个时候就会去调用底层的C语言库。

  • 凡是带了native关键字的会进入本地方法栈:当类加载进来的时候,将堆、栈内存分配好之后就会进入到本地方法栈调用start0(),而本地方法栈里的东西Java范围是作用不到的,那么本地方法栈就会调用本地方法接口【JNIJava Native Interface,作用写在下面】,通过JNI加载本地方法库中的方法去执行操作。

  • JNI的作用】:扩展Java的使用,它可以融合不同的编程语言为Java所用,最初是CC++,后续添加了其他语言。原因就是:Java刚诞生的时候C语言和C++那个时候超级流行,而想要立足的话就必须要有调用CC++的程序,于是就会在内存区域中专门开辟一个标记区域【Navicat Method Stack】用于登记native方法【只是登记而不用执行】,并且在最终执行的时候通过JNI加载本地方法库中的方法。

注意:本地方法栈、本地方法接口还有本地方法库的介绍在上面JVM体系结构部分已经解释,请往上查看。

方法区【Method Area】

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;

静态变量【static】、常量【final】、类信息(构造方法、接口定义)【Class】、运行时的常量池存在方法区中【常量池】,但是实例变量存在堆内存中,和方法区无关 。

举例子,根据代码简单画一下内存图【对象刚加载的时候是什么样子的】:

public class Test {
    private int a;
    private String name="oldou";

    public static void main(String[] args) {
        Test test = new Test();
        test.a=1;
        System.out.println(test.a+"\t"+test.name);
    }
}

在这里插入图片描述

理解一下栈【Stack】

注意:概念也许对你来说有些枯燥,但是当你真正沉下心看并且弄懂的时候,真的很有趣。下面的每句话基本上都需要理解。

stack)又名堆栈,它是一种数据结构【运算受限的线性表】。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。如下图所示:
在这里插入图片描述

  • 首先得搞清楚,的意思相当于存储货物或者供旅客住宿的地方,就是指数据暂时存储的地方,因此才会由出栈、进栈的说法。

  • 栈作为一种数据结构,是一种只能在一端进行插入【push】和删除操作【pop】的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。栈具有记忆作用,对栈的插入与删除操作中,不需要改变栈底指针。

  • 每一个执行的方法都会产生一个栈帧【以上就有两个栈帧:方法A、方法B】,程序正在执行的方法一定在栈的顶部,方法执行完之后就会被弹出栈,直到全部执行完毕。栈的执行原理本来就是先进后出,后进先出,先进入的方法就会被后进的方法压住【又名:压栈】。栈就像一个桶一样,如果栈堆满了就会抛出错误 【StackOverflowError】。

  • 栈是允许在同一端进行插入和删除操作的特殊线性表。允许进行插入和删除操作的一端称为栈顶(top),另一端为栈底(bottom);栈底固定,而栈顶浮动;栈中元素个数为零时称为空栈。插入一般称为进栈(PUSH),删除则称为退栈(POP)。栈也称为先进后出表。与栈类似的队列遵循FIFO原则【First Input First Output,先进先出】。

  • 这就是为什么我们程序中的main()方法先执行,结果最后结束的原因,因为main()方法入栈后被压在栈低,上面的方法栈帧执行一个就POP一个最后main()出栈后才结束main()方法。

  • 栈内存主管着程序的运行,生命周期以及线程同步,线程结束后,栈内存就会释放,所以对于栈来说,不存在垃圾回收问题,因为一旦线程结束了,栈就Over了。

栈中主要存放一些基本类型的变量(byte、short、int、long、float、double、boolean、char)、对象引用、实例的方法。

【注】关于栈的代码实现之类的我就不放了,网上很多都有,后面再进行整理。

堆【Heap】

什么是堆内存?

堆内存是是Java内存中的一种,它的作用是用于存储引用类型,当new实例化得到一个引用变量【对象或者数组】的时候,java虚拟机会在堆内存中开辟一个不一定是连续的空间分配给该实例,根据零散的内存地址,实则是根据哈希算法生成一长串数字指向该实例的物理地址,相当于门牌号起到标识作用。当引用丢失了,会被垃圾回收机制回收,但不是立马释放堆内存。

堆是一种数据结构,它是存储的单位,一个JVM只有一个堆内存,并且堆内存的大小是可以调节的。

  • 堆中存储的全部是对象实例,每个对象都包含一个与之对应的class的信息(class信息存放在方法区)。
  • jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身,几乎所有的对象实例和数组都在堆中分配。

堆内存的特点是什么?

堆内存的特点就是:

  • 堆内存可以看作是一个管道,FIFO【先进先出,后进后出】。
  • 堆可以动态地分配内存大小,生存期不需要事先告诉编译器,缺点是由于要在运行时动态的分配内存,所以存取的速度慢。

New对象在堆中如何分配?

由Java虚拟机的自动垃圾回收器来进行管理

堆和栈的区别

  • 存放的东西不同:堆内存用于存放由new创建的对象或者数组,栈内存用于对象引用和基本数据类型等等;

  • 存储数据的原则不一样:堆遵循FIFO原则【先进先出,后进后出】,而栈是先进后出,后进先出;

  • 当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

  • 在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。

堆内存的模型图

在这里插入图片描述

Java堆主要用于存放各种类的实例对象和数组,它是垃圾收集器管理的主要区域,因此很多时候被称为“GC堆”,在Java中堆被分为两个区域:新生代和老年代,【注意这里不包括元空间(方法区),元空间原来叫永久代,JDK1.8之后改名为元空间】

下面就开始逐一的介绍一下新生代、老年代以及元空间。

新生代

为什么堆要分代呢?

为什么要给堆分代呢?当然咯,不分代也是可以的,只是分代的话可以优化GC性能,假如不分代的话,那我们创建的所有对象都放在一块,当需要垃圾回收的时候我们需要去找哪些对象没用,这样就会对整个堆区域进行全面扫描,这样耗性能啊,如果分带的话,将新创建的对象放在某一个区域,当需要GC的时候就去扫描回收,多方便是不是。

新生代的介绍

新生代主要用于存储新生的对象,一般需要占据堆的1/3的空间,由于频繁创建对象,所以新生代会频繁的触发Minor GC进行垃圾回收。
新生代有分为Eden区【伊甸园区】、SurvivorFromSurvivorTo三个区域,下面依次介绍一下:

  • Enden区:是Java新对象的出生地(如果新创建的对象占用内存很大,则会被直接分配到老年代),当Eden区内存不够的时候就会出发Minor GC,对新生代区进行一次垃圾回收。
  • SurvivorTo:保留了一次Minor GC过程中的幸存者;
  • SurvivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者;

新生代中的GC

HotSpot JVM把年轻代分为了三部分:1个Eden区【伊甸园区】和2个Survivor区【幸存区,分别是From、To】,默认的比例为8:1,一般情况下,新创建的对象都会被分配到Eden区,这些对象再经过第一次的Minor GC后,如果还存活就会被转移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会被增长一次,当它们的年龄达到一次岁数的时候就会被移到老年代中。

因为新生代中的对象基本上都是朝生夕死(80%以上),所以在新生代中的垃圾回收算法使用的是复制算法复制算法的基本思想就是将内存分为两块,每次只使用其中的一块,当这一块用完之后就将还或者的对象复制到另一块上面,复制算法并不会产生内存碎片。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为ToSurvivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
在这里插入图片描述

一个对象的一辈子

我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了老年代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了20年(每次GC加一岁),然后被回收。

【文章地址附在文末,搜了很多篇进行学习,感觉这个看着懂一些,描述得感觉蛮到位的…】

有关年轻代的JVM参数

  • (1)-XX:NewSize和-XX:MaxNewSize:用于设置年轻代的大小,建议设为整个堆大小的1/3或者1/4,两个值设为一样大。

  • (2)-XX:SurvivorRatio:用于设置Eden和其中一个Survivor的比值,这个值也比较重要。

  • (3)-XX:+PrintTenuringDistribution:这个参数用于显示每次Minor GC时Survivor区中各个年龄段的对象的大小。

  • (4)-XX:InitialTenuringThreshol和-XX:MaxTenuringThreshold:用于设置晋升到老年代的对象年龄的最小值和最大值,每个对象在坚持过一次Minor GC之后,年龄就加1。

老年代

老年代用于存放新生代中经过多次垃圾回收仍然存活的对象。在老年代的对象都比较稳定,因此MajorGC不会频繁执行,而在进行MajorGC之前一般都会进行一次MinorGC,使得新生代的对象晋入老年代,一般是空间不够用时才触发,当无法找到足够大的连续空间分配给新创建的大对象的时候也会触发一次MajorGC进行垃圾回收腾出空间。

  • MajorGC采用标记-清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。
  • MajorGC的耗时比较长,因为要先扫描然后再回收。
  • MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。
  • 当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

下面来测试一下,模拟一下这个OOM错误:
测试代码:

/**
 * 模拟OOM错误
 */
public class hello {
    public static void main(String[] args) {
        String str = "hello world";
        while (true){ //死循环
            //通过不断的产生新对象,然后堆内存溢出
            str = str + new Random().nextInt(888888888)
                    + new Random().nextInt(999999999);
        }
    }
}

运行报错:在这里插入图片描述
关于OOM的问题,后面再稍微详细的说明,这里不做过多的介绍。

永久区【转】

永久代,指的是内存的永久保存区域,主要用于存放Class和Meta(元数据)的信息,Class在被加载的时候放入到永久区域,它和存放实例的区域不一样,GC不会在主程序运行期间对永久代区域进行清理,所以也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。

元空间【转】

其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap
我们还是通过以上模拟OOM错误分别在JDK1.6、JDK1.7、JDK1.8中运行:
JDK 1.6 的运行结果:
在这里插入图片描述
JDK 1.7的运行结果:
在这里插入图片描述
JDK 1.8的运行结果:
在这里插入图片描述
从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?

结论:
元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory、字符串池和类的静态变量放入java堆中.。这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

元空间的大小可以通过以下参数来指定元空间的大小:

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
    除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
    -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

【注:以上图片文字摘自于:https://www.cnblogs.com/paddix/p/5309550.html】

OOM的分析

编写一段代码,查看虚拟机视图使用的最大内存和JVM的总内存

public class Demo01 {
    public static void main(String[] args) {
        //返回虚拟机试图使用的最大内存
        long maxMemory = Runtime.getRuntime().maxMemory(); //字节
        //返回JVM初始化的总内存
        long totalMemory = Runtime.getRuntime().totalMemory();

        System.out.println("maxMemory="+maxMemory+"字节\t"+(maxMemory/(double)1024/1024)+"M");
        System.out.println("totalMemory="+totalMemory+"字节\t"+(totalMemory/(double)1024/1024)+"M");
    }
}

输出:
在这里插入图片描述
然后我们再打开任务管理器查看一下我们的电脑内存:
在这里插入图片描述

  • 我们将JVM虚拟机试图使用的最大内存比上JVM初始化的内存约为【14.79:1】

  • JVM虚拟机试图使用的最大内存比上系统的内存【1:4.5】

  • JVM初始化的内存比上系统的内存【1:66.77】

默认情况下,系统分配的总内存是电脑的1/4,而初始化的内存是1/64。

我们打开IDEA给我们刚刚的代码进行虚拟机调参:【-Xms1024m -Xmx1024m -XX:+PrintGCDetails 】意思就是打印一些垃圾回收的信息:
在这里插入图片描述
再次运行:
在这里插入图片描述
上图中所显示的可以对照上面我给出的堆内存模型图查看,这个时候我们将年轻代的总空间+老年代的总空间然后转换成M,305664K+699392K=1,005,056K=981.5M,仔细看看,我们的得出来的这个值竟然和虚拟机初始化总内存相等,那么问题就来了,我们后面还有一个元空间的内存,也有几个M呢去哪儿了?这个时候回顾上面关于元空间的描述【元空间使用的是直接内存,是和堆分开的

如果以后遇到OOM的情况【堆内存满了】,采取的措施:

  • 首先尝试着将堆内存扩大看结果,假设我扩大了堆内存的空间走的原来的代码还是报错,就说明代码有问题。因为这个时候就说明有垃圾代码或者死循环代码在无限的占用堆空间;
  • 之后便是分析内存,看一下哪一个地方的代码出了问题;

我们再去书写一个类测试一下OOM的问题

/**
 * 模拟OOM错误
 */
public class hello {
    public static void main(String[] args) {
        String str = "hello world";
        while (true){ //死循环
            //通过不断的追加,然后堆内存溢出
            str = str + new Random().nextInt(888888888)
                    +new Random().nextInt(999999999);
        }
    }
}

JVM调参【-Xms8m -Xmx8m -XX:+PrintGCDetails
运行输出【这里由于我的电脑比较那个啥,所以将参数值调成了1M,无奈信息还是输出比较长,所以这里用的是狂神的图】:
在这里插入图片描述
首先前面四次是轻GC,第一次轻GC只花了一点点时间就将垃圾清理了,然后后面进行了三次轻GC清理垃圾,在第四次GC的时候发现伊甸园区和幸存区都满了,这个时候就开始去走重GC去清理垃圾,走完重GC之后又可以走轻GC了,然后当轻GC清理不了的时候又去走重GC,当最后重GC清理不了的时候【注意PSYoungGen:0K–>0K …】,然后年轻代满了,老年代满了,元数据也满了,这个时候就会报OOM【内存】的错误【OutOfMemoryError

使用JProfiler工具分析OOM原因

假如我们在一个项目中遇到了OOM故障,那么我们该如何去排除呢?如何定位出错代码?

  • 使用内存快照分析工具【像Eclipse中的MAT】【JProiler工具】
  • Dubug去一行行分析代码【这个太麻烦了】

MAT、JProfiler的作用

  • 分析Dump内存文件,快速定位内存泄露;
  • 获得堆中的数据
  • 获得大的对象

这里我们就介绍一下JProfiler工具的使用。

安装JProfiler工具

话说IDEA可以集成插件的功能是真的香,这里我们使用IDEA去集成JProfiler插件。
直接打开IDEA—>Setting---->Plugins----->搜索JProfiler—>点击安装即可
在这里插入图片描述
【注意】这里可能会有点小问题,就是IDEA搜不到这个插件,然后因为IDEA版本不一样的问题,所以下图中的选项也没有,这里我的IDEA是2019的也没有这个选项
在这里插入图片描述
我的解决办法就是更新一下Stable Releases,更新好了以后就会自动重启IDEA,然后再去插件中搜JProfiler就有了,然后安装即可。如果下图中还有那个什么连接选项,可以将勾选项取消,我的IDEA没有那个选项,具体原因可能这个IDEA是个阉割版吧…
在这里插入图片描述
IDEA的JProfilter安装好以后就多了一个下面这样的东东:
在这里插入图片描述
IDEA中弄好了这个插件以后就去安装客户端,这里需要去官网下载:https://www.ej-technologies.com/products/jprofiler/overview.html
在这里插入图片描述
双击运行安装即可,安装位置可以自定义【安装位置保证无中文】。需要注册码在百度上搜。
我这里安装的是11最新版本的,下面给出注册码

A-tfbyKUM9Gw-KhGMbpYhS1#14246
S-QCM1I25qH1-CkLfdYOFs2#1018
L-GG5oEVjKQX-xEJjkR3QBb#1847
L-idEVpl1jvU-Ww3AnQGBUY#4148
S-p8q09PhrZp-ioZmzCnXlT#18231
L-Vy82rebM6e-nLYfOEykeP#34152
A-r8m8UInymG-S382j9ujs5#3265
A-iWZjln8l5O-QAG2CyKTeC#26123
L-MTGPt84xpw-06dzulmNLY#301110
L-fuoED44azj-OyQMvOutje#22275
L-J11-Everyone#speedzodiac-327a9wrs5dxvz#463a59
A-J11-Everyone#admin-3v7hg353d6idd5#9b4

这里要挨个的尝试。安装成功之后如下所示:在这里插入图片描述
接下来就是将这个客户端工具和IDEA结合起来:
在这里插入图片描述
接下来进行测试:

public class Demo03 {

    byte[] array = new byte[1*1024*1024];//1M
    
    public static void main(String[] args) {
        ArrayList<Demo03> list = new ArrayList<>();
        int count=0;
        try {
            while(true){
                list.add(new Demo03());
                count = count+1;
            }
        }catch (Exception e){ //模拟的应该是j ava.lang.OutOfMemoryError,这里却抛出异常
            System.out.println("count :   "+count);
            e.printStackTrace();
        }
    }
}

运行一下:
在这里插入图片描述
我们发现我们这个压根捕获不到,然后也不知道代码哪里出了问题【其实异常咋捕获到Error呢,所以我们这里只是模拟而已】

接下来我们要去Dump一下它的内存快照去分析一下。我们去这个Demo03中添加一些配置打印一下信息,参数【-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError】意思就是从堆里面Dump出一些文件,条件就是On后面的,当出现OutOfMemoryError错误时就把文件Dump下来。
在这里插入图片描述
再次运行:在这里插入图片描述
发现文件被Dump出来了,名字叫做java_piD46224.hprof文件,这个文件的位置在哪呢?
选中该java文件,右键选择Show in Explorer,然后到项目的根目录下,就可以看见了。

在这里插入图片描述
我们双击打开:
在这里插入图片描述
然后我们可以通过这个Diggest Objects大对象选项查看,我们看见下面的ArrayList竟然占了99%,那么就可以判断这个ArrayList出问题了,
在这里插入图片描述
然后这是定位到了哪个对象出错了,接下来便是通过线程定位到哪一行的出错了,
在这里插入图片描述
解决了错误之后记得将生成的快照文件删除掉,因为里面生成了很多文件占用内存比较大,然后下main解释一下输入参数的意思:
-Xms: 设置初始化内存分配大小,默认1/164
-Xmx:设置最大分配内存,默认是1/4
-XX:+PrintGCDetails :打印GC的垃圾回收信息
-XX:+HeapDumpOnOutOfMemoryError:当出现OutOfMemoryError错误时就从堆里面Dump出一个文件。

后面我会详细的介绍调优参数…

垃圾回收GC的学习

首先关于GC有这么一些题目【文章后面会整理题目+答案】:

  • JVM的内存模型和分区,详细到每个区放些什么东西?
  • 堆里面有哪些分区?这些分区的特点是什么?
  • GC的算法有哪些?它们的工作原理是什么?说说它们的适用场景。
  • 轻GC和重GC分别是在什么时候发生的?

垃圾回收机制的介绍

Java是一门面向对象编程的语言,而创建对象是常有的事,那么对象用完了之后该怎么处理呢?我们先做一下对比:

  • 在C++中,对象所占的内存是在程序运行结束之前一直被占用,同时释放内存需要我们开发人员手动释放,虽然工作量大,但是可控性高;【以下图片来源于文末地址】
    在这里插入图片描述
  • 在Java中,当没有对象引用指向原先分配给对象的内存时,那么该内存就成为了“垃圾”,而JVM的一个系统级的线程会自动释放该内存块,虽然是自动化的,但是可控性比较差,有些甚至会出现内存溢出的情况;
    在这里插入图片描述
    垃圾回收意味着程序不再需要的对象是“无用信息”,这些“无用信息”占用着系统的内存,因此需要将这些“无用信息”进行清除从而回收内存,也就是说当一个对象不再被引用的时候,内存就会回收被它占用的空间,以便于给后来的新对象使用。

事实上,除了释放没有的对象,垃圾回收器也会清除内存记录碎片。由于创建对象和垃圾回收器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM将整理出的内存分配给新的对象。

这么解释理解了么。

垃圾回收的区域介绍

这里引用一下《深入理解Java虚拟机》一书中的某段话:

程序计数器、 虚拟机栈、 本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器
进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。 而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存

首先我们上面的JVM的体系结构介绍就可以明白,垃圾回收的区域主要就是寻找Java堆中的对象,并且将对象进行分类判断,寻找出正在使用的对象和已经不会使用的对象,然后将不会使用的对象从堆上清除掉。而我们都知道,我们人脑是活的,但程序是死的,它咋指导哪些对象是需要清除的,哪些对象是不需要清除的呢?这就涉及到算法了,对,就是垃圾回收的算法,我们下面就进入算法学习的主题吧!!

GC之引用计数算法

概念:引用计数法,简单的来说就是判断对象的引用数量。
实现方式:给对象添加一个引用计数器,每当对对象进行引用时,该对象身上的计数器值就会加1,当引用失效后【也就是不再执行该对象的时候】,该对象身上的计数器值就会减1,若某一个对象的计数器的值为0,那么表示这个对象已经没有人对它进行引用了,也就意味着是该对象时一个失效的垃圾对象,就会被垃圾回收器GC进行回收。如下所示【画得不是很明确,但是结合文字理解起来应该行】
在这里插入图片描述

问题:该算法不能解决对象之间的循环引用问题,所以虽然简单,但是在当前的JVM中并没有采用。
假设有A和B两个对象之间互相引用,也就是说A对象中的一个属性是B,B中的一个属性时A,这种情况下由于他们的相互引用,从而是垃圾回收机制无法识别它们到底该不该被回收,感觉像是耍无赖,相互调用一直没办法回收。然后我们都知道引用计数器也是需要占内存的,每个对象身上都弄一个计数器,当对象多了自然会影响性能。

那么接下来介绍一下啥是循环引用

public  class TestRC {

    TestRC instance;
    public TestRC(String name) {
    }

    public static  void main(String[] args) {
        // 第一步
	A a = new TestRC("a");
	B b = new TestRC("b");

        // 第二步
	a.instance = b;
	b.instance = a;

        // 第三步
	a = null;
	b = null;
    }
}

按步骤一步步画图:
在这里插入图片描述
到了第三步,虽然 a,b 都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。感觉循环引用还是得靠图来解释才明白。

【关于循环引用部分学习自文末参考地址:三太子敖丙

可达性分析算法

由于引用计数法的缺点,从而引入了可达性分析算法,该算法通过判断对象的引用链(Reference Chain)是否可达来决定对象是否可以被回收。可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连(就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。【看图说话】
在这里插入图片描述
在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表),中的引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(本地方法接口)引用的对象;

GC之复制算法Copying【重点】

首先,这点要记住的是:复制算法是针对新生代的。本文的新生代中有所介绍,这里再详细的说明一下。

  • 回顾:首先我们经过上文的学习知道新生代分为了一个Eden区【伊甸园区】和两个Survivor区【幸存区,From区、To区】。【建议这里将上面新生代中的GC结合看】

  • 概念:复制算法将内存按照容量划分为了两个大小相等的两块【就是幸存区的两块】,每次只使用其中的一块内存,当这一块的内存用完了,就将还活着的对象复制到另一块上面,然后再把已经使用过的内存空间做一次清理。

  • 优点:每次对其中的一块区域的内存进行回收,内存分配时就不用考虑内存碎片等复杂的问题,并且只要移动堆顶指针,按照顺序分配内存即可,实现简单,运行高效。

  • 缺点:这种算法的代价就是将内存缩小为原来的一半。【因为两个区域中一直保持有一个区域(To)为空】

  • 模型图
    在这里插入图片描述

  • 执行过程图
    在这里插入图片描述
    这里就用上图结合新生代来解释:
    在GC【垃圾回收】开始的时候,对象只会存在于Eden区和名为“From”Survivor区【因此Eden是产生新对象的地方】,而Survivor区“To”是空的【这里是重点,两个区中To区一直保证是空的】。紧接着开始进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,默认是15,这里指的是经历15次GC还存活的对象,这个阈值可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为ToSurvivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到老年代中。如下图所示:
    在这里插入图片描述

  • 其他知识
    现在的商业虚拟机都是采用复制算法来回收新生代,并且有研究表明,新生代的98%的对象都是朝生夕死的,所以新生代中的内存区域划分比例都不是按照1:1来划分,HotSpot虚拟机默认是采用Eden:Survivor=8:1来划分内存的。【关于HotSpot虚拟机,我们都是用的这个,不信的话Win+R打开运行执行CMD打开Windows命令窗口输入java -version查看,如下所示】
    在这里插入图片描述
    当然了,如果Survivor空间不够用的时候,就会进行分配担保(Handle Promotion)依赖一下老年代的空间,也就是假如Survivor的一块空间无法存放上一次新生代经历GC后存活下来的对象,那么这些对象就会通过分配担保机制进入老年代

以上关于复制算法的解释懂了没,没懂的话就需要多看几遍多理解一下哈。下面我们继续介绍其他的算法。

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

标记清除算法(Mark-Sweep),它是最基础的一种垃圾回收【GC】算法,它分为两个步骤:

  • 第一步:标记(Mark),首先扫描内存区域中的对象,然后将内存区域中活着的对象和需要回收的对象进行标记;简单的说就是找出垃圾在哪。如下图所示:
    在这里插入图片描述
    堆中的所有对象都会被扫描一次,以此确定那些需要被回收的对象位置,这个过程比较耗时。

  • 第二步:清除(Normal Deletion),垃圾回收器会把那些标记需要回收的对象全部清理掉。清理掉的垃圾就变成未使用的内存区域,等待被再次使用。但它存在一个很大的问题,那就是内存碎片。
    在这里插入图片描述
    标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记清除法的优点:不需要额外的空间;
标记清除法的缺点:以上过程需要两次扫描,严重浪费时间,同时会产生内存碎片。

【内存碎片】:指的是那些已经分配出去(能明确指出属于哪个进程)却不能被利用的空闲内存以小而不连续方式出现在不同的位置的内存。

GC之标记压缩算法(Compacting)

由于标记清除法中对标记的对象只是简单的清除可能会导致内存碎片的问题,而内存碎片太多的话可能会导致在程序运行的过程中需要分配大对象而无法找到足够的连续内存,因此不得不提前触发一次垃圾回收动作。标记压缩算法和标记清除算法很类似,最显著的区别就是:标记清除算法仅对不存活的对象进行处理,而剩下的活着的对象不做任何处理,从而造成内存碎片,而标记压缩算法不仅对不存活的对象进行处理,还对剩下的活着的对象进行整理,将内存区域扫描一遍,然后将活着的对象向一端移动进行整理,因此不会产生内存碎片。
在这里插入图片描述
优点:相当于优化了标记清除算法产生内存碎片的缺点;
缺点:多了一次扫描,还多了一次移动对象的成本。

GC之分代收集算法【重点】

分带收集的概括

分代收集算法是一种比较智能的算法,也是现在JVM使用最多的一种算法,其实它本身并不是一个新的算法,而是他会在具体的场景自动选择以上三种算法【这里三种算法指的是复制、标记清除、标记压缩】进行垃圾对象回收,针对不同的生命周期、不同大小的对象采用不同的垃圾回收策略。

为啥分代?【上面已经解释过了】当然是为了增大垃圾收集的效率,所以JVM就将堆进行了分代:新生代、老年代。
在这里插入图片描述

新生代(复制算法)

所有新new出来的对象都会最新出现在新生代中,并且大多数对象的生命周期都是朝生夕死【研究表明这概率有98%】,只有少量的对象存活下来,因此新生代中采用的是复制算法,发生在新生代中的垃圾回收被称为Minor collections【Minor GC:轻GC】。而且上面的复制算法中我也介绍了当Survivor的一块空间无法存放上一次新生代经历GC后存活下来的对象,那么这些存活对象就会通过分配担保机制进入老年代。如果老年代也满了就会触发一次Full GC【重GC】,也就是新生代和老年代都进行回收。需要注意的是新生代发生的GC也叫Minor GC并且Minor GC发生的频率是比较高的,不一定等Eden区满了之后才会触发。【现在回过头去看我的模拟OOM错误哪个地方,增加虚拟机参数的输出,Minor GC出现的次数是不是很多】。

老年代(标记-清除标记-压缩)

老年代这里一般存放的都是一些生命周期比较长的对象,就像上面所说,在新生代中经历了N次【默认是15次】的Minor GC后仍然存活的对象就会被放到老年代中。当然了,老年代中的内存比新生代大得多,大约是新生代的两倍,当老年代内存满了之后就会触发一次Major GCFull GC:又名重GC),老年代中的对象存活时间比较长,因此出现Full GC的概率比较低。由于老年代中的对象存活率高,没有额外的空间对它进行分配担保,所以就使用标记-清除或者标记-压缩算法【又名:标记-整理】。

又重温了一下新生代和老年代的东西,下面我们还是进入主题,关于分代收集算法的分带GC的过程。

分代垃圾收集过程

  • 第一步 所有new出来的对象都会最先分配到新生代区域中,两个survivor区域初始化是为空的
    在这里插入图片描述

  • 第二步,当eden区域满了之后,就引发一次 Minor garbage collection(Minor GC:轻GC)
    在这里插入图片描述

  • 第三步,在一次Minor GC后,将存活下来的对象就会被移动到S0survivor区域
    在这里插入图片描述

  • 第四步,然后当Eden区域又填满的时候,又会发生下一次的垃圾回收,存活的对象会被移动到survivor区域而未存活对象会被直接删除。但不同的是,在这次的垃圾回收中,存活对象和之前的survivor中的对象都会被移动到s1中。一旦所有对象都被移动到s1中,那么s0中的对象就会被清除,仔细观察图中的对象,数字表示经历的垃圾收集的次数。目前我们已经有不同的年龄对象了。
    在这里插入图片描述

  • 第五步,下一次垃圾回收的时候,又会重复上次的步骤,清除需要回收的对象,并且又切换一次survivor区域,所有存活的对象都被移动至s0。eden和s1区域被清除。
    在这里插入图片描述

  • 第六步,重复以上步骤,并记录对象的年龄,当有对象的年龄到达一定的阈值的时候,就将新生代中的对象移动到老年代中。在本例中,这个阈值为8。【默认是15】
    在这里插入图片描述

  • 第七步,接下来垃圾收集器就会重复以上步骤,不断的进行对象的清除和年代的移动
    在这里插入图片描述

  • 最后,我们观察上述过程可以发现,大部分的垃圾收集过程都是在新生代进行的,直到老年代中的内存不够用了才会发起一次major GC(重GC),会进行标记和整理压缩。
    在这里插入图片描述

分带收集的工作原理【转】

1、对象在新生代的分配与回收
由以上的过程分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区
在这里插入图片描述
当 Eden 区将满时,触发 Minor GC
在这里插入图片描述
大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1,Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间,动图如下

当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。

若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。

2、对象何时晋升老年代

  • 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代

    如图示:年龄阈值设置为 15, 当发生下一次 Minor GC 时,S0 中有个对象年龄达到 15,达到我们的设定阈值,晋升到老年代!

  • 大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.

  • 还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。

3、空间分配担保

在发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代的所有对象的总空间,如果大于,那么MinorGC可以确保是安全的,如果不大于,那么虚拟机会查看HandlePromotionFailure的设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行MinorGC,否则可能进行一次Full GC。

4、Stop The World

如果老年代满了,会触发 Full GC, Full GC 会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World(简称 STW),造成挺大的性能开销。

什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
在这里插入图片描述
画外音:为啥在垃圾收集期间其他工作线程会被挂起?想象一下,你一边在收垃圾,另外一群人一边丢垃圾,垃圾能收拾干净吗。

一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。

现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。

由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:

  • 循环的末尾
  • 方法返回前
  • 调用方法的 call 之后
  • 抛出异常的位置 另外需要注意的是由于新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间)所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收。

GC算法总结

我们从各个角度比较一下复制算法、标记清除算法、标记压缩算法:

  • 内存效率:复制算法>标记-清除算法>标记-压缩算法(时间复杂度)
  • 内存整齐度:复制算法=标记-压缩算法>标记算法-清除
  • 内存利用率:标记-压缩算法=标记-清除算法>复制算法

那么问题就来了,难道就没有一个最优的算法么,emmmmmm,没有最优的,只有最合适的,现在不是一般都使用分带收集算法嘛,感觉我了解到这个算法以后,感觉确实挺优秀的,根据不同的生命周期、不同大小的对象采用不同的垃圾回收策略,挺高级的【个人觉得】。

垃圾收集器的学习【转】

垃圾收集器种类

次收集器

  • Minor GC,指发生在新生代的 GC,因为新生代的 Java 对象大多都是朝生夕死,所以Minor GC 非常频繁,一般回收速度也比较快。当 Eden 空间不足以为对象分配内存时,会触发 Scavenge GC。
  • 一般情况下,当新对象生成,并且在 Eden 申请空间失败时,就会触发Minor GC,对Eden 区域进行 GC,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区。然后整理Survivor 的两个区。这种方式的 GC 是对年轻代的 Eden 区进行,不会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。
  • 当年轻代堆空间紧张时会被触发;
  • 相对于全收集而言,收集间隔较短。

全收集器

  • Full GC(Major GC),指发生在老年代的 GC,出现了 Full GC 一般会伴随着至少一次的 Minor GC(老年代的对象大部分是 Minor GC过程中从新生代进入老年代),比如:分配担保失败。Full GC 的速度一般会比Minor GC慢 10 倍以上。当老年代内存不足或者显式调用 System.gc()方法时,会触发 Full GC。
  • 当老年代或者持久代堆空间满了,会触发全收集操作
  • 可以使用 System.gc()方法来显式的启动全收集
  • 全收集一般根据堆大小的不同,需要的时间不尽相同,但一般会比较长。

垃圾收集器的常规匹配

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器,主要有以下垃圾收集器
在这里插入图片描述

  • 在新生代工作的垃圾回收器:SerialParNewParallelScavenge
  • 在老年代工作的垃圾回收器:CMSSerial OldParallel Old
  • 同时在新老生代工作的垃圾回收器:G1
    图片中的垃圾收集器如果存在连线,则代表它们之间可以配合使用,接下来我们来看看各个垃圾收集器的具体功能。

新生代收集器

Serial 收集器(串行收集器)

Serial 收集器是Hotspot运行在 Client 模式下的默认新生代收集器,工作在新生代单线程的垃圾收集器,单线程意味着它只会使用一个 CPU 或一个收集线程来完成垃圾回收,不仅如此,还记得我们上文提到的STW了吗,它在进行垃圾收集时,其他用户线程会暂停,直到垃圾收集结束,也就是说在 GC 期间,此时的应用不可用。

特点

  • 只用一个 CPU(计算核心)/一条收集线程去完成 GC 工作, 且在进行垃圾收集时必须暂停其他所有的工作线程(“Stop The World” -后面简称STW)。可以使用-XX:+UseSerialGC 打开。
  • 虽然是单线程收集, 但它却简单而高效, 在 VM 管理内存不大的情况下(收集几十 M 到一两百 M的新生代), 停顿时间完全可以控制在几十毫秒到一百多毫秒内。

看起来单线程垃圾收集器不太实用,不过我们需要知道的任何技术的使用都不能脱离场景,在 Client 模式下,它简单有效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial单线程模式无需与其他线程交互,减少了开销,专心做 GC 能将其单线程的优势发挥到极致,另外在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆(仅是新生代的内存,桌面应用基本不会再大了),STW时间可以控制在一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的,所以对于运行在Client模式下的虚拟机,Serial收集器是新生代的默认收集器
在这里插入图片描述

ParNew 收集器(并行收集器)

  • ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行 GC 外, 包括 Serial可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与 Serial 完全一样(也是 VM 启用 CMS 收集器-XX: +UseConcMarkSweepGC的默认新生代收集器)。
  • 由于存在线程切换的开销, ParNew 在单 CPU 的环境中比不上 Serial, 且在通过超线程技术实现的两个 CPU 的环境中也不能 100%保证能超越 Serial. 但随着可用的 CPU 数量的增加,收集效率肯定也会大大增加(ParNew收集线程数与CPU的数量相同, 因此在CPU数量过大的环境中, 可用-XX:ParallelGCThreads=<N>参数控制 GC 线程数)。
    在底层上,这两种收集器也共用了相当多的代码,它的垃圾收集过程如下
    在这里插入图片描述
    ParNew 主要工作在Server模式,我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,所以是许多运行在 Server 模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是因为除了Serial 收集器,只有它能与 CMS 收集器配合工作CMS是一个划时代的垃圾收集器,是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作,它采用的是传统的 GC 收集器代码框架,与 SerialParNew共用一套代码框架,所以能与这两者一起配合工作,而后文提到的 Parallel ScavengeG1收集器没有使用传统的 GC收集器代码框架,而是另起炉灶独立实现的,另外一些收集器则只是共用了部分的框架代码,所以无法与 CMS 收集器一起配合工作。

在多 CPU 的情况下,由于ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和ParNew收集器一样,它有啥特别之处吗?

  • 关注点不同,CMS 等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge目标是达到一个可控制的吞吐量吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),也就是说 CMS 等垃圾收集器更适合用到与用户交互的程序,因为停顿时间越短,用户体验越好,而Parallel Scavenge收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。

  • Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio(默认99%)

  • 除了以上两个参数,还可以用 Parallel Scavenge收集器提供的第三个参数-XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与 Survivor 比例(SurvivorRatio)等细节,只需要设置好基本的堆大小(-Xmx设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机就会根据当前系统运行情况收集监控信息,动态调整这些参数以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标。自适应策略也是Parallel ScavengeParNew的重要区别!

参数:

Parallel Scavenge 参数描述
-XX:MaxGCPauseMillis(毫秒数) 收集器将尽力保证内存回收花费的时间不超过设定值, 但如果太小将会导致 GC 的频率增加.
-XX:GCTimeRatio(整数:0 < GCTimeRatio < 100) 是垃圾收集时间占总时间的比率
-XX:+UseAdaptiveSizePolicy启用 GC 自适应的调节策略: 不再需要手工指定-Xmn-XX:SurvivorRatio-XX:PretenureSizeThreshold等细节参数,VM 会根据当前系统的运行情况收集性能监控信息, 动态调整这些参数以提供最合适的停顿时间或最大的吞吐量

老年代收集器

Serial Old 收集器

上文我们知道, Serial收集器是工作于新生代的单线程收集器,与之相对地,Serial Old是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client模式下的虚拟机使用,如果在Server模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure时使用(后文讲述),它与 Serial 收集器配合使用示意图如下
在这里插入图片描述

Parallel Old 收集器

Parallel OldParallel Scavenge收集器的老年代版本, 使用多线程和“标记-压缩”算法, 吞吐量优先, 主要与 Parallel Scavenge配合在注重吞吐量及 CPU 资源敏感系统内使用,两者组合示意图如下,这两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标。
在这里插入图片描述

CMS 收集器

CMS 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!

我们之前说老年代主要用标记整理法,而 CMS 虽然工作于老年代,但采用的是标记清除法,主要有以下四个步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除
    在这里插入图片描述
    从图中可以的看到初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短

整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

但是 CMS 收集器远达不到完美的程度,主要有以下三个缺点

  • CMS 收集器对 CPU 资源非常敏感 ,原因也可以理解,比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的;
  • CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次Full GC的产生,由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure失败,这时会启用Serial Old 收集器来重新进行老年代的收集,而我们知道Serial Old收集器是单线程收集器,这样就会导致 STW 更长了。
  • CMS 采用的是标记清除法,上文我们已经提到这种方法会产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数-XX:CMSFullGCsBeforeCompation用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。

G1(Garbage First) 收集器(分区收集器)

G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,使用-XX:+UseG1GC启用 G1 收集器,主要有以下几个特点

  • 像 CMS 收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要 GC 停顿时间更好预测。
  • 不会像 CMS 那样牺牲大量的吞吐性能。
  • 不需要更大的 Java Heap

与 CMS 相比,它在以下两个方面表现更出色

  • 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
  • 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

为什么G1能建立可预测的停顿模型呢,主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配就像我们前文所述,是连续的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下:
在这里插入图片描述
而 G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址,如图示:
在这里插入图片描述
除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?

传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

G1 收集器的工作步骤如下

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收
    在这里插入图片描述
    可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

垃圾收集器总结【转】

在生产环境中我们要根据不同的场景来选择垃圾收集器组合,如果是运行在桌面环境处于 Client 模式的,则用 Serial + Serial Old 收集器绰绰有余,如果需要响应时间快,用户体验好的,则用 ParNew + CMS 的搭配模式,即使是号称是「驾驭一切」的 G1,也需要根据吞吐量等要求适当调整相应的 JVM 参数,没有最牛的技术,只有最合适的使用场景,切记!

JVM调优

调优的目的

  • 1.控制GC的行为
    GC是一个后台处理,但是它也是会消耗系统性能的,因此经常会根据系统运行的程序的特性来更改GC行为。

  • 2.控制JVM堆栈大小
    一般来说,JVM在内存分配上不需要你修改,但是当你的程序新生代对象在某个时间段产生的比较多的时候,就需要控制新生代的堆大小,同时,还要需要控制总的JVM大小避免内存溢出。

  • 3.控制JVM线程的内存分配
    如果是多线程程序,产生线程和线程运行所消耗的内存也是可以控制的,需要通过一定时间的观测后,配置最优结果。

JDK常用JVM优化相关命令

待更新…

JVM 常见参数

配置方式java [options] MainClass [arguments]
options - JVM启动参数。 配置多个参数的时候,参数之间使用空格分隔。
参数命名: 常见为 -参数名
参数赋值: 常见为 -参数名=参数值 | -参数名:参数值

内存设置

  • -Xms:初始堆内存大小,JVM 启动的时候,给定堆空间大小。
  • -Xmx:最大堆内存大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多
    少。
  • -Xmn:设置新生代大小。整个堆大小=新生代大小+老年代大小+持久代大小。持久代一般固定大小为 64M,所以增大新生代后,将会减小老年代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的3/8
  • -Xss: 设置每个线程的 Java 栈大小。JDK5.0 以后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000 左右。
  • -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:MaxPermSize=n:设置持久代大小
  • -XX:MaxTenuringThreshold:设置垃圾最大年龄。如果设置为 0 的话,则新生代对象不经过 Survivor 区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再新生代的存活时间,增加在新生代即被回收的概率。

内存设置经验分享

JVM 中最大堆大小有三方面限制:相关操作系统的数据模型(32-bt 还是 64-bit)限制;
系统的可用虚拟内存限制;系统的可用物理内存限制。32 位系统 下,一般限制在 1.5G~2G;
64 为操作系统对内存无限制。

Tomcat 配置方式 : 编写catalina.bat|catalina.sh, 增加JAVA_OPTS参数设置 。
windows和 linux 配置方式不同。
windows - set "JAVA_OPTS=%JAVA_OPTS%自定义参数 "
linux -JAVA_OPTS="$JAVA_OPTS 自定义参数 "

常见设置

  • -Xmx3550m -Xms3550m -Xmn2g -Xss128k适合开发过程的测试应用。要求物理内存大于
    4G。

  • -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=160m -XX:MaxTenuringThreshold=0:适合高并发本地测试使用。且大数据对象相对较多(如 IO 流)

环境: 16G 物理内存,高并发服务,重量级对象中等(线程池,连接池等),常用对象比例为 40%(运行过程中产生的对象 40%是生命周期较长的)
-Xmx10G -Xms10G -Xss1M -XX:NewRatio=3 -XX:SurvivorRatio=4 -XX:MaxPermSize=2048m -XX:MaxTenuringThreshold=5

收集器设置

收集器配置的时候,次收集器和全收集器必须匹配。具体匹配规则见下图
在这里插入图片描述

-XX:+UseSerialGC:设置串行收集器,年轻带收集器, 次收集器

-XX:+UseParallelGC:设置并行收集器

-XX:+UseParNewGC:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK5.0 以上,JVM会根据系统配置自行设置,所以无需再设置此值。

-XX:+UseParallelOldGC:设置并行年老代收集器,JDK6.0 支持对年老代并行收集。

-XX:+UseConcMarkSweepGC:设置年老代并发收集器,测试中配置这个以后,-XX:NewRatio的配置失效,原因不明。所以,此时年轻代大小最好用-Xmn 设置。

-XX:+UseG1GC:设置 G1 收集器

垃圾回收统计信息

类似日志的配置信息。会有控制台相关信息输出。 商业项目上线的时候,不允许使用。
一定使用loggc

-XX:+PrintGC
-XX:+Printetails
-XX:+PrintGCTimeStamps
-Xloggc:filename

并行收集器设置

  • -XX:ParallelGCThreads=n:设置并行收集器收集时最大线程数使用的 CPU 数。并行收集线程数。
  • -XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间,单位毫秒。可以减少 STW 时间。
  • -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为 1/(1+n)并发收集器设置
  • -XX:+CMSIncrementalMode:设置为增量模式。适用于单 CPU 情况。
  • -XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的 Survivor 区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。
  • -XX:CMSFullGCsBeforeCompaction=n:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次 GC 以后对内存空间进行压缩、整理。
  • -XX:+UseCMSCompactAtFullCollection:打开对年老代的压缩。可能会影响性能,但是可以消除碎片

收集器设置经验分享

关于收集器的选择 JVM 给了三种选择:串行收集器、并行收集器、并发收集器,但是串行收集器只适用于小数据量的情况,所以这里的选择主要针对并行收集器和并发收集器。

默认情况下,JDK5.0 以前都是使用串行收集器,如果想使用其他收集器需要在启动时加入相应参数。JDK5.0 以后,JVM 会根据当前系统配置进行判断。

常见配置:
并行收集器主要以到达一定的吞吐量为目标,适用于科学计算和后台处理等。
-Xmx3800m -Xms3800m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20
使用 ParallelGC 作为并行收集器, GC 线程为 20(CPU 核心数>=20 时),内存问题根据
硬件配置具体提供。建议使用物理内存的 80%左右作为 JVM 内存容量。

-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseParallelOldGC
指定老年代收集器,在JDK5.0之后的版本,ParallelGC对应的全收集器就是ParallelOldGC。可以忽略

-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:MaxGCPauseMillis=100
指定 GC 时最大暂停时间。单位是毫秒。每次 GC 最长使用 100 毫秒。可以尽可能提高工作线程的执行资源。

-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:MaxGCPauseMillis=100 -XX:+UseAdaptiveSizePolicy
UseAdaptiveSizePolicy 是提高年轻代 GC 效率的配置。次收集器执行效率。

并发收集器主要是保证系统的响应时间,减少垃圾收集时的停顿时间。适用于应用服务
器、电信领域、互联网领域等。
-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:ParallelGCThreads=20
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC

指定年轻代收集器为 ParNew,年老代收集器 ConcurrentMarkSweep,并发 GC 线程数为20(CPU 核心>=20),并发 GC 的线程数建议使用(CPU 核心数+3)/4 或 CPU 核心数【不推荐使用】。

-Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseConcMarkSweepGC
-XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection
CMSFullGCsBeforeCompaction=5 执行 5 次 GC 后,运行一次内存的整理。
UseCMSCompactAtFullCollection 执行老年代内存整理。可以避免内存碎片,提高 GC 过
程中的效率,减少停顿时间。

总结

  • 新生代大小选择
    (1)响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择) 。 在此种情况下, 新生代收集发生的频率也是最小的 。 同时 , 减少到达老年代的对象。
    (2)吞吐量优先的应用:尽可能的设置大,可能到达 Gbit 的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合 8CPU 以上的应用。

  • 年老代大小选择
    响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。
    最优化的方案,一般需要参考以下数据获得:
    (1)并发垃圾收集信息
    (2)持久代并发收集次数
    (3)传统 GC 信息
    (4)花在年轻代和年老代回收上的时间比例
    (5)减少年轻代和年老代花费的时间,一般会提高应用的效率

吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代存放长期存活对象。

较小堆引起的碎片问题,因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、整理方式进行回收。如果出现“碎片”,可能需要进行如下配置:

-XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。
-XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次 Full GC后,对年老代进行压缩

【关于JVM调优这一块,后面再花时间详细整理描述,此处不再说明】

JVM面试题

关于JVM的面试题部分,我觉得吧,如果将以上知识点能够顺序理解并且掌握,其实问啥啥都会,这里给出一个关于JVM面试题的链接,这是别人的,我觉得这位大佬整理得特别好,还给出了分析思路,面试题链接:点击直达

结束语

到此关于JVM的初步理解就到这里了,原谅本人知识水平有限,文中难免会有疏漏或者笔误的地方,还望大家监督指出,我会及时进行更正。等我对JVM这部分有更深层次的理解一定再写一篇文章分享给大家,如果感兴趣的话可以关注一波,我们一起学习。创作不易,如果本文对你有所帮助,点赞就不用我多说了吧,哈哈哈,下篇文章再见!

参考文章

类加载参考:https://zhuanlan.zhihu.com/p/44670213
双亲委派机制参考:https://www.jianshu.com/p/1e4011617650
沙箱安全机制参考:https://www.cnblogs.com/MyStringIsNotNull/p/8268351.html
栈参考:https://baike.baidu.com/item/%E6%A0%88/12808149?fr=aladdin
新生代参考:http://ifeve.com/jvm-yong-generation/
永久代参考:https://www.cnblogs.com/paddix/p/5309550.html
GC参考文章:https://www.jianshu.com/p/ee3e9dff5700
GC参考文章:CSDN
垃圾收集器参考文章:三太子敖丙
分带回收工作原理参考文章:三太子敖丙

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐