JVM笔记

JVM概述

JVM是一个跨语言的平台。从上面的图中可以看到,实际上JVM上运行的不是.java文件,而是.class文件。这就引出一个观点,JVM是一个跨语言的平台,他不仅仅能跑java程序,只要这种编程语言能编译成JVM可识别的.class文件都可以在上面运行。

所以除了java以外,能在JVM上运行的语言有很多,比如JRuby、Groovy、Scala、Kotlin等等。

è¯­è¨€æ— å…³æ€§

从本质上讲JVM就是一台通过软件虚拟的计算机,它有它自身的指令集,有它自身的操作系统。

所以Oracle给JVM定了一套JVM规范,Oracle公司也给出了他的实现。基本上是目前最多人使用的java虚拟机实现,叫做Hotspot。使用java -version可以查看:

image-20210423084956521

一些体量较大,有一定规模的公司,也会开发自己的JVM虚拟机,比如淘宝的TaobaoVM、IBM公司的J9-IBM、微软的MicrosoftVM等等。

JVM位置

image-20210428111245026

JVM体系结构

img

img

类加载系统

什么是类加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的*方法区*内,然后在*堆区*创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误!

加载.class文件的方式
– 从本地系统中直接加载
– 通过网络下载.class文件
– 从zip,jar等归档文件中加载.class文件
– 从专有数据库中提取.class文件
– 将Java源文件动态编译为.class文件

类生命周期

image-20210423114433059

加载

查找并加载类的二进制数据

1、通过一个类的全限定名来获取其定义的二进制字节流。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

链接

  • 验证:确保被加载的类的正确性

    目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

    验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

    验证阶段大致会完成4个阶段的检验动作:
    
    1. 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
    2. 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
    3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
    4. 符号引用验证:确保解析动作能正确执行。
    
  • 准备:为类的*静态变量*分配内存,并将其初始化为默认值

    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

    注意事项:
    1.这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
    
    2.这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
    例:假设一个类变量的定义为:public static int value = 3;
    那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
    
    3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
    例:假设上面的类变量value被定义为: public static final int value = 3;
    编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。
    
    这里还需要注意如下几点:
    1.对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
    2. 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
    3.对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
    4.如果在数组初始化时没有对数组中的各元素赋值,那么其中元素将根据对应的数据类型而被赋予默认的零值。
    
  • 解析:把类中的符号引用转换为直接引用

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

    符号引用就是一组符号来描述目标,可以是任何字面量。

    直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

初始化,执行类构造器方法的过程,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式: ①声明类变量是指定初始值 ②使用静态代码块为类变量指定初始值

JVM初始化步骤

1、假如这个类还没有被加载和连接,则程序先加载并连接该类

2、假如该类的直接父类还没有被初始化,则先初始化其直接父类

3、假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,

类的主动使用包括以下六种

– 创建类的实例,也就是new的方式

– 访问某个类或接口的静态变量,或者对该静态变量赋值

– 调用类的静态方法

– 反射(如Class.forName(“com.shengsiyuan.Test”))

– 初始化某个类的子类,则其父类也会被初始化

– Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

类的被动引用(不会发生类的初始化)

– 当访问一个静态域时,只有真正声名这个域的类才会被初始化

– 通过子类引用父类的静态变量,不会导致子类初始化

– 通过数组定义类的引用,不会触发此类初始化

– 引用常量不会触发此类的初始化(常量在编译阶段就存入调用类的常量池中了)

接下来的代码主动引用及被动引用类初始化的实例分析:

package com.hing.cn.TestJVM;

public class Demo04 {
    static {
        System.out.println("静态初始化Demo04");
    }

    public static void main(String[] args) throws Exception {
        System.out.println("Demo04的main方法!");
		//接下来分析执行main方法的不同输出信息
        //......此处为增加的不同代码.................
    }
}
class B extends A {
    static {
        System.out.println("静态初始化B");
    }
}

class A extends A_Father {
    public static int width=100; //静态变量,静态域 field
    public static final int MAX=100;

    static {
        System.out.println("静态初始化类A");
        width=300;
    }
    public A(){
        System.out.println("创建A类的对象");
    }
}

class A_Father extends Object {
    static {
        System.out.println("静态初始化A_Father");
    }
}

主动引用

new A(); //main()方法增加代码
//控制台输出
静态初始化Demo04     //调用了Demo04的静态main方法,所以此处会初始化Demo04
Demo04的main方法!  //执行main方法打印信息
静态初始化A_Father  //创建A类的实例,则初始化A,初始化某个类A_Father的子类A,则其父类A_Father也会被初始化,所以先初始化A_Father再初始化A
静态初始化类A
创建A类的对象   //初始化后创建A的对象
//--------------------------------------------------------------------------------    
System.out.println(A.width);//main()方法增加代码
//控制台输出
静态初始化Demo04
Demo04的main方法!
静态初始化A_Father  //访问某个类A的静态变量,则初始化A,初始化某个类A_Father的子类A,则其父类A_Father也会被初始化,所以先初始化A_Father再初始化A
静态初始化类A
300  //主函数main输出
//--------------------------------------------------------------------------------    
Class.forName("com.hing.cn.TestJVM.A");//main()方法增加代码
//控制台输出
静态初始化Demo04
Demo04的main方法!
静态初始化A_Father  //反射则初始化待反射类A,初始化某个类A_Father的子类A,则其父类A_Father也会被初始化,所以先初始化A_Father再初始化A
静态初始化类A

被动引用

System.out.println(A.MAX);//main()方法增加代码
//控制台输出
静态初始化Demo04
Demo04的main方法!
100            //引用常量不会触发此类的初始化
//--------------------------------------------------------------------------------    
A[] as = new A[10];//main()方法增加代码
//控制台输出
静态初始化Demo04
Demo04的main方法!
//通过数组定义类的引用,不会触发此类初始化
//--------------------------------------------------------------------------------    
System.out.println(B.width);  //main()方法增加代码
//控制台输出
静态初始化Demo04    
Demo04的main方法!  
静态初始化A_Father  //子类B引用父类A的静态变量,不会导致子类B初始化,访问某个类或接口的静态变量则A类会进行初始化,初始化某个类的子类A,则其父类A_Father也会被初始化,所以此处先初始化A_Father再初始化A
静态初始化类A
300				   //执行main方法输出的属性值

结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

– 执行了System.exit()方法

– 程序正常执行结束

