深入理解Java-JVM-一文学习Java虚拟机(暂更到JDK9)

Java虚拟机体系

java虚拟机总结将使用HotSpot VM

image-20211129222404758

JVM 是 java虚拟机,是用来执行java字节码(二进制的形式)的虚拟计算机,一般有以下5个模块:

  • 类加载子系统:所有的Class都是由ClassLoader【类的加载器】进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例;
  • 运行时数据区:程序运行时所有数据均在此处记录和释放
  • 执行引擎
  • 本地方法接口和本地方法库
  • 垃圾收集模块

基础介绍

img

类加载

  • Class类:面对对象的基本思想是万物皆对象,对象在java中的静态模型就是类所以:类Class实例表示正在运行的 Java 应用程序中的类和接口。 枚举是一种类,注解是一种接口。 每个数组也属于一个类,该类反映为一个Class对象; 原始 Java 类型( boolean 、 byte 、 char 、 short 、 int 、 long 、 float和double )和关键字void也表示为Class对象;这也是为什么后面说可以通过Class找到方法区的该类的各种数据信息,因为Class实例的作用就是用来作为接口找到对应的加载的类信息;那么数组显然不是一般的类,但是数组元素是,jvm会自动将数组降维,然后创建Class对应相应的方法区的数组元素实例,而这个数组元素的类表示就是数组的类标识【就是比如说static 那么这个数组元素类表示也是static】

Class文件

Class信息

我们知道加载是将class文件的数据加载进方法区;那么class到底由什么信息呢?规定:

·无符号数属于基本的数据类型以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
·表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由下列数据项按严格顺序排列构成

image-20211130213304480

分别为:魔数、副版本号、住版本号、常量表、访问标志(access_flags)【这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等】、类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引表(interfaces)是一组u2类型的数据的集合,因为前面是接口索引个数【这也是为什么可以实现接口可以多个,但是只能继承一个父类】;后面就是字段表方法表属性表【为了让类能在某些场合使用】;

编译信息

​ class在编译后会有一系列重要的信息:魔数,主副版本号,常量池等;

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

常量池(常量表)

常量池:也称class常量池或金泰常量池由俩部分组成:常量池计数器(constant_pool_count):记录常量个数,和常量池项(存储常量实体);

常量池计数器是由1开始计算的,常量池项结构非常简单可以简单概括为一个结构体

cp_inf{
    //常量类型
    u1: tag;
    //常量数据
    u2: inf[];
}
image-20211130200504070 image-20211130200828410

常量类型主要有20种:

image-20211130202408245

image-20211130202422785

1.String、Int、Float、Long、Double
	String由俩个部分组成:第一个是CONSTANT_String_info第二部分是CONSTANT_Utf8_info;其中CONSTANT_Utf8_info是真正保存数据的所以会由一个length字段,而CONSTANT_String_info只保存指向对应的CONSTANT_Utf8_info的索引;
2.实体的Feild、Method(类中方法和接口中方法)
3.符号引用
这里写图片描述 image-20211130202759401

基础

  • 按照Java虚拟机规范,从**.class加载到运行时内存再到销毁回收**中总共有7个阶段;
  • 7个节点分别是:加载 【相当于计算机的装入】[验证、准备、解析]【这三个阶段又称为连接,下文统称为链接是为了更好的理解特别是结合计算机的链接的理解】相当于计算机的链接初始化、使用和销毁;下面将重点介绍各个生命周期其主要完成什么任务;
  • 类加载器作用在加载阶段,【在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载
  • 在HotSpot VM中**,加载、验证、准备和初始化会按照顺序有条不紊地执行**,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行;后面先介绍链接和初始化在介绍类加载;
连接

链接的过程和计算机的链接阶段几乎一样

​ **验证:**确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性;主要包括四个验证:格式验证、语义验证、字节码验证、符号引用验证;

格式验证:魔数检查、版本检查、长度检查
语义验证:包括抽象类接口的实现类是否已经实现相关方法,final关键字是否被继承
字节码验证:主要是跳转指令是否正确
符号引用验证:主要是符号引用的直接引用是否存在

准备:主要就是为类变量赋值,换句话说就是为方法区的该类的非final类变量赋值【这也是为什么类变量有初始值的原因】;

  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式赋值
  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java堆中
  • 注意:Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的,boolean的默认值就是false,下面时初始化值;
  • image-20211128161909107

解析将常量池中的符号引号转换为直接引用的过程(简言之,将类、接口、字段和方法的符号引用转为直接引用);这是由于java没有像cpp那样有一个专门的链接完成这个步骤,所以会有符号引用;

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量、但是必须能无歧义地定位到目标。符号引号有**:类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用**;符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中;
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
初始化

​ 类初始化阶段:使用为类变量赋予正确的初始化值

  • Java类型初始化过程中对static变量的初始化操作依赖于static域和static代码块的前后关系,static域与static代码块声明的位置关系会导致java编译器生成方法字节码。

  • 若该类具有父类,Jvm会保证子类的< clinit >() 执行前,父类的< clinit >() 已经执行完成。

  • 初始化阶段就是执行类构造器方法< clinit >()的过程。此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码快中的语句合并而来

  • 不会为所有的类都产生< clinit>()初始化方法如果没有静态变量、代码块或者静态变量不需要赋值则不会产生,特别的:对于静态常量,JVM尽可能的将其赋值在准备阶段完成显式赋值,但是如果是涉及到引用则会等到初始化阶段才赋值【也就是说只有这种情况下才会产生< clinit>;使用static + final修饰,且显示赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行。】,如果其调用方法赋值的则会到该阶段才赋值;

  • clinit()的调用会死锁吗?
    虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕
    正是因为函数()带锁线程安全的,因此**,如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁**。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息

  • 什么时候回触发类初始化?【即调用clinit()】

    • 显然需要时还未初始化,而且必须为主动引用;
    • 主动引用:1.通过反射、克隆、反序列化、new;2.访问某个类或接口的静态变量,或者对该静态变量赋值;3.调用类的静态方法;4.**初始化该类的一个子类;**5.如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。6.虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类);
    • 当通过子类引用父类的静态变量,不会导致子类初始化

