JVM运行原理以及垃圾回收机制

一、Java代码运行流程

1.1 Java程序运行流程

  我们平时写的代码究竟是怎么运行起来的?我们简单看一下一个 Java 程序的执行过程,它到底是如何运行起来的?
在这里插入图片描述

  1. 我们写好的代码,是要通过JVM才能运行的。
  2. 编译,也就是将我们写好的.java代码编译成.class文件,.class字节码文件才是可以被运行起来的。
  3. JVM 想要运行.class字节码文件中的代码,首先要把.class文件中包含的各种类加载进来,此时用到了类加载器
  4. JVM采用类加载器把编译号的.class字节码文件加载到JVM内存中,然后供后续代码运行使用。
  5. JVM基于自己的字节码执行引擎,来执行加载到内存里的我们写好的那么类。

1.2 类加载过程

类加载的过程,分这么几步,加载验证准备解析初始化

  • 加载的话,就是类加载器去加载类,下面会说类加载器加载类过程
  • 验证阶段,主要是验证加载的字节码是否符合JVM规范,不然你的.class文件被人篡改了,里面的字节码文件压根不符合JVM规范,JVM是无法执行的。
  • 准备阶段,主要是给类分配一定的内存空间,然后给它里面的类变量(也就是static修饰的静态变量)分配内存,并给它一个默认值,该设置0的设置0,该设置null的设置null
  • 解析阶段,主要是给符号引用变成直接引用,就是把一些变量什么temp,直接换成物理地址,不然执行的时候JVM也不认识temp是啥
  • 初始化阶段:主要是给变量赋值,准备阶段只是设置了初始值,这个是核心阶段,执行类的初始化,如果发现这个类的父类没有初始化,会先暂停,然后去初始化父类,也是走类加载的一套流程,直到父类加载完了,再执行子类的初始化

JVM什么时候会加载一个类? 代码中用到这个类的时候,JVM才会去加载这个类

这是类加载的过程,加载的类是放到了JVM的元数据空间,也就是永久代

1.3 类加载器和双亲委派机制

1.3.1 类加载器

类加载器是分层级的,遵循双亲委派机制,

  1. 最上层是Bootstrap ClassLoder,加载java的核心类库,加载java安装目录下的lib目录的class文件
  2. 第二层是Extension ClassLoder,加载一些java的其他类库,加载java安装目录下的lib/ext目录下的class
  3. 第三层是Application ClassLoder,应该程序类加载器,这个类加载器是加载我们写的类
  4. 自定义类加载器

  类加载器遵循双亲委派机制,就是说,如果要加载一个类,首先委派给自己的父类加载器去加载,最终传导到最顶层的父类去加载,如果父类加载器不能加载这个类,就下推加载权利给自己的子类加载器,如果所有父类都加载不了,那就自己加载。

这么做的好处是,不会重复加载一个类
在这里插入图片描述

1.3.2 为什么需要双亲委派模型?

为什么需要双亲委派模型呢?假设没有双亲委派模型,试想一个场景:

  黑客自定义一个java.lang.String类,该 String类具有系统的 String 类一样的功能,只是在某个函数稍作修改。比如 equals 函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到 JVM 中。此时,如果没有双亲委派模型,那么 JVM 就可能误以为黑客自定义的java.lang.String 类是系统的 String 类,导致“病毒代码”被执行。

  而有了双亲委派模型,黑客自定义的 java.lang.String 类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的 java.lang.String 类,最终自定义的类加载器无法加载 java.lang.String 类。

  或许你会想,我在自定义的类加载器里面强制加载自定义的 java.lang.String 类,不去通过调用父加载器不就好了吗?确实,这样是可行。但是,在 JVM 中,判断一个对象是否是某个类型时,如果该对象的实际类型与待比较的类型的类加载器不同,那么会返回false。

举个简单例子:
  ClassLoader1ClassLoader2 都加载 java.lang.String 类,对应Class1Class2对象。那么 Class1对象不属于 ClassLoad2 对象加载的 java.lang.String 类型。

1.3.3 如何实现双亲委派模型

  双亲委派模型的原理很简单,实现也简单。每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。其实 ClassLoader 类默认的 loadClass 方法已经帮我们写好了,我们无需去写。

