取法其上,得乎其中

对Java虚拟机的系统性学习: JVM的概述与整体结构

为什么学习JVM

从很早之前开始就关注到JVM是一门理应深入学习的方向,但我一直将其归咎为为了面试而去学习的一类,对于我目前又并无益处,以致于并不想投入尽力于其中。但近期在牛客网练习Java方向题目时才发现,某种程度上来讲,想要深入理解Java这门语言,必须要从JVM的角度进行切入。只有这样才能称之为对Java很是了解。

于是学习JVM的目的可分为三个。

  1. 面试需要,本质来讲还是内卷化导致必须要拿起更上一层的技术来过滤大量的CRUD工程师...
  2. 深入理解Java,当工作年限久了之后,很多时候需要解决的问题都必须要深入到字节码层次去分析才可以得到准确的结论。从之前学习Redis、Mysql底层的知识就可以知道,其实大多概述大家都是人云亦云、抄来抄去,谁也不知道谁对谁错,只有深入底层去了解才可以明了。
  3. 基于JVM优化(排查)问题,当线上出现问题时,很有可能因为JVM的参数分配不合理而导致内存溢出、GC频繁导致响应慢等问题。

简单了解

本质来讲Java最大的特性就是使用JVM去兼容了不同平台的不同指令,以此Java成为了一门跨平台语言。大概流程是:

  1. 编写.java文件
  2. 编译.java文件倒.class字节码文件,称为前端编译器
  3. 由JVM的类加载器加载.class文件
  4. 由JVM的执行引擎进行执行(解释执行亦或JIT实时编译)

而其他的语言,如C、C++、Golang则是直接由编译器编译成二进制文件。

狭义的讲JVM的作用就是代操作系统去运行程序。  那么JVM名为虚拟机便好理解了,这也意味着它为了支撑程序在其上运行,必然实现了类系统的种种机制。在此其中,JVM还会自动的在运行时优化我们的程序,以及自动对不需要的对象(垃圾)进行回收。这样便可以在很多时候规避内存泄漏、溢出。

所以我们需要了解的部分为:

  • 程序(类)是如何加载的?
  • 程序(类)是如何运行的?
  • 程序(类)是如何优化的?
  • 垃圾回收是怎么回事?

此外,JVM本质上是一种规范,而我们所要学习的虚拟机其实是要对应到JVM规范的一个落地实现。其中自JDK1.3依赖,JVM的默认虚拟机为Hotspot。因此后续所有的理论均以Hotspot为准。

程序(类)的加载

类的加载一般要引入加载器的概念,即加载器将类加载到内存之中。而不同的类对应不同的加载器,一般划分为如下三种:

  • 引导类加载器,主要加载jre/lib下的jar包,主要是Java的核心库。
  • 扩展类加载器,主要加载jre/lib/ext下的jar包,核心类以外的拓展类。
  • 应用程序加载器(系统类加载器),主要加载用户自行编写的类,以及引入的依赖库。

其中引导类加载器由c/c++进行编写,而扩展类加载器以及应用程序类加载器继承于ClassLoader类,用户也可以继承ClassLoader类自定义自己的类加载器。比如可以对网络传递的类进行加载。此外,从继承关系来看这三个加载器应该没有任何关联,但我们一般会认为这三种加载器自前往后在逻辑上归为父子关系

而主要体现就在于双亲(parent)委派机制。即,当类加载器收到类的加载通知的时候,不会首先由自己进行直接加载,而是将次加载请求委托至自己的父类,只有当父类并不认为该类属于自己的职责范围的时候,才会进行加载。

而之所以这样做,主要是一种沙箱机制——为了保护核心类库不会被“替换。

当通过类加载器成功加载后,需要将当前类与依赖项进行链接,即为链接阶段

  • 校验,验证对应的类是否符合JVM的规范。
  • 准备,为类中的类变量(静态变量)分配空间并初始化默认值(并非赋值,如int赋以0)。
  • 解析,将类中的依赖项由原本的符号引用转换为直接引用,即原本类中所定义的类型只是一个“名字”,而此时将对应的内存地址直接拿过了。显然,如果对应的类型还没有加载,那么此时会去先加载对应的类型,然后再进行下一步。
  • 初始化,执行类默认构造器(<init>)。