类加载器

加载的过程
  1. 通过类的全名,获取类的二进制数据流
  2. 解析类的二进制数据流通过其转化为在方法区内的数据结构(Java类模型)
  3. 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
对于类的二进制数据流,虚拟机可以通过多种途径产生或获得(只要所读取的字节码符合JVM规范即可)
1.虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
2.读入jar、zip等归档数据包,提取类文件。
3.事先存放在数据库中的类的二进制数据
4.使用类似于HTTP之类的协议通过网络进行加载
5.在运行时生成一段Class的二进制信息等
类加载器基础
  • JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)JAVA类加载器(Defined ClassLoader)

  • 启动(引导)类加载器 BootstrapClassLoader相当于计算机的引导程序
    ①. 这个类加载使用C/C++语言实现的,嵌套在JVM内部所以就省略。

    ②. 它用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sum.boot.class.path路径下的内容),用于提供JVM自身需要的类(String类就是使用的这个类加载器)

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

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

    ⑤. 加载扩展类和应用程序类加载器,并指定为他们的父类加载器

  • SecureClassLoader:继承自ClassLoader,添加了关联类源码、关联系统policy权限等支持。

  • URLClassLoader:继承自SecureClassLoader,支持从jar文件和文件夹中获取class,继承于classload,加载时首先去classload里判断是否由bootstrap classload加载过,1.7 新增实现closeable接口,实现在try 中自动释放资源,但扑捉不了.close()异常【一般我们扩展用户类加载器就是基于这个类,例如热部署https://www.cnblogs.com/lichmama/p/12858517.html】

  • 扩展类加载器 ExtClassLoader相当于计算机的第二阶段的引导程序
    ①. Java语言编写,由sum.music.Launcher$ExtClassLoader实现

    ②. 派生于ClassLoader类,父类加载器为启动类加载器

    ③. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

  • 应用程序(系统)类加载器 AppClassLoader
    ①. java语言编写,由sum.misc.Launcher$AppClassLoader实现

    ②. 派生于ClassLoader类,父类加载器为扩展类加载器

    ③. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

    ④. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载

    ⑤. 通过ClassLoader的getSystemClassLoader()方法可以获取到该类加载器

  • 自定义类加载器

    ①. 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们换可以自定义类加载器,来定制类的加载方式(自定义类加载器通常需要继承于 ClassLoader)

    ②. 体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java 开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源

    ③. 自定义 ClassLoader 的子类时候,我们常见的会有两种做法:

    重写loadClass()方法(不推荐,这个方法会保证类的双亲委派机制)
    重写findClass()方法 -->推荐

    所有java的类加载器都是基于ClassLoader,关系图如下:【扩展类加载器没有重写loadClass方法,而AppClassLoader的loadClass中同样调用了:parent.loadClass(name, false),使其符合双亲委派机制】

image-20211129210503529

类加载器间的协作关系:【从上面可以知道ExtClassLoader并没有重写URL的ClassLoader的loaderClass方法,所以调用AppClassLoader的符类加载器就是调用ExtClassLoader,具体看java11的Launcher的155和216行】

image-20211129213633866

双亲委派机制

​ 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先检查是否加载,如果没有首先把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求,子加载器才会尝试自己去完成加载。【代码如下的LoaderClass.loadClass】

​ java并没有要求一定要是双亲委派机制,但是双亲委派机制无疑具有许多好处;

ClassLoader抽象类

loadClass

//换句话说默认loadClass只会加载
public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

//resolve==true,加载class的同时需要进行链接
     protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
         //同步操作,保证只能加载一次
        synchronized (getClassLoadingLock(name)) {
            //在缓存中判断是否已经加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                //先进行双亲委派机制实现,1.查看是否有双亲--》2.尝试在双亲中加载
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        //parent==null 父类加载器是引导类加载器
                        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();
                    // 调用当前classloader的findClass;
                    	//查找具有指定二进制名称的类。 
                    	//这个方法应该被遵循加载类委托模型的类加载器实现覆盖,并且在检查请求类的父类加载器后将由loadClass方法调用。 
                    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;
        }
    }
类加载器的重点
  • 为什么需要类加载器而且是多级类加载器【使用双亲委派机制】:显然类加载器就是将class字节码文件加载进java虚拟机;同时为了安全,避免java的核心api被修改替换,我们知道每一级类加载器都会加载相应部分的类库的类,这样就可以避免个人篡改类库的类;而且,使用双亲委派机制也可以避免对于类的重复加载

  • 为什么父类加载器不能获取感知子类加载器加载的类,但是子类加载器可以感知父类加载器加载的类;这和类加载器的实现有关,从上面我们知道每次他都指只会检查本类加载器加载的类的缓冲不会检查子类的,然后类加载器使用的是双亲委派机制就是使得子类加载器可以看到父加载器的,但是父类加载看不到子类加载器加载的;

  • 双亲委派机制的优缺点:很显然双亲委派机制使得java更加安全、健壮、即避免核心api被破坏又节省内存空间;但是正是由于其双亲委派机制是单向的使得如果希望父类加载器可以加载子类加载器不能实现;

  • 怎么打破双亲委派:本质就是打破双亲委派机制的逐层传递的原则;一般有俩种实现,父加载器调用子加载器【就是反转:TTCL线程上下文容器类加载器】、还有就是形成网状的【就是平级:OSGi】;

  • OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为
    Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实
    现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更
    加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
    1)将以java.*开头的类,委派给父类加载器加载。
    2)否则,将委派列表名单内的类,委派给父类加载器加载。
    3)否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
    4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
    5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器
    加载。
    6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
    7)否则,类查找失败。
    

运行时内存

java内存模型

image-20211129220753444