– 程序在执行过程中遇到了异常或错误而异常终止

– 由于操作系统出现错误而导致Java虚拟机进程终止

类加载器

类加载器(ClassLoader)加载Class文件,本质是将字节码文件通过类加载器加载到内存中。

//测试类加载器的层级
public class ClassLoaderTest {
     public static void main(String[] args) {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        System.out.println(loader);
        System.out.println(loader.getParent());
        System.out.println(loader.getParent().getParent());
    }
}
//输出:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null

Java中的类加载器由上到下分为:

  • Bootstrap ClassLoader(启动类加载器):c++实现无法直接访问
  • ExtClassLoader(扩展类加载器):上级为Bootstrap,显示为null
  • AppClassLoader(应用程序类加载器):上级为ExtClassLoader

ExtClassLoader和AppClassLoader都是ClassLoader的子类。所以如果要自定义一个类加载器,可以继承ClassLoader抽象类,重写里面的方法。

image-20210423134452093

JVM类加载机制

  • 全盘负责:当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

  • 父类委托:先让父类加载器试图加载该类只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类

  • 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

类加载方式

类加载有三种方式:

1、命令行启动应用时候由JVM初始化加载

2、通过Class.forName()静态方法动态加载

3、通过ClassLoader.loadClass()方法动态加载

public class Demo {
    public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader loader = HelloWorld.class.getClassLoader();
        System.out.println(loader);
        //使用ClassLoader.loadClass()来加载类,不会执行初始化块
        loader.loadClass("com.hing.cn.TestJVM.Test1");
        //使用Class.forName()来加载类,默认会执行初始化块
        Class.forName("com.hing.cn.TestJVM.Test2");
        //使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
        Class.forName("com.hing.cn.TestJVM.Test3", false, loader);
    }
}
//Test1与Test2与Test3一样
public class Test1 {
    static {
        System.out.println("Test1静态初始化块执行了!");
    }
}

/*总结:Class.forName()和ClassLoader.loadClass()区别
  1 Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
  2 ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
  3 注意:Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
Class.forName的一个很常见的用法是在加载数据库驱动的时候。如加载 Apache Derby 数据库的驱动 :Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance()。
*/

类加载过程中出现的异常分析:静态加载NoClassDefFoundError。动态加载ClassNotFoundException。

异常类型 ClassNotFoundException NoClassDefFoundError
继承模型 从java.lang.Exception继承,是一个Exception类型 从java.lang.Error继承,是一个Error类型
触发原因 当动态加载Class的时候找不到类会抛出该异常 程序在编译时可以找到所依赖的类,但是在运行时找不到指定的类文件,运行过程中Class找不到导致抛出该错误
触发主体 动态类加载:一般在执行Class.forName()、ClassLoader.loadClass()或ClassLoader.findSystemClass()的时候抛出 静态类加载:JVM或者ClassLoader实例尝试加载类的时候,找不到类的定义而发生,通常在importnew一个类的时候触发
处理方式 程序可以从Exception中恢复,ClassNotFoundException可由程序捕获和处理 程序无法从错误中恢复,Error是系统错误,用户无法处理
可能原因 要加载的类不存在;类名书写错误 jar包缺失;调用初始化失败的类
例如 当我们使用JDBC去连接数据库的时候,我们一般会使用Class.forName()的方式去加载JDBC的驱动,如果我们没有将驱动放到应用的classpath下,那么会导致运行时找不到类,所以运行Class.forName()会抛出ClassNotFoundException。 首先这里我们先创建一个TempClass,然后编译以后,将TempClass生产的TempClass.class文件删除,然后执行程序。

双亲委派模型

image-20210420103149619

双亲委派模型意义:1 系统类防止内存中出现多份同样的字节码 2 保证Java程序安全稳定运行

不同的类加载器为相同名称的类创建了额外的名称空间。相同名称的类可以并存在 Java 虚拟机中,只需要用不同的类加载器来加载它们即可。不同类加载器加载的类之间是不兼容的,这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间。