其中初始化中的默认构造器<init>主要会执行类中显式复制的类变量语句,以及代码块语句,其中先后顺序为自前往后顺序。

程序(类)运行需要的环境

如果想要了解程序是如何运行的,则不得不需要了解JVM是如何设计它的“系统”。JVM虚拟机主要划分为五块区域,统称为运行时数据区

下述对不同数据的规划属于JVM官方规范,但并不意味JVM落地实现会遵守规范。最主要容易受到歧义的就是关于“运行时常量池”和“静态变量”到底存放在哪里的问题,该问题将在后续关于方法区的段落进行解释。

对线程共享的区域:

  • 方法区,存放类信息、运行时常量池、静态变量、即时编译器编译后的代码缓存。
  • 堆,存放对象及其他的数据

线程私有的区域:

  • 虚拟机栈,负责维护方法的执行、嵌套
  • 本地方法栈,负责维护底层C/C++方法的执行、嵌套
  • 程序计数器(PC计数器),表明下一条指令的地址

下面将逐个解释对应的区域作用。

虚拟机栈

虚拟机栈内部保存一个个的栈帧(stack frame),一个栈桢对应一个方法。

一个活跃的线程中,一个时间点只有一个活跃的栈桢,成为“当前栈桢”,对应的方法称之为“当前方法”,如果当前方法调用了新的方法,那么会使对应的方法成为新的栈桢。而当前方法执行完毕后会将结果返回给前一个栈桢,并出栈。

显然,如果方法嵌套过多,将会因为虚拟机栈过多而导致栈溢出。可以通过JVM指令设置栈大小。

而每个栈桢由:局部变量表、操作数栈、动态链接、方法返回地址,以及一些附加信息,五种区域组成。

局部变量表

本质为一个数字数组,存储方法参数和定义的局部变量。在其中32位大小的类型占用一个slot,64位的long和double占用两个。其中一个方法如果非静态方法的话,在局部变量表的0号slot会去指向当前对象 this。

操作数栈

根据字节码指令向操作数栈中push或pop数据,为指令临时存储指令间的中间值。可以类比使用栈计算逆波兰表达式的代码。

LeetCode:150. 逆波兰表达式求值

动态链接

Java源程序在被编译成字节码时会将需要从外部依赖调用的类型、数据存储为符号引用。该符号引用由包名、类名、方法名共同组成,而动态链接存储的是符号引用的是外部依赖对应的直接引用。


一般无法在编译期间确定具体调用的目标成为虚方法,反之为非虚方法。非虚方法由静态方法、私有方法、final方法、实例构造器、父类方法组成。之所以分虚与非在于底层会使用不同的字节码指令。

方法返回地址

存放当前栈桢入栈时的程序计数器的地址即,当前方法执行完毕后回到上一个栈桢对应方法继续执行。此外,当前方法结束后还要把返回值压入上一个栈桢的操作数栈之中。而当方法出现异常时,会由维护的异常表确定程序计数器地址。

本地方法栈

本地方法指调用一个被标记为native的非Java实现的方法,一般称为本地方法接口。其意义在于Java的一些需求依赖于操作系统,而不可避免的需要基于c/c++的。

而本地方法栈即是本地方法的虚拟机栈,在Hotspot虚拟机中将两者合二为一。

堆是Java内存管理的核心区域,一个JVM实例中仅存一个。可修改其对应的空间大小,但启动后便固定,其中默认堆的初始大小为内存的六十四分之一,而最大为内存的四分之一00。而一般建议将初始大小和最大大小通过指令设置为相同,以避免系统的频繁扩容与回收

前述可知堆在运行时数据区属于线程共享区域,不过还存在一小块的线程私有区域——TLAB线程缓冲区。