首先介绍线程私有的三个部分

  • 虚拟机栈:1.虚拟机栈是一个以栈帧为单位栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。2.每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。【后面重点介绍】
  • 本地方法栈:本地方法区顾名思义就是保存非java语言的方法的栈,其作用和虚拟机栈基本一样;调用c、cpp等本地方法,这些方法可以直接操作内存等硬件,和虚拟机拥有同等权限,也就是说也可以直接操作java虚拟机。java的CAS就是调用本地方法实现的,在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一了。
  • 程序计数器其实和PC程序计数器基本一样,主要区别式PC程序计数器是CPU独占的,所以需要中断和中断恢复、寄存器等,这里直接每个线程一个就相当方便】:当前线程所执行的字节码的行号指示器分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成;如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

然后就是线程共有的俩个部分:【这里jdk1.8前后差距有点大】

  • 方法区:【简单来说方法区就是保存编译后类加载的的class信息】
    • 方法区可以认为由存储:运行时常量池、已加载的类信息、静态变量和编译器编译后的代码缓存的几个部分组成;虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是方法区也称为非堆就是为了区分堆的;
    • 在java8前,其就是和堆相连的,也叫永久区【主要是为了方便使用堆的垃圾回收器】,用于保存一些相当永久的数据,其内存回收条件相当苛刻【所以很难进行GC垃圾回收】,这就导致其空间大小的规定相当的困难;jdk1.7开始就对方法区进行极大的改革;将字符串常量池保存移出永久区,改为java head,并且符号引用(Symbols)移至native heap,不过他们仍然是方法区的一部分;只是他们还是和堆连续的、在java虚拟机内存中;
    • java8对其进行了大幅改进,其一部分将不在在虚拟机内存中(就是jdk1.7后的永久区,主要就是类的元信息),改为了元空间;这样就可以不用纠结在为该区域分配多少空间,理论上其空间将是整个内存空间;【之所以将其移出虚拟机的运行时内存主要是考虑到其java的类规模越来越大且大小由不能预测,很难确定永久区的大小】
    • Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外
    • 堆是java中很重要的一个部分,几乎所有对象实例都是保存在其中当然也有的是经过逃逸分析认为可以保存在虚拟机栈中的标量);所以其也是java虚拟机占用内存中最大的部分;
    • 堆可以是不连续的,但是逻辑一定是连续的;
    • Java堆既可以被实现成固定大小的,也可以是可扩展的
    • 由于堆是对象的主要保存空间,而且其又是可以共享的;所以堆设计既有“分代”又有“线程共享的分线程区”;
    • GC主要就是针对这一部分进行,堆的GC往往也是影响性能的重要因素【后面将重点介绍堆结构】
    • 到目前为止我们知道堆中至少有:StringTable实例、已经加载的Class实例【至少main的class实例必然在】;

虚拟机栈

image-20211129230146638

虚拟机栈的基本单位是栈帧,栈帧主要由:局部变量表,操作数栈,动态链接,返回地址、附加信息等五个部分组成;每个栈帧对应一个方法,可以这么说java程序的执行就是虚拟机栈的栈帧的进出栈【这里并不是说所有的方法都会进入虚拟机栈,显然本地方法和static方法都不会进入】;

局部变量表

局部变量表的特点
  • 局部变量表的基本单位是solt【槽位】;
  • 槽位的起使点是0,0默认保存this,然后就是按顺序是各个参数;
  • 槽位是可以复用的,按照程序计数器可以知道槽位什么时候回失效,但是这里要注意如果就没有再去修改局部变量表这种失效时不存在的,因为复用本质就是得去用,所以程序计数器并不能决定一个变量是否有用,而是局部变量表决定得【如果想要一个极其占用内存得局部变量失效可以尽快为任意局部变量赋值】;
  • 最大内存在编译的时候会保存在class文件的max_local中。
  • 一个槽位为8个字节【32位】,基本数据类型除了double、long都是8个字节的,另外returnAddress【现在普遍改为异常表来代替】;reference可能是32位或者64位;
  • 对于64位的数据,直接使用两个连续的槽位【一般不允许这种情况下单独访问其中一个solt】;

操作数栈

操作数栈的特点
  • 可以认为操作数栈相当于寄存器;
  • 操作数栈显然符合先进先出特点,而且其保存得数据序列应该严格符合指令得序列;例如a = a*b+c;必然时abc进出栈
  • 方法一开始是操作数栈式空的,随着程序的进行回不断进出栈;
  • 操作数栈是可以重叠的,java为了节省空间允许俩个栈帧间的操作数栈重叠一部份;
  • 操作数栈同样每一个基本单位都是8个字节,对于64位的数据会连续占用两个栈深度;
  • 当然操作数栈也有最大深度;
image-20211129231802317

动态链接和返回地址

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

正如前文堆类加载得分析:Java代码在进行Javac编译的时候,并不像C和C++那样有“链接”这一步骤,而是在虚拟机加载Class文件后进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,在类创建时或运行时解析、翻译到具体的内存地址之中。

返回地址:很显然当前java得返回主要有俩种,异常中断抛出和正常返回;方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值方法异常退出时,返回地址是要通过异常处理器表来确定的

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:当前栈帧出栈,恢复上层方法的局部变量表和操作数栈【显然在Java虚拟机栈中弹栈后栈顶完成了恢复了】;把返回值(如果有的话)压入调用者栈帧的操作数栈中【一般而言有返回值都会有该操作,因为后面往往伴随赋值或者其他操作均会使用到操作数栈】,调整PC计数器的值以指向方法调用指令后面的一条指令等;

  • 堆是一个很重要得概念,堆占据了一大半得Java虚拟机空间;
  • 其可以时物理上不相连得空间,当然逻辑必须相连
  • 几乎所有实例都保存在其中,GC回收主要也是针对这个区域;
  • 由于对象【实例】的存活时间有巨大的差异,jvm的堆使用同样使用经典的分代理论,以方便垃圾回收器进行回收;
  • 对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间
  • Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

java的堆结构

基础

堆按照对象生存年代【新生和老年】和大小可以分为三部分:eden、survivor1、survivor2【上面三个区保存新生代,俩个survivor可以认为时中年】、older四个区【老年期】