//ClassLoader源码分析:
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 {
                      //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,
                      //通过调用本地方法native Class<?> findBootstrapClass(String name);
                        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
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

深入探讨双亲委派模型

问题:双亲委派模型虽然保障了Java核心类库的安全问题,但是双亲委派模型也有其缺点,那就是如果基础类又要调用用户的代码,那该怎么办? 此部分内容参考其他博文,文章后部分有链接信息!

比如我们经常使用的JDBC技术,学习过JDBC的应该知道JDBC只是一组接口规范,具体的实现是由数据库厂商实现的,那么JDBC的接口代码存在于核心类库中,是由启动类加载器加载的,但是JDBC的实现代码是由各个厂商提供,是由系统类加载器加载。启动类加载器是无法无法找到 JDBC接口 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的双亲委派模型无法解决这个问题。

看到这里你可能会产生一个疑问,那就是为啥上层的类加载器加载的类无法访问下层类加载器加载的类,但是下层的类加载器加载的类可以访问上层类加载器加载的类,比如:我们写的类Person由系统类加载器加载,String类由启动类加载器加载,也就是说Person类中可以访问到String,但是String类中无法访问到Person(这里是一个不太恰当的例子,因为我们无法具体的测试String类中是否可以访问到Person类)。

首先是两个术语:在前面介绍类加载器的双亲委派模型的时候,提到过类加载器会首先代理给其它类加载器来尝试加载某个类。这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,有可能不是同一个。真正完成类的加载工作是通过调用defineClass来实现的;而启动类的加载过程是通过调用loadClass来实现的。前者称为一个类的定义加载器(defining loader),后者称为初始加载器(initiating loader)。

注意:初始类加载器对于一个类来说经常不是一个,比如String类在加载的过程中,先是交给系统类加载器加载,但是系统类加载器代理给了扩展类加载期,扩展类加载器又代理给了引导类加载器,最后由引导类加载器加载完成,那么这个过程中的定义类加载器就是引导类加载器,但是初始类加载器是三个(系统类加载器、扩展类加载器、引导类加载器),因为这三个类加载器都调用了loadClass方法,而最后的引导类加载器还调用了defineClass方法。

JVM为每个类加载器维护的一个“表”,这个表记录了所有以此类加载器为“初始类加载器”(而不是定义类加载器,所以一个类可以存在于很多的命名空间中)加载的类的列表。属于同一个列表的类可以互相访问。这就可以解释为什么上层的类加载器加载的类无法访问下层类加载器加载的类,但是下层的类加载器加载的类可以访问上层类加载器加载的类?的疑问了。

在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。

解决双亲委派模型的缺陷

线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 java.lang.Thread中的方法getContextClassLoader()和 setContextClassLoader(ClassLoader cl)用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。

通过使用线程上下文类加载器可以实现父类加载器请求子类加载器去完成类加载的动作。

若要详细了解参考其他博文信息:https://blog.csdn.net/yangcheng33/article/details/52631940

自定义类加载器

通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。

自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,自己开发的类加载器只需要复写findClass(String name)方法即可。java.lang.ClassLoader类的方法loadClass()封装了双亲委派模型的实现。该方法会首先调用findLoadedClass()方法来检查该类是否已经被加载过;如果没有加载过的话,会调用父类加载器的 loadClass()方法来尝试加载该类;如果父类加载器无法加载该类的话,就调用findClass()方法来查找该类。因此,为了保证类加载器都正确实现代理模式,在开发自己的类加载器时,最好不要覆写 loadClass()方法,而是覆写findClass()方法。

下面我们通过一个示例来演示自定义类加载器的流程:

public class FileClassLoader extends ClassLoader {
    private String rootDir;

    public FileClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    /**
     * 编写findClass方法的逻辑
     * @param name
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 获取类的class文件字节数组
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            //直接生成class对象
            return defineClass(name, classData, 0, classData.length);
        }
    }

    /**
     * 编写获取class文件并转换为字节码流的逻辑
     * @param className
     * @return
     */
    private byte[] getClassData(String className) {
        // 读取类文件的字节
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 读取类文件的字节码
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 类文件的完全路径
     * @param className
     * @return
     */
    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }

    public static void main(String[] args) throws ClassNotFoundException {
        String rootDir="/Users/zejian/Downloads/Java8_Action/src/main/java/";
        //创建自定义文件类加载器
        FileClassLoader loader = new FileClassLoader(rootDir);

        try {
            //加载指定的class文件
            Class<?> object1=loader.loadClass("com.zejian.classloader.DemoObj");
            System.out.println(object1.newInstance().toString());

            //输出结果:I am DemoObj
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

运行时数据区

概述

此图先大体有个了解,整个章节看完再回来深入理解!!!

image-20210426095115573

数据区按照是否被线程共享分为两大类:
1 每个线程独享的:程序计数器、栈、本地方法栈
  生命周期与Thread相同,即:线程创建时,相应的内存区创建,线程销毁时,释放相应内存。
2 所有线程共享的:堆、方法区、运行时常量池
  在虚拟机启动时创建,虚拟机退出时释放。
  
注:堆被所有线程共享,如果严格意义上抠字眼的话,也不完正确,事实上,由于TLAB的存在,为了防止并发对象分配时,多个对象分配到同1块内存,heap中的TLAB区域,在分配时,是被线程独占写入的。
注:方法区,虚拟机规范只是说必须要有,但是具体怎么实现,是交给具体的JVM实现去决定的,逻辑上讲,视为Heap区的一部分。所以有时候方法区称为堆的永久区。

PC 计数器不会抛出StackOverflowError或OutOfMemoryError ,其它5个区域,当请求分配的内存不足时,均会抛出OutOfMemoryError (即:OOM),其中thread独立的JVM Stack区及Native Method Stack区还会抛出StackOverflowError.

PC计数器

程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。是一个非常小的内存空间,几乎可以忽略不计。

为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。

栈内存,主管程序的运行,生命周期和线程同步,每个JVM都有一个私有的栈,一个线程的java栈在线程创建的时候就被创建,线程结束,栈内存也就释放了,对于栈来说,不存在垃圾回收问题

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame,如果没有进入JVM叫方法,进入JVM的方法区后就叫栈帧)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 线程中每次有方法调用时,会创建Frame,方法调用结束时Frame销毁。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

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

本地方法栈

native与栈类似,最大的不同是本地方法栈用于本地方法调用。调用操作系统原生本地方法时,所需要的内存区域,java虚拟机允许java直接调用本地方法。(通常使用C编写)

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

public class Test {
    public static void main(String[] args) {
        new Thread(()->{
            System.out.println("线程启动");
        },"thread name").start();
    }

    // native 凡是带有native关键字的,说明Java的作用范围达不到了,会去调用底层的c语言的库
    // 会进入本地方法栈-->调用本地方法接口 JNI
    // JNI的作用:扩展Java的使用,融合不同的语言为Java所用! 最初的c/c++
    // Java诞生的时候,c/c++横行,想要立足必须调用c/c++
    // 它在内存区域中专门开辟了一块标记区域:Native Method Stack,登记native方法
    // 在最终执行的时候,加载本地方法库中的方法通过JNI
    private native void start0();
}

在JVM启动时建立java堆,它是java程序最主要的内存工作区域,几乎所有的对象实例都存放到java堆中,一个JVM只有一个堆内存,堆空间是所有线程共享的。堆内存的大小时可以调节的。并且java堆完全是自动化管理的,GC回收主战场,通过垃圾回收机制,垃圾对象会自动清理,不需要显式的释放。

根据垃圾回收机制不同,java堆可能有不同的结构。最为常见的就是将整个java堆分为新生代和老年代。其中新生代存放新生的对象或者年龄不大的对象,老年代则存放老年对象。

新生代分为eden区、s0区、s1区,s0和s1也被称为from和to区域,它们是两块大小相等并且可以互换角色的空间。(复制算法)
绝大多数情况下,对象首先分配在eden区,在新生代回收后,如果对象还存活,则会进入s0或者s1区,之后每经过一次新生代回收,如果对象存活,则它的年龄就加1,当对象达到一定年龄后,则进入老年代。

对象进入老年代策略

  • 迭代年龄判断

    在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次YoungGC之后对象的移区操作中增加,每一次移区年龄加一.当这个年龄达到15(默认)之后,这个对象将会被移入老年代.
    
    - XX:MaxTenuringThreshold
    
  • 大对象直接进入老年代

    有一些占用大量连续内存空间的对象在被加载伊始就会直接进入老年代.这样的大对象一般是一些数组,长字符串之类的对象.
    
    - XX:PretenureSizeThreshold
    
  • YoungGC之后需要移区的对象放不下

    在进行移区的时候,可能需要移区的对象大于所移区的空间大小,那么这些对象会被直接放入老年代,毕竟总不能在对象还被引用的时候就对其进行回收.
    
    对象的几种引用类型.下面引用类型等级依次向下
    1 强引用:平常的代码创建对象都属于强引用,之后当对象变为垃圾对象才会被回收
    2 软引用:被SoftReference这个类包裹起来的对象,在进行垃圾收集发现剩余空间不够的时候,全部已创建软引用对象会被一次性回收,这种引用类型常用于对内存比较敏感的缓存中
    3 弱引用:被WeekReference这个类包裹起来的对象,每次进行垃圾收集操作的时候都会将弱引用对象一次性回收,基本不使用
    4 虚引用:又称幽灵引用,随时都会被回收
    
  • 对象动态年龄判断

    此策略发生在Survivor区,当Survivor区中的一批对象的总大小大于Survivor区空间大小的一半,在这个区域中,对象年龄大于这批对象的最大年龄的所有对象会被移入老年代.看下面的例子
    
    假设我这里按照年龄划分了10批对象,对象年龄依次为1-10,现在年龄1到3这批对象年龄1+年龄2+年龄3的多个年龄对象总和超过Survivor区域的50%,则对象为4-10的所有对象会被放入老年代。
    

总结:

1 策略一:将可能长期存活的对象直接放入老年代

2 策略二:避免移区时的复制操作浪费资源

3 策略三:不能将还有引用的对象当做垃圾回收掉

4 策略四:将可能长期存活的对象直接放入老年代

堆内存调优

img

-Xms:初始堆大小    	 默认:物理内存的1/64(<1GB)
-Xmx:最大堆大小		默认:物理内存的1/4(<1GB)
-Xmn:年轻代大小
Perm:永久代(Permanent Generation,也就是方法区)
-XX:PermSize:持久代初始值  默认:物理内存的1/64
-XX:MaxPermSize:持久代最大值 默认:物理内存的1/4
-XX:NewRatio:年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)
-XX:SurvivorRatio:年轻代中Eden区与Survivor区的大小比值

// -XX:+PrintGCDetails  打印GC垃圾回收
// -XX:+HeapDumpOnOutOfMemoryError OOM Dump

一个启动类,加载了大量的第三方jar包,Tomcat部署了太多的应用,大量动态生成的反射类。不断的被加载。直到内存满,就会出现OOM!堆内存不够!java.lang.OutOfMemoryError:Java heap space!

OOM: 1 尝试扩大堆内存查看结果

​ 2 分析内存,看那个地方出现问题,内存快照分析工具:MAT、Jprofiler

MAT、Jprofiler作用:

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

查看内存信息

元空间逻辑上存在物理上不存在

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

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

//手动设置内存:-Xms1024m -Xmx1024m -XX:+PrintGCDetails
//元空间逻辑上存在物理上不存在
1029177344字节=305664K+305664K=(305664+305664)*1024
    
//控制台输出信息
max=1029177344字节	 981.5M
total=1029177344字节	 981.5M
Heap
 PSYoungGen      total 305664K, used 15729K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 262144K, 6% used [0x00000000eab00000,0x00000000eba5c420,0x00000000fab00000)
  from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
  to   space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
 ParOldGen       total 305664K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
  object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
 Metaspace       used 3322K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 360K, capacity 388K, committed 512K, reserved 1048576K                  

了解GC过程

轻GC-->重GC-->OOM

//首先调整内存较小一点:-Xms8m -Xmx8m -XX:+PrintGCDetails
public static void main(String[] args) {
    String s="hing`s java";
    while (true){
        s+=s+new Random().nextInt(888888888)+new Random().nextInt(999999999);
    }
}
//控制台输出
[GC (Allocation Failure) [PSYoungGen: 1533K->488K(2048K)] 1533K->632K(7680K), 0.0026931 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1936K->504K(2048K)] 2080K->973K(7680K), 0.0132683 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 1894K->376K(2048K)] 3262K->1969K(7680K), 0.0010220 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1344K->376K(2048K)] 6536K->5567K(7680K), 0.0019210 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) --[PSYoungGen: 1319K->1319K(2048K)] 6511K->6511K(7680K), 0.0021639 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1319K->0K(2048K)] [ParOldGen: 5191K->2400K(5632K)] 6511K->2400K(7680K), [Metaspace: 3277K->3277K(1056768K)], 0.0075304 secs] [Times: user=0.00 sys=0.03, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 94K->160K(2048K)] 4295K->4360K(7680K), 0.0007274 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 160K->64K(2048K)] 4360K->4264K(7680K), 0.0006852 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 64K->0K(2048K)] [ParOldGen: 4200K->3301K(5632K)] 4264K->3301K(7680K), [Metaspace: 3282K->3282K(1056768K)], 0.0079845 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 3301K->3301K(7680K), 0.0003640 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) Exception in thread "main" [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 3301K->3281K(5632K)] 3301K->3281K(7680K), [Metaspace: 3282K->3282K(1056768K)], 0.0084019 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3332)
	at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
	at java.lang.StringBuilder.append(StringBuilder.java:208)
	at com.hing.cn.TestJVM.Demo01.main(Demo01.java:11)