几个重要函数

loadClass 默认实现如下:

public Class<?> loadClass(String name) throws ClassNotFoundException {
	return loadClass(name, false);
}

再看看 loadClass(String name, boolean resolve)函数:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

从上面代码可以明显看出, loadClass(String, boolean) 函数即实现了双亲委派模型!整个大致过程如下:

  1. 首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  2. 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用 parent.loadClass(name, false);).或者是调用 bootstrap 类加载器来加载。
  3. 如果父加载器及 bootstrap 类加载器都没有找到指定的类,那么调用当前类加载器的 findClass 方法来完成类加载。

换句话说,如果自定义类加载器,就必须重写 findClass 方法!

findClass 的默认实现如下:

protected Class<?> findClass(String name) throws ClassNotFoundException {
	throw new ClassNotFoundException(name);
}

  可以看出,抽象类 ClassLoaderfindClass 函数默认是抛出异常的。而前面我们知道, loadClass 在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass 函数,因此我们必须要在 loadClass 这个函数里面实现将一个指定类名称转换为 Class 对象.

  如果是读取一个指定的名称的类为字节数组的话,这很好办。但是如何将字节数组转为 Class 对象呢?很简单,Java 提供了 defineClass 方法,通过这个方法,就可以把一个字节数组转为Class对象

  defineClass 主要的功能是:将一个字节数组转为 Class 对象,这个字节数组是 class 文件读取后最终的字节数组。如,假设 class 文件是加密过的,则需要解密后作为形参传入 defineClass 函数。

defineClass 默认实现如下:

protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError {
	return defineClass(name, b, off, len, null);
}

二、Java虚拟机的内存管理

2.1 JVM整体架构

根据 JVM 规范,JVM 内存共分为虚拟机栈方法区程序计数器本地方法栈五个部分。

在这里插入图片描述
JVM分为五大模块: 类装载器子系统运行时数据区执行引擎本地方法接口垃圾收集模块

在这里插入图片描述

2.2 JVM内存

2.2.1 PC 程序计数器

  • 程序计数器(Program Counter Register):也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器

  • 在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 大家都知道JVM是支持多线程的,所以你写好的代码可能会开启多个线程并发执行不同的代码,所以就会有多个线程来并发的执行不同的代码指令,因此每个线程都有自己的一个程序计数器,专门记录当前线程执行到了哪一条字节码指令了。

2.2.2 虚拟机栈

(1)什么是虚拟机栈?

  Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直到执行完成的过程就对应着一个栈帧虚拟机栈中从入栈出栈的过程。

(2)什么是栈帧?

  栈帧(Stack Frame)是用于支持虚拟机进行方法调用方法执行的数据结构。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈出栈的过程。

在这里插入图片描述

2.2.3 本地方法栈

  本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

特点

  1. 本地方法栈加载native的但是方法, native类方法存在的意义当然是填补java代码不方便实现的缺陷而提出的。
  2. 虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
  3. 是线程私有的,它的生命周期与线程相同,每个线程都有一个。

在Java虚拟机规范中,对本地方法栈这块区域,与Java虚拟机栈一样,规定了两种类型的异常:

  1. StackOverFlowError :线程请求的栈深度>所允许的深度。
  2. OutOfMemoryError:本地方法栈扩展时无法申请到足够的内存。

2.2.4 堆

2.2.4.1 简介

  对于Java应用程序来说, Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。“几乎”是指从实现角度来看,随着Java语 言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

2.2.4.2 堆的特点
  1. 是Java虚拟机所管理的内存中最大的一块。
  2. 堆是jvm所有线程共享的。
    堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB)
  3. 在虚拟机启动的时候创建。
  4. 唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
  5. Java堆是垃圾收集器管理的主要区域。
  6. 因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代老年代;新生代又可以分为:Eden 空间From Survivor空间、To Survivor空间。
  7. java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms-Xmx控制)。
  8. 方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候时候才移除。
  9. 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
2.2.4.3 堆的分类

现在垃圾回收器都使用分代理论,堆空间也分类如下:

在Java7 Hotspot虚拟机中将Java堆内存分为3个部分:

  • 青年代Young Generation
  • 老年代Old Generation
  • 永久代Permanent Generation

在这里插入图片描述
  在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace,所以永久代就不存在了,在2018年9约25日Java11正式发布以后,从官网上找到了关于Java11中垃圾收集器的官方文档,文档中没有提到“永久代”,而只有青年代和老年代。

在这里插入图片描述
(1)年轻代和老年代

  1. 年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space2个Suvivor Space(from 和to)
  2. 老年代(Tenured Gen):老年代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

在这里插入图片描述
(2)配置新生代和老年代堆结构占比

  • 默认 -XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3
    修改占比 -XX:NewPatio=4 , 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5
  • 默认新生代中Eden空间和另外两个Survivor空间占比分别为8:1:1,即-XX:SurvivorRatio=8
    可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例。 比如 -XX:SurvivorRatio=8

几乎所有的java对象都在Eden区创建,,但80%的对象生命周期都很短,创建出来就会被销毁。

在这里插入图片描述

2.2.4.4 对象分配过程

  JVM设计者不仅需要考虑到内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,因此还需要考虑GC执行完内存回收后是否存在空间中间产生内存碎片。

对象分配过程

  1. new的对象先放在Eden区。该区域有大小限制
  2. 当Eden区域填满时,程序又需要创建对象,JVM就会触发新生代垃圾回收(Minor GC/Young GC),从GC Roots开始跟踪存活对象
  3. 通过复制算法将存活对象移到Survivor From区,然后清空Eden区。
  4. 如果再次触发垃圾回收,继续从GC Roots跟踪存活对象,然后将存活对象移到Survivor To区
  5. 如果再次经历垃圾回收,此时存会活对象移到Survivor From区,接着再移到Survivor To区。
  6. 如果累计次数到达默认的15次,一直存活的对象就会进入老年代。
    可以通过设置参数,调整阈值-XX:MaxTenuringThreshold=N,默认值15
  7. 老年代内存不足时,会触发Old GC老年代垃圾回收,进行老年代的内存清理
  8. 如果老年代执行了垃圾回收后仍然没有办法进行对象的保存,就会报OOM异常

2.2.5 方法区

2.2.5.1 元空间

  在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收。而从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。 HotSpots取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。

(1)它和永久代有什么不同的?

  • 存储位置不同永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存
  • 存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了

在这里插入图片描述
(2)为什么要废弃永久代,引入元空间?

相比于之前的永久代划分,Oracle为什么要做这样的改进呢?

  • 在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。
  • 移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。

(3)废除永久代的好处

  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。不会遇到永久代存在时的内存溢出错误。
  • 将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。
  • 将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。

(4)Metaspace相关参数

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。JVM也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。
    如果设置了该参数,当Metaspace剩余空间不足,会抛出:java.lang.OutOfMemoryError: Metaspace space
  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
2.2.5.2 方法区的理解

  方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载 的类型信息、常量、 静态变量、 即时编译器编译后的代码缓存等数据。

  元空间、永久代是方法区具体的落地实现。方法区看作是一块独立于Java堆的内存空间,它主要是用来存储所加载的类信息的

2.2.5.3 方法区的特点
  • 方法区与堆一样是各个线程共享的内存区域
  • 方法区在JVM启动的时候就会被创建并且它实例的物理内存空间和Java堆一样都可以不连续
  • 方法区的大小跟堆空间一样 可以选择固定大小或者动态变化
  • 方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类 导致方法区溢出虚拟机同样会跑出(OOM)异常(Java7之前是 PermGen Space (永久带) Java 8之后 是MetaSpace(元空间) )
  • 关闭JVM就会释放这个区域的内存
2.2.5.4 方法区结构

方法区的内部结构

在这里插入图片描述
类加载器将Class文件加载到内存之后,将类的信息存储到方法区中。

方法区中存储的内容:

  • 类型信息(域信息、方法信息)
  • 运行时常量池
    在这里插入图片描述

(1)类型信息

  对每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名 = 包名.类名)
  2. 这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类)
  3. 这个类型的修饰符( public, abstract,final的某个子集)
  4. 这个类型直接接口的一个有序列表

