我把面试问烂了的⭐JVM⭐总结了一下(带答案,万字总结,精心打磨,建议收藏)

💂 个人主页: Java程序鱼

💬 如果文章对你有帮助,欢迎关注、点赞、收藏(一键三连)和订阅专栏

👤 微信号:hzy1014211086,想加入技术交流群的小伙伴可以加我好友,群里会分享学习资料、学习方法


序号内容链接地址
1Java基础知识面试题https://blog.csdn.net/qq_35620342/article/details/119636436
2Java集合容器面试题https://blog.csdn.net/qq_35620342/article/details/119947254
3Java并发编程面试题https://blog.csdn.net/qq_35620342/article/details/119977224
4Java异常面试题https://blog.csdn.net/qq_35620342/article/details/119977051
5JVM面试题https://blog.csdn.net/qq_35620342/article/details/119948989
6Java Web面试题https://blog.csdn.net/qq_35620342/article/details/119642114
7Spring面试题https://blog.csdn.net/qq_35620342/article/details/119956512
8Spring MVC面试题https://blog.csdn.net/qq_35620342/article/details/119965560
9Spring Boot面试题https://blog.csdn.net/qq_35620342/article/details/120333717
10MyBatis面试题https://blog.csdn.net/qq_35620342/article/details/119956541
11Spring Cloud面试题待分享
12Redis面试题https://blog.csdn.net/qq_35620342/article/details/119575020
13MySQL数据库面试题https://blog.csdn.net/qq_35620342/article/details/119930887
14RabbitMQ面试题待分享
15Dubbo面试题待分享
16Linux面试题待分享
17Tomcat面试题待分享
18ZooKeeper面试题待分享
19Netty面试题待分享
20数据结构与算法面试题待分享

在这里插入图片描述
作者金华,上海张江信息技术专修学院副院长,上海师范大学兼职教授,软件与信息技术讲师,长期从事软件与信息技术技能培训与职业规划工作,本书将相关知识的系统整合,符合现在Java的主流应用,拒绝全面不实用;本书知识点主要围绕技术升级和面试技巧展开,让你在升级专业知识的同时更能顺利通过面试。

京东自营购买链接:
《Java核心技术及面试指南》- 京东图书

当当自营购买链接:
《Java核心技术及面试指南》- 当当图书

截止到9月24日14:00,留言获赞最高的两位同学,将获得《Java核心技术及面试指南》图书一本


前言

目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了"自动化"时代,那么为什么我们还要去了解GC和内存分配呢?
当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些"自动化"的技术进行必要的监控和调节。 要想实现性能调优,得具备相关工具监控程序性能,有了监控信息,才能进行调优。

一、虚拟机类加载机制

Class文件中描述的各种信息,最终都是要加载到虚拟机中之后才能运行和使用。

JVM把Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。(例如Java多态、动态代理)

Java虚拟机中的类加载(JVM把class文件加载到内存),按先后顺序需要经过加载、链接、初始化三个步骤。其中,链接过程中同样需要验证;而内存中的类没有经过初始化,同样不能使用。

ClassLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine决定。

1.虚拟机类加载过程

在这里插入图片描述

在这里插入图片描述

加载阶段

什么情况下需要开始类加载过程的加载阶段?这个「 Java虚拟机规范」中没有强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,「 Java虚拟机规范」则是严格规定了有且只有六种情况必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

    (1)使用new关键字实例化对象的时候。(new)
    (2)读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    (3)调用一个类型的静态方法的时候。(invokestatic)

  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  • 当虚拟机启动时,被标明为启动类的类(用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类)。

  • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

  • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

Java程序对类的使用方式分为:主动使用和被动使用。
除了上述6种方式,其他使用Java类的方式都被看作为是对类的被动使用,都不会导致类的初始化。

加载、验证、准备、初始化、卸载这5个阶段顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

加载阶段,Java虚拟机需要完成三件事:

  • 通过类的全限定名(例如:org.apache.commons.lang3.StringUtils)来获取定义此类的二进制字节流(Class文件字节流)
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口

连接阶段

(1)验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

主要包括四种验证:文件格式验证、元数据验证、字节码验证、符合引用验证。

(2)准备
准备阶段是正式为类变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值(即0)的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

public static int value = 123,那么value在准备阶段过后的初始值为0,而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放在类构造器<clinit>()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

public static final int value = 123456,final在编译的时候就会分配了,准备阶段会显示初始化,编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123456。

(3)解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

初始化阶段

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及<clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作