Heap
 PSYoungGen      total 2048K, used 54K [0x00000000ffd80000, 0x0000000100000000, 0x0000000100000000)
  eden space 1536K, 3% used [0x00000000ffd80000,0x00000000ffd8d9f0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 5632K, used 3281K [0x00000000ff800000, 0x00000000ffd80000, 0x00000000ffd80000)
  object space 5632K, 58% used [0x00000000ff800000,0x00000000ffb34410,0x00000000ffd80000)
 Metaspace       used 3329K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 1

JProfiler使用

  1. 首先Idea安装JProfiler插件

  2. 安装JProfiler工具

  3. 设置idea的JProfiler:setting--tools--JProfiler--选择2中安装的工具的目录\bin\jprofiler.exe

  4. 编写测试代码:执行后会报OOM

    public class Demo03 {
        byte[] array=new byte[1024*1024];//1m
    
        public static void main(String[] args) {
            ArrayList<Demo03> arrayList=new ArrayList<>();
            int  count=0;
            try {
                while (true){
                    arrayList.add(new Demo03());
                    count++;
                }
            }catch (Error e){
                System.out.println(count);
                e.printStackTrace();
            }
        }
    }
    
  5. 设置VM参数:-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError

  6. 执行测试代码,项目的根目录下生成Dump文件

  7. 用JProfiler工具打开6中的文件查看问题所在

方法区

方法区(Method Area)可以理解为永久区,被所有的线程共享。这个区域不存在垃圾回收!关闭VM虚拟就会释放这个区域的内存!

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

运行时常量池,比如:字符串,int -128~127范围的值等,它是方法区中的一部分。

JDK1.8以后,永久存储区改了名字(元空间)!永久区或者元空间都是方法区的实现,在JVM规范中方法区被描述为堆的一个逻辑部分, 也会被成为非堆目的就是为了和堆区分开来。元空间逻辑上存在物理上不存在!!!

  • jdk1.6之前:永久代,常量池在方法区
  • jdk1.7 : 永久代,慢慢退化了去永久代常量池在堆中
  • jdk1.8之后:无永久代,常量池在元空间

堆栈方法区的概念及联系

堆:解决的是数据存储的问题,即数据怎么放,放在哪里。
栈:解决的是程序的运行问题,即程序如何执行,或者说如何处理数据。
方法区:辅助堆栈的块永久区(Perm),解决堆栈信息的产生,是先决条件。

我们创建一个新的对象User user=new User();
1 User类信息,静态信息都存在于方法区中;
2 User类被实例化出来之后被存放在java堆中,一块内存空间;
3 使用的时候都是使用User对象的引用,这里的user就是存放在java栈中的,即User真实对象的一个引用。

JIT即时编译

即时编译器

即时编译器(Just In Time Compiler):在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器

HotSpot虚拟机使用解释器与编译器并存的架构

解释器与编译器两者各有优势:

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率

当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。

为何HotSpot虚拟机要实现两个不同的即时编译器?

HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。

用Client Complier获取更高的编译速度,用Server Complier 来获取更好的编译质量。为什么提供多个即时编译器与为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。

热点检测

哪些程序代码会被编译为本地代码?如何编译为本地代码?

程序中的代码只有是热点代码时,才会编译为本地代码,那么什么是热点代码呢?

运行过程中会被即时编译器编译的“热点代码”有两类:1、被多次调用的方法。2、被多次执行的循环体。

两种情况,编译器都是以整个方法作为编译对象。 这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了。

如何判断方法或一段代码是不是热点代码呢?

需要进行Hot Spot Detection(热点探测),目前主要的热点探测方式有以下两种:

  1. 基于采样的热点探测
    采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

  2. 基于计数器的热点探测

    采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

HotSpot虚拟机中使用的是哪钟热点检测方式呢

基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

方法调用计数器:这个计数器用于统计方法被调用的次数。

image-20210425161806343

回边计数器:就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

image-20210425162013806

编译优化

JIT在做了热点检测识别出热点代码后,除了会对其字节码进行缓存,还会对代码做各种优化。这些优化中,比较重要的几个有:逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型检测消除、 公共子表达式消除等。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

栈上分配

JIT经过逃逸分析之后,如果发现某个对象并没有逃逸到方法体之外的话,就可能对其进行优化,而这一优化最大的结果就是可能改变Java对象都是在堆上分配内存的这一原则。

有了逃逸分析之后,发现一个对象并没有逃逸到放法外的话,通过什么办法可以进行优化,减少对象在堆上分配可能呢?

这就是栈上分配。在HotSopt中,栈上分配并没有真正的进行实现,而是通过标量替换来实现的。

所以我们重点介绍下,什么是标量替换,如何通过标量替换实现栈上分配。

标量替换

标量(Scalar):不可被进一步分解的量,而JAVA的基本数据类型就是(如:int,long等基本数据类型以及reference类型等);

聚合量(Aggregate):标量的对立就是可以被进一步分解的量称为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

JVM参数配置

-XX:+DoEscapeAnalysis     启用逃逸分析	默认启用
-XX:-DoEscapeAnalysis     关闭逃逸分析
-XX:+EliminateAllocations 启用标量替换,允许对象打散分配到栈上	默认启用
-XX:-EliminateAllocations 关闭标量替换
-XX:-UseTLAB	          关闭TLAB TLAB(Thread Local Allocation Buffer)线程本地分配缓存区

TLAB

TLAB(Thread Local Allocation Buffer)线程本地分配缓存区,这是一个线程专用的内存分配区域。TLAB占用eden区的空间。在TLAB启用的情况下(默认开启),JVM会为每一个线程分配一块TLAB区域。

这是为了加速对象的分配。由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。为了避免指针碰撞而出现。

-XX:+UseTLAB

在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

XX:TLABWasteTargetPercent

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

TLAB的本质

有三个管理区域的指针:start;top;end

每个线程都会从Eden区拿到一块空间,start与end作占位用的,即start与end之间的空间不允许被其他线程申请(允许访问,不允许申请)。

总结:TLAB只是允许每个线程都有分配指针,其他线程仍然可以访问TLAB里面的对象。即可访问,但不可申请内存。当一个TLAB满了(即top指针指到了end指针指向的位置),会申请一个新的TLAB。旧TLAB里面的对象仍留在原地,它们无法感知自己是否是从TLAB分配出来的,它们只关心是自己在Eden区分配的。

缺点

  1. 因为TLAB很小,缺省情况下是Eden区的1%,所以放不下大对象。(比如TLAB有100KB,此时来了个110KB的对象)
  2. TLAB剩余的空间不足以存放新new出来的对象,会有点浪费。(比如TLAB有100KB,剩余20KB可用,此时来了一个30KB的对象)

所以出现最大浪费空间

  1. 当剩余空间<最大浪费空间 时,该TLAB所属的线程会重新向Eden申请一个TLAB(旧的TLAB不作清理,会留在原地)。创建对象时发现还是不够空间,则此对象太大,直接去Eden区创建(即TLAB外的Eden区域)。
  2. 当剩余空间>最大浪费空间时,不重新申请TLAB,直接去Eden创建对象。
  3. Eden区不够内存了,堆的Eden区开始GC。
  4. 因为TLAB允许空间浪费,所以会存在很多不连续的空间(空间碎片),以后还需要人整理。

JVM参数配置

【默认情况下,TLAB和refill_waste最大浪费空间都是会在运行时不断调整的,使系统的运行状态达到最优。】

-XX:-UseTLAB	          关闭TLAB TLAB(Thread Local Allocation Buffer)线程本地分配缓存区
-XX:+UseTLAB	          启用TLAB	默认启用
-XX:-ResizeTLAB			  禁止系统自动调整TLAB大小	 
-XX:TLABSize	          指定TLAB大小	单位:B
-XX:+PrintTLAB            打印TLAB详细信息
-XX:TLABRefillWasteFraction	设置允许空间浪费的比例	默认值:64,即:使用1/64的TLAB空间大小作为refill_waste值

GC垃圾回收

垃圾收集系统是java的核心,也是必不可少的,java有一套自己进行垃圾清理的机制,开发人员无需手工清理。

GC种类:轻GC(普通的GC),重GC (全局的GC)。

类加载器读取类之后,需要把类、方法、常量放到方法区中,保存着所有引用类型的真实数据。对象存放到JVM堆中,更加具体是存放到堆中新生区的伊甸区。GC垃圾回收,主要是在伊甸园区及养老区。

如何判断对象是垃圾 ?

引用计数法

img

img

引用计数法,思路很简单,但是如果出现循环引用,即:A引用B,B又引用A,这种情况下就不好办了,所以JVM中使用了另一种称为“可达性分析”的判断方法

如果A引用B,B又引用A,这2个对象是否能被GC回收?

答案:关键不是在于A,B之间是否有引用,而是A,B是否可以一直向上追溯到GC Roots。如果与GC Roots没有关联,则会被回收,否则将继续存活。

img

上图是一个用“可达性分析”标记垃圾对象的示例图,灰色的对象表示不可达对象,将等待回收。

哪些内存区域需要GC?

img

thread独享的区域:PC计数器、栈、本地方法栈,其生命周期都与线程相同(即:与线程共生死),所以无需GC。线程共享的堆区、方法区则是GC关注的重点对象。

常用的GC算法

标记复制法(mark-copy)

复制算法最初的理论是将可用内存分为1:1的两块,每次只使用其中一块,当这块内存满后,就先标记存活对象并将其复制到另一块内存,然后将满的内存释放掉

这种算法非常简单高效,只需要将标记的存活对象复制到另一半空间,同时内存始终保持规整,不会出现内存碎片,但缺点也很明显,可用内存减少了一半,另外复制的对象不能太大,否则复制的效率会比较低。

因为新生代中的对象大多“朝生夕死”,在JVM新生代中的垃圾收集器都是采用的复制算法。

但是为避免浪费的空间太多,提出了一种更为优化的复制算法,称为Appel式回收

该算法不再是简单的“半区复制”,而是将新生代分为了三块:一块Eden区和两块Survivor区(分别标记为from和to),默认的分配比例是8:1:1(-XX:SurvivorRatio=8表示两个Survivor区和Eden区比例为2:8,即每个Survivor占10%),每次分配对象都只使用Eden区和其中一块Survivor区(from区)。其中Eden区最大,新对象都在该区域创建,当Eden区满后,会进行一次MinorGC,并将Eden区和from区中存活对象都复制到to区中,然后调换from和to指针。当然肯定是存在to区装不下一次MinorGC存活对象的情况,这时就需要老年代进行分配担保

标记清除法(mark-sweep)

老年代

标记清除是最早出现的垃圾回收算法,由Lisp之父提出。这个算法也很简单,首先标记存活的对象,然后统一回收未被标记的对象。相较于复制算法的缺点也很明显,效率更低,同时会导致内存碎片。内存碎片会导致堆中明明还有足够的内存,但却没有足够的连续内存来存放大对象,导致对象直接进入老年代。

缺点:两次扫描浪费时间,会产生内存碎片

优点:不需要额外的空间!

标记-整理/标记-压缩法(mark-compact)

老年代

避免了上述二种算法的缺点,将垃圾对象清理掉后,同时将剩下的存活对象进行整理挪动(类似于windows的磁盘碎片整理),保证它们占用的空间连续,这样就避免了内存碎片问题,但是整理过程也会降低GC的效率。

此算法需要移动内存,而移动内存是一种非常“危险”的操作,需要暂停其它用户线程的执行,确保内存指向的正确性,所以这就是STW出现的原因。

分代回收算法(generation-collect)

分代回收严格意义上并不算一种算法,而是各回收算法的实践理论。它建立在两个分代假说之上:

弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

上面两个假说共同确定了垃圾收集器一致的设计原则,即新生代老年代。在新生代中使用复制算法,如上所说,大部分对象朝生夕灭,所以只需要将少量存活对象复制到另一块区域后再统一格式化之前的区域;而老年代因为大量对象存活,只能采用标记清除标记整理算法。

  • 年轻代:存活率低,标记复制算法
  • 老年代:区域大存活率高,标记清除法(内存碎片不是太多)+标记压缩混合(碎片多的时候进行一次压缩)

总结

内存效率:标记复制算法>标记清除算法>标记压缩算法(时间复杂度)

内存整齐度:标记复制算法=标记压缩算法>标记清除算法nianq

内存利用率:标记压缩算法=标记清除算法>标记复制算法

GC的主要过程:

img

垃圾回收器

image-20210426152213046

上图中展示的就是目前主流的垃圾回收器,有连线的代表两者可以搭配使用,而打“X”的表示在JDK9中已经废弃的组合。

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

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

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

串行垃圾回收器

在JDK1.3.1之前,单线程回收器是唯一的选择。它的单线程意义不仅仅是说它只会使用一个CPU或一个收集线程去完成垃圾收集工作。而且它进行垃圾回收的时候,必须暂停其他所有的工作线程(Stop The World,STW),直到它收集完成。它适合Client模式的应用,在单CPU环境下,它简单高效,由于没有线程交互的开销,专心垃圾收集自然可以获得最高的单线程效率。

  1. Serial:新生代收集,复制算法。
  2. Serial Old:老年代收集,标记-整理算法。

-XX:+UseSerialGC开启上述回收模式

并行垃圾回收器

整体来说,并行垃圾回收相对于串行,是通过多线程运行垃圾收集的。也会stop-the-world。适合Server模式以及多CPU环境。一般会和jdk1.5之后出现的CMS搭配使用。

  1. ParNew:新生代收集,复制算法。Serial收集器的多线程版本,默认开启线程数和cpu数量一样。

    • -XX:ParallelGCThreads,限制垃圾收集的线程数。

    • -XX:+UseConcMarkSweepGC,在激活CMS后默认新生代收集器

    • -XX:+/-UseParNewGC,强制指定或者禁用它。

    JDK9以后ParNew成为了CMS的一部分。

  2. Parallel Scavenge:新生代收集,复制算法。

    关注吞吐量,吞吐量优先,吞吐量=代码运行时间/(代码运行时间+垃圾收集时间),比如虚拟机运行100分钟,垃圾回收耗时1分钟,那么吞吐量就是99%。也就是高效率利用cpu时间。但是这款收集器在JDK1.6之前比较尴尬,没有与之对应的并行的老年代收集器,只能采用SerialOld老年代收集器,使得表现比不上PareNew+CMS的组合。直到ParallelOld出现后,ParallelScavenge才能真正的展现它吞吐量的优势。

    • -XX:MaxGCPauseMillis,最大停顿时间,该参数的值是一个大于0的毫秒数,收集器尽量保证GC停顿时间不超过该值,但是不要天真的认为该值越小越好。该值设置的太小会导致每次GC的回收率降低,垃圾堆积,GC发生的越来越频繁。比如原先需要100ms收集500M空间,现在设置为50ms,那么可能就只能回收300M或者更小的垃圾。

    • -XX:GCTimeRatio,控制垃圾回收时间比率。比如允许最大垃圾回收时间占总时间的5%,那么需要将该值设置为19(公式是1/(1 + 19))

    • -XX:+UseAdaptiveSizePolicy,这个参数激活后,就不再需要我们手动设定新生代各区(Eden、from、to)的比例(-XX:SurvivorRatio),晋升老年代对象的大小(-XX:PretenureSizeThreshold),虚拟机会监控运行时的状态,进行动态的调整,这种方式称为垃圾收集的自适应调节策略(GC Ergonomics)。

    • -XX:+UseParallelGC,Server模式下默认提供了其和SerialOld进行搭配的分代收集方式。

  3. Parllel Old:老年代收集,Parallel Scavenge老年代版本。

    • -XX:+UseParallelOldGC,Parallel Scavenge + Parallel Old器组合进行内存回收。

CMS收集器

CMS(Concurrent Mark Sweep)是第一款并发垃圾收集器,并发是指垃圾收集可以和用户线程同时进行。同时它也是唯一采用标记清除算法对老年代进行回收的垃圾回收器。分为以下4个阶段:

1 初始标记:STW,只标记与GC Roots直接关联的对象

2 并发标记:和用户线程同时运行,进行可达性分析

3 重新标记:STW,暂停用户线程,修正上一阶段变动的对象

4 并发清除:最后是并发的清除掉垃圾

CMS的整个过程中只有初始标记重新标记是需要暂停用户线程的,而初始标记只是标记与GCRoots直接关联的对象,所以耗时只和GCRoots的数量有关,非常快;重新标记的耗时会比初始标记略长,但也远远比并发标记用时短,所以CMS就是通过细分GC的阶段来降低GC的停顿时间。

缺点

  1. CPU敏感:虽然并发标记并发标记是和用户线程并发执行的,但是也因此占用了系统的资源,导致应用程序忽然变慢,降低吞吐量。CMS默认启动的线程数是(处理器核心数+3)/4,因此当核心数量大于等于4时,GC占用资源不超过25%,但核心数小于4时,就会占用大量系统资源。

  2. 大量的内存碎片:因为CMS是使用标记清除算法实现垃圾回收,所以会产生大量的内存碎片。为了避免这个问题,CMS采用了一个折中的办法,即提供一个-XX:+UseCMS-CompactAtFullCollection参数,该参数默认开启,控制CMS在进行FullGC的同时进行空间整理,但这样又会导致停顿时间加长,所以还提供了-XX:CMSFullGCsBefore-Compaction参数,控制CMS在进行了多少次不带整理的FullGC后进行一次带整理的FullGC,默认值是0,即每次FullGC都会整理,该参数JDK9后被废弃。

  3. 浮动垃圾:因为最终清除的过程也是和用户线程并发执行的,因此这个过程中必然会产生新的垃圾,这一部分垃圾需要预留空间来存放,等待下一次GC的时候再清理,因此会浪费一部分空间。在JDK5的默认配置下,当老年代使用空间超过68%时就会进行GC,到JDK6时,这个阈值就提高到了92%,另外也可以通过-XX:CMSInitiatingOccu-pancyFraction参数控制。但该值越高,那么并发清理过程中可使用的内存就越小,当放不下时,就会出现一次Concurrent Mode Failure,这时候虚拟机就会冻结线程并采用SerialOld进行垃圾回收,导致停顿时间变得更长。

G1收集器

G1垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器。并在JDK 9中成为了默认的垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

G1的内存模型

image-20210427103817078

--分区概念:
1.分区Region
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。
-XX:G1HeapRegionSize=n  指定分区大小(1MB~32MB,且必须是2的幂)  默认将整堆划分为2048个分区

2.卡片Card
在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

3.堆Heap
G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。

image-20210427105007232

--分代
分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但G1依然使用了分代的思想。与其他垃圾收集器类似,G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

-XX:G1NewSizePercent     初始空间(默认整堆5%) 整个年轻代内存在初始空间及最大空间之间动态变化
-XX:G1MaxNewSizePercent  最大空间(默认60%)
-XX:MaxGCPauseMillis 目标暂停时间  默认200ms  且由暂停时间及已记忆集合(RSet)计算得到
G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

Lab:本地分配缓冲 Local allocation buffer
由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。

应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

image-20210427110148763

--分区模型
G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

1. 巨形对象Humongous Region
一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

2. 已记忆集合Remember Set (RSet)
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。

3. Per Region Table(PRT)
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
	稀少:直接记录引用对象的卡片索引
	细粒度:记录引用对象的分区索引
	粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。

image-20210427110834940

--收集集合 (CSet)
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

1. 年轻代收集集合 (CSet of Young Collection)
应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。
同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。

2. 混合收集集合 (CSet of Mixed Collection)
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。

RSet的维护

由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)