(2)域信息

  • 域信息,即为类的属性,成员变量
  • JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序。
  • 域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient的某个子集)

(3)方法信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称方法的返回类型(或void)
  2. 方法参数的数量和类型(按顺序)
  3. 方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集
  4. 方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)
  5. 异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

(4)方法区设置

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

jdk7及以前

  • -XX:Permsize来设置永久代初始分配空间。默认值是20.75M
  • -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
  • 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。

JDK8以后

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize指定
  • 默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-xx:MetaspaceSize值为
    21MB。这就是初始的高水位线,一旦触及这个水位线,FullGC将会被触发并卸载没用的类(即这些类对应的类加载
    器不再存活)然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到FullGC多次调用。为了避免频繁地GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

(5) 运行时常量池

常量池vs运行时常量池

  • 字节码文件中,内部包含了常量池
  • 方法区中,内部包含了运行时常量池
  • 常量池:存放编译期间生成的各种字面量与符号引用
  • 运行时常量池:常量池表在运行时的表现形式
  • 编译后的字节码文件中包含了类型信息域信息方法信息等。通过ClassLoader将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池中。
  • 理解为字节码中的常量池 Constant pool 只是文件信息,它想要执行就必须加载到内存中。而Java程序是靠JVM,更具体的来说是JVM的执行引擎来解释执行的。执行引擎在运行时常量池中取数据,被加载的字节码常量池中的信息是放到了方法区的运行时常量池中。
  • 它们不是一个概念,存放的位置是不同的。一个在字节码文件中,一个在方法区中。

在这里插入图片描述
对字节码文件反编译之后,查看常量池相关信息:

在这里插入图片描述
要弄清楚方法区的运行时常量池,需要理解清楚字节码中的常量池。

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表( Constant pool table),包括各种字面量和对类型、域和方法的符号引用。

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

常量池表Constant pool table:

在这里插入图片描述
在方活中对常量池表的符号引用

在这里插入图片描述
为什么需要常量池?

举例来说:

public class Solution {
	public void method() {
		System.out.println("are you ok");
	}
}

  这段代码很简单,但是里面却使用了 String、 System、 PrintStream及Object等结构。如果代码多,引用到的结构会更多!这里就需要常暈池,将这些引用转变为符号引用,具体用到时,采取加载。

2.3 直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分。

  在JDK 1.4中新加入了NIO(New Input/Output)类, 引入了一种基于通道(Channel)缓冲区 (Buffer)的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。
在这里插入图片描述
  NIO的Buffer提供一个可以直接访问系统物理内存的类——DirectBuffer。DirectBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同。普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制。而DirectBuffer直接分配在物理内存中,并不占用堆空间。在访问普通的ByteBuffer时,系统总是会使用一个“内核缓冲区”进行操作。而DirectBuffer所处的位置,就相当于这个“内核缓冲区”。因此,使用DirectBuffer是一种更加接近内存底层的方法,所以它的速度比普通的ByteBuffer更快。

在这里插入图片描述
通过使用堆外内存,可以带来以下好处:

  1. 改善堆过大时垃圾回收效率,减少停顿。Full GC时会扫描堆内存,回收效率和堆大小成正比。Native的内存,由OS负责管理和回收。
  2. 减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用。
  3. 可突破JVM内存大小限制。

三、垃圾回收机制及算法

3.1 垃圾回收概述

3.1.1 什么是垃圾回收

  说起垃圾收集(Garbage Collection, 下文简称GC) , 有不少人把这项技术当作Java语言的伴生产物。 事实上,垃圾收集的历史远远比Java久远, 在1960年诞生于麻省理工学院的Lisp是第一门开始使 用内存动态分配和垃圾收集技术的语言。垃圾收集需要完成的三件事情:

(1)哪些内存需要回收?

  堆内存、方法区

(2)什么时候回收?

  新生代: 平时我们系统运行创建的对象都是优先分配在新生代里的,如果新生代里的对象越来越多,都快满了,放不下了,这时候就会触发新生代垃圾回收。

  老年代: 年轻代垃圾回收后,会有部分存活对象达到某些条件而进入老年代,老年代空间满后,无法在存放对象后会触发老年代垃圾回收。

