栏目分类:
子分类:
返回
终身学习网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
终身学习网 > IT > 软件开发 > 后端开发 > Java

JVM虚拟机相关知识

Java 更新时间:发布时间: 百科书网 趣学号
JVM虚拟机结构


类加载子系统 类加载过程

类加载子系统负责从文件系统或者网络中加载Class文件,文件开头有特定的标识,类加载只负责Class文件的加载,至于它是否可以运行则由执行引擎决定。加载的类信息存放在方法区的内存空间。

  • 加载阶段:虚拟机在加载阶段需要完成以下三件事情:
    • 通过一个类的全限定名获取定义此类的二进制流。
    • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。
    • 在内存中生成一个代表这个类的Calss对象作为方法区这个类各种数据的访问入口。
  • 链接阶段
    • 验证阶段:目的是确保Class文件的字节流中包含信息是否符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
      • 验证阶段主要包含四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证
        • 文件格式验证:验证字节流是否符合Class文件格式的规范,目的是保证输入的字节流能正确解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求,此阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。主要验证以下几点:
          • 是否以魔数0xCAFEBABE开头。
          • 主、次版本号是否在当前Java虚拟机接受范围之内。
          • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志) 。
          • 指向常量中的各种索引值中是否有指向不存在的常量或不符合类型的常量。
          • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
          • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
        • 元数据验证:对字节码描述的信息进行语义分析,保证符合虚拟机规范要求。
          • 这个类是否有父类,除了Object之外,所有类都应当有父类。
          • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
          • 如果这个类不是抽象类,是否实现了其父类或接口之中要实现的所有方法。
          • 类中的字段、方法是否与父类产生矛盾。
        • 字节码验证:通过数据分析和控制流分析确定程序语义是合法的,对类的方法体(Class文件中的Code属性)进行校验,保证被校验类的方法在运行时不会危害虚拟机。
          • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现"在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中"这样的情况。
          • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
          • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系的数据类型,则是不合法的。
        • 符号引用验证:发生在虚拟机将符号引用转化为直接引用时,这个转化发生在解析阶段;符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,判断该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
          • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
          • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
          • 符号引用中的类、字段、方法的可访问性是否可被当前类访问。
    • 准备阶段是正式为类中定义的变量(被static修改的变量,即静态变量)分配内存并设置类变量初始值的阶段。
      • 从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但是方法区本身是一个逻辑上的区域,JDK7以前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;JDK8及以后,类变量会随着Class对象一起存放在Java堆中,这时候"类变量在方法区"就完全是一种对逻辑概念的表述了。
      • 不包含被final修饰的static,因为final修饰的在编译时期就会分配了,准备阶段显示初始化。
      • 准备阶段不会为实例变量分配初始化,实例变量将会在对象实例化时随着对象一起分配在Java堆。
      • 案例:public static int value = 123;
        • 变量value在准备阶段过后的初始值为0而不是123,因为这时还未执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放于类构造器clinit方法中,所以赋值动作要到类的初始化阶段才会被执行。
    • 解析阶段是Java虚拟机将常量池内的符号引用转换为直接引用的过程。
      • 符号引用:一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的。
      • 直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局直接相关的,同一符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,则引用的目标必定已经在虚拟机的内存中存在。
      • 对同一个符号引用进行多次解析请求是很常见的,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,比如在运行时直接引用常量池中的记录并把常量标识为已解析状态,从而避免解析动作重复进行。如果一个符号引用之前已经成功被解析过,则后续的引用解析请求就应当一直能够成功;如果第一次就解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后面已成功加载进Java虚拟机内存之中。
      • 当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效,因为invokedynamic指令目的就是用于动态语言支持,它对应的引用称为"动态调用点限定符",动态是指必须等到程序实际运行到这条指令时,解析动作才能进行,相对地,其余可触发解析的指令都是"静态"的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
      • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别位于常量池的8种常量类型: CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info
        • 类或接口的解析
          • 假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,则整个解析过程需要包括以下步骤:
          • 如果C不是一个数组类型,那么虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口,一旦这个加载过程出现了任何异常,解析过程就宣告失败。
          • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是"java.lang.Integer",接着由虚拟机生成一个代表该数组维度和元素的数组类型。
          • 如果前面两步没有出现任何异常,那么C在虚拟机中实际上已经成为了一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确定D是否具备对C的访问权限,如果发现不具备访问权限,则会跑出java.lang.IllegalAccessError异常。
        • 字段解析:对解析成功的类或接口C进行后续字段的搜索:
          • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
          • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父类接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
          • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
          • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
          • 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
        • 方法解析:如果解析步骤成功,我们用C表示这个类,接下来虚拟机按照如下步骤进行搜索:
          • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常。
          • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
          • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
          • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。
          • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
          • 最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。
        • 接口方法解析
          • 与类的方法解析相反,如果在接口方法表中发现C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
          • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
          • 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
          • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找。
          • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
          • JDK 9之前,接口中的所有方法都默认是public的,所以不存在访问权限问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError异常。
  • 初始化阶段:进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据编码制定的主观计划去初始化类变量和其他资源:

初始化阶段就是执行类构造器方法clinit的过程,clinit方法是Javac编译器的自动生成物

public class ClassInitTest {
    private static int num=1;

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


client方法不需要定义,是Javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来:包含static变量时就会有clinit方法

public class ClassInitTest {
    private static int num=1;
    static {
        num=2;
    }

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


编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问

public class ClassInitTest {
    private static int num=1;
    static {
        num=2;
        number = 20;
        //System.out.println(number); //报错,非法的前向引用
    }
    private static int number = 10;

    public static void main(String[] args) {
        System.out.println(ClassInitTest.number);//10
    }
}


若该类具有父类,Java虚拟机会保证在子类的clinit方法执行前,父类的clinit方法已经执行完毕

public class ClinitTest {
    static class Father {
        public static int A = 1;
        static {
            A = 2;
        }
    }
    static class Son extends Father {
        public static int B = A;
    }
    public static void main(String[] args) {
        System.out.println(Son.B);
    }
}

首先加载ClinitTest时会找到main方法,然后执行Son的初始化
但是Son继承了Father,因此还需要执行Father的初始化,同时将A赋值为2;
通过反编译得到Father的加载过程,首先看到原来的值被赋值成1,然后又被赋值成2返回,这就意味着父类中定义的静态语句块要优于子类的变量赋值操作

虚拟机必须保证一个类的clinit方法在多线程下被同步加锁

public class ClassInitTest {
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程 t1 开始");
            new DeadThread();
        }, "t1").start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程 t2 开始");
            new DeadThread();
        }, "t2").start();
    }
}
class DeadThread {
    static {
        if (true) {
            System.out.println(Thread.currentThread().getName() + "初始化当前类");
            while(true) {
            }
        }
    }
}

//结果
t1线程 t1 开始
t2线程 t2 开始
t2初始化当前类

从上面可以看出初始化后,只能够执行一次初始化,这就是同步加锁的过程
程序卡死原因分析:
两个线程同时加载ClassInitTest类,但ClassInitTest类中静态代码块中有一处死循环
先加载ClassInitTest类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,另一个线程在等待同步锁的释放
所以无论哪一个线程先执行ClassInitTest类的加载,另一个类也不会继续执行

任何一个类声明以后,内部至少存在一个类的构造

public class ClassInitTest {
    //任何一个类声明以后,内部至少存在一个类的构造器
    private int a=1;
    private static int c=3;
    public static void main(String[] args) {
        int b=2;
    }

    public ClassInitTest(){
        a=10;
        int d=20;
    }
}

类加载器分类