image-20210428084952993

--栅栏Barrier
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。

写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。

写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
--起始快照算法Snapshot at the beginning (SATB)
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。

SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
--并发优化线程Concurrence Refinement Threads
G1中使用基于Urs Hölzle的快速写栅栏,将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。

当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。

并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。

G1的活动周期

image-20210427144023944

G1的垃圾收集周期主要有4种类型:年轻代收集周期、多级并发标记周期、混合收集周期和full GC(转移失败的安全保护机制)。

GC工作线程数: -XX:ParallelGCThreads,默认值并不是固定的,而是根据当前的CPU资源进行计算。如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;当然也可以人工指定与CPU核数相等。

--年轻代收集周期
每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。

并行活动:外部根分区扫描 Ext Root Scanning,更新已记忆集合 Update RS,RSet扫描 Scan RS,代码根扫描 Code Root Scanning,转移和回收 Object Copy,终止 Termination。

串行活动:代码根更新 Code Root Fixup,代码根清理 Code Root Purge,清除全局卡片标记 Clear CT,选择下次收集集合 Choose CSet,引用处理 Ref Proc,引用排队 Ref Enq,卡片重新脏化 Redirty Cards,回收空闲巨型分区 Humongous Reclaim,释放分区 Free CSet,其他活动 Other。
--并发标记周期
当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。
并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。

