面试必问JVM篇

前言

Java的其中一个特性:『一次编译,到处运行』,相信大家都不陌生,直到现在,我的脑海里依然浮现的是我大学时初学Java这门语言时的画面,那时候对这句话只知道字面意思,并不理解深层的含义,直到参加工作多年后,才慢慢的有意识的去认识、理解『一次编译,到处运行』这一特性的含义。
其实,我也在想这样一个问题:我们在电脑上coding完之后,产生的是一个个的.java文件,也就是我们所说的源码文件,其实这个是给我们开发人员看的,学过计算机的都知道,计算机是针对指令来执行的,也就是计算机只认识指令,不能识别源码文件,也就无法执行我们所编写的程序,这时候就要提到JVM了。

JVM是什么

在这里插入图片描述
先来看这张图,再结合上面的文字描述,加深印象,便于理解。

JVM=Java virtual machine,即JAVA虚拟机,说白了就是模拟一台计算机所具有的计算功能,实际上就是用来执行Java字节码的,并非实际的计算机,它运行在操作系统之上(看上图),与硬件没有关系。JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。
在这里插入图片描述
看到这张图,是不是可以大致理解JVM是什么了?java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

JVM的体系结构

不管是初级Java程序员,还是工作了多年的老司机,对JVM体系结构都应该有一个清楚的认识,因为这是基础理论,当然,这里面也是面试必问的内容。
在这里插入图片描述
画图呢,就是为了能直观的让大家对JVM的体系结构有一个清楚的认识,我本人就喜欢这种方式记忆,不像文字那么枯燥。大家可以从图上清楚的看到JVM体系结构里面每一部分都包含了哪些东西,至于这些东西是什么时候用来干什么的,下面再详细给大家列出,目前大家就是要对这个图有一定的记忆。

  • 类加载子系统:根据给定的全限定名类名(如java.lang.Object)在JVM启动时或者运行时将需要的class文件的内容加载到JVM的运行时数据区中。程序员也可以通过继承java.lang.ClassLoader类来定义自己的Classloader。
  • 运行时数据区:这就是我们常说的JVM的内存了。它主要分为五个部分:方法区、堆、本地方法栈、虚拟机栈和PC计数器,将运行时数据区划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者PC指针的记录器等。执行引擎在执行一段程序时需要存储一些东西,如操作码需要的操作结果、操作数等,这些都保存在内存中。
  • 执行引擎:负责执行class文件中包含的字节码命令,相当于实际机器上的CPU。执行引擎也就是执行一条条代码的流程,而代码都包含在方法内,所以执行引擎本质上就是执行一个个方法所串起来的流程,也就是对应我们通常所说的Java线程,每个Java线程就是一个执行引擎的实例
  • 本地方法接口:与本地方法库交互,是其它编程语言交互的接口。当调用native方法的时候,就进入了一个全新的并且不再受虚拟机限制的世界,所以也很容易出现JVM无法控制的native heap OutOfMemory。
  • 本地方法库:就是一些 C/C++ 的库函数。
总结起来就是,程序员编码后生成.java源码文件,然后经过javac命令前端编译后编译成.class字节码文件,然后由JVM中的类加载器子系统按需加载到运行时数据区,执行引擎通过与本地方法接口交互,间接的调用本地方法库里面的C/C++ 的库函数,生成指令,然后调用OS的API完成动作。

JVM是如何工作的

有人可能想问,为什么我要学习JVM是如何工作的呢?这有什么用呢?我想说的是,学习JVM是如何工作的,能够让我们知道JVM是干什么的,具体是怎么干的,能够让我们更加全面的理解初学时一些不懂的东西,此外,面试的时候也是必问的,如果只是零散的去看面试题应付面试,当然,可能会过关,但是我想说的是,看(背)面试题的过程是非常枯燥并且很容易会忘记的。

要了解JVM是如何工作的,那就要从java的编译和运行过程说起,为什么呢?因为OS是针对机器码指令来工作的,不是字节码,也不是java源码文件,而从java源码文件->字节码文件->机器码文件并进行执行的过程是由JVM来完成的。

java的编译

java的编译分为前端编译和后端编译。

前端编译(javac编译)

在这里插入图片描述
我们在初学的时候肯定用过javac来编译.java文件代码,用过java命令来执行编译后生成的.class文件(字节码文件),这就是前端编译,主要包括词法分析、语法分析、语义分析和生成字节码这几个步骤。

  • 词法分析:将获得的Java源代码信息转化为标记(Token)集合,比如关键字、变量、运算符等等,都是一个个的标记。词法分析的过程就是将这些标记解析出来。
  • 语法分析:在词法分析得到的标记集合的基础上,抽象出对应的语法树(语法树,简单说,一个Java源文件中包信息、import信息、类定义信息、方法信息、字段信息等作为一个个的项,这些项集合在一起就抽象为一棵语法树)。
  • 语义分析:复杂的语法简单化,比如foreach转化为for循环等。在语法分析之后,编译器获得了程序源码的抽象语法树,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的,语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。
  • 字节码生成器:将生成的语法树、符号表等信息转化为字节码输出到磁盘,即生成字节码文件,并且会进行相关的代码添加和转换工作,比如多个字符串变量相加a+b+c,实际上是创建了一个StringBuilder对象,对这些字符串变量进行append()操作,这些通过javap都能看到。

在这里插入图片描述

执行javac命令并查看字节码文件

前面我们已经讲了前端编译的整个过程,下面我们用javac命令执行一下,看一下详细的过程:
在这里插入图片描述
源码编写完成之后,我们执行javac -verbose HelloWorld.java:
在这里插入图片描述
之后,编译器编译我们的.java源码文件生成了.class字节码文件。无论是为了应付面试还是为了满足自己的好奇心,都应该认真的分析一下字节码文件,对它要有更进一步的理解。

为了能更清楚的分析字节码文件的组成,我们将上面HelloWorld.java修改一下,增加一些变量的定义,然后再javac编译一下。

在查看字节码文件之前,我们先从整体看下java字节码文件包含了哪些类型的数据:
在这里插入图片描述
下面我们查看一下字节码文件,查看的方式有编辑器工具、javap命令和idea 插件jclasslibBytecodeViewer,这是我目前知道的方式,javap命令这种方式是java提供的,也是最经常使用的方式。

  • 编辑器查看字节码文件
    在这里插入图片描述
    魔数magic&主版本号&次版本号:每个.class文件的前4个字节被称为魔数(magic number):0x CAFEBABE。魔数的作用在于可以轻松的分辨出是否是java class文件。如果一个文件不是以0xCAFEBABE开头的,那肯定不是java class文件。魔数后面的4个字节包含了主、次版本号。对于JVM来说,版本号确定了特定的class文件格式,通常只有给定主、次版本号后,JVM才能读取class文件。
    常量池数和常量池:即constant_pool.count 和 constant_pool,版本号后面跟着的就是常量池,它包含了源码文件中类和接口相关的常量,常量池可以理解为.class文件的资源仓库,是class文件结构中关联最多的数据类型,包含了源码文件中类和接口相关的常量,常量池中存储了比如文字字符串、final变量值、类名和方法名等等的常量。常量池标志如下:
入口类型标志值描述
CONSTANT_Utf81UTF-8 编码的Unicode字符串
CONSTANT_Integer3int类型字面值
CONSTANT_Float4float 类型字面值
CONSTANT_Long5long类型字面值
CONSTANT_Double6double 类型字面值
CONSTANT_Class7对一个类或接口的符号引用
CONSTANT_String8string 类型字面值
CONSTANT_Fieldref9对一个字段的符号引用
CONSTANT_Methodref10对一个类中声明的方法的符号引用
CONSTANT_InterfaceMethodref11对一个接口中声明的方法的符号引用
CONSTANT_NameAndType12对一个字段或方法的部分符号引用

访问标志:即access_flags,它展示了文件中定义的类或接口的几段信息。表示该class的属性和访问类型。比如该class是类还是接口,它的访问类型是否是public,类型是否被标记为final。access_flags的标志位如下:

标志名设置后的含义设置名
ACC_PUBLIC0x0001public类型类和接口
ACC_FINAL0x0010类为final类型只有类
ACC_SUPER0x0020使用新型的invokespecial语义类和接口
ACC_INTERFACE0x0200接口类型,不是类类型:所有的接口,没有类
ACC_ABSTRACT0x0400abstract类型所有的接口、部分类

:即this_class,它是一个对常量池的索引。

父类:即super_class,在class文件中,紧接在this_class 之后的是 super_class 项,它是一个两个字节的常量池索引。

接口计数和接口列表:即interfaces_count 和 intefaces,紧接着super_class的是 interfaces_count。此项的含义为:在文件中该类直接实现或者由接口所扩展的父接口的数量。在这个计数的后面是名为 interfaces的数组,它包含了对每个由该类或者接口直接实现的父接口的常量池索引。

类索引、父类索引和接口索引可以理解为一种描述的数据项目,.class文件就是靠这三项数据来确定这个类的继承关系的。

字段计数和字段列表:即fields_count 和 fields,紧接着在interfaces后面的是对在该类或者接口中所声明的字段的描述。首先是名为fields_count的计数,它是类变量和实例变量的字段的数量总和。在这个计数后面的是不同长度的 field_info 表的序列。只有在文件中由类或者接口声明了的字段才能在 fields 列表中列出。在 fields 列表中,不列出从超类或者父接口继承而来的字段。另一方面,fields 列表可能会包含在对应的java源文件中没有叙述的字段,这是因为java编译器可能会在编译时向类或者接口添加字段,添加进去的字段使用 Synthetic 属性标识。用于描述接口或者类中声明的变量。比如变量的作用域(public、private、protected)、是否是静态变量(static)、可变性(final)、数据类型(基本类型、对象、数组)等等。

方法计数和方法列表:即methods_count 和 methods,紧接着fields后面的是对该类或者接口中所声明的方法的描述。首先是名为methods_count的计数,它是一个双字节长度的对于该类或者接口中声明的所有方法的总计数。这个总计数只包含在该类或者接口中显式定义的方法(从超类或者父接口中集成来的方法不被计入)。在methods_count 后面的是方法的本身,它在一个method_info的列表中进行了阐述(methods_count指出了列表中有多少method_info表)。与字段表属性类似,不过方法描述的是方法的类型、作用域等等。

属性计数和属性列表:用于描述某些场景专有的信息,比如字段表中的特殊属性、方法表中特殊的属性等等。

编辑器工具查看字节码文件这种方式,我相信不会有多少人这样做的,即便是初学者,可能基于好奇心会去尝试打开看一下,但是当看到这些16进制的内容时,估计也会一脸懵,然后就不再继续了,因为这个是非常不方便查看的,非常不直观的,这个大家了解一下即可。下面我们看一下javap命令方式查看的效果怎么样吧?

  • javap命令查看字节码文件
    执行以下命令:
 javap -c HelloWorld //打印类中每个方法的反汇编代码,例如包含Java字节码的指令。

在这里插入图片描述
在这里插入图片描述
这个呈现出来的内容相比编辑器工具那种方式更加能让大家有点亲切感,比如看到里面的publicclassmain等字样时,就会觉得接近了,但是大家可以发现,javap命令虽然简单方便,分析起来还是比较麻烦,还不是那么的直观,那么有没有一种方式可以以界面的方式直观的分析字节码文件包含哪些东西的呢?下面我们看一下idea的jclasslibBytecodeViewer这个插件查看字节码的效果怎么样吧?!

  • idea 插件jclasslibBytecodeViewer查看字节码文件
    前提:本机idea要安装有jclasslib Bytecode Viewer插件(没有安装的就自行百度吧),即在这里插入图片描述
    然后我们选择我们要查看字节码的源码文件,点击菜单栏的View->Show Bytecode With jclasslib可以看到:
    在这里插入图片描述
    从上面图上,大家可以很清楚、很直观的看到字节码文件里面的内容,idea这个插件都帮我们分好类了,便于我们查看,比如我们之前说的主版本号、次版本号、常量池个数、访问标志等这些一般信息,如果想要再详细的看常量池数组都有什么,也可以点开常量池文件目录,以及接口、字段、方法等。下面我们一一的进行介绍一下。
    在这里插入图片描述
    一般信息:包括主版本号、次版本号、常量池计数、访问标志、本类索引、父类索引、接口计数、字段计数、方法计数、属性计数等字节码文件里面的一些基本信息。
    常量池:它包含了源码文件中类和接口相关的常量,常量池中存储了比如文字字符串、final变量值、类名和方法名的常量。具体的常量池标志列表可以看一下上面列出的表格。
    在这里插入图片描述
    接口:这里会列出本类实现的接口列表信息,包含了对每个由该类或者接口直接实现的父接口的常量池索引。
    字段:只有在文件中由类或者接口声明了的字段才会列出。在列表中,不列出从超类或者父接口继承而来的字段。另外列表可能会包含在对应的java源文件中没有叙述的字段,这是因为java编译器可能会在编译时向类或者接口添加字段,添加进去的字段使用Synthetic属性标识。
    方法:是对该类或者接口中所声明的方法的描述。这里会出现我们不认识一些字节码命令,但是只要点击一下,它就会直接跳到浏览器的jdk官网处的字节码命令去,里面有对应的解释。在这里插入图片描述在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    属性
    在这里插入图片描述
    至此,前端编译(Java源文件编译成.class字节码文件)的详细过程以及字节码文件内容就基本介绍完毕了。

后端编译(jit即时编译)