四个区的大致作用如下:

  • eden区时绝大多数对象实例创建后保存的地方;一般而言对于eden区的垃圾回收速度页相对较快不需要中断所有线程;
  • 俩个survivor区主要是将eden和survivor进行gc仍然存活的对象保存起来每次有一个survivor作为接受方一个作为发出方【第一次gc的时候发送方为空,后面每次都是上一次的接收方就是发送方】这样做的好处是survivor的空间是连续的可以最大化使用内存空间,也可以加快执行速度;其实survivor也可以说是eden的一部分,只不过这一部分专门用来保存gc后eden生存着的实例对象;
  • older区一般保存着大对象或者超过特定次数gc仍然存活的对象实例,对其回收应该非常谨慎,因为其回收回收会用major gc【时间是minor gc的10倍以上】或full gc【会stop the world会使得终止服务、当前的所有服务可能由于失效而过期】;
image-20211203143231336
内存模型

java的内存模型由俩种

  • 空闲表:这个模型很大程度和计算机的基于顺序搜索的动态分区分配基本没有太大区别,就是方便分配和回收;这种如果不进行紧凑的话,无论使用哪种顺序搜索方法都会引起巨大的问题【比如最佳匹配算法,这时就会出现很多空隙而无法装入】,紧凑又是一个极其浪费时间的过程;
  • 指针碰撞:每次分配都是在该区的前之后分配,利用碰撞指针来确定当前分配到的位置,碰撞指针前的都是已经分配的,后的都是为分配的;这种是基于java虚拟机时分代模型的,换句话说进行GC的时候前面的eden区所有实例都会被移走,和survivorFrom的也都会被移除到survivorTo所以不会有需要调整空间的问题;但是显然老年区就有这个问题所以老年区需要的GC时间更长
eden区

为了避免线程在创建对象的时候加锁,一般而言还会将eden分为俩部分线程区eden和公用eden;线程区eden又分给若干个线程,这样每个线程都有自己的eden区【TLAB Thread Local Allocation Buffer】,在创建对象的时候就不要同步;

TLAB
  • TLAB基于线程jvm需要频繁的创建对象,但是堆是共享的,所以希望线程可以有独立一部分空间用来创建实例对象,这里注意这里并不会改变堆还是共享的基本特定
  • 每一个线程独有的用来创建对象实例的内存空间,所有TLAB均在eden内;
  • TLAB可以有效加快对象实例的创建速度,因为其不在需要等待其他对象创建完对象后再占用堆空间;
  • TLAB是线程创建对象的首选,只有TLAB内存不足的时候才会在共享eden区创建对象【eden区为了避免加锁,采用CAS和自旋的形式】
  • 通过 -xx:UseTLAB 指令开启 通过-xx:TLABWasteTargetPercent 修改TLAB占用内存的大小,一般情况下默认为1%

下图为对象实例化简图:具体看对象篇

img

重点分析

配置

  • 配置新生代与老年代在堆结构占比
    默认:-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
    可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

  • 调整这个空间比例

    -XX:SurvivorRatio:(Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1)

    -Xmn:设置新生代最大内存大小,一般使用默认值就可以了

  • 大对象配置

    -XX:PretenureSizeThreshold 超过大对象就会直接再老年区创建而不在eden创建

  • . -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄

  • -XX:HandlePromotionFailure:是否设置空间分配担保

    (JDK6之后,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC)

对象由eden向older过程

  • 除非超过指定大小的大对象实例否则只要在堆中进行对象实例都会自动在eden创建;
  • eden和survivor1、2的垃圾回收是Minor GC【记住该GC不会中断所有服务,后面垃圾回收再细说】;
  • 每渡过一次Minor GC但是没有回收都会自动将生存时间+1,并将其移动到一个survivor中;
  • 当eden满的时候会自动触发minor gc或Full GC
  • 当达到eden区的最大年龄的时候就会将其复制到older区
  • 当Survivor空间不足, 那么从 Eden 存活下来的和原来在Survivor空间中不够老的对象占满Survivor后, 就会提升到老年代;换句话说如果Survivor空间不足会提前升级到老年区;

eden性能分析

  • 增大新生代空间, Minor GC 频率降低, Minor GC 时间上升。
  • 降低新生代空间, Minor GC 频率上升, Minor GC 时间下降。
  • 保证足够的survivor,避免premature promotion【过早提升】

java堆数据的保存

我们知道堆主要保存java对象实例,那么java堆的数据保存主要分析的也是对象到底是怎么存在的;

对象存在形式
在这里插入图片描述

对象在堆中包括三个部分组成:对象头(Object Header)、实例数据(Instance Data)、对齐填充

对象头:对象头主要由标志字段(Mark Word)和类型指针组成,很显然类型指针和前面的reference斗志由虚拟机决定其为8个字节还是16个字节【jvm支持对于指针的压缩】,jvm固定了标志字段页根据虚拟机不同为8或者16个字节;这样对象头就占用了16或者32个字节;

标志字段(mark word):用于存储对象自身的运行时数据如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据这里需要注意由于mark word是固定大小的,这就有一个问题mark word可能不够用,jvm的Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据;例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向) [1] 下对象的存储内容如表2-1所示

image-20211130154952646

类型指针对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例;并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身这就话就是说对于对象的定位有俩种策略,直接定位【就是像这里再对象中添加一个字段:类型指针】和间接定位【句柄池的方式】;

句柄java堆会划分出一块内存来作为句柄池,reference中存储对象的句柄地址,而句柄中又包含了实例数据与类型数据各自的具体地址信息。优式:稳定

实例数据是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容;

HotSpot虚拟机默认的分配策略如下所示:相同宽度的字段总是被分配到一起,并且在满足这个条件的前提下,在父类中定义的字段会出现在子类字段之前

  • doubles & longs
  • ints & floats
  • shorts & chars
  • booleans & bytes
  • references

对齐填充:HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,所以对象的大小也必须为8的整数倍;对齐填充可以认为是为了方便对于内存的管理;