整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。
初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。

--并发标记线程
要标记存活的对象,每个分区都需要创建位图(Bitmap)信息来存储标记数据,来确定标记周期内被分配的对象。G1采用了两个位图Previous Bitmap、Next Bitmap,来存储标记数据,Previous位图存储上次的标记数据,Next位图在标记周期内不断变化更新,同时Previous位图的标记数据也越来越过时,当标记周期结束后Next位图便替换Previous位图,成为上次标记的位图。同时,每个分区通过顶部开始标记(TAMS),来记录已标记过的内存范围。同样的,G1使用了两个顶部开始标记Previous TAMS(PTAMS)、Next TAMS(NTAMS),记录已标记的范围。

在并发标记阶段,G1会根据参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4),分配并发标记线程(Concurrent Marking Threads),进行标记活动。每个并发线程一次只扫描一个分区,并通过"手指"指针的方式优化获取分区。并发标记线程是爆发式的,在给定的时间段拼命干活,然后休息一段时间,再拼命干活。

每个并发标记周期,在初始标记STW的最后,G1会分配一个空的Next位图和一个指向分区顶部(Top)的NTAMS标记。Previous位图记录的上次标记数据,上次的标记位置,即PTAMS,在PTAMS与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在PTAMS与Top之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next位图吸收了Previous位图的标记数据,同时每个分区都会有新的对象分配,则Top与NTAMS分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出NTAMS与PTAMS之间的所有存活对象,将标记数据存储在Next位图中。同时,在NTAMS与Top之间的对象即成为已标记对象。如此不断地更新Next位图信息,并在清除阶段与Previous位图交换角色。