几乎所有的对象实例和数组都存放在堆上,这也意味着它是垃圾回收的重点区域。根据规范,一般分为两块:年轻代区域、老年代区域。其中二者默认比例为1:2,亦可使用参数 --XX:NewRatio进行设置。

那么堆中为什么将堆划分为年轻代和老年代呢?最主要的原因是系统中的对象并非都可以长久存于内存的,有的对象可能刚存在一小会儿就失去用处,而有的对象可能一直持续存在到程序结束。而对于JVM而言,这两类对象在执行垃圾回收的时候必须要作以区分才能使算法达到最优

其中,新生代划分为下述三个部分,且默认比例为8:1:1,但由于JVM自适应内存分配策略的缘故,一般实际比例为6:2:2。可通过参数 --XX:SurvivorRatio 进行设置。

  • Eden 伊甸园区,当对象新生时存储于此
  • Survivor0区,幸存者0区
  • Survivor1区,幸存者1区

总而言之,之所以这样划分本质来讲也是因为为了契合垃圾回收算法的缘故,因此下面需要了解对象分配过程及新生代执行垃圾回收才可以理清为何这样设计。

对象分配过程

  1. 新生对象放入Eden之中
  2. 当新生代内存空间慢时,将会触发垃圾回收对新生代回收空间

    1. 对新生代中非垃圾对象以及对已存在数据的幸存者区中非垃圾对象进行标记,该幸存者区称为from。
    2. 将两块空间中非垃圾对象放入当前没有对象的幸存者区,该幸存者区成为to。
  3. 当幸存者区中的对象经历过15次垃圾回收仍未被当作垃圾,那么将其放入老年代。
特殊情况

当超大对象在创建时Eden无法存入的时候

  1. 对新生代进行垃圾回收

    1. 垃圾回收后幸存者区无法存入
    2. 直接晋升老年代
  2. 否,将其存入老年代

    1. (老年代无法存入)对老年代进行垃圾回收
    2. (老年代仍然无法存入)如果无法未开启自适应内存的话,爆出OOM内存溢出

TLAB

每个线程在Eden中都会分配一个私有缓存区域,其占有Eden的1%空间。之所以TLAB是私有的,主要面对在于让线程为新创建对象申请堆内空间时不需要进行加锁保证数据操作的原子性,因此线程会优先在TLAB中进行分配。

堆是分配对象的唯一方式吗?

总而言之,一个对象如果没有在它方法外的地方进行调用,那么此对象将会被优化后成在栈上分配,从而无需在堆中分配空间,当栈桢执行结束后自会被消除。而具体如何判定其是否在方法外地方调用,使用的技术称之为逃逸分析

除此之外,逃逸分析还可以检测如果某些被锁的对象是否逃逸出方法作用域,如果该对象只被当前对象所持有,那么锁就会被消除。

其次,当对象不会发生逃逸,且该对象可以被分解为若干成员变量时,会将对象所属属性转换为局部变量,称之为标量替换

方法区

首先,方法区是JVM规范中定义的一种概念,主要目的在于存储类信息、运行时常量池、静态变量,以及即时编译器编译后的代码缓存(JVM规范)。

但值得注意的是,在JDK1.6及以前,静态变量存于永久代。但JDK1.7时,字符串常量及静态变量就存放在堆了。其后当永久代被移除替换为元空间时,此两者仍然存于堆空间之中。

其在Hotspot虚拟机中共有两种落地实现,其一为JDK7及以前版本的永久代,以及JDK8开始支持的元空间元空间与永久代最大的区别在于其并不使用JVM内存区域,而直接单独像系统进行申请内存空间因此大部分场景下无需担心元空间的内存是否溢出。

元空间默认大小为21M,当超出其最高水位线(21M)时将会对元空间进行垃圾回收,去卸载一些无用的类,如果并不能有效的进行回收,则选择进行扩展空间。反之如果有效,则降低高水位线。不过为了避免元空间频繁的扩容与缩容,则应提高元空间默认大小。