(1)<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下所示。

public class StaticTest {
    static {
        i = 0; //  给变量复制可以正常编译通过
        System.out.println(i); // 这句编译器会报错“非法向前引用” 
    }
    static int i = 1;
}

public class StaticTest {
    static {
        i = 2; //  给变量复制可以正常编译通过
    }
    static int i = 1;

    public static void main(String[] args) {
        System.out.println(StaticTest.i);
    }
}

答案:1,为什么可以呢?在linking阶段的准备阶段,已经把i加载到内存,并且赋初始值(零值)了。

如果没有静态变量赋值动作和静态语句块,就不会生成

(2)<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下所示,字段B的值将会是2而不是0。

public class TestDemo {

    static class Parent{
        public static int A = 1;

        static {
            A = 2;
        }
    }

    static class Sub extends Parent{
        public static int B  = A;
    }

    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}

(3)Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。代下所示:

public class DeadThreadTest {
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName()+"开始");
            DeadThread deadThread = new DeadThread();
            System.out.println(Thread.currentThread().getName()+"结束");
        };

        Thread t1 = new Thread(r,"线程1");
        Thread t2 = new Thread(r,"线程2");

        t1.start();
        t2.start();
    }
}

class DeadThread{
    static{
        if(true){
            System.out.println(Thread.currentThread().getName()+"初始化当前类");
            while(true){
                
            }
        }
    }
}

结果:
线程2开始
线程1开始
线程2初始化当前类

线程2在初始化当前类时死循环了,会造成后面所有的线程全部阻塞。

2.类加载器(ClassLoader)

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)

目前类加载器在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩。

类加载器:把我们硬盘上编译好的.Class文件,通过类装载器将字节码文件加载到内存中,生成一个Class对象。
在这里插入图片描述
在这里插入图片描述

这里的四者是包含关系,不是上下层,也不是子父类的继承关系

ClassLoader:是一个抽象类,我们可以继承它实现自定义加载器。

启动类加载器(Bootstrap)

启动类加载器(Bootstrap):主要加载jre/lib/rt.jar(Java核心API ),getClassLoader为null。(C++实现的)

出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

Object object = new Object();
object.getClass().getClassLoader();//null

String string = new String();
string.getClass().getClassLoader();//null

并不继承自java.lang.ClassLoader,没有父加载器

扩展类加载器(Extension)

扩展类加载器(Extension):通过反射创建Class实例,而这个类在jre/lib/ext的jar包中,这时加载器就是Extension ClassLoader,加载jre/lib/ext里的类。

getClassLoader:sun.misc.Launcher$ExtensionLoader@HashCode

直接继承自URLClassLoader,间接继承ClassLoader

应用程序类加载器(APP)

应用程序类加载器(APP):它负责加载用户类路径(ClassPath)上所有的类库。

getClassLoader:sun.misc.Launcher$AppLoader@HashCode

直接继承自URLClassLoader,间接继承ClassLoader

对于用户自定义的类,如果没有自定义过自己的类加载器,默认使用应用程序类加载器加载

可以通过ClassLoader.getSystemClassLoader();获取应用程序类加载器

自定义类加载器

自定义类加载器的父类是应用程序类加载器

sun.misc.Launcher:它是一个Java虚拟机的入口应用

获取父类加载器:classLoader.getParent()

扩展类加载器和应用程序类加载器都继承了ClassLoader.

获取ClassLoader方法:
方式一:获取当前类的ClassLoader
clazz.getClassLoader();
方式二:获取当前线程上下文的ClassLoader
Thread.currentThread().getContextClassLoader()
方式三:获取系统的ClassLoader
ClassLoader.getSystemClassLoader()
方式四:获取调用者的ClassLoader
DriverManager.getCallerClassLoader()

3.用户自定义加载器

为什么要自定义类加载器?

  • 隔离加载类(通过类加载器实现类的隔离、重载等功能)
  • 修改类加载的方式
  • 扩展加载源(增加除了磁盘位置之外的Class文件来源)
  • 防止源码泄露

用户自定义类加载器实现步骤:
(1)开发人员通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求。
(2)在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载,但是在JDK1.2之后,已不再建议用户去覆盖loadClass()方法,而是建议把自定义类的加载逻辑写在findClass()中。
(3)在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()发放及其获取字节码流的方式,使自定义类加载器编写更加简洁。

举例:
防止源码泄露实现步骤:
(1)继承ClassLoader,重写findClass()
(2)在findClass()中,传入的name是加密的,先写解密逻辑,然后在获取字节码二进制流

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