类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每个类加载器都拥有一个独立的类名称空间

比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

public class ClassLoaderTest {
    public static void main(String[] args) {
        //获取系统类加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        //获取其上层的:扩展类加载器:sun.misc.Launcher$ExtClassLoader@1540e19d
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

        //试图获取根加载器:null
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);

        //获取自定义加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);

        //获取String类型的加载器:null
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1);
    }
}
  • 启动类加载器
public class ClassLoaderTest {
    public static void main(String[] args) {

        System.out.println("***************启动类加载器***************");
        URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        for (URL element:urLs){
            System.out.println(element.toExternalForm());
        }
        //从上面路径中随意选择一个类,看它的加载器:classLoader = null
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println("classLoader = " + classLoader);
    }
}
//输出结果:
***************启动类加载器***************
file:/D:/Java/jdk1.8/jre/lib/resources.jar
file:/D:/Java/jdk1.8/jre/lib/rt.jar
file:/D:/Java/jdk1.8/jre/lib/sunrsasign.jar
file:/D:/Java/jdk1.8/jre/lib/jsse.jar
file:/D:/Java/jdk1.8/jre/lib/jce.jar
file:/D:/Java/jdk1.8/jre/lib/charsets.jar
file:/D:/Java/jdk1.8/jre/lib/jfr.jar
file:/D:/Java/jdk1.8/jre/classes
classLoader = null

启动类加载器负责加载存放在lib目录或者-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按文件名识别)类库加载到虚拟机的内存中

  • 扩展类加载器
public class ClassLoaderTest {
    public static void main(String[] args) {

        System.out.println("***************扩展类加载器***************");
        String extDirs = System.getProperty("java.ext.dirs");
        for(String path:extDirs.split(";")){
            System.out.println(path);
        }
        //从上面路径中随意选择一个类,看它的加载器:classLoader = sun.misc.Launcher$ExtClassLoader@29453f44
        ClassLoader classLoader = CurveDB.class.getClassLoader();
        System.out.println("classLoader = " + classLoader);
    }
}
//输出结果:
***************扩展类加载器***************
D:Javajdk1.8jrelibext
C:WINDOWSSunJavalibext
classLoader = sun.misc.Launcher$ExtClassLoader@29453f44

扩展类加载器负责加载lib/ext目录中或者Java.ext.dirs系统变量所指定的路径中所有的类库

  • 系统类加载器
    应用程序类加载器,负责加载环境变量classpath或系统指定路径下的类库,是程序中默认类加载器,Java中应用类都是由它加载的

  • 用户自定义类加载器:通过继承Java.lang.ClassLoader类的方式实现

    • 用户自定义加载器方式?
      • 通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器。
        • JDK1.2之前:继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类
        • JDK1.2之后: 把自定义类加载逻辑写在findclass()方法中
      • 若没有复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
双亲委派机制

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终到达顶层的启动类加载器
  • 如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
  • 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类则抛出异常

优势

  • 避免类的重复加载,确保一个类的全局唯一性的
    • 使用双亲委派模型的好处是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系;例如类Java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类;如果没有双亲委派模式,都由各个类加载器自行去加载的话,用户可以自己定义一个Java.lang.Object的同类名并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证;
  • 保护程序安全,防止核心API被随意篡改
    • 通过委托方式不会去篡改.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象,不同的加载器加载同一个.class也不是同一个Class对象,保证了Class执行安全​​​​​
protected Class loadClass(String var1, boolean var2) throws ClassNotFoundException {
        synchronized(this.getClassLoadingLock(var1)) {
            Class var4 = this.findLoadedClass(var1);
            if (var4 == null) {
                long var5 = System.nanoTime();

                try {
                    if (this.parent != null) {
                        var4 = this.parent.loadClass(var1, false);
                    } else {
                        var4 = this.findBootstrapClassOrNull(var1);
                    }
                } catch (ClassNotFoundException var10) {
                    ;
                }

                if (var4 == null) {
                    long var7 = System.nanoTime();
                    var4 = this.findClass(var1);
                    PerfCounter.getParentDelegationTime().addTime(var7 - var5);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(var7);
                    PerfCounter.getFindClasses().increment();
                }
            }

            if (var2) {
                this.resolveClass(var4);
            }

            return var4;
        }
    }

由上面代码可以看出:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器,加入父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

Class.forName与ClassLoader.loadClass区别?????

  • Class.forName是一个静态方法,最常用的是Class.forName(String className),根据传入的类的全限定名返回一个Class对象,该方法将Class文件加载到内存的同时,会执行类的初始化:例如Class.forName(“com.test.HelloWorld”);
  • ClassLoader.loadClass是一个实例方法,需要一个ClassLoader对象来调用该方法,该方法将Class文件加载到内存时并不会执行类的初始化,直到这个类第一次使用时才进行初始化,该方法因为需要得到一个ClassLoader对象,所以可以根据需要指定使用哪个类加载:例如:ClassLoader c1=…; c1.loadClass(com.test.HelloWorld);

双亲委派模型破坏

  • 第一次破坏:双亲委派机制是JDK1.2才引入的,JDK1.2之前是没有双亲委派机制的。但是类加载器的概念和抽象类Java.lang.ClassLoader在JDK1.0时就已经出现了,抽象类不能实例化,需要提供具体的子类重写loadClass方法;面对已经存在的用户自定类加载器的代码,为了兼容已有的代码,无法再以技术手段避免loadClass被子类覆盖的可能性;只能JDK1.2之后添加一个新的findClass方法并引导用户编写的类加载逻辑时尽可能去重写这个方法;双亲委派机制是写在loadClass这个方法中的,按照loadClass方法的逻辑,如果父类加载失败,会自动调用自己的findClass方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的;
  • 第二次破坏:双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以成为"基础",是因为它们总是作为被用户代码继承、调用的API存在,如果有基础类型又要调用回用户代码?????Java设计团队引入了一个不太优雅的设计:线程上下文类加载器,这个类加载器可以通过Java.lang.Thread类的setContext-ClassLoader方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器;有了线程上下文类加载器,父类加载器无法处理的就可以通过线程上下文加载器去请求子类加载器完成类加载的行为,这种行为实际上打通了双亲委派模型的层次结构来逆向使用类加载器,违背了双亲委派模型的一般性原则。
  • 第三次破坏:双亲委派模型第三次"被破坏"是由于用户对程序动态性的追求而导致的,例如:代码热替换、模块热部署等;就是希望程序Java应用程序像我们的电脑外设一样,接上鼠标、U盘,不用重启机器就可以立即使用,在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
类初始化情况

Class只有在必须要首次使用时才会被装载,虚拟机不会无条件地装载Class类型,一个类或接口在初次使用前,必须要进行初始化,这里的"使用"指的是主动使用,主动使用Client方法就会被调用,主动情况有以下几种情况:

主动使用

  • 主动使用一:当创建一个类的实例时,比如new关键字、通过反射、克隆、反序列化