与元空间反之,永久代的劣处自然就是其只能是JVM内存区域中的一块,其空间大小会受到JVM的掣肘,又因为类信息的回收条件极其苛刻,当项目长时间运行,且使用大量动态工厂生成的类,很容易导致内存溢出。

类型信息

  • 完整名称,包名以及类名
  • 父类的完整名称
  • 类型修饰符
  • 所实现了哪些接口
  • 字段信息
  • 方法信息,名称、返回类型、参数、修饰符、栈桢信息、异常表

运行时常量池

在类的编译期间会为每个类生成该类所需要依赖类的列表,该表称之为类常量表。而其本质存储的仅是符号引用。而运行时常量池是根据所有已加载的类的常量表去维护一个整体的常量表(池),且其中根据符号引用名称对应到在内存中所属类的直接地址。

造一个对象吧

创建对象的步骤

  1. 判断对应类是否被加载

    1. 通过符号引用去方法区寻找
    2. 存在则进行下一步,反之加载对应依赖类
  2. 在堆中分配内存空间,其中普通类型变量根据其自己定义的空间大小去分配,而引用类型是4字节。此外在分配的过程中会使用CAS的方式对区域进行加锁以保证更新原子性。

    1. 判断空间是否规整,空间是否规整取决于对应垃圾回收器使用的垃圾回收算法
    2. 内存规整使用指针碰撞法,反之通过维护的空闲空间法
  3. 初始化类属性默认值,如int、boolean初始化为0值,又有其他初始化为null
  4. 设置对象头信息,哈希值、垃圾回收信息、锁信息
  5. 执行构造方法

对象在内存中的存储

对象分为两块空间,其一为对象头,存于方法区之中。其二为示例数据存于堆之中。

对象头共有类型指针以及运行时元数据,其中类型指针即指向到对象对应类的地址。而运行时元数据共有以下数据:

  • 哈希值,在堆中的标识
  • GC信息,主要是经历过的GC次数,即分代年龄
  • 锁状态标志,被那些线程所持有
  • 偏向锁信息,偏向线程ID与偏向时间戳

对象是如何被引用的

一般共有两种方式,其一为句柄引用,其二为直接地址引用。

首先,Hotspot中主要使用直接地址引用,即每个对象的引用地址是直接指向到对应对象(头)的地址。其主要劣处在于,当对象的地址被更改的时候(比如垃圾回收之后)则需要将所有之前引用该对象的引用地址全部进行更换,这将耽搁非常多的时间。

而句柄引用则规避了这个问题,主要思路在于为每个对象的当前地址维护到一个句柄表之中,其他对象都直接对应句柄表,而垃圾回收后也只需要修改句柄表。但主要劣处在于需要维护一个与JVM中对象数量成正比的句柄表。占内存~

程序(类)的运行

对于程序的运行而言,JVM早期仅有基于解释器的执行引擎进行逐行解析字节码。但时至今日,基于解释器执行已沦为低效的代名词。为了解决这个问题,就出现了JIT(Just in time)即时编译技术,其主要实现了在程序基于解释器执行的同时去分析那些代码属于经常执行的代码,即热点代码,并将其编译为机器码,其后当需要执行热点代码时只需要直接执行机器码就可以。

而判定代码是否为热点代码是根据某个方法、某个循环体的执行频率,而对应的执行频率的要求根据不同编译器而不同。在HotSpot虚拟机之中支持了两种JIT编译器,分别是对应32位系统的C1(Client)编译器,以及64位系统的C2(Server)编译器,其中在c1模式下需要执行1500次,而c2模式下是10000次,可通过参数进行修改。这个数量是指在一个时间段内的频次,如果超出时间限度,则减去一半的次数。

随着JIT技术的落地,这代表着Java项目在长期运行的情况下,其几乎所有的代码都会被编译成机器码,这也代表其性能将直逼C/C++以及其他原生编译的语言。

对Java虚拟机的系统性学习: JVM的概述与整体结构

https://ku-m.cn/index.php/archives/706/

作者

KuM

发布时间

2022-11-10

许可协议

CC BY 4.0

添加新评论