逃逸分析

  • 没有发生逃逸:当一个对象在方法中被定义后,对象只在方法内部(或者本线程内使用)使用(这里关注的是这个对象的实体)。没有逃逸的对象可以通过:栈上分配、标量替换和同步省略:加快对象的创建回收和使用;而且由于其在栈上实现显然其不需要经过GC垃圾回收即可自动回收;

  • 逃逸:当一个对象在方法中被定义后,它被外部所引用程度可以分为:不逃逸(只在本线程本方法能够访问)、线程逃逸(外部方法和其他线程均能使用),方法逃逸(其他线程不能引用,尽管本线程其他方法可以访问)。对于不逃逸及方法逃逸均可能使用栈上分配;

  • JDK1.7版本之后,HotSpot中默认就已经开启了逃逸分析;

  • 配置

    -XX:+DoEscapeAnalysis 显式开启逃逸分析
    -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果、

    同步消除(Synchronization Elimination)线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

标量替换

  • 标量:就是上面所说的局部变量表中能保存的数据,他们是不可再分的;
  • 聚合量:聚合量就是标量的集合,多个标量一起就是聚合量;
  • 如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换
  • 开启标量替换 (-XX:+EliminateAllocations)

栈上分配

  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸本线程的话,就可能被优化成栈上分配
  • 所谓的可能就是大对象并不会将其优化到栈上,除此之外开启逃逸分析和标量替换【标量替换可以认为是栈上分配的特例,如果可以标量替换极大基本可以栈上分配】
  • 栈上分配具有极大的优点,至少其回收和创建都明显更快;之所以其创建更快主要是由标量分析;
  • 栈上分配可以支持方法逃逸,但不能支持线程逃逸。【?】

方法区

基础

jdk1.8的方法区对于运行时常量池和字符串常量池的位置网上没有明确答案,深入理解java虚拟机也比较含糊。结合深入理解java虚拟机和网上的基本论断,我认为运行时常量池逻辑上是包含字符串常量池的,但是运行时常量池的非字符串部分不在虚拟机内存中而是在元空间堆中是字符串常量池 【深入理解java虚拟机的:1.到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出;2.Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。3.运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常
量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。】

image

方法区主要分为俩个部分java head的字符串常量池和和元空间

首先是字符串常量池StringTable类,它是一个Hash表,默认值大小长度是1009;使用链地址法处理冲突【在JDK7.0中,StringTable的长度可以通过参数指定。】;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

运行时常量池

  • 运行时产量池是java实现运行时多态的关键,**在jvm中,每个已加载的类型都维护一个常量池。**常量池就是这个类型用到的常量的一个有序集合,也称为:常量池表
  • 存放常量池表(Constant Pool Table):用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
    • 对于实例方法:此时静态分派的编译时多态已经不可变,运行时多态的方法只保留符号引用将根据实例对象的方法表决定真正的方法

元空间还保存着其他的信息,包括类型信息、feild信息、方法信息和jit代码缓存

对象

对象的创建

new普通对象的过程

  • 某一方法执行new创建一个对象,首先就会在操作数栈中执行new
  • 首先会在方法区检查是否有该类型信息【检查该类的的符号引用是否存在,回忆上文类加载的链接过程中有一个解析的过程就是将符号引用改为直接引用,如果没有改为直接引用就执行链接过程然后如果在常量池中没有找到符号引用说明还未加载该类,进行类加载( 由于类加载的时机没有明确的规定,假设这里尝试执行类加载:ClassLoader+包名+类名)】
  • 加载首先就是选择类加载器、一般会在AppClassLoader开始使用双亲委派机制执行,加载最重要的是将class信息加载进方法区,创建Class实例到堆中作为找到class信息的类接口【很明显这个也是需要分配内存……】;
  • 加载后就会执行连(链)接过程,包括验证、准备、解析和初始化;
  • 根据jvm选择合适的内存分配方式【一般是指针碰撞】,分配内存【就是在eden区的本线程区–》不足才会到堆区通过CAS创建;还不足就要进行minor gc甚至full gc】
  • 分配后会将进行实例变量的零值初始化,规则和类变量的一致;这个时候作为程序角度已经完成创建了,但是作为java对象还需要进行实例化【就是,会首先按顺序执行父类的然后是本类的成员变量和代码块,然后执行构造器方法】;
    • 怎么找到父类的的呢,本质是通过构造器的super方法,所以要求super方法必须在本构造器的第一行,没有的话JVM自动加上;
  • 所以其会将本次操作压入操作数栈栈顶,然后复制一份, 栈顶的对象执行invokespecial:调用对象实例方法,通过栈顶的引用变量调用init方法。执行完后将原先得执行赋值、然后赋值结果返回局部变量表;

有了以上基础:大致可以总结如下:

  • 准备阶段:静态变量首先赋初值、初始化阶段首先执行静态代码和静态代码块
  • 实例化阶段:执行

对象的定位

​ 其实对象得定位取决于对象得访问方式:直接访问或者间接访问;直接访问显然reference就可以直接定位到对象得实例变量;还有一种是句柄池得方式,先去句柄池中找到对象得实例指针到真正得实例变量;下面分别是间接访问和直接访问示意图

image-20211130233505386 image-20211130233525599

对象已死

java对于对象是否存活得判断使用得是可达性分析,并不是通过计数器计数得办法【计数器计数有一个最大得问题是可能有已死得对象循环引用导致不能失活,更严重得是一个见循环引用可能会使得绝大多数死亡对象不能正常销毁,例如a->b b->a a->c a->d b->h;上面得对象都只有着部分得引用】

可达性分析

java使用的是可达性分析来解决计数器无法解决循环引用的问题;这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集【着意味着GC roots是多个的】,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达

根节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain);

image-20211201170639673

GC Root对象一般可以简单分为俩大类:不死的对象和普通的对象【前面的4个都都是普通对象,一般很可能随着线程死亡而消失;后面的2个几乎不会死亡,方法区常量引用的对象死亡条件比较苛刻】

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除此之外还有一些各个虚拟机根据相应回收算法持有的临时对象;

引用的类型