并发标记线程

å¹¶å‘æ ‡è®°ä½å›¾è¿‡ç¨‹

--混合收集周期
当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。
单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
--full GC
转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。
Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
  从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  从老年代分区转移存活对象时,无法找到可用的空闲分区
  分配巨型对象时在老年代无法找到足够的连续分区

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

JVM参数配置汇总

-Xms                      初始堆大小		默认:物理内存的1/64(<1GB)
-Xmx                      最大堆大小		默认:物理内存的1/4(<1GB)
-Xmn                      年轻代大小
-XX:PermSize              持久代初始值	默认:物理内存的1/64
-XX:MaxPermSize           持久代最大值	默认:物理内存的1/4
-XX:MetaspaceSize 		  JDK1.8以后PerSize 
-XX:MaxMetaspaceSize 	  JDK1.8以后MaxPermSize
-XX:NewRatio              年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)
-XX:SurvivorRatio         年轻代中Eden区与Survivor区的大小比值
-XX:+PrintGCDetails       打印GC垃圾回收
-XX:+HeapDumpOnOutOfMemoryError OOM Dump
-XX:+DoEscapeAnalysis     启用逃逸分析	默认启用
-XX:-DoEscapeAnalysis     关闭逃逸分析
-XX:+EliminateAllocations 启用标量替换,允许对象打散分配到栈上	默认启用
-XX:-EliminateAllocations 关闭标量替换
-XX:-UseTLAB	          关闭TLAB TLAB(Thread Local Allocation Buffer)线程本地分配缓存区
-XX:+UseTLAB	          启用TLAB	默认启用
-XX:-ResizeTLAB			  禁止系统自动调整TLAB大小	 
-XX:TLABSize	          指定TLAB大小	单位:B
-XX:TLABRefillWasteFraction	设置允许空间浪费的比例	默认值:64,即:使用1/64的TLAB空间大小作为refill_waste值
-XX:+UseG1GC               开启G1垃圾回收器