(3)调用defineClass()把二进制流字节转化为Class

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

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。 这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

4.双亲委派机制

双亲委派模型的工作原理:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

为什么根类加载器为NULL?
根类加载器并不是Java实现的,而且由于程序通常须访问根加载器,因此访问扩展类加载器的父类加载器时返回null。

出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

自定义一个包java.lang,自定义Java核心类库没有的类,运行时报错。
java.lang.SecurityException:prohibited package name:java.lang

举例:自定义一个包java.lang,自定义一个类String,然后里面声明main方法,运行时报错。(沙箱机制)

package java.lang;

public class String {
	public static void main(String[] args) {
		System.out.println(1);
	}
}
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

加载String时,使用的是BootstrapClassLoader,加载的是Java核心类库的String,并非我们自定义的String,核心类库的String类,没有main方法,因此报错。

沙箱机制:是由基于双亲委派机制上采取的一种JVM的自我保护机制,假设你要写一个java.lang.String 的类,由于双亲委派机制的原理,此请求会先交给Bootstrap试图进行加载,但是Bootstrap在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,因此就保证了java的运行机制不会被破坏.(安全特性,防止恶意代码对Java的破坏)

双亲委派优势:

  • 避免类的重复加载
  • 保护程序安全,防止核心API被篡改

二、Java运行时数据区

JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

JVM运行时数据区:Java代码运行的时候每个数据区的区块存的是什么用来干什么怎么存的

需提前理解的概念:
一个类可以看成三类,数据(int i = 0等…)、指令(int c = i…代码)、控制(if else switch…)

Java 虚拟机运行时数据区:Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域有各自的用途,以及创建和消耗的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和消耗。
在这里插入图片描述

1.Program Counter Register(程序计数器)

Program Counter Register:程序计数器

作用:程序计数器用来存储指向下一条指令的地址,也就是将要执行的指令代码。由执行引擎读取下一条指令。

它是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。

比如我A()方法调用了B()方法,执行完B之后怎么恢复,这时就需要程序计数器,字节码解释器就是通过改变计数器的值来选取下一条执行的字节码指令。

如果线程执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是native方法,这个计数器的值为undefined。

2.Java虚拟机栈

栈是运行时的单位,而堆是存储的单位。

即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

作用:主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

它描述的是Java方法执行的线程内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在线程上执行的每一个方法都对应着一个栈桢

假如执行A方法创建一个A栈帧,A栈帧入栈,A方法调用B方法,需要为B方法创建一个B栈帧,然后入栈,B方法执行到方法出口之后,B栈帧出栈,然后A方法执行到方法出口之后,A栈帧出栈,这就是方法执行过程。

栈帧伴随着方法从创建到执行完成。

Java虚拟机规范允许Java栈的大小是动态或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将抛出一个StackOverflowError异常。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。

栈没有GC

设置栈内存大小
我们可以使用参数-Xss(stack size)选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
默认值:
Linux/x64(64-bit):1024KB
macOs(64-bit):1024KB

JVM直接对Java栈的操作只有两个,就是对栈桢的压栈和出栈,遵循先进后出原则。

在一条活动线程中,一个时间点上,只会有一个活动的栈桢,即只有当前正在执行的方法的栈桢(栈顶栈桢)是有效的,这个栈桢被称为当前栈桢,与当前栈桢对应的方法就是当前方法,定义这个方法的类就是当前类

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令,另一种是抛出异常。不管使用哪种方式,都会导致栈桢被弹出.

栈桢内部结构:

局部变量表(Local Variables)

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。

局部变量所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

其中64位长度的long和double类型的数据会占用2个Slot,其余的数据类型只占用1个Slot,局部变量所需的内存空间在编译期间分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
注意:虚拟机栈的大小会变,因为不停的创建和销毁栈帧,还有别的操作都会改变虚拟机栈大小。

局部变量表最基本的存储单元是Slot(变量槽),32位一个变量槽

JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中的指定局部变量值。(如果占两个槽,使用起始索引)

补充:0槽位放this,所以从1开始

注意:局部变量必须显式赋值

1)boolean——1byte 0为false 非0为true
2)byte——1 byte
3)short——2 bytes
4)int——4 bytes
5)long——8 bytes
6)float——4 bytes
7)double——8 bytes
8)char——2 bytes

操作数栈(Operand Stack)

在方法执行过程中,根据字节码指令,往栈中写入数据(ipush)或提取数据(iload),即入栈/出栈。