引用分为:强引用,软引用,弱引用,虚引用;

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

对象死亡

对象的死亡有俩次标记:1.在可达性分析算法中判定为不可达,进行第一次标记;2.如果对象执行过finalize方法或者没有重写该方法进行第二次标记;否则将其移到F-Queue等待该线程执行F-Queue的对象的finalize方法【注意虚拟机并不保证该方法一定会被执行或者执行完成,收集器会对该队列进行一次小规模回收】,没有重新可达的将进行第二次标记;俩次标记后就标志对象已死【另外绝对不推荐使用finalize进行复活,因为具有不确定性和对虚拟机的性能消耗比较大

垃圾收集模块

基础

有了上面的基础后我们可以比较好的理解垃圾回收机制【就是内存回收和整理嘛】:

  • 首先是怎么知道对象是否可回收:可达性分析和俩次标记、方法区特定的回收对象的三个条件
  • 基本回收策略:复制算法标记清除和整理算法【基于分代理论】
  • 触发条件:MinorGC、FullGC、MajorGC条件
    • image-20220225161425375

再论可达性分析

我们知道通过一系列的GC root就可以把对象是否可达找出来,但是这里涉及一个重要问题GC Root的所有根节点怎么找出来,这里涉及三个部分:虚拟机栈到堆空间堆空间的同代引用堆空间的跨代引用

对于虚拟机栈到堆空间

  • **HotSpotVM在每个栈帧中加入了OoMap记录该栈帧中对于引用的记录,**这样就可以通过所有OoMap找到所有由虚拟机栈到堆的引用;这里又有一个问题OoMap的产生和修改问题:
  • 如果所有导致虚拟机栈引用变化都要由对应的OoMap指令显然时不能接受的,所以引进了安全点的概念;只有在安全点的位置才会区更新OoMap
  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化【就是俩个安全点之间】,因此,在这个区域中任意地方开始垃圾收集都是安全的。
  • 到这里栈中GCRoot基本解决,还有一个问题就是怎么让线程在安全点停下来;主流有俩种策略:所有线程被动停下,主线程再让没到达安全点的线程继续移动到安全点,第二种就是:让线程主动停下,所有线程不断主动轮询是否需要停下,如果需要停下垃圾回收线程会设置轮询的标志为真,需要的话运行到最近安全点后自动停下,已到达的直接停下
1.oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 
2.每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 
3.循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

堆空间的跨代引用

  • 为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围*;记录全部含跨代引用对象的实现无论是空间占用还是维护成本都相当高昂*。所以,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了【类似计算机的内存分页】,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本;
  • image-20211202171957553

卡表(记忆卡的实现)在HotSpotVM中以数组形式存在,数组每个元素对应的内存块称为卡页;一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0;如何标记脏页?一般来说就是当有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,所以需要把维护卡表的动作放到每一个赋值操作之中通过写屏障实现,这里的写屏障就是一个类似AOP的环绕通知,简单来说:进行写操作(即引用赋值)之前或之后附加执行的逻辑

安全点选择:安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的【方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点】;

三色标记法和问题解决

为了在垃圾回收器进行遍历GCRoot图的可达性分析的时候尽可能的能够并发的处理业务线程【即在进行查找回收对象的时候,不中断用户线程】,为此引进三色标记法:三色标记法规定:如果垃圾回收器没有遍历到的对象为白色、已经访问过但是其引用对象还没全部访问过为灰色,已经访问过且其全部子对象都访问过为黑色; 三色标记法存在漏标黑色和多标黑色的问题【这时不得不说一下赋值器,我们知道:新建引用最后到达安全点的时候会将其加入Oomap,对于赋值器如果不是新建引用而是赋值引用【或者引用的引用……】必然是GCRoot可达的【不然你怎么找到该引用并然后赋值?】】;漏标:漏标就是用户线程在黑色节点上进行赋值引用并且恰好只有收集器标记节点的引用指向该节点而且该节点还未访问又被删除了该节点;

image-20211202223235007

其中漏标非常严重和危险;但是漏标必须满足以下俩个条件:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部灰色对象白色对象的直接或间接引用

由于必须满足俩种条件,所以有俩个解决思路:增量更新和原始快照

  • 增量更新:简单来说就是在对于黑色节点增加指向白色节点的新引用时,将该引用节点记录下来,等扫描结束后再对这部分节点进行扫描【很明显就是打破第一个条件】
  • 原始快照:只要删除灰色节点中未扫描的节点就将该节点记录下来,等扫描结束后再对这节点进行扫描【很明显就是打破第二个条件】

回收算法

标记-复制算法
  • 标记赋值算法要点就是:存在一片足够大空白空间将另一边的所有存活对象复制进来【这里就是为什么要有俩个survivor区、以及只要进行MinorGC时eden就会被清空的原因】
  • 标记赋值算法对于存活率低的区非常高效,而且其空间不需要整理就会保持规整状态;
  • 标记复制算法主要消耗时间在复制上,所以如果存活率高的话会十分消耗时间;而且其会有一段空闲区域不能被分配,这个空闲区域太小会使得出现大量对象提前进入老年区。太大又会浪费内存
标记-清除算法
  • 标记清除算法就是回收所有白色节点的对象;由于不是连续空间所以需要空闲分区表和对象已经分配的分区表;
  • 标记清除算法有一个很巨大的弊端就是会使得空间碎片化,而且这又会和该区空间分配算法密切相关【参考操作系统的顺序搜索的内存分配算法】;这个时候往往需要考虑对分区进行整理,整理本身又浪费时间;
  • 还有就是执行效率不稳定,其执行效率和回收对象的数量有关;
标记-整理算法

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的;

  • 标记整理算法核心就是尽可能将存活对象移动到分区的头部,也就是说最后所有对象都会只在分区的开始部分;其实就是将整理和回收合并操作节省时间
  • 老年区往往存活率高,每次可能需要移动大量对象而且需要修改这个对象的全部引用同样执行效率也不稳定,如果死亡对象都是后面的对象,显然就很少移动,相反如果前面又一个对象死亡,往往需要移动后面大量对象;