(3)如何回收?

  垃圾回收线程通过垃圾回收器使用垃圾回收算法进行垃圾回收

3.1.2 java垃圾回收的优缺点

优点:

  1. 不需要考虑内存管理,
  2. 可以有效的防止内存泄漏,有效的利用可使用的内存,
  3. 由于有垃圾回收机制,Java中的对象不再有"作用域"的概念,只有对象的引用才有"作用域"

缺点:
  java开发人员不了解自动内存管理, 内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力。

3.2 垃圾回收 - 判断对象是否存活

3.2.1 引用计数算法

  引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。

优点:

  实现简单,执行效率高,很好的和程序交织。

缺点:

  无法检测出循环引用。

  譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了。

public class App {
	public static void main(String[] args) {
		Test object1 = new Test();
		Test object2 = new Test();
		object1.object = object2;
		object2.object = object1;
		object1 = null;
		object2 = null;
	}
}

class Test {
	public Test object = null;
}

  这两个对象再无任何引用, 实际上这两个对象已 经不可能再被访问, 但是它们因为互相引用着对方, 导致它们的引用计数都不为零, 引用计数算法也 就无法回收它们 。

但是在java程序中这两个对象仍然会被回收,因为java中并没有使用引用计数算法。

3.2.2 可达性分析算法

  在主流的商用程序语言如Java、C#等的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。此算法的基本思路就是通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
在这里插入图片描述
在Java语言中,可作为GC Roots的对象包括下面几种,被GC Roots所引用的对象是不能回收的。

  • 栈帧中的局部变量表中的reference引用所引用的对象
  • 方法区中static静态引用的对象
  • 方法区中final常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象
  • Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 NullPointExcepiton、
    OutOfMemoryError) 等, 还有系统类加载器。
  • 所有被同步锁(synchronized关键字) 持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等。

在这里插入图片描述

3.3 JVM之判断对象是否存活

finalize()方法最终判定对象是否存活:

  即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓 刑”阶段, 要真正宣告一个对象死亡,至少要经历两次标记过程:

第一次标记:

  如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。

没有必要:

  假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。

有必要:

  如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字) 赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
在这里插入图片描述
注意:

  Finalizer线程去执行它们的finalize() 方法,这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

3.4 引用类型

  在JDK1.2以前,Java中引用的定义很传统:如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。

  我们希望能描述这一类对象:当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。很多系统中的缓存对象都符合这样的场景。在JDK1.2之后,Java对引用的概念做了扩充,将引用分为 强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)四种,这四种引用的强度依次递减。

(1)强引用(StrongReference)

  强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 ps:强引用其实也就是我们平时A a = new A()这个意思。

(2)软引用(SoReference)

  如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

(3)弱引用(WeakReference)

  用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只 被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

弱引用与软引用的区别在于:

  1. 更短暂的生命周期;
  2. 一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

(4)虚引用(PhantomReference)

  “虚引用” 顾名思义,它是最弱的一种引用关系。如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

虚引用与软引用和弱引用的一个区别在于:

  1. 虚引用必须和引用队列 (ReferenceQueue)联合使用。
  2. 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

3.5 垃圾收集算法

3.5.1 分代收集理论

  思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理。当前商业虚拟机的垃圾收集器, 大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

1) 弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。
2) 强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡。

  这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

  在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了Minor GCMajor GCFull GC这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了标记-清除算法标记-复制算法标记-整理算法等针对性的垃圾收集算法。

3.5.2 标记-清除算法

(1)什么是标记-清除算法?

  最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

  标记过程就是对象是否属于垃圾的判定过程。 之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
在这里插入图片描述
标记-清除算法有两个不足之处:

  1. 第一个是执行效率不稳定, 如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标
    记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以
    后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作,即容易增加垃圾回收频率。

3.5.3 标记-复制算法

(1)什么是标记-复制算法

标记-复制算法常被简称为复制算法。

  为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可
在这里插入图片描述
但是这种算法也有缺点:

  1. 需要提前预留一半的内存区域用来存放存活的对象(经过垃圾收集后还存活的对象),这样导致可用的对象区域减小一半,总体的GC更加频繁了
  2. 如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低
  3. 如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的。