jit就是即时编译技术。为了提高热点代码的执行效率,在运行时, 虚拟机将会把这些代码编译成与本地平台相关的机器码完成这个任务的后端编译器称为即时编译器(JIT编译器)。
将java源码文件javac编译成.class字节码文件,再由类加载器加载到JVM的内存中,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还有一层编译,那就是即时编译,也叫后端编译。

最初,虚拟机中的字节码是由解释器(Interpreter)完成编译的,当虚拟机发现某个方法或者代码块的运行特别频繁的时候,就会把这些代码认定为『热点代码』。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。

执行即时编译是比执行传统编译要慢的,之所以说即时编译快,是当方法被判定热点方法经过即时编译优化后会执行更快。

即时编译是将编译成本地机器相关的机器码,并进行优化,然后再把变以后的机器码缓存起来,以备下次使用。

即时编译器类型

我们可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式

C1编译器(主要针对客户端程序)

特点是启动快。C1编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如GUI应用对界面启动速度有一定的要求。

C2编译器(主要服务器端应用程序)

特点是启动慢,内存管理快。C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或峰值性能有要求的程序。

分层编译

Java7引入了分层编译,这种风湿综合了C1的启动性能优势和C2的峰值性能优势,我们也可以通过参数"-client"“-server"强制指定虚拟机的即时编译模式。分层编译将JVM的执行状态分为了5个层次:
在这里插入图片描述
Java8中,默认开启分层编译,-client和-server的设置已经是无效的了。如果只想开启C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用C1,可以在打开分层编译的同时,使用参数:-XX:-TieredStopAtLevel=1。除了这种默认的混合编译模式,我们还可以使用”-Xint"参数强制虚拟机运行于只有解释器的编译模式下,这时JIT完全不介入工作,我们还可以使用参数"-Xcomp"强制虚拟机运行于只有JIT的编译模式下。

我本机使用的是jdk1.8,我们使用java -version来查看一下当前使用的编译模式(默认的混合编译模式):
在这里插入图片描述
我们使用java -Xint -version强制虚拟机运行于只有解释器的编译模式下:
在这里插入图片描述
我们再使用java -Xcomp -version强制虚拟机运行于只有JIT的编译模式下:
在这里插入图片描述

热点代码

后端编译(jit即时编译)主要是针对热点代码做编译的,而非热点代码是由字节码解释器逐条解释成机器码的。
热点代码:多次调用的方法或者代码块。

如何判断是否是热点代码呢?

  • 采样
  • 计数器
    • 方法调用计数器
      用于统计方法被调用的次数。
      默认阈值在C1模式下是1500次,在C2模式下是10000次,可通过-XX:CompileThreshold来设定,而在分层编译的情况下,-XX:CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整,当方法计数器和回边计数器之和超过方法计数器阈值时就会触发JIT编译器。
    • 回边计数器
      用于统计一个方法中循环体代码执行的次数。
      在字节码中遇到控制流向后跳转的指令称为『回边(Back Edge)』,该值用于计算是否触发C1编译的阈值。
      在不开启分层编译的情况下,C1默认为13995,C2默认为10700,可通过-XX:OnStackReplacePercentage=N来设置;
      而在分层编译的情况下,-XX:OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
      建立回边计数器的主要目的是未来触发OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。

java的运行过程

java的运行其实就是类加载器将java编译器编译好的字节码文件加载到内存中,然后由执行引擎去执行,说白了,就是类加载器将字节码文件信息加载到JVM的运行时数据区。运行过程就分两步:先类加载再类执行。

类加载

在这里插入图片描述
如图,类加载器子系统负责从文件系统或者网络中加载.class文件,.class文件在文件开头有特定的文件标识(连接阶段验证)。ClassLoader只负责.class文件的加载,至于它是否可以运行,是由执行引擎决定的,加载的类信息存放于一块称为方法区的内存空间,除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是.class文件中常量池部分的内存映射)。进入的是方法区.在方法区存储的是类的信息,包含常量,静态变量以及类元信息。

那么类加载器将.class文件加载到了JVM 的哪里呢?是怎么加载的呢?是一次性加载所有的字节码文件吗?

java中类的加载是动态的,并不是一次性加载所有的字节码文件,而是保证程序运行的基础类(基类)完全加载到JVM中,至于其他类,则在需要的时候才加载(懒加载),这也是为了节省内存开销。VM规定有且只有以下5种情况的类会被立即初始化(class文件加载到JVM中):

  1. 反射的方式。
  2. 创建实例的方式(new的方式)。访问某个类或接口的静态变量,或者对该静态变量赋值,调用类的静态方法。
  3. 被标明为启动类的类,直接使用java.exe命令来运行某个主类(包含main方法的那个类)。
  4. 初始化某个类的子类,则其父类也会被初始化。
  5. 当使用jdk1.7动态语言支持时。
如何将类加载到JVM

class文件是通过类加载器装载到JVM中的。基本上所有的类加载器都是java.lang.ClassLoader类的一个实例,它负责将Class的字节码形式转换成内存形式的Class对象,还负责加载Java应用所需的资源,如图像文件和配置文件等。在介绍类加载器之前,我们先说一下类的生命周期以及类的加载过程。

类的生命周期与类加载过程

在这里插入图片描述
如图,类的生命周期与类加载过程的关系清晰可见,类的生命周期分为七个阶段:加载、验证、准备、解析、初始化、使用和卸载。类的加载过程包括了加载、验证、准备、解析和初始化这五个阶段,其中验证、准备和解析这三个阶段又称为连接阶段。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,但是解析却不一定,为什么呢?先抛出这个问题,咱们接下来我们详细介绍一下这七个阶段,后面大家可以在评论区里面回答这个问题

  • 加载:查找和导入.class文件。
    加载就是将字节码文件加载到机器内存中,并在内存中构建出Java类的原型(类模板对象),JVM将字节码文件中解析出来的常量池、类字段、类方法都存储在模板中,这样JVM在运行期间可以通过类模板获取到类中的任意信息,遍历成员变量、方法调用,反射机制也是基于这个。

    加载时都干了什么?
    加载阶段主要是查找并加载二进制字节流,生成class的实例。
    在加载类的时候,JVM主要完成以下3件事:
    1、通过类的全限定名获取类的二进制字节流;
    2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    3、在内存中生成一个代表这个类的java.lang.Class对象,以此作为方法区这个类的各种数据的访问入口。

    加载class文件的方式

    • 从本地系统中直接加载
    • 从网络获取,比如Web Applet
    • 从zip包、jar包读取
    • 运行时计算生成,比如动态代理
    • 由其他文件生成,比如JSP应用从专有数据库中提取.class文件
    • 从加密文件中获取,比如防class文件被反编译的保护措施

    类模板与Class实例的位置
    类的存放位置是方法区(JDK7之前是永久代,JDK8之后是元空间)
    Class实例的位置就是堆,class文件加载到方法区之后,会在堆中创建对应类的Class类型对象。
    在这里插入图片描述

  • 验证:确保被加载的类的正确性,是对语法的正确性校验。
    验证的目的在于确保class文件的字节流中包含的信息符合当前VM的要求(比如字节码文件的魔数),保证被加载的正确性,并且不会危害VM自身安全。
    主要包括以下4中验证:

    • 文件格式验证:验证字节流是否符合Class文件格式的规范,比如是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型等;
    • 元数据验证:保证字节码在语义上是符合规范的,包括是否继承final、是否有父类、抽象方法是否有实现等;
    • 字节码验证:保证二进制文件的字节码是符合规范的,包括跳转指令是否指向正确位置、操作数类型是否合理等;
    • 符号引用验证:对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,如果校验不通过就会抛出异常,比如java.lang.NoSuchMethodError;

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

  • 准备:JVM为类的静态变量分配内存,并将其初始化然后设置默认值。
    这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化。
    在该阶段内存分配仅仅包括类变量(类变量会分配在方法区中),不包括实例变量(实例变量是会随着对象一起分配到Java堆中),而静态常量在准备阶段分配内存值是其真正的值。