注意:这里栈不是指栈桢,指的是操作数栈

作用:用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

某些字节码指令值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。

动态连接(Dynamic Linking)

方法返回地址(Return Address)

存储调用该方法的程序计数器的值
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的程序计数器的值作为返回地址。而通过异常退出的,返回地址是要通过异常表来确定,栈桢中一般不会保存这部分信息。

一些附加信息

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(死循环递归);如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可以动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

递归死循环调用,会一直创建栈帧,从而导致栈内存溢出,假如我们不限制栈深度,无法申请到足够的内存就会抛出内存溢出

方法中定义局部变量是否线程安全?

  • 内部定义内部消亡,是线程安全的。
  • 内部产生,但是没有在内部消亡,返回到方法外,这是线程不安全的。(逃逸)

3.本地方法栈(线程私有)

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构没有强制规定,因此具体的虚拟机可以自由实现它,甚至有的虚拟机(Sun公司的HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一,与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常

4.Java堆

在虚拟机启动时创建,其空间大小也就确定了,是JVM管理的最大一块内存空间(堆内存的大小可调节)。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
注意:这里不是所有的对象实例都分配在堆内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap),从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代,再细致一点的有Eden空间(伊甸园)、From Survivor(幸存者0区)空间、To Survivor(幸存者1区)空间等,从内存分配角度来看,线程共享的Java堆中可能划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),不过无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都仍然是对象实例,进一步划分目的是为了更好地回收内存,或者更快地分配内存。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上连续即可,就像我们的磁盘空间一样,在实现时,既可以实现成固定大小的,也可以是可扩展的,不过目前主流的虚拟机都是按照可扩展的来实现的,通过-Xmx和-Xms控制,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常(OutOfMemoryError: Java Heap space)。

-XX:SurvivorRatio,设置新生代中Eden和S0/S1空间的比例,默认-XX:SurvivorRatio=8,Eden:S0:S1=8:1:1,假设设置成-XX:SurvivorRatio=4,Eden:S0:S1=4:1:1
总结:SurvivorRatio值就是设置Eden区的比例占多少,S0/S1相同

-XX:NewRatio,配置年轻代与老年代堆结构的占比,默认-XX:NewRatio=2新生代占1,老年代占2,年轻代占整个堆的1/3,假如-XX:NewRatio=4新生代占1,老年代占4,年轻代占整个堆的1/5
总结:NewRatio值就是设置老年代的占比,剩下的1给新生代

(1)new的对象先放伊甸园区。此区有大小限制
(2)当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃
圾回收(Minor GC), 将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
在这里插入图片描述
(3)然后将伊甸园中的剩余对象移动到幸存者0区
在这里插入图片描述
(4)如果再次触发垃圾回收,【本次存活对象】 和 【上次幸存下来的放到幸存者0区的且本次没有被回收的对象】,都会被放到幸存者1区。

(5)如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区(form/to)
在这里插入图片描述

(6)啥时候能去养老区呢?可以设置次数。默认是15次。
补充:可以设置参数: -XX:MaxTenuringThreshold=进行设置。

针对幸存者 S0,S1区总结:复制之后有交换,谁空谁是 to
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集

注意:Eden 区满时,会触发 Minor GC,此时会回收 Eden 区和幸存者区,但是幸存者区满了不会触发Minor GC,那怎么办?

当Survivor空间不足以容纳一次 Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代) 进行分配担保(Handle Promotion)。 (Serial、ParNew等新生代收集器均采用这种策略来设计新生代的内存布局)(来源JVM深入理解虚拟机)

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了,内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。这对虚拟机来说就是安全的。
在这里插入图片描述

如果Survivor 区中相同年龄的存活对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
理由:相同年龄对象占Survivor空间的一半,每次复制算法都要从form到to,非常耗时

堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据

为什么有TLAB(Thread Local Allocation Buffer)?

  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是TLAB(Thread Local Allocation Buffer)?

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

①尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
②在程序中,开发人员可以通过选项“-Xx :UseTLAB” 设置是否开启TLAB空间。
③默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通
过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
④一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操
作的原子性,从而直接在Eden空间中分配内存。

一个JVM实例只存在一个堆内存, 堆内存的大小是可以调节的。 类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存逻辑上分为三部分:新生代+老年代+元数据(JDK8)
新生代包含:伊甸园区、幸存0区、幸存1区

堆:
优点:运行时的数据区,可以动态的分配内存大小,生存期也不必事先告诉编译器,因为它是运行时动态分配内存空间,垃圾收集器会自动收走不再使用的数据
缺点:运行时动态时分配内存空间,因此存取速度慢些