注意事项:

  现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1∶1的比例来划分新生代的内存空间。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

3.5.4 标记-整理算法

  标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间, 就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。所以在老年代一般不能直接选用这种算法。

  针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的标记-整理(Mark-Compact)算法, 其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存 。

  标记-清除算法标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
在这里插入图片描述
  是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

3.6 垃圾收集器

3.6.1 垃圾收集器概述

(1)垃圾回收器与垃圾回收算法

  垃圾回收算法分类两类,第一类算法判断对象生死算法,如引用计数法、可达性分析算法;第二类收集死亡对象方法有三种,如标记-清除算法标记-复制算法、标记-整理算法。一般的实现采用分代回收算法,根据不同代的特点应用不同的算法。垃圾回收算法是内存回收的方法论。垃圾收集器是算法的落地实现。和回收算法一样,目前还没有出现完美的收集器,而是要根据具体的应用场景选择最合适的收集器,进行分代收集。

(2)垃圾收集器分类

在这里插入图片描述
串行垃圾回收(Serial)

  串行垃圾回收是为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境
在这里插入图片描述
并行垃圾回收(Parallel)

  多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景。
在这里插入图片描述
并发垃圾回收(CMS)

  用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用对响应时间有要求的场景。
在这里插入图片描述
G1垃圾回收

  G1垃圾回收器将堆内存分割成不同的Region区域,然后并发地对其进行垃圾回收。

(3)七种垃圾收集器及其组合关系

根据分代思想,我们有7种主流的垃圾回收器
在这里插入图片描述
垃圾收集器的组合关系
在这里插入图片描述

  • JDK8中默认使用组合是:Parallel Scavenge GC 、ParallelOld GC
  • JDK9默认是用G1为垃圾收集器
  • JDK14 弃用了: Parallel Scavenge GC 、Parallel OldGC
  • JDK14 移除了 CMS GC

3.6.2 Serial收集器

  这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。 “Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。

  • Serial收集器也并不是只有缺点;Serial收集器由于简单并且高效;
  • 对于单CPU环境来说,由于Serial收集器没有线程间的交互,专心做垃圾收集自然可以做获得最高的垃圾收集效率
  • 使用方式:-XX:+UseSerialGC

3.6.3 ParNew 收集器

  ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

  • ParNew收集器在单CPU服务器上的垃圾收集效率绝对不会比Serial收集器高;但是在多核CPU服务器上,效果会明显比Serial好
  • 使用方式:-XX:+UseParNewGC
  • 设置线程数: -XX:ParllGCThreads=5

3.6.4 Parallel Scavenge收集器

(1)什么是Parallel Scanvenge

  又称为吞吐量优先收集器,和ParNew收集器类似,是一个新生代收集器。使用复制算法的并行多线程收集器。Parallel Scavenge是Java1.8默认的收集器,特点是并行的多线程回收,以吞吐量优先。

(2)特点

  • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput);
    吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
    (虚拟机总共运行100分钟,垃圾收集时间为1分钟,那么吞吐量就是99%)
  • 自适应调节策略,自动指定年轻代、Eden、Suvisor区的比例。

(3)适用场景

  适合后台运算,交互不多的任务,如批量处理,订单处理,科学计算等。

(4)参数

  • 使用方式:-XX:+UseParallelGC
  • 分别是控制:最大垃圾收集停顿时间-XX:MaxGCPauseMillis
    -XX: MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • 吞吐量大小-XX:GCTimeRatio
    -XX: GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率, 相当于吞吐量的倒数。假设GCTimeRatio的值为n,那么系统将花费不超过1/(1+n)的时间用于垃圾收集。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99)) 的垃圾收集时间
  • 设置年轻代线程数-XX:ParllGCThreads
    当cpu核数小于等于8,默认cpu核数相同;当cpu核数超过8,ParllGCThreads设置为 3+(5*CPU_COUNT)/8
  • 与Parallel Scavenge收集器有关的还有一个参数:-XX:+UseAdaptiveSizePolicy(有了这个参数之后,就不要手工指定年轻代、Eden、Suvisor区的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节)