public class Test {
  // 准备阶段 a = 0
  // 初始化阶段 a = 1
  private int a = 1;
  //准备阶段value的值就是100
  public static final int value=100;
}
  • 解析:把符号引用变成直接引用的过程。
    符号引用就是一些字面量的引用,就是一组符号来描述所引用的目标。
    直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄,也就是具体的内存地址。
    解析主要针对类或接口(对应常量池中CONSTANT_Class_info)、字段(对应常量池中CONSTANT_Fieldref_info)、类方法(对应常量池中CONSTANT_Methodref_info)、接口方法(对应常量池中CONSTANT_InterfaceMethodref_info)、方法类型(对应常量池中CONSTANT_MethodType_info)、方法句柄(对应常量池中CONSTANT_MethodHandle_info)和调用点(对应常量池中CONSTANT_InvokeDynamic_info)限定符7类符号引用转换为直接引用。
    在实际运行的时候,需要把符合引用中引用的相关类、接口加载到内存中,也就是转换成直接引用。在这里插入图片描述
通常是在初始化之后进行的,字符串常量池中不存在重复项。
  • 初始化:该阶段主要是为类的静态变量赋予正确的初始值并执行静态代码块,其重要的工作是执行<clinit>()方法,是类加载的最后阶段。
<clinit>()方法不需要定义,是javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,该方法仅能由javac编译器生成并由JVM调用,开发者无法定义同名方法,也无法调用,它是由类静态成员变量的赋值语句以及static代码块合并产生的。
  1. 构造器方法中指令按语句在源文件中出现的顺序执行。
public class Test {
    public int b = 3;

    static{
        a = 2;
    }

    public static int a = 1;

    public static void main(String[] args) {
        System.out.println(a); // a = 1
    }
}

为了能更好的理解这部分内容,我们写一个示例代码,如上,编译后,用idea的插件查看字节码文件如下图:在这里插入图片描述
我们把里面的特殊需要说明的地方拷贝下来:

0 iconst_2
1 putstatic #4 <com/vdong/wechat/core/controller/Test.a : I>
4 iconst_1
5 putstatic #4 <com/vdong/wechat/core/controller/Test.a : I>
8 return

从这段字节码能够看出先给变量a赋值为2,再给变量a赋值为1,变量b没有任何赋值操作,由此可以推断出<clinit>()方法中的指令由上到下顺序执行,并且a在连接阶段的准备过程中已经默认被初始化为0了。

  1. <clinit>()方法不同于类的构造器(构造器是VM视角下的())。
    <clinit>()方法只针对静态变量和静态代码块,那么我们把上面的例子修改一下,去掉里面的静态变量和静态代码块,再重新编译,我们看一下:在这里插入图片描述
  2. 若该类具有父类,JVM会保证子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
public class Test {
    public int b = 3;
    static class Father{
        public static int A =1;
        static{
            System.out.println("我是父类");
            A=2;
        }
    }

    static class Son extends Father{
        public static int B = A;
        static {
            System.out.println("我是子类");
        }
    }
    public static void main(String[] args) {
        System.out.println(Son.B);
    }
}

在这里插入图片描述
从执行的结果可以看出:先执行父类的<clinit>()方法,后执行子类的<clinit>()方法
4. VM必须保证一个类的<clinit>()方法在多线程下被同步加锁。因为<clinit>()方法已经被加锁了,所以在一个线程已经开始执行<clinit>()方法的时候,其它线程时没办法执行的,并且这个锁是隐式的锁,如果一个类的<clinit>()方法耗时很长,那么可能存在线程阻塞,引发死锁,并且这种情况不好排查。在这里插入图片描述
从执行结果看到<clinit>()方法在多线程下只会被执行一次。

初始化时机:
虚拟机启动时,初始化包含main方法的主类;
遇到new指令创建对象时;
访问静态方法或者静态字段的指令时;
子类的初始化过程中,如果发现父类还没有初始化时,则先初始化父类;
使用反射API进行调用时;
第一次调用java.lang.invoke.MethodHandle实例时,需要初始化MethodHandle指向方法所在类;

初始化顺序:
静态变量/静态代码块->普通代码块->构造函数

  1. 父类静态变量和静态代码块;
  2. 子类静态变量和静态代码块;
  3. 父类普通成员变量和普通代码块;
  4. 父类的构造函数;
  5. 子类普通成员变量和普通代码块;
  6. 子类的构造函数;
  • 使用
    类访问方法区内的数据结构的接口, 对象是Heap区的数据。

  • 卸载
    Java虚拟机将结束生命周期的几种情况
    1、执行了System.exit()方法
    2、程序正常执行结束
    3、程序在执行过程中遇到了异常或错误而异常终止
    4、由于操作系统出现错误而导致Java虚拟机进程终止

一个类何时结束生命周期,取决于class对象何时结束生命周期。满足以下3种情况才能允许类卸载:
1、该类所有对象、子类对象都被回收;
2、类的加载器被回收了;(很难)
3、Class对象也没有在任何地方被引用;
启动类加载器无法被卸载,扩展类加载器和应用程序类加载器都很难被卸载,因为都会直接或间接的被用到,只有自己开发的类加载器才可能被卸载。

类加载器的分类

Java中的类加载器大致可以分为两类:一类是系统提供的,另一类是研发人员自定义的。如图
在这里插入图片描述
启动类加载器、扩展类加载器、应用程序类加载器和用户自定义类加载器这四者是包含关系,不是子父继承的关系。

每一个Class对象都拥有对应的.class字节码内容,每一个class对象都有一个getClassLoader()方法,得到是谁把我从.class文件加载到内存中变成Class对象的。下面我们举例:

import sun.misc.Launcher;

import java.net.URL;

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("获取启动类加载器加载的路径-------");
        URL[] url = Launcher.getBootstrapClassPath().getURLs();
        for (URL url1 : url) {
            System.out.println(url1.toExternalForm());
        }
    }
}