栈:(线程私有)
优点:存取速度比堆快,仅次于计算机里的寄存器,栈的数据可以共享。
缺点:大小和生存期是确定的,缺乏灵活性。

对象的引用存放在栈中,对象本身存放在堆中。

5.方法区

方法区(Method Area)是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non Heap),目的是与Java堆区分开来。

例如java核心java,会加载到方法区

(1)栈、堆、方法区关系
在这里插入图片描述
虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpot JVM而言,方法区还有一个别名叫做Non-Heap (非堆),目的就是要和堆分开。

(2)方法区基本理解:

  • 方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区
    溢出,虚拟机同样会抛出内存溢出错误: java.lang .OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace。
  • 关闭JVM就会释放这个区域的内存。

(3)Hotspot中方法区的演进
在JDK7及之前,习惯上把方法区称为永久代。JDK8开始,使用元空间取代了永久代
补充:可以把方法区理解为Java接口,永久代是Java接口实现类

本质上,方法区和永久代并不等价,仅是对HotSpot而言。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。

现在看来,当年使用永久代,不是好的idea,导致Java程序更容易OOM(超过-XX:MaxPermSize上限)

到了JDK8时,HotSpot终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常(OutOfMemoryError: Metaspace)

当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace) 来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

(4)设置方法区大小
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整
①JDK7及之前:
通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M
-XX:MaxPermSize来设置永久代最大可分配空间。32位机器默认是64M,64机器默认是82M
当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError: PermGenspace
②JDK8及以后:
元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代上述原有的两个参数

默认值依赖于平台。Windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制
补充:-XXMaxMetaspaceSize一般不会改

与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace

-XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类( 即这些类对应的类加载器不再存活) ,然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX :MetaspaceSize设置为一个相对较高的值。

JDK1.6及之前:有永久代,静态变量存放在永久代上
JDK1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
JDK1.8及之后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

6.运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的需求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过,一般来说,除了保持Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
补充:String.intern():用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后返回引用。
案例:String a = “abc”; String b = new String(‘abc’), a==b.intern();//true

既然运行时常量池也是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

7.直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制,服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

三、对象内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

在这里插入图片描述

1.对象头

对象头,HotSpot虚拟机的对象头包括两部分信息。

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,
  • 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,

另外,如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

在这里插入图片描述

2.数据实例

数据实例:是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类定义的,都需要记录,这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中的定义顺序的影响,HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从策略分配中可以看出,相同宽度的字段总是被分配到一起,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前,如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量可能会插入到父类的空隙之中。

3.对齐填充

对齐填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

四、对象访问定位

JVM是如何通过栈桢中的对象引用访问到其内部的对象实例的呢?
在这里插入图片描述

建立对象是为了使用对象,我们的Java程序需要通过栈的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的,目前主流的访问方式有使用句柄和直接指针两种

对象访问的两种方式:
(1)句柄访问
如果使用句柄访问的话,那么Java堆中将划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
在这里插入图片描述

补充:使用句柄方式需要保存到对象实例数据的指针和到对象类型数据的指针。

(2)直接指针

如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
在这里插入图片描述

使用直接指针方式,需要保存到对象类型数据的指针就行。

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本,HotSpot使用的就是直接指针访问。

五、如何判定对象为垃圾对象?

1.引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,当引用失效时,计数器值减1,任何时刻计数器为0的对象就是不可能再被使用的。
引用失效:把对象引用赋值null

优点: 引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
  • 它很难解决对象之间相互循环引用的问题,因此主流的Java虚拟机里没有选用引用计数算法来管理内存。

举例:假设p.next = A, A.next = B, B.next=C,C.next = A,此时计数A是2,B是1,C是1,然后p.next=null,此时计数A是1,B是1,C是,此时会导致A、B、C不会被回收

在这里插入图片描述

垃圾回收详细日志信息:-XX:+PrintGCDetails

public class ReferenceCoatingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
 	
    /**
      * 这个成员属性的唯一意义就是占内存,以便能在GC日志中看清楚是否被回收过
      */
    private byte[] bigSize = new byte[2 * _1MB];
    public static void testGC() {
        ReferenceCoatingGC objA = new ReferenceCoatingGC();
        ReferenceCoatingGC objB = new ReferenceCoatingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 假设在这行发生GC,objA和objB
        System.gc();
    }
 	
    public static void main(String[] args) {
        testGC();
    }
}