问题

怎么更新引用?

  • 标记复制算法:很明显标记复制算法会改变对象的内存地址所以标记复制算法需要更改引用的地址【怎么更改呢?】;标记复制算法是在标记的过程复制的【换句话说这个时候就可以更改引用地址】,然后将地址保存到对象头的MarkWord中,下次又有新的节点指向该节点时就可以借用MarkWord直接更改引用地址而不是去复制

  • 标记整理算法:简单来说就是首先标记,最后将存活对象复制到新空间;,在整理前先计算出所有对象保存的新位置保存在MarkWord,然后通过修改所有的引用

触发条件

基础垃圾回收器

首先垃圾回收器的类型主要有:【很显然他们都是作用在堆上的】

image-20211201202554488

  • 很显然新生代只需要用标记复制算法就既能保证空间规则,又不需要太多的处理时间
  • 但是老年代就极大不同:老年代作为最后一代显然没有更多空间使用复制算法了,这时有俩种算法:标记清除和标记压缩
  • CMS是jdk1.7前的默认垃圾回收器,G1是jdk1.7后的默认垃圾回收器【G1可以说是跨时代的垃圾回收器,其具有并行的能力而且作用在整个堆】;后面重点介绍俩者的实现理念;

在开始介绍俩个重点的垃圾回收器前,现在重点关注另外5个回收器;

Serial和Serial Old

Serial的运行过程示意图,Serial Old就是将新生代换为老年代

image-20211201204340561

  • serial和serial old的最大区别是一个作用在新生区一个作用在老年区
  • 俩者都是单线程的垃圾回收的整个过程都要stop the world;
  • 由于是单线程所以对于单核的明显处理速度就会很快;
  • 它是所有收集器里额外内存消耗最小的;

ParNew

image-20211201204746377

  • ParNew收集器实质上是Serial收集器的多线程并行版本【换句话说其他规则和Serial没有区别】
  • 除了Serial收集器外,目前只有它能与CMS收集器配合工作。【说明Parallel Scavenge不能和CMS配合工作呗】

Parallel Scavenge和Parallel Old

  • 在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
  • Parallel Scavenge不能和CMS配合工作【很自然就会问为什么这个收集器就不没能和GMS一起工作呢?网上所说:HotSpot VM是有一个GC的框架,上面的Serial 和Serial Old、ParNew、CMS都是基于这个框架内实现的,但是在实现Parallel Scavenge时,设计者使用了另外一套设计框架,导致俩者不兼容

Parallel Scavenge

  • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量;【这里的吞吐量就是有效运行时间/(有效运行时间+垃圾回收器回收时间)】
  • 并行收集的多线程收集器
  • Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”
  • 重要参数设置
    • 控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数
    • 设置吞吐量大小的-XX:GCTimeRatio参数

Parallel Old

image-20211201210328860

  • Parallel Old是Parallel Scavenge收集器的老年代版本
  • 多线程并发收集

CMS垃圾回收器

image-20211203143052653

  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
  • 作用在老年区使用标记清除算法,所以其缺点就是标记清除算法的缺点,优点既然也是调集清除算法的优点;另外正如前面所说的,CMS不能和Parallel Scavenage共用;
  • CMS收集器是基于标记-清除算法,可以将其分为4个阶段:初始标记、并发标记、重新标记和并发清除;其中仅仅只有初始标记和重新标记需要STW,其他时候都是可以并行执行用户线程的;
    • 初始标记:标志阶段仅仅是将所有GCRoots根节点指向的直接节点标为黑色;【这个阶段尽管需要STW但是速度会比较快】
    • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图;
    • 重新标记:使用增量更新的方法将并发标记期间出现的用户线程增加的黑色节点到白色引用的集合进行遍历,这个过程同样不能并行执行;【这个阶段主要看到底用户线程修改了多少这种引用,速度比初始标记慢】
    • 并发清除:并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 参数设置
    • -XX:+UseConcMarkSweepGc:手动指定使用CMS收集器执行内存回收任务
    • -XX:CMSlnitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收
    • -XX:ParallelCMSThreads:设置CMS的线程数量
    • -XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理
  • CMS收集器在JDK9中被废弃,在JDK14中被移除

G1垃圾回收器

https://blog.csdn.net/qq_38350925/article/details/104957595

Region(区)

  • 每个region总体不严格属于哪一个分代,只在其在存有数据时变为只属于一个分代这使得各个年龄带不再是连续的;

    image-20220409175837937

  • 每个区有三个指针 preTams、nextTAMS、top指针,其中top指针指向当前分配到的位置,在 GC 过程中新分配的对象都当做是活的,G1 在 Region 中通过 top-at-mark-start (TAMS) 指针来解决这个问题,分别使用 prevTAMS 和 nextTAMS 来记录新分配的对象。

在这里插入图片描述

基础

image-20211203150938397

  • G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征G1作用在整个堆空间,就是说新生代和老年代都可以用G1进行回收
  • 在分年代理论下,G1对于大对象【默认超过是1.5个region大小则为大对象】专门在Region中还有一类特殊的Humongous区域存储;该区域可以认为是老年区区域【-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂】;所有region同样是2的N次幂大小【范围是1MB到32MB之间,-XX:G1HeapRegionSize:设置每个Region的大小;目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000】;
  • G1最大的特点是,其将堆区分为若干个管理区region【类似于ConcurrentHashMap1.8前的方法,化整为零来增强并行性,不过由于堆分年代的理论所以其会复杂得多】
  • 每个region大小都相同、内存的回收是以region作为基本单位的Region之间是复制算法,但整体上实际可看作是标记一压缩(Mark一Compact)算法
  • G1可以只选取部分区域进行内存回收,也就是选取部分region的无效对象更多的区进行回收(所以说是Garbel First)
  • G1具有:可预测的停顿时间模型(即:软实时soft real一time)【能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒、可以通过参数**-XX:MaxGCPauseMillis进行设置**)】
  • G1收集器的运作过程大致可划分为以下四个步骤;初始标记、并发标记、最终标记和筛选回收【前三个阶段几乎和CMS没有区别】
    • 初始标记(Initial Marking):【和CMS一样】仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
    • 并发标记(Concurrent Marking):【和CMS一样】从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
    • 最终标记(Final Marking):【这里使用原始快照SATB】对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
    • 筛选回收(Live Data Counting and Evacuation)负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
  • 在JDK1.7版本正式启用,是JDK 9以后的默认垃圾收集器,取代了CMS 回收器;
  • 配置;
    • -XX:+UseG1GC:手动指定使用G1收集器执行内存回收任务
    • -XX:MaxGCPauseMillis:设置期望达到的最大Gc停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
    • -XX:ParallelGCThread:设置stw时GC线程数的值。最多设置为8(垃圾回收线程)