在这里插入图片描述
可以看到启动类加载器加载的是\jre\lib\rt.jar或者-Xbootclasspath参数指定的jar包,下面我们再修改一下代码,获取一下启动类加载器,看看是否能获取到:

import sun.misc.Launcher;

import java.net.URL;
import java.security.Provider;

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("获取启动类加载器加载的路径-------");
        URL[] url = Launcher.getBootstrapClassPath().getURLs();
        for (URL url1 : url) {
            System.out.println(url1.toExternalForm());
        }

        System.out.println("获取启动类加载器---------");
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);
    }
}

结果:在这里插入图片描述
我们拿不到启动类的类加载器,原因是BootstrapLoader(启动类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null。那扩展类加载器和应用程序类加载器是java语言编写的,是sun.misc.Launcher的内部类,是否可以拿到它们的类加载器呢?

import sun.misc.Launcher;

import java.net.URL;
import java.security.Provider;

public class ClassLoaderTest {
    public static void main(String[] args) {
        System.out.println("获取启动类加载器加载的路径-------");
        URL[] url = Launcher.getBootstrapClassPath().getURLs();
        for (URL url1 : url) {
            System.out.println(url1.toExternalForm());
        }

        System.out.println("获取启动类加载器---------");
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);

        System.out.println("获取扩展类加载器加载的路径-------");
        String extDir = System.getProperty("java.ext.dirs");
        System.out.println(extDir);

        System.out.println("获取应用程序类加载器加载的路径-------");
        String classpath = System.getProperty("java.class.path");
        for (String path : classpath.split(";")) {
            System.out.println(path);
        }
    }
}

在这里插入图片描述
结果是我们可以获取到扩展类加载器和应用程序类加载器的。可能有人会问了,启动类加载器、扩展类加载器和应用程序类加载器都了解了,应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。那用户自定义类加载器呢?这个什么场景下会用到呢?这个也是面试中会问到的一道题。

为什么要用自定义类的加载器呢?(或者说自定义类的加载器的使用场景是什么?)
因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

  • 隔离加载类。(主要解决不同版本jar包的版本冲突。比如Spring框架和RocketMQ有包名路径完全一样的类,类名也一样,这时候类就冲突了,不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间是隔离的。)
  • **修改类加载的方式。**比如在执行非置信代码之前,自动验证数字签名。
  • 扩展加载源。(还可以考虑从数据库中加载类,路由器等等不同的地方)
  • 防止源码泄露。(对字节码文件进行加密,自己用的时候通过自定义类加载器对其进行解密)

了解了使用场景之后,那么如何实现自定义类加载器呢?有如下几个步骤:
1、继承java.lang.ClassLoader抽象类;
2、在jdk1.2之后,建议把自定义的类加载器逻辑写在findclass()方法中;
3、如果没有太过于复杂的需求,可以继承URLClassLoader,这样就可以避免自己去编写findclass()方法及其读取字节码流的方式,使自定义类加载器编写更加简洁。
总结:
启动类加载器(Bootstrap ClassLoader):

1、这个类加载使用C/C++语言实现的,嵌套在JVM内部;
2、它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类;
3、并不继承自java.lang.ClassLoader,没有父加载器
4、加载扩展类和应用程序类加载器,并作为他们的父类加载器
5、出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

扩展类加载器(Extension ClassLoader):

1、Java语言编写,由sum.misc.Launcher$ExtClassLoaer实现
2、派生于ClassLoader类
3、父类加载器为启动类加载器
4、从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

系统类加载器:

1、Java语言编写,由sun.misc.LaunchersAppClassLoader实现;
2、派生于ClassLoader类;
3、父类加载器为扩展类加载器;
4、它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库;
5、该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载;
6、通过classLoader.getSystemclassLoader()方法可以获取到该类加载器;

用户自定义类加载器:

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。

类的加载方式

类的加载有三种方式:

  • 命令行启动应用程序的时候由JVM初始化加载
  • 通过Class.forName()方法动态加载
  • 通过ClassLoader.loadClass()方法动态加载

定义一个测试类TestLoader和TestLoader2,代码如下:

public class TestLoader {
    public static void main(String[] args) throws ClassNotFoundException{
        ClassLoader loader = HelloWorld.class.getClassLoader();
        System.out.println(loader);

        loader.loadClass("com.xxx.TestLoader2");
//        Class.forName("com.xxx.TestLoader2");
//        Class.forName("com.xxx.TestLoader2", false, loader);
    }
}

public class TestLoader2 {
    static {
        System.out.println("执行静态代码块。。。。。。");
    }
}

此时我们执行main方法,结果如下:
在这里插入图片描述
仅仅是打印出了类加载器,我们看到TestLoader2只有一块代码,还是静态代码块,但是运行结果显示loader并没有执行这个静态代码块。

我们把loader.loadClass这行注释,把Class.forName(“com.xxx.TestLoader2”);打开,执行main方法,结果如下:
在这里插入图片描述
可以看到执行了静态代码块,然后我们把loader.loadClass这行和Class.forName(“com.xxx.TestLoader2”);这行都注释,打开Class.forName(“com.xxx.TestLoader2”, false, loader);然后执行main方法,结果如下:
在这里插入图片描述
然后我们把false改为true,再执行:
在这里插入图片描述
由此得出,Class.forName()和ClassLoader.loadClass()区别?

  • Class.forName(): 除了将类的.class文件加载到JVM中之外,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到JVM中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName(name, initialize, loader)带参函数也可控制是否加载static块,并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
类加载机制
  • 全盘负责。当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  • 父类委托。先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
  • 缓存机制。缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效的原因。
  • 双亲委派机制。如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制过程
在这里插入图片描述
如图,是类加载器加载类的详细过程,总结起来就是以下几点:
0、当CustomClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器AppClassLoader去完成(前提是有CustomClassLoader,如果无,跳过此步骤)。
1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
2、当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
3、如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
4、若ExtClassLoader也加载失败,则会使用AppClassLoader来加载
5、如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

其实这就是所谓的双亲委派机制。简单来说:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上。
好处

  • 避免类的重复加载(安全性角度)。
  • 保护程序安全,防止核心 API 被随意篡改。
  • 节省内存开销。

沙箱安全机制:java核心API中定义类型不会被随意替换,我们可以在java.lang包下新建了一个String类,运行main方法,通过类双亲委派机制传递到启动类加载器,而启动类加载器在核心JavaAPI发现这个名字的类,发现该类已被加载,并不会重新加载java.lang.String咱们自定义的这个类,而是直接返回已经加载过的String.class,这样便可以防止核心API库被随意篡改。回到例子中,运行main方法就会报错,提示找不到main方法。

在 JVM 中表示两个 Class 对象是否为同一个类,存在两个必要条件:
1、类的完整类名必须一致,包括包名
2、加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同