从上面运行结果可以看出,虚拟机并没有因为两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

2.可达性分析算法

根节点枚举:所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,即便号称停顿时间可控或几乎不会发生停顿的CMS、G1等收集器,在这一步骤也会暂停用户线程

执行效率比引用计算法低一点,但是可以解决循环引用问题

这个算法的基本思路就是通过一系列的称为‘GC Roots’的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在这里插入图片描述

假如给object1赋值null,那么object1、object12、object3、object4全部变为可回收对象

总结:基本思路就是通过一系列名为”GC Roots"的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被
遍历到的(可到达的)对象就被判定为存活;没有被遍历到的就自然被判定为死亡。

Java中,可作为GC Roots的对象包括下面几种:
1)虚拟机栈(栈帧中的本地变量表)中引用的对象(t1)
2)方法区中类静态属性引用的对象(t2)
3)方法区中常量引用的对象(t3)
4)本地方法栈中JNI(即一般说的Native方法)引用的对象

public class GCRootDemo {
    
    private static GCRootDemo t2 = new GCRootDemo();
    
    private static final GCRootDemo t3 = new GCRootDemo();
    
    public static void main(String[] args) {
        m1();
    }

    private static void m1() {
        GCRootDemo t1 = new GCRootDemo();
        System.gc();
        System.out.println("第一次GC完成");
    }
}

六、引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与‘引用’有关,在JDK1.2以前,Java 中的引用的定义很传统,如果reference类型的数据中存储数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用,这种定义很纯粹,但是太过狭隘,一个对象在这种定义下就只有被引用或者没有被引用两种状态了,对于如何描述一些‘食之无味,弃之可惜’的对象就显得无能为力。我们希望能描述这样一类对象:当存储空间还足够时,则能保留在内存之中;如果存储空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景。

在JDK1.2后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。

  • 强引用:强引用就是指在程序代码之中普遍存在的,类似Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用:软引用是用来描述一些还有用但并非必要的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常,在JDK1.2之后,提供了SoftReference类来实现软引用。
  • 弱引用:弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,在JDK1.2后,提供了WeakReference类来实现弱引用
  • 虚引用:虚引用也称为幽灵或者幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知,在JDK1.2之后,提供PhantomReference类来实现虚引用

生存还是死亡:即使在可达性分析算法中不可达的对象,也并非是’非死不可’的,这时候它们暂时处于‘缓刑’阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为‘没有必要执行’。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立、低优先级的Finalizer线程去执行它,这里所谓的’执行’是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救自己-------只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除‘即将回收’的集合,如果对象这时候还没有逃脱,那基本上它就真的被回收了。

总结:对象可以在被GC时自我拯救,但是这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次,不建议使用这种方法拯救对象,它的运行代价高昂,不确定性大,无法保证各个对象调用顺序。

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
 	
    public void isAlive() {
 	System.out.println("yes,i am still alive");
    }
 	
    @Override
    protected void finalize() throws Throwable {
 	super.finalize();
 	System.out.println("finalize method executed!");
 	FinalizeEscapeGC.SAVE_HOOK = this;
    }
 	
    public static void main(String[] args) {
 	SAVE_HOOK = new FinalizeEscapeGC();
 		
 	// 对象第一次成功拯救自己
 	SAVE_HOOK = null;
 	System.gc();
 	try {
 	    // 因为finalize方法优先级低,所以暂停0.5秒等待它
 	    Thread.sleep(500);
 	    if(null != SAVE_HOOK) {
 		SAVE_HOOK.isAlive();
 	    }else {
 		System.out.println("no i am dead");
 	    }
 	} catch (InterruptedException e) {
 	    e.printStackTrace();
 	}
 		
    // 对象第二次自救,自救失败
 	SAVE_HOOK = null;
 	System.gc();
 	try {
 	    // 因为finalize方法优先级低,所以暂停0.5秒等待它
 	    Thread.sleep(500);
 	    if(null != SAVE_HOOK) {
 		SAVE_HOOK.isAlive();
 	    }else {
 		System.out.println("no i am dead");
 	    }
        } catch (InterruptedException e) {
 	    e.printStackTrace();
 	}
    }
}

执行结果:
finalize method executed!
yes,i am still alive
no i am dead

七、如何回收垃圾对象?

1.垃圾收集算法

内存回收的方法论,垃圾收集器是方法论的落地实现

JVM中比较常见的三种垃圾收集算法:

标记-清除算法