public class ActiveUser1 {
    public static void main(String[] args) {
        //1.new的方式:输出结果:"order类的初始化过程",说明调用了Clinit方法
        Order order=new Order();
    }
    //2.序列化的过程
    @Test
    public void test1(){
        ObjectOutputStream oos=null;
        try {
            oos = new ObjectOutputStream(new FileOutputStream("order.dat"));
            oos.writeObject(new Order());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (oos!=null)
                oos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    //3.反序列化过程:输出结果:"order类的初始化过程",说明调用了Clinit方法
    @Test
    public void test2(){
        ObjectInputStream ois = null;
        try {
            ois = new ObjectInputStream(new FileInputStream("order.dat"));
            Order order = (Order) ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } finally {
            try {
                if(ois!=null)
                ois.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
class Order implements Serializable{
    //Clinit方法
    static{
        System.out.println("order类的初始化过程");
    }
}

  • 主动使用二:当调用类的静态方法时
  • 主动使用三:当使用类、接口的静态字段时(final修饰特殊考虑)

  • 主动使用四:使用Java.lang.reflect包中的方法反射类的方法时,例如Class.forName
public class ActiveUser3 {
    @Test
    public void test1(){
        try {
            //反射调用Order类时,输出"order类的初始化过程"说明执行了初始化
            Class clazz = Class.forName("com.Order");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
//order类的初始化过程
  • 主动使用五:当初始化子类时,如果发现其父类还未进行初始化,在先触发父类的初始化。

  • 主动使用六:如果一个接口定义了default方法,那么直接实现或间接实现该接口的类的初始化。
  • 主动使用七:虚拟机启动时,用户需制定一个要执行的主类,虚拟机会先初始化这个主类。

被动使用

被动使用Clinit方法不会被调用

  • 被动使用一:访问一个静态字段时,只有真正声明这个字段的类才会被初始化,当通过子类引用父类的静态变量,不会导致子类初始化
  • 被动使用二:通过数组定义类引用,不会触发该类的初始化
public class PassiveUser1 {
    @Test
    public void test1(){
        //不会调用Parent初始化过程
        Parent[] parents=new Parent[10];
        //class [Lcom.Parent;
        System.out.println(parents.getClass());
        //class java.lang.Object
        System.out.println(parents.getClass().getSuperclass());
        //输出结果:Parent初始化过程,new调用初始化过程
        parents[0]=new Parent();
    }
}
class Parent{
    static {
        System.out.println("Parent初始化过程");
    }
    public static int num=1;
}
  • 被动使用三:引用常量不会触发此类或接口的初始化,因为常量在链接阶段就已经显式赋值了
public class PassiveUser1 {
    @Test
    public void test1(){
        //输出结果"Person初始化过程"和"1"
        System.out.println(Person.num);
        //输出结果为1
        System.out.println(Person.num2);
    }
}
class Person{
    static {
        System.out.println("Person初始化过程");
    }
    public static int num=1;
    //在链接过程的准备环节就被赋值为1了
    public static final int num2=1;
}
  • 被动使用四:调用ClassLoader类的LoadClass方法加载一个类不是主动使用,不会导致类的初始化
public class PassiveUser1 {
    @Test
    public void test1(){
        try {
            //不会输出"Person初始化过程",即不会初始化类
            Class loadClass = ClassLoader.getSystemClassLoader().loadClass("com.Person");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
class Person{
    static {
        System.out.println("Person初始化过程");
    }
    public static int num=1;
    public static final int num2=1;
}

如何判定一个类型是否属于"不再被使用的类"?????

  • 该类所有的实例都已经被回收
  • 加载该类的类加载器已经被回收
  • 该类对应的Java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

类加载方式

  • 显示加载:通过class.forname()等方法显示加载需要的类。
  • 隐式加载:程序在运行过程中当碰到通过new等方式生成对象时,隐式调用类装载器加载到对应的类到JVM中。

Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类完全加载到JVM中,至于其他类则在需要的时候才加载,这样可以节省内存开销。

运行时数据区

程序计数器

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

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

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native本地方法,这个计数器值则应为空(Undefined),此内存区域没有规定任何OutOfMemoryError情况的区域和GC垃圾回收。

程序计数器为什么设定为私有的?????

多线程在一个特定的时间段内只会执行其中某一线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每个线程都分配一个PC寄存器,这样各个线程之间可以进行独立计算,不会出现互相干扰的情况;

虚拟机栈

虚拟机栈是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量、操作数栈、动态链接、方法出口等信息,每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 如果固定大小的虚拟机栈,那每一个线程虚拟机栈容量可以在线程创建时独立选定时,如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,虚拟机栈会抛出一个StackOverflowError异常。
  • 如果虚拟机可以动态扩展并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么虚拟机将会抛出OutOfMemoryError异常。
  • 设置栈内存大小:参数 -Xss选项来设置线程的最大栈空间,栈的大小决定了函数调用的最大可达深度。

    每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的。线程上正在执行的每个方法都各自对应一个栈帧。栈帧是一个内存块,是一个数据集,维系着方法执行过程中的各种数据信息。

运行原理

  • JVM直接对栈的操作只有两个:就是栈帧的"压栈"和"出栈",遵循"先进后出"、"后进先出"原则
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行方法的栈帧;栈顶栈帧是有效的,即当前栈帧;与当前栈帧相对应的方法就是当前方法;定义这个方法的类就是当前类
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • 如果在该方法中调用了其它方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为新的当前栈帧
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧
  • 如果当前方法调用了其它方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
  • Java方法有两种返回函数的方式:一种是正常的函数返回,使用return指令;另一种是抛出异常,不管使用哪种方式,都会导致栈帧被弹出

内部结构

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用类型(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其它与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

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

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

方法嵌套调用的次数由栈的大小决定,栈越大方法嵌套调用次数越多。对于一个参数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧越大,以满足方法调用所需传递的信息增大的需求,进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会越少

局部变量表中的变量只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量的传递过程;当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

变量槽

  • 局部变量表的容量以变量槽位最小单位
  • 参数值的存放总是从局部变量数组索引0的位置开始,到数组长度-1的索引结束
  • 局部变量表里,32位以内的类型只占用一个slot,64位的类型占用两个slot
public class LocalVariablesTest {

   public String test2(Date dateP, String name2) {
       dateP = null;
       name2 = "songhongkang";
       double weight = 130.5;//占据两个slot
       char gender = '男';
       return dateP + name2;
   }
}

  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
  • 如果需要访问局部变量表中一个64bit的局部变量值时(占用两个索引),只需要使用前一个索引即可
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处(如上图索引为0处),其余的参数按照参数表顺序继续排列。

变量槽的重复利用
变量槽是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的

public class LocalVariablesTest {
    public void test4() {
        int a = 0;
        {
            int b = 0;
            b = a + 1;
        }
        //变量c使用之前已经销毁的变量b占据的slot的位置
        int c = a + 1;
    }
}


变量分类

  • 按照数据类型分:基本数据类型和引用数据类型
  • 按照类中声明位置分:成员变量(类变量、实例变量)和局部变量
  • 类变量:Linking的准备阶段给类变量默认赋值,初始化阶段给类变量显示赋值
  • 实例变量:随着对象创建会在堆空间中分配实例变量空间并进行默认赋值
  • 局部变量:在使用前必须进行显示赋值,否则编译不通过

类变量有两次初始化的机会:一次是在"准备阶段",执行系统初始化,对类变量设置零值;另一次则是在"初始化"阶段,赋予代码中定义的初始值。

局部变量表不存在系统初始化的过程,这意味着一旦定义局部变量则必须人为的初始化,否则无法使用。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

操作数栈

操作数栈是一个后进先出的栈,栈的最大深度在编译时期被写入Code属性的max_stacks数据项中。方法刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,即出栈和入栈操作。

操作数栈作用

  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
  • Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

操作数栈案例

首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入栈

执行完第一条语句后,让PC+1指向下一行代码:就是将操作数栈的元素存储到局部变量表索引1的位置
为什么存储到局部变量索引1的位置?
因为该方法为实例方法,局部变量表索引为0的位置存放的是this

然后PC+1,指向的是下一行,让操作数8也入栈,同时执行store操作,存入局部变量表中


然后从局部变量表中依次将数据放在操作数栈中等待执行add操作


然后将操作数栈中的两个元素执行相加操作并存储在局部变量表3的位置


最后PC寄存器的位置指向10,也就是return方法,则直接退出方法

操作数栈方法调用

public class OperandStackTest {
    public void testAddOperation(){
        byte i=15;
        int j=8;
        int k=i+j;
    }
    public int getSum(){
        int m = 10;
        int n = 20;
        int k = m + n;
        return k;
    }
    public void testGetSum(){
        //获取上一个栈桢返回的结果,并保存在操作数栈中
        int i = getSum();
        int j = 10;
    }
}



总结:如果被调用的方法带有返回值的话,其返回值会将被压入当前栈帧的操作数栈中并更新PC寄存器中下一条需要执行的字节码指令

栈顶缓存技术

  • 由于操作数是存储在内存的,因此频繁地执行内存读/写操作必然会影响执行速度,为了解决这个问题,提出了栈顶缓存技术:将栈顶元素全部缓存在物理CPU的寄存器中(指令少,执行速度快),以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时就被转化为直接引用,这种转化成为静态解析;另一部分将在每一次运行期间都转化为直接引用,这部分成为动态链接。

案例代码追踪

public class DynamicLinkingTest {

    int num = 10;

    public void methodA(){
        System.out.println("methodA()....");
    }

    public void methodB(){
        System.out.println("methodB()....");
        methodA();
        num++;
    }
}

  • 在字节码指令中methodB()方法中通过invokevirtual #7;指令调用了方法A,那么#7是什么呢?
  • 找到常量池的定义:#7=Method #8.#31
  • 先找#8
    • #8=Class #32
    • 去找#32: #32=com/DynamicLinkingTest;
    • 结论:通过#8找到了DynamicLinkingTest这个类
  • 再找#31
    • #31=NameAndType #19:#13;// methodA:()V
    • 找#19和#31
    • #19= Asciz methodA;方法名为MethodA
    • #13=Asciz ()V;方法没有形参,返回值为void
  • 通过#7就能找到需要调用的MethodA()方法并进行调用

为什么要用常量池?????

  • 在不同的方法都可能调用常量或者方法,所以只需要存储一份即可,然后记录其引用,节省空间。
  • 常量池作用:提供了一些符号和常量,便于指令的识别

动态链接与静态链接

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程。
  • 动态链接:如果被调用的方法在编译期无法确定下来,只能够在程序运行期间将调用的符号引用转换为直接引用,由于这种引用转换过程具备动态性,所以称为动态链接。
  • 静态类型的语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息。

早期与晚期绑定

  • 早期绑定:静态链接的机制是早期绑定是指被调用的目标方法如果在编译期可知且运行期保持不变,即可将这个方法所属类型进行绑定,由于明确了被调用了目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定:动态链接的机制是晚期绑定,如果被调用的方法在编译期无法确定下来,只能够在运行期根据实际的类型绑定相关的方法。
  • 绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,仅仅发生一次。

虚和非虚方法

  • 只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本。Java中静态方法、私有方法,final修饰的方法(尽管是被invokevirtual指令调用),这5种方法调用会在类加载时就可以把符号引用解析为该方法的直接引用,这些方法统称为"非虚方法",其它的方法为虚方法。

普通调用指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法(final修饰除外)
  • invokeinterface:调用接口方法

动态调用指令:

  • invokedynamic:动态解析出需要调用的方法,然后执行

方法重写本质:

  • 找到操作数栈顶的第一个元素所执行的对象的实际类型记作C
  • 如果在类型C中找到与常量中的描述和简单名称都相符的方法,则进行访问权限校验
    • 如果通过则返回这个方法的直接引用,查找过程结束
    • 如果不通过,则返回java.lang.IllegalAccessError异常
  • 否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

虚方法表

  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率
  • 因此,为提高性能,JVM采用在类的方法区建立一个虚方法表来实现,非虚方法不会出现在表中使用索引表来代替查找
  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口
  • 虚方法表是什么时候被创建的呢?虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的虚方法表也初始化完毕
  • 如图所示:如果类中重写了方法,那么调用的时候,就会直接在该类的虚方法表中查找
方法返回地址

当一个方法开始执行后,有两种方式退出这个方法:

  • 执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为"正常调用完成"。
  • 另一种退出方式是在方法执行的过程中遇到异常并且这个异常没有在方法体内得到妥善的处理。无论是Java虚拟机内部产生的异常,还是代码中athrow子节码指令产生的异常,只要在本地方法的异常表中没有搜到匹配的异常处理器,就会导致方法退出,这种退出方式称为"异常调用完成",一个方法使用异常完成出口的方式退出是不会给它的上层调用者提供任何返回值的。

无论以哪种方式退出,在方法退出后都返回到该方法被调用的位置;方法正在退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址值;方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中就一般不会保存这部分的信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。只有具体到某一款Java虚拟机实现,会执行哪些操作才会确定下来。

堆是虚拟机所管理的内存中最大的一块内存空间并且被所有线程共享,在虚拟机启动时创建并确定内存大小(大小是可以调节的,主流虚拟机都是按可扩展实现的,通过参数-Xmx和-Xms设定),此内存区域的唯一目的就是存放对象实例,"几乎"所有的对象实例都是在这里分配内存的,从实际使用角度看,还有一些对象是在栈上分配的(逃逸分析)。

堆是GC执行垃圾回收的重点区域。

堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。像我们使用磁盘空间存储文件一样,并不要求每个文件都连续存放,但对于大对象,多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

从分配内存的角度看,所有线程共享的堆中可以划分出多个线程私有的分配缓存区(TLAB),以提升对象分配时的效率。

问题:堆空间都是共享的吗?????

  • 不到一定,因为还有TLAB,在堆中划分出一块区域,为每个线程所独占。

问题:为什么要有TLAB?????

  • 堆区是线程共享区域,任何线程都是可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间时不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,但会影响分配速度

TLAB说明:

  • 从内存模型对Eden区进行划分,JVM为每个线程分配了一个私有缓存区,它包含在Eden空间内
  • 多线程同时分配内存时,使用TLAB可以避免一系列线程安全问题并且还可以提高内存分配的吞吐量,将这个内存分配方式成为快速分配策略
  • 并不是所有的对象实例都能够在TLAB中成功分配内存
  • 可以通过-XX:UserTLAB设置是否开TLAB空间
  • 默认TLAB内存空间非常小,仅占整个Eden空间1%,可以设置参数XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而在Eden空间中分配内存

Java7及之前堆内存逻辑分为三部分:新生区+养老区+永久区
Java8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

堆内存大小设置

  • -Xms表示堆区的起始内存;-Xmx表示堆区的最大内存
  • 一旦堆区的内存大小超过-Xmx所指定的最大内存将会抛出

OutOfMemoryError异常

  • 通常将-Xms和-Xmx配置相同的值,目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
  • 默认情况下:初始内存大小:物理电脑内存大小/64;最大内存大小:物理电脑内存大小/4

年轻代与老年代相关参数的设置?????

  • 年轻代:Eden区、Survivor0和Survivor1,有时也叫From区和To区
  • 一般新生代:老年代为1:2,Eden:From:To为8:1:1(可以通过参数-XX:SurvivorRatio调整)
  • 默认-XX:NewRatio=2:表示新生代占1,老年代占2,当发现整个项目中生命周期长的对象偏多就可以通过调整老年代的大小来进行调优
  • 在HotSpot中Eden:From:To所占比例是8:1:1,实际是6:1:1???这是因为存在自适应机制(-XX:-UseAdaptiveSizePolicy +启用;-禁用),但这种方法一般不能生效,所以采用-XX:SurvivorRatio=8
对象分配

对象分配过程

  • 对象优先分配在伊甸园区,当Eden区放满后没有足够的空间放置新的对象时,虚拟机开始执行一次垃圾回收(YGC或Minor GC)
  • 当进行一次垃圾回收后,将伊甸园区中不再被其他对象所引用的对象进行销毁(红色的对象会被回收)
  • 将伊甸园区还在被占用的对象(绿色的对象)移动到幸存者S0(Survivor From)区
  • 虚拟机为每个对象定义了一个年龄计数器,如果对象经过了一次Minor GC进入到幸存者S0区后,将其年龄加1
  • 注意:From区和To区:谁空谁是To区

  • 同时Eden区继续存放对象,当Eden区再次存满时,再次触发Minor GC操作,此时GC会将Eden和Survivor From区中的对象再一次进行垃圾回收,把存活对象放到S1(Survivor To)区,同时让存活对象的年龄加1

  1. 继续不断进行对象生成和垃圾回收,当Survivor中的对象年龄达到15时,将会触发一次Promotion晋升操作(将年轻代中的对象晋升到老年代中)
  2. 啥时候能去养老区呢?可以设置次数:默认是15 可设置参数:-XX:MaxTenuringThreshold=


内存分配策略

  1. 对象优先分配到Eden区
    1. 大多数情况下,对象在新生代Eden区中分配。
  2. 大对象直接进入老年代
    1. 大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发垃圾收集,以获取足够的连续空间来安置新对象,当复制对象时,大对象意味着高额的内存复制开销。
    2. 新生代使用的是标记清除算法处理垃圾回收的,若大对象直接在新生代分配会导致Eden区和Survivor区之间发生大量的内存复制。
    3. HotSpot虚拟机中-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配。
  3. 长期存活对象将进入到老年代
    1. 虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头中。
    2. 对象通常在Eden区诞生,如果经过第一次新生代收集后仍然存活并且能被Survivor区容纳,该对象会移动到Survivor空间中并将其对象年龄设为1岁。
    3. 对象在Survivor区每熬过一次新生代收集,年龄就会增加1,当年龄增加到一定程度(默认15),就会被晋升到老年代。
  4. 动态对象年龄判断
    1. HotSpot虚拟机并不是永远要求对象的年龄必须达到参数设置的值才能晋升到老年代。
    2. 如果幸存者区中相同年龄的所有对象大小的总和大于幸存者区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
  5. 空间分配担保
    1. 在发生新生代收集之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
    2. 如果大于,则这次新生代收集可以确保是安全的。
    3. 不成立的话,虚拟机会先查看XX:HandlePromotionFailure参数设置的值是否允许担保失败,如果允许,则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次新生代收集,尽管这次新生代收集是有风险的;如果小于或者参数设置的是不允许冒险,这时就要改为进行一次整堆收集(Full GC)。
堆分代思想

其实不分代完全可以,分代的唯一理由就是优化GC性能,如果没有分代,所有的对象都在一块,就如同把一个学校的人都关在一个教室,GC时要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC时先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

逃逸分析

堆是分配对象的唯一选择吗?????

  • 对象是在Java堆中分配内存的,但有一种特殊情况如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就无需在堆上分配内存,也无需进行垃圾回收了,这就是常见的堆外存储技术。
  • 逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,这种称为方法逃逸;甚至还有可能被外部线程访问到,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
  • 开发中能使用局部变量的就不要在方法外定义
代码优化-栈上分配

堆上分配创建对象的内存空间,堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那可以让这个对象在栈上分配内存将是一个不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁,在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占比例是很大的。如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力会下降很多,栈上分配可以支持方法逃逸,但是不能支持线程逃逸。

案例:关闭逃逸分析:执行时间长;内存实例对象为遍历添加次数


案例:开启逃逸分析:执行时间短;内存中添加的对象大量减少


案例:将内存参数从1G改为256,关闭逃逸分析情况:发生GC,执行时间长

案例:将内存参数从1G改为256,开启逃逸分析情况:未发生GC,执行时间短

代码优化-同步省略

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只能够被一个线程访问而没有被发布到其它线程,如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这个取消同步的过程叫做同步省略,也叫锁消除。

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中并不会被其它线程所访问到,所以JIT编译阶段就会被优化掉。

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

所以在使用synchronize的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。

代码优化-标量替换

标量是指一个无法再分解成更小的数据的数据;Java中的原始数据类型就是标量,相对的那些还可以分解的数据就叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量,在JIT阶段如果经过逃逸分析发现一个对象不会被外界访问的话,那么经过JIT优化就会把这个对象拆解成若干个其中包含的若干个成员变量来替换,这个过程就是标量替换。

public static void main(String[] args) {
   alloc();
}
private static void alloc() {
   Point point = new Point(1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

以上代码Ponit对象并没有逃逸出alloc方法并且point对象是可以拆解成标量的,那么JIT就不会直接创建Point对象,而是直接使用两个标量int x、int y来替代Ponit对象。

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

可以看到Point这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个聚合量了,这样可以大大减少堆内存的占用,因为一旦不需要创建对象了,那么就不需要分配堆内存了,为栈上分配提供了基础。

方法区

  • Person:存放在元空间,也可以说方法区
  • person:存放在Java栈的局部变量表中
  • new Person():存放在 Java 堆中

方法区是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译期编译后的代码缓存等数据。和Java堆一样它的实际的物理内存空间可以是不连续的(物理上可以不连续,逻辑上连续)。

方法区的大小可以选择固定大小或者可扩展,方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError:Metaspace

JVM启动时,方法区创建,JVM关闭时就是释放这个区域的内存。

HotSpot方法区演进

  • JDK7及以前习惯上把方法区称为永久代;JDK8开始使用元空间取代了永久代;本质上方法区和永久代并不等价
  • 方法区类比作Java中的接口,永久代或元空间比作Java中具体的实现类
  • 元空间与永久代最大的区是:元空间不在虚拟机设置的内存中,而是使用本地内存。

方法区大小的设置

  • 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,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会重置,新的高水位线的值取决于GC后释放了多少元空间,如果释放的空间不足,那么在不超过 MaxMetaspaceSize时,适当提高该值,如果释放空间过多,则适当降低该值,如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收器的日志可以观察到 Full GC 多次调用,为了避免频繁地GC,建议将 -XX:MetaspaceSize设置为一个相对较高的值

如何解决OOM异常

  • 要解决OOM 异常或Heap Space的异常,一般是首先通过内存映像分析工具对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出
  • 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和 GC ROOT 有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题
  • 内存泄漏得不到解决,从而占据满整个内存空间就会造成内存溢出
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的,掌握了泄漏对象的类型信息以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置
  • 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms)与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗

方法区的内部结构


类型信息

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

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

域信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序,域的相关信息包括:

  • 域名称
  • 域类型
  • 域修饰符(public、private、protected、static、final、volatile、transient的某个子集)

方法信息

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

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

类型信息

  • 在运行时方法区,类信息中记录了哪个加载器加载了该类同时类加载器也记录了它加载了哪些类
  • 从反编译文件可以看出字节码文件记录继承了哪些类和实现了哪些方法
Compiled from "MethodInnerStrucTest.java"

/
    public Object pop(){
        if (size==0){
            throw new EmptyStackException()
        }
        Object result=elements[--size];
        elements[size]=null;
        return result;
    }
    public void ensureCapacity(){
        if (elements.length==size){
            elements= Arrays.copyOf(elements,2*size+1);
        }
    }
}

如果一个栈先是增长,然后再收缩,从栈中弹出来的对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收。因为栈内部维护着对这些对象的过期引用。过期引用指永远也不会再被解除的引用。

如果一个对象引用被无意识地保留了,垃圾回收机制不仅不会回收这个对象,而且不会回收被这个对象所引用的所有其他对象。

解决方法:一旦对象引用已经过期,只需清空这些引用即可。在本例中,只要一个元素被弹出栈,指向它的引用就过期了。

push时

pop时,当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的

从上图中看以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些对象,它们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。

Stop The World

STW:指的是GC事件发生过程中会产生应用程序的停顿,停顿时整个应用程序线程都会被暂停,没有任何响应。STW事件和采用哪款GC无关,所有的GC都有这个事件,哪怕G1也不能完全避免STW情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能的缩短暂停时间。

并发与并行概念

并发

  • 在操作系统中是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发并不是真正意义上的"同时进行",只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这个几个时间区间之间来回切换。
  • 由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
  • 指的是多个事情在同一时间段内同时发生了,并发的多个任务之间是相互抢占资源的。

并行

  • 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个线程,两个进程互不抢占CPU资源,可以同时进行。
  • 决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
  • 指的是多个事情在同一时间点上同时发生了并行的多个任务之间是不互相抢占资源的;只有在多CPU或者一个CPU多核的情况中才会发现并行。
安全点概念
  • 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定位置才能停顿下来开始GC。
  • 安全点的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。
  • 大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准,比如:选择一些执行时间较长的指令作为Safe Point:如方法调用、循环跳转和异常跳转等。

安全点中断实现方式

如何在GC发生时检查所有线程都跑到最近的安全点停顿下来呢?????

  • 抢先式中断:(目前没有虚拟机采用了)首先中断所有线程,如果还有线程不在安全点就恢复线程,让线程跑到安全点。
  • 主动式中断:设置一个中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真则将自己进行中断挂起(有轮询的机制)。

安全区域

  • 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点,但是程序"不执行"时呢?
  • 例如程序处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,"走"到安全点去中断挂起,JVM也不太可能等待线程被唤起。
  • 对于这种情况,就需要安全区域来解决,安全区域是指在一段代码片段中对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的,可以把安全区域看做是被扩展了的安全点。

安全区域执行流程

  • 当线程运行到安全区域的代码时,首先标识已经进入了安全区域,如果这段时间内发生GC,JVM会忽略标识为安全区域状态的线程。
  • 当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开安全区域的信号为止。
JVM中的引用概述
  • 强引用
    程序代码中普遍存在的引用赋值,例如Object obj=new Object();无论任何情况下,只要引用关系还在,垃圾收集器就永远不会回收掉被引用的对象;即使在内存不足的情况下,JVM宁愿抛出OOM错误也不会回收这种对象;强引用是造成内存泄漏的主要原因之一。
  • 软引用
    在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收;如果这次回收后还没有足够的内存,才会抛出内存溢出的异常;内存足够时不会回收软引用可达的对象,内存不够时,会回收软引用的可达对象。
  • 弱引用
    只被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾收集器线程扫描到它所管辖的内存区域的过程中,无论内存空间是否足够,都会回收掉被弱引用关联的对象;软引用、弱引用都非常适合来保存那些可有可无的缓存数据,如果这么做:当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出,当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
  • 虚引用
    如果一个对象仅持有虚引用,那么它就没没有任何引用一样,在任何时候都可能被垃圾回收器回收;虚引用主要就是用来跟踪对象被垃圾回收器回收的活动,由于虚引用可以跟踪对象的回收时间,所以可以将一些资源释放操作放置在虚引用中执行和记录。
public class PhantomReferenceTest {
    //当前类对象的声明
    public static PhantomReferenceTest obj;
    //引用队列
    static ReferenceQueuephantomQueue=null;
    public static class CheckRefQueue extends Thread{
        @Override
        public void run(){
            while (true){
                if (phantomQueue!=null){
                    PhantomReferenceobjt=null;
                    try {
                        objt = (PhantomReference) phantomQueue.remove();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    if (objt!=null){
                        System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
                    }
                }
            }
        }
    }
    @Override
    protected void finalize() throws Throwable{
        super.finalize();
        System.out.println("调用当前类的finalize方法");
        obj=this;
    }
    public static void main(String[] args) {
        CheckRefQueue t1 = new CheckRefQueue();
        t1.setDaemon(true);
        t1.start();
        phantomQueue = new ReferenceQueue();
        obj = new PhantomReferenceTest();
        //构造了PhantomReferenceTest 对象的虚引用并指定了引用队列
        PhantomReference phantom = new PhantomReference<>(obj, phantomQueue);
        try{
            //获取不到虚引用的对象
            System.out.println(phantom.get());
            //将强引用去除
            obj=null;
            //第一次进行GC,由于对象可复活,GC无法回收该对象
            System.gc();
            Thread.sleep(1000);
            if (obj==null){
                System.out.println("obj 是null");
            }else{
                System.out.println("obj 可用");
            }
            System.out.println("第2次GC");
            obj=null;
            //一旦将obj对象回收,就会将虚引用存放到引用队列中
            System.gc();
            if (obj==null){
                System.out.println("obj 是null");
            }else{
                System.out.println("obj 可用");
            }
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}
//输出结果
null
调用当前类的finalize方法
obj 可用
第2次GC
obj 是null
追踪垃圾回收过程:PhantomReferenceTest实例被GC了
垃圾回收算法

垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾。如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。

GC除了释放没用的对象也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。

垃圾回收主要关注于方法区和堆中的垃圾收集。堆是垃圾收集器的工作重点:频繁收集Young区、较少收集Old区、基本不收集元空间。

堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会在执行垃圾回收时释放掉其占用的内存空间,这个过程称为垃圾标记阶段;

当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。判断对象存活一般有两种:引用计数算法和可达性分析算法

引用计数算法

引用计数算法对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况 。每当有一个地方引用该对象时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,可进行回收。

public class ReferenceCountingGC {
    //这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    Object reference = null;

    public static void main(String[] args) {
        ReferenceCountingGC obj1 = new ReferenceCountingGC();
        ReferenceCountingGC obj2 = new ReferenceCountingGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;

        //显式的执行垃圾回收行为,这里发生GC,obj1和obj2能否被回收?
        // System.gc();
    }
}


如上图可知:不进行垃圾回收时,Eden区占用率为25%

如上图:Eden区占用率仅为3%,说明进行了垃圾回收,所以Java中使用的不是引用计数算法(如果采用的是引用计数算法,obj1和obj2存在相互引用,不应该被回收)。

优点:实现简单、垃圾对象便于辨识、判断效率高、回收没有延迟性。

缺点:需要单独字段存储计数器,增加了存储空间的开销;每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销;无法处理循环引用的情况;

可达性垃圾回收算法
  • 以根对象集合为起始点,按照从上至下的方式搜素被根对象集合所连接的目标对象是否可达。
  • 内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链。
  • 若目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

优点

可达性算法具有简单和执行高效的特点,并且可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

注意:不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程,两次标记后仍然是可回收对象则将面临回收。

GC Roots

  • 虚拟机栈中引用的对象:比如各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内引用的对象方法区中类静态属性引用的对象:比如Java类的引用类型静态变量。
  • 方法区中常量引用的对象:比如:字符串常量池里的引用
  • 所有被同步锁持有的对象
  • Java虚拟机内部的引用
  • 基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器

总结:堆空间外的一些结构,比如虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的都可以作为GC Roots进行可达性分析。

注意:如果要使用可达性分析算法来判断内存是否可回收,分析工作必须在一个能保证一致性(指整个分析期间整个执行系统看起来像被冻结在某个时间点上)的快照中进行,如果不满足的话分析结果的准确性就无法保证;这点也是导致GC进行时必须STW的重要原因,即使号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下把用户正常的工作线程全部停掉。

对象的Finalization机制

对象的三种可能状态:

  • 如果从所有根节点都无法访问到某个对象,说明对象已经不再使用,一般来说此对象需要被回收。
  • 但也并非"非死不可",它们暂时处于"缓刑"阶段,一个无法触及的对象有可能在某一个条件下"复活"自己,所以对它立即回收是不合理的。
  • 由于finalize()方法的存在,虚拟机对象一般处于三种可能的状态。
    • 可触及的:从根节点开始,可以达到这个对象
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活
    • 不可触及的:对象finalize被调用并且没有复活,则会进入不可触及状态,不可触及的对象不可能被复活,因为finalize只会被调用一次。
  • 以上3种状态是由于finalize方法的存在进行的区分,只有在对象不可触及时才可以被回收。

Finalize具体执行过程

判断一个对象是否可回收,至少要经历两次标记过程

  • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,则进行第一次标记
  • 随后进行一次筛选,判断此对象是否有必要执行finalize方法。
    • 假如对象没有重写finalize方法,或者finalize方法已经被虚拟机调用,则虚拟机视为"没有必要执行",对象被判定为不可触及的。
    • 假如对象重写了finalize方法但还未执行过,那么对象将会被插入到F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去触发其finalize方法。
    • 这里所说的"执行"是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束,因为如果某个对象的finalize方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
  • finalize方法是对象逃脱死亡的最后机会。稍后GC会对F-Queue队列中的对象进行第二次小规模标记,如果对象在finalize方法中与引用链上的任何一个对象建立联系,那么第二次标记时它将被移出"即将回收"的集合。
  • 如果对象再次出现没有引用的情况,finalize方法不会被再次调用,对象直接变成不可触及的状态,即一个对象的finalize只会被调用一次。
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK=null;

    public void isAlive(){
        System.out.println("Yes,I am still alive");
    }

    protected void finalize() throws Throwable{
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK=this;
    }

    public static void main(String[] args) {
        try{
            SAVE_HOOK=new FinalizeEscapeGC();

            //对象第一次成功拯救自己
            SAVE_HOOK=null;
            //调用垃圾回收器
            System.gc();
            System.out.println("第一次 gc");
            //因为Finalizer线程优先级很低,暂停2秒等待它
            Thread.sleep(2000);
            if (SAVE_HOOK==null){
                System.out.println("SAVE_HOOK is dead");
            }else{
                System.out.println("SAVE_HOOK is still alive");
            }
            System.out.println("第二次 gc");
            //这次自救却失败了
            SAVE_HOOK=null;
            System.gc();
            //因为Finalizer线程优先级很低,暂停2秒等待它
            Thread.sleep(2000);
            if (SAVE_HOOK==null){
                System.out.println("SAVE_HOOK is dead");
            }else{
                System.out.println("SAVE_HOOK is still alive");
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}


当不执行finalize方法时第一次就直接死亡

当执行finalize方法时,对象会进行一次自救。

标记清除算法

  • 当堆中的有效内存空间被耗尽时,就会停止整个程序,然后进行两项工作:标记和清除。
  • 标记:垃圾回收器从引用根节点开始遍历,标记所有被引用的对象。
    • 一般是在对象的Header中记录为可达对象
    • 标记的是引用对象,不是垃圾
  • 清除:垃圾回收器对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象则将其回收。

清除并不是真的置空而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,够就存放覆盖原有的地址。

  • 如果内存规整:采用指针碰撞的方式进行内存分配
  • 如果内存不规整:虚拟机需要维护一个空闲表,采用空闲列表分配内存

算法缺点

  • 执行效率不稳定:需要进行遍历
    • Java堆中包含大量对象并且其中大部分是需要回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间的碎片化
    • 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的练习内存而不得不提前触发另一次垃圾收集动作。
标记复制算法


算法原理

  • 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
  • 垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象
    • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
    • 但是对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象
  • 每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只需要移动堆顶指针,按顺序分配即可

优点

  • 没有标记和清除过程,实现简单、运行高效
  • 复制过去以后保证空间的连续性,不会出现碎片问题

缺点

  • 需要两倍的内存空间
  • 对于G1这种拆分为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管内存占用或者时间开销也不小
标记整理算法

算法原理

  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下,这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象
  • 如果依然使用复制算法,由于存活对象较多,复制的成本也将很高
  • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片

执行过程

  • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,之后清理边界外所有的空间

标记清除和标记整理区别

  • 标记整理算法最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理
  • 标记清除算法是一种非移动式的回收算法;标记整理算法是移动式的
  • 标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉;当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,比维护一个空闲列表少很多开销

指针碰撞

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时只需要通过修改指针的偏移量将新的对象分配在第一个空闲内存位置上,这种分配方式叫做指针碰撞

优点

  • 消除了标记清除算法当中内存区域分散的缺点,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
  • 消除了复制算法当中内存减半的高额代价

缺点

  • 从效率上来说:标记-整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序
分代收集理论

分代收集原因

  • 分代收集算法是基于这样一个事实:不同的对象的生命周期是不一样的,因此不同生命周期的对象可以采取不同的收集方式以便提高回收效率。
  • 一般是把Java堆分为新生代和老年代这样就可以根据各个年代的特点使用不同的回收算法。
  • 在程序运行的过程中会产生大量的对象,有些对象是与业务信息相关
    • 例如Http请求中的 Session对象、线程、Socket 连接这类对象跟业务直接挂钩,因此生命周期比较长。
    • 但还有一些对象主要是程序运行过程中生成的临时变量,例如String对象由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

分代收集依据

目前几乎所有的GC都采用分代收集算法执行垃圾回收的。

在HotSpot中基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代:区域相对老年代较小、对象生命周期短、存活率低、回收频繁
    • 这种情况使用复制算法的回收整理速度是最快的,复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收,复制算法内存利用率不高的问题通过HotSpot中的两个 Survivor的设计得到缓解。
  • 老年代:区域较大、对象生命周期长、存活率高、回收不及年轻代频繁
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适,一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

增量收集算法

在垃圾回收过程中应用软件将处于一种Stop the World的状态,该状态下应用程序所有的线程都会挂起,暂停一切正常的工作等待垃圾回收的完成

如果垃圾回收时间过长,应用程序会被挂起很久将严重影响用户体验或者系统的稳定性,为解决这个问题导致了增量收集算法的诞生。

思想:

  • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行,每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成;
  • 总的来说增量收集算法的基础仍是传统的标记-清除和复制算法,增量收集算法通过对线程间冲突的妥善处理允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

使用这种方式由于在垃圾回收过程中间断性地还执行了应用程序代码,所以能减少系统的停顿时间,但是因为线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升造成系统吞吐量的下降。

分区算法

一般来说在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关 GC 产生的停顿也越长,为更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间,每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间 。

垃圾收集器


两个收集器间有连线,表明它们可以搭配使用。

红色虚线:表示在JDK8组合声明为废弃;JDK9中取消了这些组合的支持。

绿色虚线:JDK14中已经弃用。

青色虚线:JDK14中删除CMS垃圾回收器。

Serial收集器

  • Serial收集器曾是JDK1.3之前虚拟机新生代收集器的唯一选择。
  • Serial是一个单线程收集器,采用复制算法,在进行垃圾收集时,必须暂停其它所有工作线程,直到收集结束。
  • Serial是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,Serial简单高效(与其它单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。
  • Serial收集器没有线程交互的开销,垃圾收集时可以获得最高的单线程收集效率。
Serial Old收集器
  • Serial Old是Serial收集器的老年代版本,是单线程收集器和支持STW机制,回收算法采用标记-整理算法,主要是供客户端模式下HotSpot虚拟机使用。
  • 服务端模式显有两种用途
    • 一是JDK5以及以前版本中与新生代的Parallel Scavenge收集器搭配使用;
    • 另外一种就是作为老年代CMS收集器发生失败时后备预案,在并发收集发生"并发失败"时使用。
ParNew收集器

  • ParNew收集器是Serial收集器的多线程并行版本,只能处理新生代,除了同时使用多条线程进行垃圾收集之外,使用复制算法,支持STW机制。
  • ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
Parallel Scavenge收集器

  • Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(处理器用于运行用户代码的时间与处理器总消耗时间的比值)。
  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量。
    • 控制最大垃圾收集停顿时间的:-XX:MaxGCPauseMillis参数。
    • 直接设置吞吐量大小的:-XX:GCTimeRatio参数。
  • 参数-XX:+UseAdaptiveSizePolicy,这个参数被激活后就不需要人工指定新生代的大小、Eden与Survivor的比例,晋升老年代对象大小等等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略。
Parallel Old收集器
  • Parallel Old收集器是Parallel Scavenge老年代版本,支持多线程收集,采用标记整理算法,存在STW机制。
CMS收集器

  • CMS收集器第一次实现了让垃圾收集线程与用户线程同时工作,是一种以获取最短回收停顿时间为目标的收集器。
  • 采用标记-清除算法,同时也会有STW机制。

CMS整个过程分为4个主要阶段:初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

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

优点

  • 并发收集
  • 低停顿:最耗时的并发标记与并发清除阶段都不需要暂停工作,所以是低停顿的。

缺点

  • CMS收集器对处理器资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程导致应用程序变慢,降低吞吐量。
    • CMS默认启动的回收线程数=(处理器核心数量+3)/4,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降,但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。
    • 如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户线程的执行速度忽然大幅度降低。
    • 为了缓解这种情况,虚拟机提供了一种"增量式并发收集器"的CMS收集器的变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理时让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。JDK9以及以后"增量收集器"模式被完全废弃。
  • 由于CMS收集器无法处理"浮动垃圾",有可能出现"并发失败"(Concurrent Mode Failure)进而导致另一次完全STW的独占式收集的产生。
    • 在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
    • 同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
    • 在JDK5的默认设置下CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。
    • JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
  • CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
    • 为解决这个问题,CMS提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的。
    • 这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量
      由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。
Garbage First收集器

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

G1收集器可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是垃圾优先,哪块内存中存放的垃圾数量最多,则侧重收集哪块区域。

G1是一个兼具并行与并发的回收器,它把堆内存分割成很多个大小相等的不相关的区域Region,每个区域都可以根据需要来扮演Eden空间、Survivor空间或者老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

  • 每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

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

垃圾回收过程

G1 GC的垃圾回收过程主要包括以下三个环节:

  • 年轻代GC
  • 老年代并发标记过程
  • 混合回收


年轻代GC

  • JVM启动时G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden区空间耗尽时G1会启动一次年轻代垃圾回收过程。
  • 年轻代垃圾收集时,首先G1停止应用程序的执行,G1创建回收集,回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

然后开始如下回收过程:

  • 扫描根
    • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。
    • 根引用连同记忆集记录的外部引用作为扫描存活对象的入口。
  • 更新记忆集
    • 处理脏卡队列中的Card,更新记忆集
    • 此阶段完成后,记忆集可以准确的反映老年代对所在的内存分段中对象的引用。
    • 对于应用程序的引用赋值语句 object.field = object,JVM 会在之前和之后执行特殊的操作以在脏卡队列中入队一个保存了对象引用信息的Card,在年轻代回收时,G1会对脏卡队列中所有的Card进行处理,以更新记忆集,保证记忆集实时准确的反映引用关系。那为什么不在引用赋值语句处直接更新记忆集呢?这是为了性能的需要,记忆集的处理需要线程同步,开销会很大,使用队列性能会好很多 。
  • 处理记忆集
    • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  • 复制对象
    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段。
    • Survivor区内存段中存活的对象如果年龄未达阈值年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。
    • 如果Survivor 空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  • 处理引用
    • 处理引用:处理Soft、Weak、Phantom、Final、JNI Weak等引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

初始标记阶段

  • 标记从根节点直接可达的对象,这个阶段是STW的并且会触发一次年轻代GC。
  • 正是由于该阶段时STW,所以我们只扫描根节点可达的对象,来节省时间。

根区域扫描

  • G1 GC扫描Survivor区直接可达的老年代区域对象并标记被引用的对象。
  • 这一过程必须在Young GC之前完成,因为Young GC会使用复制算法对Survivor区进行GC。

并发标记

  • 在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断。
  • 在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。
  • 同时并发标记过程中会计算每个区域的对象活性(区域中存活对象的比例)。

再次标记

  • 由于应用程序持续进行,需要修正上一次的标记结果,是STW的。
  • 采用的是初始快照算法。

独占清理

  • 计算各个区域的存活对象和GC回收比例并进行排序,识别可以混合回收的区域。
  • 是STW的,这个阶段并不会实际上去做垃圾的收集,为并发清理阶段做铺垫。

并发清理阶段

  • 识别并清理完全空闲的区域。

混合回收

  • 当越来越多的对象晋升到老年代Old Region时,为避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个年轻代,还会回收一部分的老年代。
  • 这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。

    默认情况下,这些老年代的内存分段会分为8次被回收,G1会优先回收垃圾占内存比例高的并且有一个阈值会决定内存分段是否被回收(XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要高达65%才会被回收,如果垃圾占比太低,意味着存活的对象占比高,在复制时会花费更多的时间)。并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。混合回收并不一定要进行8次,有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收,因为GC会花费很多的时间但是回收到的内存却很少。

回收可选过程

  • G1的初衷就是要避免Full GC 的出现,但是如果上述方式不能正常工作,G1会停止应用程序的执行,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
  • 要避免Full GC的发生,一旦发生需要进行调整;什么时候会发生Full GC呢?比如堆内存太小,当 G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。
  • 导致G1 Full GC的原因可能有两个:
    • 内存回收的时候没有足够的内存空间来存放晋升的对象
    • 并发处理过程完成之前空间耗尽

收集器存在的问题

  • 问题一:Region里面存在的跨Region引用对象如何解决?
    • 为了解决对象跨代引用带来的问题,垃圾收集器在新生代中建立了记忆集的数据结构,来避免全堆作为GC Roots扫描。
    • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。
    • 但G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
    • G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
    • 在HotSpot虚拟机中卡表是一个字节数组的形式,卡表的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。
    • 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
  • 问题二:在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
    • 首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误。
    • CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照算法来实现的。
    • 此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的并发失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。
  • 问题三:怎样建立起可靠的停顿预测模型?但G1收集器要怎么做才能满足用户的期望呢?
    • 通过-XX:MaxGCPauseMillis参数指定停顿时间指标。G1收集器的停顿预测模型是以衰减均值为理论基础实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时,每个Region记忆集里的脏卡数量等各个可测量的步骤花费成本,并分析出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
转载请注明:文章转载自 www.051e.com
本文地址:http://www.051e.com/it/986856.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 ©2023-2025 051e.com

ICP备案号:京ICP备12030808号