对类加载器的引用:
1、JVM 必须知道一个类型是由启动加载器加载,还是由用户类加载器加载
2、如果一个类型是由用户类加载器加载,则 JVM 会将这个类加载器的一个引用,作为类型信息的一部分保存在方法区中
3、当解析一个类型到另一个类型的引用时,JVM 需要保证这两个类型的类加载器是相同的

双亲委派机制代码的JDK的源码实现:

public Class<?> loadClass(String name) throws ClassNotFoundException {
   return loadClass(name, false);
}
//resolve参数告诉类装载器时候需要解析该类
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            //首先,检查类文件是否已经被加载过
            Class<?> c = findLoadedClass(name);
            //类文件未被加载过
            if (c == null) {
            //设定加载起始时间
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                    //如果存在父类加载器,就委派给父类加载器加载
                        c = parent.loadClass(name, false);
                    } else {
                    //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法findBootstrapClass(String name)
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    //如果非空父加载器中找不到name指定的类文件
                    //抛出ClassNotFoundException异常
                }
				//父加载器或者启动类加载器中都未搜索到该类文件
                if (c == null) {
                    //设定加载起始时间
                    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) {
            //resolve为true时解析类文件
                resolveClass(c);
            }
            return c;
        }
    }

private Class<?> findBootstrapClassOrNull(String name)
    {
        if (!checkName(name)) return null;

        return findBootstrapClass(name);
    }

    
private native Class<?> findBootstrapClass(String name);

看完loadClass()我们再看看findClass:

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

怎么默认直接就抛了异常呢?
其实是因为ClassLoader这个类是一个抽象类,实际在使用的时候会写个子类,这个方法的作用就是按照需要被重写,来完成业务需要的加载过程。
再简单介绍一下其他几个方法:
Class defineClass(String name,byte[] b,int len):这个方法在编写自定义classloader的时候非常重要,它能将类文件的字节数组转换成JVM内部的java.lang.Class对象。字节数组可以从本地文件系统、远程网络获取。参数name为字节数组对应的全限定类名。

Class findSystemClass(String name) :这个方法从本地文件系统装入文件。它在本地文件系统中寻找类文件,如果存在,就使用defineClass将原始字节转换成Class对象,以将该文件转换成类。

Class findLoadedClass(String name):调用该方法来查看ClassLoader是否已载入某个类。如果已载入,那么返回java.lang.Class对象;否则返回null。如果强行加载某个已存在的类,那么则抛出异常。

ClassLoader getParent():获取类加载器的父加载器。除启动类加载器外,所有的类加载器都有且仅有一个父加载器。ExtClassLoader的父加载器是启动类加载器,因为启动类加载器非java语言编写,所以无法获取,将返回null。

这样的双亲委派机制有好处,但是有些场景我们是希望破坏这种机制的

  1. 我们想在顶层的ClassLoader中加载底层的ClassLoader;
    可以在线程中放入底层的ClassLoader到Thread.setContextClassLoader()中,然后在顶层的ClassLoader中使用Thread.getContextClassLoader()加载第三方的ClassLoader实现。
  2. 实现类热部署;
    一个class只能被一个ClassLoader加载一次,当需要实现代码热部署的时候可以每次都new一个自定义的ClassLoader来加载新的Class文件。
  3. Tomcat中使用WebAppClassLoader进行单独加载,加载不了再去委托父类加载器去加载;
  4. SPI服务提供发现,全称为 (Service Provider Interface) ,是JDK内置的一种服务提供发现机制。比如你想扩展一些框架,如spring的一些功能,就是要实现它接口,然后自己配置了。JDBC4.0之后支持SPI方式加载 java.sql.Driver 的实现类,SPI 实现方式为:通过ServiceLoader.load (Driver.class) 方法,去各自实现 Driver 接口的 lib 的 META-INF/services/java.sql.Driver 文件里找到实现类的名字,通过 Thread.currentThread ().getContextClassLoader () 类加载器加载实现类并返回实例。JDBC是在DriverManager类里调用Driver类的,DriverManager类的加载器是BootstrapClassLoader,用BootstrapClassLoader去加载非rt.jar包里的类Driver,就会找不到,要想加载就需要在 BootstrapClassLoader加载的类里调用 AppClassLoader或其他自定义ClassLoader。说白了就是BootstrapClassLoader类加载不了,也不能加载,这样就造成向下依赖。
  5. 自定义类加载器也是破坏了双亲委派机制的,因为会继承ClassLoader,一旦重写了loadClass()就会有可能打破双亲委派机制。

运行时数据区

在前面我们已经介绍了由类加载子系统将字节码文件加载到运行时数据区的方法区中,那么接下来我们就研究一下这个运行时数据区吧。看看是将字节码文件里面的什么内容加载到了运行时数据区的哪里,之后JVM又干了什么吧?!

JVM的内存模型

我们知道字节码文件主要分为三部分:结构信息、元数据和方法信息,那么这些信息就会按照一定的规则被加载到指定的运行时数据区对应的内存中去,而运行时数据区主要分为五部分:方法区、堆、虚拟机栈、本地方法栈、PC计数器,那么如何将字节码的三部分和运行时数据区的五部分对应起来呢?那首先就要知道这五个部分分别存储什么样的数据或者是在执行什么的时候的运行时内存区。

在前面JVM的体系结构图里面大家已经了解了运行时数据区主要部分。

方法区

用于存储类/接口等元数据信息以及常量池,也可以进行垃圾回收,主要针对废弃常量和无用的类,是否对类进行垃圾回收可以通过-Xnoclassgc参数进行控制。

废弃常量:比如常量池里面的字面量,如果没有任何对象引用这个常量也没有其他地方引用这个常量,这时候就会垃圾回收。
无用的类:同时满足三个条件:
1、该类的所有实例都已经被回收;
2、加载该类的ClassLoader已经被回收;
3、该类对应的对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
这种类会被垃圾回收。

方法区也是堆中的一部分,也叫永久区,因为垃圾回收主要是针对java堆里面的对象的,而且一般可以回收70%-95%的空间,而永久区执行效率很低,所以这里说的是可以执行垃圾回收,但不是必须的。当方法区要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息,大小可以通过参数来设置,-XX:PermSize指定初始值,-XX:MaxPermSize指定最大值。

在大量使用反射、动态代理、CGLib等bytecode框架的场景以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。永久区是线程共享的内存区,在jdk7的时候永久代的部分数据就已经在转移到Java堆和本地堆了,只是没有完全移除,比如符号引用转移到本地堆,字面量和类的静态变量转移到了Java堆。

**在jdk8永久代被元空间代替了**原因是:
1、永久代大小不好设置,太小了,永久代容易溢出,太大了,就会造成老年代溢出;
2、永久代的垃圾回收效率低;
3、字符串在永久代中容易出现性能问题和内存溢出。

元空间和永久代的区别:元空间不在虚拟机中,而是在本地内存中,其大小取决于本地内存。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放,前面介绍字节码文件时已经有对应的截图展示,有兴趣的可以自己看看。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