标记无用对象,然后进行清除回收。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

  • 效率问题,标记和清除两个过程的执行效率都随对象数量增长而降低(标记时通过GC Root递归遍历可达对象,清除时需要遍历所有堆空间的对象)。
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表。

标记-清除算法的执行过程如图所示:
在这里插入图片描述
标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,在找合适内存空间的时候也比较耗时。

需要暂停用户线程,执行标记和清除操作

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

复制算法的执行过程如图所示:
在这里插入图片描述
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是’朝生暮死’的,所以不需要按照1:1比例来划分内存空间,回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性 复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot 虚拟机默认Eden和Survivor的大小比例是 8:1,也即每次新生代中可用内存空间为 整个新生代容量的90% (Eden的80%加上 一个Survivor的10%),只有一个 Survivor空间,即10%的新生代是会被 “浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此这种回收方式还有一个充当罕见情况的“逃生门” 的安全设计,当Survivor空间不足以容纳一次 Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代) 进行分配担保(Handle Promotion)。 (Serial、ParNew等新生代收集器均采用这种策略来设计新生代的内存布局)

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了,内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。这对虚拟机来说就是安全的。

总结:它将堆分为新生代和老年代,新生代又分为Eden空间和两块Survivor空间,它们的比例大概是8: 1: 1。 新生代中的对象大多存活率不高,所以我们一般采用复制算法。每次使用Eden 空间和其中的一块Survivor空间,当进行回收时, 将该两块空间中还存活的对象复制到另一块 Survivor空间中,每进行一次Minor GC对象的年龄就会加1, 默认达到15就可以进入老年代 (数值可以自己用调优参数设定)。Survivor区存放不下的对象,因为每次Minor GC的时候会将Eden区和一个from区的存存活对象放入to区,所以当to区装不下的对象时就会进入老年代

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:

  • 在对象存活率较高时,复制的对象很多时,效率大大降低
  • 内存缩小了一半,需要额外空间做分配担保(老年代)

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

“标记- 整理”算法的执行过程如下图所示:
在这里插入图片描述

2.垃圾收集算法

Serial收集器

在JDK1.3之前,它是虚拟机新生代收集的唯一选择,Serial 是一个单线程的收集器, 它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。

Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
在这里插入图片描述
-XX:UseSerialGC,开启后会使用Serial(Young区用)+ Serial Old(Old区用)的收集器组合

ParNew收集器

ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样, ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。

ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。

ParNew 虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 Java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。

在这里插入图片描述

并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可
以理解为:
①并行(Parallel) :并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。(指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态)
②并发(Concurrent) :并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。(指用户线程与垃圾收集线程同时执行,但不一定是并行的,可能会交替执行,用户程序在继续运行,而垃圾收集程序运行于另一个CPU上)

Parallel Scavenge收集器

Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器, 它重点关注的是程序达到一个可控制的吞吐量(Thoughput, CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
在这里插入图片描述
-XX:ParallelGCThreads:设置年轻代并行收集器的线程数。

-XX:UseParallelGC、-XX:UseParallelOldGC可互相激活,不管配置哪个,两个都会开启,ParallelGC采用复制算法,ParallelOldGC采用标记-整理算法

JDK1.8 默认收集器: Parallel Scavenge (新生代) 和 Parallel Old (老年代)

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用’标记-整理’算法,这个收集器的主要意义也是在给Client模式下的虚拟机使用,如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

在这里插入图片描述

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于’标记-整理’算法实现,这个收集器是在JDK1.6中才开始提供,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态,原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器外别无选择,由于老年代Serial Old收集器在服务端应用性能上的‘拖累’,使用了Parallel Scavenge收集器也未必在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件条件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS组合’给力’。

直到Parallel Old收集器出现后,’吞吐量优先’收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器,Parallel Old收集器的工作过程如图所示:
在这里插入图片描述

CMS收集器

CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
在这里插入图片描述①初始标记(CMS initial mark)
②并发标记(CMS concurrent mark)
③重新标记(CMS remark)
④并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过图3-11可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

safepoint是还原点

优点:并发收集低停顿

CMS是一款优秀的收集器,它最主要优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector) 。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
①首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3) /4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器’(Incremental Concurrent Mark Sweep / iCMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从JDK 7开始,iCMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9发布后iCMS模式被完全废弃。

②然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全 “Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX: CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需
要,就会出现一次“并发失败”(Concurrent Mode Failure) ,这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX: CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

③还有最后一个缺点,在本节的开头曾提到,CMS是一款基于 “标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了解决这个问题,CMS收集器提供了一个XX: +UseCMSCompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在 Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数- XX: CMSFullGCsBeforeCompaction (此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

适用场景:CMS非常适合堆内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器

开启该收集器的JVM参数:-XX:+UseConcMarkSweepGC开启该参数后会自动将 -XX:+UseParNewGC打开

开启该参数后,使用ParNew(Young区用) + CMS(Old区用) + Serial Old的收集器组合,Serial Old将作为CMS出错的后备收集器

G1收集器

G1是一款主要面向服务端应用的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK 5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate) 的收集器。如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。

但作为一款曾被广泛运用过的收集器,经过多个版本的开发迭代后,CMS (以及之前几款收集器)的代码与HotSpot的内存管理、执行、编译、监控等子系统都有千丝万缕的联系,这是历史原因导致的,并不符合职责分离的设计原则。为此,规划JDK 10功能目标时,HotSpot虛拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离,CMS以及其他收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为CMS退出历史舞台铺下最后的道路了。

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java (RTSJ) 的中软实时垃圾收集器特征了。

那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet) 进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region) ,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间, 或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置这些子区域的大小,在堆的使用上,G1并不要求对象的存储一定是物理上连续的只要逻辑上连续即可,每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