问题汇总

JVM探究

谈谈你对JVM的理解?Java8和之前的变化更新?

什么是OOM,什么是栈溢出StackOverFlowError?怎么分析?

JVM的常用调优参数有哪些?

内存快照如何抓取,怎样分析Dump文件?

谈谈JVM中类加载器的认识?

为什么要有Survivor区

Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

为什么要设置两个Survivor区

设置两个Survivor区最大的好处就是解决了碎片化,永远有一个survivor space是空的,另一个非空的survivor space无碎片。

这里以假设法进行分析。如果没有Survivor区,那么新生代每次GC后存活对象会直接进入老年代,导致老年代迅速填满,频繁的触发FullGC;如果只有一块Survivor区,那么为了保证复制算法的特性(内存规整和高效),Eden区经过一次MinorGC后会将对象复制到Survivor区,这时新对象只能在Survivor区创建,否则无法保证内存规整,但又由于Survivor区非常小,就会导致很快又触发有一次MinorGC;而如果有两块Survivor区就很好的解决了上面所说的问题,而更多的Survivor区就没有必要了。

GC垃圾回收问题

  • JVM内存模型和分区-详细到每个区放什么?
  • 堆里边的分区有哪些?eden、from、to、老年区,说说他们的特点?
  • GC算法有哪些?怎么用?
  • 轻GC与重GC什么时候发生

java对象真的都存储在堆?

image-20210426090943871

栈上分配及TLAB分配查看JIT即时编译及TLAB章节。

参考

本文部分内容及图片参考其他文章,在此表示感谢!整理时间较长,部分会有遗漏见谅!

参考文章:

https://blog.csdn.net/q982151756/article/details/81540903

https://blog.csdn.net/yangcheng33/article/details/52631940

https://www.cnblogs.com/yjmyzz/p/jvm-memory-structure-and-gc.html

https://blog.csdn.net/shenwansangz/article/details/95601232

https://blog.csdn.net/fedorafrog/article/details/104503829/

posted @ 2021-04-28 11:23  地球小星星  阅读(158)  评论(0)    收藏  举报