为什么需要常量池?
一个 Java 源文件中的类、接口,编译后产生一个字节码文件,而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

堆(heap)区

用于存放各种对象信息,几乎所有的对象实例都在这里分配内存,是垃圾回收的主要部分。对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的**唯一目的就是存放对象实例**,几乎所有的对象实例以及数据都在这里分配内存。

为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):

  • 新生代:新对象和没达到一定年龄的对象都在新生代。
    新生代是所有新对象创建的地方,当填充新生代时,执行垃圾回收,这种垃圾回收叫做Minor GC,新生代又被分为三个部分:Eden区和两个Survivor区(被称为from/to或s0/s1),默认比例是8:1:1。
    • 大多数新创建的对象都位于Eden区
    • 当Eden区满了的时候,执行Minor GC,并将所有的存活对象移动到Survivor from区
    • Minor GC检查存活对象,并将它们移动到Survivor to区,然后将Survivor的两个区进行交换,并清空交换后的Survivor to区
    • 经过多次GC循环后仍然存活下来的对象会被移动到老年代,通常,这是通过设置新生代对象的年龄阈值来实现的,然后它们才有资格提升到老年代
  • 老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大。
    • 老年代包含的是经过多次YGC依然存活的对象和大对象(需要大量连续内存空间的对象),当老年代满了的时候,会执行垃圾回收,这个垃圾回收叫做Major GC,通常需要更长的时间。大对象直接进入老年代的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。
  • 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存。

我们通常会将 -Xmx 和 -Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。

Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。
在这里插入图片描述
可以看到i=806时出现了OOM异常:java.lang.OutOfMemoryError: Java heap space,代码如下:

import java.util.ArrayList;
import java.util.List;

public class HeapOOMTest {
    public static void main(String[] args) {
        List<Byte[]> list = new ArrayList<>();
        int i=0;
        boolean flag = true;
        while (flag){
            try {
                i++;
                list.add(new Byte[1024*1024]);
            }catch (Throwable e) {
                e.printStackTrace();
                flag = false;
                System.out.println("i="+i);
            }
        }
    }
}

这里我有一个疑问:到底new ArrayList<>()在堆里面分配了多大的内存空间

随着这个疑问,我们来研究一下JVM是如何分配内存的?

我们都知道堆占用的内存空间最多的,堆的存取类型为管道类型,先进先出,在程序运行中,可以动态的分配堆的内存大小。

JVM分配内存
JVM分配内存的方式有以下几种:

  1. 指针碰撞:将一块内存分配为已使用和未使用,中间分配使用指针分隔,分配内存就是将指针移动到与对象大小相同的内存块处;
  2. 空闲列表:一般内存中可能有不相邻内存块,JVM会维护一个列表,标记哪里是使用过的内存和未使用的内存,分配内存的时候会更新这个列表。

JVM解决分配内存并发问题的方法:
1、CAS+失败重试的方式:CAS就是判断是否相同,如果相同更新的方式没有更新成功,还是失败重试机制。
2、TLAB:将分配内存的动作按照线程划分为在不同空间,每个空间预先分配一段内存,默认使用-XX:+/-UseTLAB参数设定是否开启TLAB,默认JDK1.8开启,使用-XX:TLABSIZE指定TLAB大小。

什么是TLAB(Thread Local Allocation Buffer)?
从内存模型的角度来说,是对Eden区继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

为什么要有TLAB?

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

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM确实是将 TLAB 作为内存分配的首选。在程序中,可以通过-XX:UseTLAB设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过-XX:TLABWasteTargetPercent 设置TLAB空间所占用Eden空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

对象在内存中的布局:
对象在内存中分为对象头、实例数据和对齐填充。
对象头有包括mark word、Klass Pointer指针(堆中对象指向方法区的类信息)和数组长度。
查看对象头实例:

ClassLayout classLayout = ClassLayout.parseInstance(new ArrayList<>());
System.out.println("classLayout="+classLayout.toPrintable());

在这里插入图片描述
如图,就可以计算出来对象实例大小,但是这个不包括超类继承下来的和当前类声明的实例引用字段的对象本身的大小、实例引用数组引用的对象本身的大小。

JVM对象的内存分配:
在这里插入图片描述
简单来说就是:

  1. new 的对象先放在Eden区,此区有大小限制;
  2. 当Eden区的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区;
  3. 然后将Eden区中的剩余对象移动到S1区;
  4. 如果再次触发垃圾回收,此时上次存活下来的放到S1区,如果没有回收,就会放到S2区;
  5. 如果再次经历垃圾回收,此时会重新放回S1区,接着再去S2区;
  6. 什么时候才会去老年代呢? 默认是 15 次回收标记;
  7. 当老年代内存不足时,再次触发 Major GC,进行老年代的内存清理;
  8. 若老年代执行了 Major GC 之后发现依然无法进行对象的保存,就会产生OOM异常;
垃圾回收

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。 针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)。

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 会有单独收集老年代的行为
      • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾。

如何判断一个对象是否可以被回收?

  • 引用计数法
    给对象添加一个引用计数器,当对象增加一个引用时计数器加1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。 但是两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。 正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
  • 可达性分析算法
    通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
    1、虚拟机栈中引用的对象
    2、本地方法栈中引用的对象
    3、方法区中类静态属性引用的对象
    4、方法区中的常量引用的对象

对象有哪些引用类型?
无论是通过引用计数法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 具有四种强度不同的引用类型:
强引用:被强引用关联的对象不会被回收。使用 new 一个新对象的方式来创建强引用。
软引用:被软引用关联的对象只有在内存不够的情况下才会被回收。使用 SoftReference 类来创建软引用。
弱引用:被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。使用 WeakReference 类来实现弱引用。
虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。 为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。 使用 PhantomReference 来实现虚引用。

有哪些基本的垃圾回收算法?
标记 - 清除:将存活的对象进行标记,然后清理掉未被标记的对象。不足:1、标记和清除过程效率都不高;2、会产生大量不连续的内存碎片,导致无法给大对象分配内存。
标记 - 整理:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
复制:将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。主要不足是只使用了内存的一半。虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
分代收集:现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代。新生代使用:复制算法(每次垃圾收集都能发现大批对象已死, 只有少量存活,因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集),老年代使用:标记 - 清除或者标记 - 整理(因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清除”或“标记—整理”算法来进行回收,不必进行内存复制,且直接腾出空闲内存)算法。
分区收集算法:分区算法则将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收,这样做的好处是可以控制一次回收多少个小区间,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次 GC 所产生的停顿。G1:应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。ZGC: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收。

虚拟机栈

虚拟机栈是执行引擎执行字节码是的运行时内存区,用于存放执行每个方法时的调用数据,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用来存储局部变量表、操作栈、动态链接、方法出口等信息,调用开始,执行入栈,调用返回,执行出栈。