3.6.5 Serial Old收集器

  Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

特点:

  • 针对老年代;
  • 采用"标记-整理"算法;
  • 单线程收集;

应用场景: 主要用于Client模式

  • 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
  • 作为 CMS收集器的后备预案 ,在并发收集发生Concurrent Mode Failure时使用

参数设置:

  使用方式:-XX:+UseSerialOldGC

注意事项:

  需要说明一下, Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集, 并非直接调用Serial Old收集器, 但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的, 所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解.

3.6.6 Parallel Old收集器

  Parallel OldParallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep) 收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。

应用场景

  • JDK1.6及之后用来代替老年代的Serial Old收集器;
  • 特别是在Server模式,多CPU的情况下;
  • 这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;

设置参数
  -XX:+UseParallelOldGC:指定使用Parallel Old收集器;

3.6.7 CMS 收集器

  CMS(concurrent mark sweep)是以获取最短垃圾收集停顿时间为目标的收集器,CMS收集器的关注点尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序,目前很大一部分的java应用几种在互联网的B/S系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收集器使用的算法是标记-清除算法实现的。

3.6.7.1 CMS垃圾收集过程

  CMS整个过程比之前的收集器要复杂,整个过程分为4个阶段,即初始标记并发标记重新标记并发清除,其中 初始标记重新标记 都需要Stop The World

  • 初始标记(Initial-Mark)阶段:这个阶段程序所有的工作线程都将会因为"Stop-the-Wold"机制而出现短暂的的暂停,这个阶段的主要任务标记处GC Roots 能够关联到的对象,一旦标记完成后就恢复之前被暂停的的所有应用。 由于直接关联对象比较少,所以这里的操作速度非常快。
  • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要暂停用户线程,用户线程可以与垃圾回收器一起运行。
  • 重新标记(Remark)阶段:由于并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此,为了修正并发标记期间因为用户继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常比初始标记阶段长一些,但也远比并发标记阶段时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记判断已经死亡的对象,并释放内存空间。由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发运行。

由于最消耗时间的并发标记并发清除阶段都不需要暂停工作,因此整个回收阶段是低停顿(低延迟)的。

3.6.7.2 并发可达性分析

  当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析。

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾(可回收)。
  2. 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用。

三色标记

  三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象尚未全部访问完。全部访问后,会转换为黑色。

要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:
在这里插入图片描述
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中;
  2. 将GC Roots 直接引用到的对象挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
    3.1. 将本对象引用到的其他对象全部挪到 【灰色集合】中;
    3.2. 将本对象挪到【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

  当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。 而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

多标-浮动垃圾

  假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null
在这里插入图片描述
  此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存

  这部分本应该回收但是没有回收到的内存,被称之为**“浮动垃圾”**。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

漏标

假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:

var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G

在这里插入图片描述
  此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。 最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

不难分析,漏标只有同时满足以下两个条件时才会发生:
  条件一:灰色对象断开了白色对象的引用;即灰色对象原来成员变量的引用发生了变化。
  条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了新的引用。

从代码的角度看:

var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
  1. 读取 对象E的成员变量fieldG的引用值,即对象G;
  2. 对象E 往其成员变量fieldG,写入 null值。
  3. 对象D 往其成员变量fieldG,写入 对象G ;

  我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)。

  重新标记是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。

3.6.7.3 CMS收集器三个缺点

(1)CMS收集器对CPU资源非常敏感。

  其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(处理器核心数量 +3) /4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

(2)CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次Full GC的产生。

  由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为"浮动垃圾"。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

  在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。 到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险: 要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案: 冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。

(3)空间碎片:CMS是一款基于标记-清除算法实现的收集器,所有会有空间碎片的现象。

  当空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

  为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的, 此参数从 JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定) 不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

3.6.8 G1收集器

3.6.8.1 G1垃圾收集器简介

Garbage First是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

3.6.8.2 G1收集器特点
  • G1把内存划分为多个独立的区域Region
  • G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理隔离,而是一部分Region的集合
  • G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW
  • G1整体整体采用标记整理算法,局部是采用复制算法,不会产生内存碎片
  • G1的停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设置时间
  • G1跟踪各个Region里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限事件内高效的收集垃圾。