详细执行流程

https://blog.csdn.net/qq_43295483/article/details/120243977

image-20220409180056447
  • 由于G1管理整个堆(年轻和老年)、而且需要满足用户指定的软中断时间;
    • Full GC就是:解决由于需要满足软中断导致其回收太少,而使得分配空间不足触发的Full GC
  • 新生代:标椎的标记复制算法
  • 老年代GC会伴随年轻代GC:即前面的四个阶段

执行引擎

image-20211201150005435

hostVM的执行引擎主要是即时编译器和解释器,可以说java是一门半解释半编译的语言;默认情况下都是同时具有解释器和即时编译器的

image-20211201163832253

解释器

  • 当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行;简单来说就是:解释器是一个将指定高级语言转为机器指令的软件
    • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下
    • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
    • 在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。
      • Interpreter模块:实现了解释器的核心功能
      • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
  • 最大的问题是解释的过程是在程序执行的时候进行的,而且每次执行该代码都要重新解释,比较消耗时间;
  • 解释器有一个巨大的优点就是不用全局编译,所以一般java程序开始都是用解释器运行的,后面才逐步使用即时编译器;但是一般解释器都是低效的代名词,因为其逐条解释的特点;

JIT编译器

​ 重点介绍即时编译器:java的即时编译器有三种:C1【客户端即时编译器】、C2【服务端即时编译器】、Graal【jdk10新进即时编译器】

  • 即时编译器编译出的机器指令保存到方法区元空间的JIT指令保存区
  • 即时编译器最大的特点就是可以让机器指令重用,即保存编译的机器指令
  • 即时编译器还可以优化代码

client即时编译器

特点
  • 即c1模式下的即时编译器;其最大特点是编译的速度快,优化具有局部性
  • client编译之所以速度快是因为其优化仅仅局部的:冗余消除、方法内联、去虚拟化
    • 冗余消除:将运行期间不会用到的代码折叠
    • 方法内联:将符号引用的方法编译进来,减少虚拟机栈栈帧的生成、参数传递和跳转的过程
    • 去虚拟化:对唯一的实现类进行内联
实现

在了解了其特点后重点关注其实现的过程:client即时编译器是一个三段式的编译器

  • 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion , HIR)。在此之前,编译器会在字节码上完成一部分基础优化,如 方法内联,常量传播等优化。
  • 第二阶段,一个平台相关的后端从 HIR【高级中间代码】 中产生低级中间代码表示(Low-Level Intermediate Representation ,LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除,范围检查消除等,让HIR 更为高效。
  • 第三阶段,在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,做窥孔(Peephole)优化,然后产生机器码
image-20211201163150368

server即时编译器

  • 即c2模式下的即时编译器,其特点是从全局的角度进行优化,所以其耗时也相对更长,不过其优化后代码的执行效率远高于c1模式下的优化的代码
  • 当然其也会有c1的局部优化
  • c2模式的优化基于性能检测和逃逸分析,最求极致优化【会尝试进行激进的守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)】,如果优化失败将使用解释器作为后门,让程序继续执行;
    • 分层编译(Tiered Compilation)策略|: 程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。
  • 根据逃逸分析的结果优化主要就是:1.栈上分配;2.标量替换;3.同步消除

Graal编译器

  • Graal编译器在JDK 9时以Jaotc提前编译工具的形式首次加入到官方的JDK中,从JDK 10起,Graal编译器可以替换服务端编译器
  • Graal可以从HotSpot的代码中分离出来,并不没有和hostpotVM代码整合【由于jdk9增加了Java虚拟机编译器接口(Java-Level JVM CompilerInterface,JVMCI)】

热点探测

​ 什么时候使用即时编译器什么时候使用解释器在hostpotVM中是通过热点探测实现的,所谓的热点探测就是根据代码执行的频率确定其是否为热点代码,对于热点代码使用即时编译器否则使用解释器;

回边计数器

​ 所谓的回边计数器就是对于循环的循环次数进行统计,超过一定的循环次数就会使用即时编译器堆代码进行编译;其执行过程可以大致认为如下:

image-20211201155805959
方法计数器

​ 方法计数器和回边计数器的思想一致,不过由于方法执行的时间具有跨度性,所以可以分为俩种:绝对次数的方法计数器和相对次数的方法计数器;默认情况使用相对次数的方法计数器;

相对次数的方法计数器:在设置的时间跨度内如果执行次数超过阈值就会使用即时编译器编译代码,否则就会热度半衰【就是热度减半】,一般这个时间跨度称为半衰期;

绝对次数的方法计数器:等到执行次数超过阈值就进行编译,一般所有方法最后都会被编译;

阈值设置

  1. 这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译
  2. 这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定
  3. -XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
  4. 使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减

执行示意图

image-20211201162050845

AOT编译器

  • jdk9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
  • 所谓AOT编译,是与即时编译相对立的一个概念即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程 .java -> .class -> .so
  • 最大好处:Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验
  • 缺点:破坏了java"一次编译,到处运行",必须为每个不同硬件、OS编译对应的发行包。降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知

PS:后续所有开源学习笔记同步到gitee,有需要去拉取 https://gitee.com/wusport/open-source-notes

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

舔猫

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

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

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

打赏作者

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

抵扣说明:

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

余额充值