虚拟机栈总是跟线程关联在一起,每当创建一个线程就会创建对应的虚拟机栈,虚拟机栈又包含了多个栈帧,栈的大小决定了栈帧的深度。可以通过-XSS来配置栈的大小。栈帧包括局部变量表、操作数栈、动态链接方法和返回地址。每当一个方法调用完成时,这个栈帧就会弹出栈帧的元素作为这个方法的返回值并且清除这个栈帧,虚拟机栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向该地址,只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧被创建,这个新创建的栈帧被放到栈顶,变为当前的活动栈,同样,现在只有这个栈的本地变量才能被使用,当这个栈帧中所有指令都完成时,这个栈帧被移除虚拟机栈,刚才的那个栈帧变为活动栈帧,前面栈帧的返回值变为这个栈帧的操作栈的一个操作数,由于虚拟机栈是与线程对应起来的,虚拟机栈数据不是线程共享的,所以不需要关心其数据一致性,也不会存在同步锁的问题。在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,栈溢出的代码实例:

public class StackOverTest {
    private static int i=1;
    public void call(){
        i++;
        call();
    }

    public static void main(String[] args) {
        StackOverTest stackOverTest = new StackOverTest();
        try {
            stackOverTest.call();
        }catch(Throwable t){
            System.out.println("stack deep:"+i);
            t.printStackTrace();
        }
    }
}

在这里插入图片描述
可以看到栈深度达到17324时,抛出了java.lang.StackOverflowError异常,如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。在Hot Spot虚拟机中,可以使用-Xss参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度;

本地方法栈

执行引擎执行本地方法时的运行时内存区,也会出现StackOverflowError异常和OutOfMemoryError异常;

PC寄存器(程序计数器)

指示下一步要执行的指令。因为java是支持多线程的,所以当有线程交叉执行时,被中断的线程必须要保存当前执行到哪儿了,以便后面恢复被中断的线程按照被中断时的指令地址继续执行下去;

字节码内容与运行时数据区对应起来

在这里插入图片描述
如图,我们简单的把字节码文件和运行时数据区对应起来了,但是这样看来还是比较抽象,我们用例子来宏观的说明一下:
在这里插入图片描述
User.java的代码:

public class User {
    private String name;

    public User(String name) {
        this.name = name;
    }

    public void printName(){
        System.out.println("我的名字是:"+name);
    }
}

UserTest.java的代码:

public class UserTest {
    public static void main(String[] args) {
        User user = new User("张三");
        user.printName();
    }
}

简单的把这两个类的字节码文件内容和运行时数据区存储域进行映射一下:在这里插入图片描述
更直观的就是:在这里插入图片描述
这个就是我简单的根据上面的代码实例结合字节码文件和运行时数据区的映射图大概列了一下,比如常量池里面的字面量还有在方法区里面加载类信息和方法信息等,便于大家理解。

为什么main()不在方法列表里面?

因为静态绑定机制不会使用到方法表,而main()是静态方法,使用的是静态绑定机制。
Java类中public和protected实例方法会使用到动态绑定机制,私有方法/静态方法/初始化方法/构造器等使用的是静态绑定机制,而动态绑定机制会使用到方法表,静态绑定机制不会使用到方法表。

如果还不太理解,那就简述一下工作流程:

  1. 通过运行UserTest.class,随后被加载到JVM中,元空间存储着类的信息(包括类的名称、方法信息、字段信息。。);
  2. JVM找到UserTest的主函数入口,也就是咱们图上的②,并在虚拟机栈中为main函数创建栈帧,开始执行main();
  3. main()的第一行就是User user = new User("张三");,就是让JVM创建一个User的对象,但是这时候方法区里面没有User类的信息,所以需要马上加载User类,把对应的类型信息放到方法区(元空间)中;
  4. 加载完User类之后,JVM做的第一件事就是在堆里面为一个新的User实例分配内存并调用构造函数初始化User实例,这个User实例持有指向方法区User类的类型信息(其中就包括方法表,Java动态绑定的底层实现)的引用;
  5. 当使用user.printName();的时候,JVM根据user引用找到User对象,然后根据User对象持有的引用定位到方法区中User类的类型信息的方法表,获得printName()的字节码地址;
  6. printName()创建栈帧并开始运行;

至此,大家也就能明白了类加载器将字节码文件的内容分别加载到了运行时数据区的哪些部分,目前图上看到的是方法区、堆和虚拟机栈中,其实在运行过程中还会被加载到程序计数器、在执行本地方法时还会被加载到本地方法栈中。那到底是怎么运行的呢?这就要说一下执行引擎了。

执行引擎

执行引擎的任务就是把字节码文件编译成操作系统可识别的的本地机器指令。执行引擎的工作过程:

  1. 执行引擎在执行过程中究竟需要执行什么样子的字节码指令完全依赖于PC寄存器;
  2. 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址;
  3. 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在java堆区中的对象实例信息以及通过对象头中的元数据指针定位到目标对象的类型信息(方法区);

执行引擎包括:解释器、JIT即时编译器和GC。
解释器:就是一个运行时“翻译者”,将字节码文件中的内容翻译成对应平台的本地机器指令执行,当一条字节码指令被翻译执行完成后,接着再根据PC寄存器中记录的下一条需要被执行字节码指令进行解释操作,分为字节码解释器(效率低)、模板解释器(效率高),不论哪种都是低效的代名词,为了解决这个问题,JIT编译器诞生了。

JIT(just in time)编译器:把字节码按照方法为单元进行编译并缓存到方法区。优点就是速度快。

既然JIT编译器效率高,为什么要留着解释器呢?
1、在项目启动的时候,解释器可以立马发挥作用,省去编译的时间,立即执行;
2、当JVM启动后,解释器可以首先发挥作用,不必等着全部编译完才能执行。随着运行时间的推移,根据热点探测技术,将有价值的字节码翻译为本地指令,缓存到方法区;
3、JIT编译器出现问题的时候,解释器作为备用方案;

JIT编译器在运行时会针对那些调用频繁的“热点代码”做出深度优化,将其直接翻译为本地平台的本地机器指令,缓存到方法区,这个过程叫做栈上替换或者(OSR)。

总结

以上就是将Java源码文件编译成class文件,然后JVM将class文件的字节码指令进行识别并调用OS的API完成动作的全部流程。
文章以微观角度描述的东西更多一些,更偏重细节一些,也更加容易理解,之前都是知其然,不知其所以然,这也解开了我之前好奇的地方,不知道大家有没有过和我一样的好奇的地方。
本文是以个人对JVM的了解认知以及个人的疑问来完成的一篇文章,主要是记录一下,便于后续阅读参考,其中也有很多实例(测试)帮助更好的理解中间的细节,在编写的过程中,我也有一种茅塞顿开的感觉,可能记录的并不完整,并不是完整的JVM体系相关的内容,但是也有一些可以参考的价值,希望能帮助到其他人。

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值