3.6.8.3 Region区域

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、 Survivor空间, 或者老年代空间。
在这里插入图片描述
将整个堆空间细分为若干个小的区域。

  • 使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。
  • 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1 .5个region,就放到H。一般被视为老年代。
3.6.8.4 G1 GC过程

G1提供了两种GC模式,Young GC和Mixed GC,两种均是完全Stop The World的。

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

在G1 GC垃圾回收的过程一个有四个阶段:

  • 初始标记:和CMS一样只标记GC Roots直接关联的对象
  • 并发标记:进行GC Roots Traceing过程
  • 最终标记:修正并发标记期间,因程序运行导致发生变化的那一部分对象
  • 筛选回收:根据时间来进行价值最大化收集

(1)G1 YoungGC

YoungGC执行前

  堆分为大约2000个区域。最小大小为1Mb,最大大小为32Mb。蓝色区域保存老年代对象,绿色区域保存年轻对象。
在这里插入图片描述
执行YoungGC

  将存活的对象(即复制或移动)到一个或多个幸存者区域。如果满足老化阈值,则某些对象将被提升到老年代区域。
在这里插入图片描述
G1的年轻GC结束
在这里插入图片描述
最近升级的对象以深蓝色显示。幸存者区域为绿色。

总而言之,关于G1的年轻一代,可以说以下几点:

  • 堆是单个内存空间,分为多个区域。
  • 年轻代内存由一组非连续区域组成。
  • 年轻一代的垃圾收集器或年轻的GC出现STW。将停止所有应用程序线程以进行操作。
  • 年轻的GC使用多个线程并行完成。
  • 将活动对象复制到新的幸存者或老年代的地区。

(2)G1 Mix GC

初始标记阶段(initial mark,STW)

  存活的对象的初始标记背负在年轻的垃圾收集器上。在日志中,此标记为 GC pause (young)(inital-mark) 。
在这里插入图片描述
并发标记阶段(Concurrent Marking)

  如果找到空白区域(如“ X”所示),则在Remark阶段将其立即删除。另外,计算确定活跃度的信息。
在这里插入图片描述
最终标记阶段(Remark,STW)

  空区域将被删除并回收。现在可以计算所有区域的区域活跃度。
在这里插入图片描述
筛选回收阶段/复制清理阶段(Cleanup,STW)

  G1选择“活度”最低的区域,这些区域可以被最快地收集。然后与年轻的GC同时收集这些区域。这在日志中表示为[GC pause (mixed)] 。因此,年轻代和老年代都是同时收集的。
在这里插入图片描述
筛选回收阶段-(复制/清理)阶段之后

  选定的区域已被收集并压缩为图中所示的深蓝色区域和深绿色区域。
在这里插入图片描述
总结:

  • 并发标记阶段
    • 活动信息是在应用程序运行时同时计算的。
    • 该活动信息标识在疏散暂停期间最适合回收的区域。
    • 像CMS中没有清扫阶段。
  • 最终标记阶段
    • 使用开始快照(SATB)算法,该算法比CMS使用的算法快得多。
    • 完全回收空区域。
  • 筛选回收阶段
    • 同时回收年轻一代和老一代。
    • 老年代地区是根据其活跃度来选择的。
3.6.8.5 G1常用参数
参数/默认值含义
-XX:+UseG1GC使用 G1 垃圾收集器
-XX:MaxGCPauseMillis=200设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)
-XX:InitiatingHeapOccupancyPercent=45mixed gc中也有一个阈值参数 ,当老年代大小占整个堆大小百分比达到该阈值时,会触发一mixed gc. 默认值为 45.
-XX:NewRatio=n新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为2.
XX:SurvivorRatio=neden/survivor 空间大小的比例(Ratio). 默认值为 8.
-XX:MaxTenuringThreshold=n提升年老代的最大临界值(tenuring threshold). 默认值为 15.
-XX:ParallelGCThreads=n设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同
-XX:ConcGCThreads=n并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同
-XX:G1ReservePercent=n设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10
-XX:G1HeapRegionSize=n使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size算出最优解. 最小值为 1Mb, 最大值为 32Mb
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值