大小范围在1MB~32MB,最多能设置2048个区域,也即能够支持的最大内存为: 32MB * 2048 = 65536MB = 64G内存

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB ~ 32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,如图3-12所示。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
在这里插入图片描述

针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块,避免内存碎片
*Eden区的数据移动到Survivor区,假如出现Survivor区空间不够,Eden区数据会部会晋升到Old区
*假如出现Survivor区空间不够,Survivor区的数据移动到新的Survivor区,部会数据晋升到Old区
*最后Eden区收拾干净了,GC结束,用户的应用程序继续执行。

G1将堆内存“化整为零”的“解题思路”,看起来似乎没有太多令人惊讶之处,也完全不难理解,但其中的实现细节可是远远没有想象中那么简单,否则就不会从2004年Sun实验室发表第一篇关于G1的论文后一直拖到2012年4月JDK 7 Update4发布,用将近10年时间才倒腾出能够商用的G1收集器来。G1收集器至少有(不限于)以下这些关键的细节问题需要
妥善解决:
①譬如,将Java堆分成多个独立Region后,Region 里面存在的跨Region引用对象如何解决?使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

②譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误:CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS (Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中“Concurrent Mode Failure” 失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“StopThe World”。

③譬如,怎样建立起可靠的停顿预测模型?用户通过-XX: MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢? G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:
1)初始标记(Initial Marking) :仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

2)并发标记(Concurrent Marking) :从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

3)最终标记(Final Marking) :对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

4)筛选回收(Live Data Counting and Evacuation) :负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

从Oracle官方透露出来的信息可获知,回收阶段(Evacuation) 其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region, 停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC) 中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过图3-13可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。
在这里插入图片描述
毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得:
非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”在未来,G1收集器最终还
是要取代CMS的,而当下它们两者并存的时间里,分个高低优劣就无可避免。

相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两
种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过,G1相对 于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload) 都要比CMS要高。就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB) 算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。

-XX:+UseG1GC

-XX:G1HeapRegionSize=n,设置的G1区域的大小,值是2的幂,范围是1MB到32MB。目标是根据最小的Java堆大小划分出约2048个区域
-XX:MaxGCPauseMillis=n,最大Gc停顿时间,这是个软目标,JVM将尽可能(但不保证)停顿小于这个时间
XX:InitatingHeapOccupancyPercent=n,堆占用了多少的时候就触发GC,默认为45
-XX:ConcGCThreads=n,并发Gc使用的线程数
-XX:G1ReservePercent=n,设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是10%

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默认垃圾收集器G1

参考文献:
《Oracle HotSpot》
《深入理解Java虚拟机》
在这里插入图片描述
作者金华,上海张江信息技术专修学院副院长,上海师范大学兼职教授,软件与信息技术讲师,长期从事软件与信息技术技能培训与职业规划工作,本书将相关知识的系统整合,符合现在Java的主流应用,拒绝全面不实用;本书知识点主要围绕技术升级和面试技巧展开,让你在升级专业知识的同时更能顺利通过面试。

京东自营购买链接:
《Java核心技术及面试指南》- 京东图书

当当自营购买链接:
《Java核心技术及面试指南》- 当当图书

截止到9月24日14:00,留言获赞最高的两位同学,将获得《Java核心技术及面试指南》图书一本

评论 91
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值