Java底层知识之JVM基础(上)

65 阅读43分钟

JVM与Java体系结构

image-20221206083218136

image-20221206083727547

image-20221206083915244

image-20221206084155450

image-20221206084248596

image-20221206084845061

image-20221206084932679

image-20221206085036827

Java以及JVM简介

image-20221206085725572

image-20221206085749803

image-20221206085915488

image-20221206090629056

JVM是跨语言的平台,许多平台的代码都会通过编译器转化为字节码文件被Java虚拟机识别并装载并运行

image-20221206090700554

image-20221206090914862

image-20221206092150645

Java发展中的重大事件

image-20221206093521657

image-20221206093618742

image-20221206093801946

image-20221206094918688

image-20221206095614968

JVM的位置

无聊的知识点度过之后,我们现在正式来进行JVM的学习,首先我们要了解JVM的位置

image-20221206100022850

JVM运行在操作系统之上,与硬件没有直接的交互,用户的高级语言必须通过编译器翻译成字节码文件之后才能被JVM读取并加载

image-20221206103205437

JVM位于JRE中的最底层

image-20221206103343372

我们这里讲解的JVM结构是HotSpot VM的结构,其他的虚拟机可能结构有所不同

字节码文件首先要经过类装载器子系统,根据字节码的内容在内存中生成真正的对象,运行时数据区有很多内容,这些内容我们后面会讲解,操作系统只能接受机器指令,而我们的字节码指令必须要经过执行引擎才能够翻译成机器指令,最后被操作系统识别

java代码执行流程

下面是Java代码的执行流程图

image-20221208074341972

更加具体的图如下所示,编译器为JVM前端编译器内容,我们看看即可,总之其令源码变为字节码,然后字节码进入虚拟机最后会进入执行引擎,执行引擎内有翻译字节码和JIT编译器,前者翻译字节码为机器指令,后者负责编译指令内的内容并生成对象

image-20221208074520979

最后我们看一下语言转换的流程图

image-20221208074654806

JVM的架构模型

image-20221208075038222

JVM的架构模型总共有两种,一种是基于栈式架构 ,一种是基于寄存器架构,两者的优缺点都在上图中

一般来说,我们的JVM都是栈式架构的,最起码我们的JVM就是栈式架构的

image-20221208080916397

JVM的生命周期

JVM的生命周期分为启动、执行和退出

image-20221208081540020

启动是通过引导类加载器创建一个初始类来完成,该类是由虚拟机具体实现指定的

image-20221208081634098

我们JVM中的运行时数据区其实就是Runtime类,该类是一个单例类

JVM的发展历程

了解为主的内容,最主要的是前三个虚拟机,这才是大头,后面则是自己看的内容

image-20221208094523483

第一款虚拟机只能在解释器和JIT上二选一,这就导致效率上不尽人意

image-20221208095438567

第二款虚拟机具有现代化雏形,但是很短命

image-20221208102215502

第三款虚拟机则是我们本章需要介绍的虚拟机,也是目前的占有率最高的虚拟机

image-20221208102800409

下面都是些了解为主的内容

image-20221208104359190

image-20221208105243054

image-20221209090732113

image-20221209091017602

image-20221209091036146

image-20221209091613242

image-20221209092013688

image-20221209092158027

image-20221209094452474

image-20221209100349439

image-20221209100445413

内存结构概述

接下来我们来学习JVM内部的内存结构,首先我们来学习第一个结构,也就是字节码文件的编译的结构

首先字节码文件要经过类加载器子系统(Class Loader),将字节码文件加载到内存当中并生成对应的对象,当需要真正执行指令时,则是执行引擎在发挥作用,需要在虚拟机栈中取出数据

image-20221209101701401

然后我们来看看详细的流程

image-20221209102602834

字节码文件进行编译首先需要进入加载环节,加载环节中有三个加载器,分别是引导类加载器、扩展类加载器和应用类加载器,当然,我们也可以自定义加载器。然后是连接,连接又分为验证、准备和解析三个环节,最后经过初始化后创建所需要的对象到内存中

内存中有许多内容,首先是PC寄存器,其下存有多个线程,中间是栈,栈下每个线程占据一个空间,线程包含栈帧以及LV OS DL RA等内容,这些内容我们后面会演示。右边是本地方法栈,调用本地方法时需要用到。左边是方法区和堆区,后者是内存中最大的一块空间,同时堆区是共享的,前者则主要存放类信息,常量,域信息和方法信息。

代码的执行需要使用执行引擎,其中包括解释器、JIT即时编译器以及垃圾回收器,其可以将代码翻译成机器指令,从而令操作系统执行对应的动作,下面的本地方法接口和本地方法库则是我们需要使用的时候才会调用的东西

image-20221209103858879

类加载器概述及类加载过程

需要讲解的内容上面都说过了,直接看图吧

image-20221209104358204

然后来看看下面的例子

image-20221209104703687

这是执行过程,其中之所以严重是靠字节码文件中的标识来进行验证,验证依靠的是字节码文件内的标识,字节码文件翻译之后自然是一串二进制的内容,里面的二进制的一些位数就是代表标识

比如Java虚拟机使用的字节码会在开头有CA FE BA BY这八位代表其是用于Java虚拟机的使用的字节码,若不同则不是

后面我们会更加详细地提及

image-20221209105238815

这是执行的流程图

image-20221209105251577

执行时先判断是否装载对应类,若无则进行装载,装载完成之后对其进行初始化并调用其下的主方法,这就是类加载并执行的过程

Loading(加载)

字节码进入编译器中首先要进行加载,加载的过程如下图所示

image-20221209143301827

这里我们最主要记住生成大的Class对象是在加载时生成的即可,下面是补充加载.class文件的方式的图片

image-20221209144022733

Linking(连接)

连接分为验证,准备和解析三个过程,验证主要是为了确保加载类的正确性

image-20221209145141808

准备阶段会给类中所有变量设置为默认的0值,不同的数据类型会有不同的零值。同时被final修饰的变量会在编译时就分配零值,准备阶段不会再次分配,同时也不会给实例变量进行初始化,类变量会分配在方法区中,实例变量会随着对象一起分配到Java堆中

后续的解析最主要要记住的是,我们一个普通的类实际在底层时是会加载出许多其他的类和符号引用的,解析会将这些符号引用转换为直接引用

Initialization(初始化)

接着我们来讲前端编译器的最后一个过程,初始化

image-20221210022419619

初始化的阶段其实是执行类构造器方法()的过程,这个方法是由java编译器自动收集类中的所有类变量的父值动作和静态代码块中的语句合并而来,当然,如果类中不存在赋值动作和静态代码块,那么该方法也就不会存在

image-20221210024030015

上图中的例子之所以可以在静态代码块中赋值,是因为在prepare过程中就会先给num赋值为0,所以可以进行赋值,但是这里值得一提的是,我们是不可以在静态代码块中调用外面定义的变量的,会报非法的前向引用异常。这是当然的,因为静态代码块最先执行,而此时如果调用前面的变量,此时由于前面的变量还没完成赋值,会出现预期之外的内容,因此会报错

同时在这个过程中还存在()方法,该方法其实是类的构造器方法,任何一个类都一定有该方法,换言之,任何一个类的编译都会有()方法执行

()方法还有两点,一是若类有父类,则会保证父类的()方法先执行,同时会给类中的()方法加锁,一个线程进入之后没有执行完毕时另一个线程就不可进入

image-20221210024738015

上图中的例子,子类Son的值为2,因为先执行父类,会令A为2导致的

类加载器分类

JVM引导类加载器和自定义加载器,引导加载器特指Bootstrap ClassLoader,而自定义加载器只要实现了ClassQLoader的类则都为自定义类加载器

image-20221210031335011

类加载器有很多,最底层的是引导类加载器,其由C++实现,往下是扩展器加载类、系统类加载器再往下则是用户的自定义加载器

image-20221210031406798

这里四者的关系是包含关系,下面的类是上面的类的内部类,不是继承关系或者是上下层关系

同时根据定义,第一个是类加载器,其他都属于是自定义类加载器

同时用户自定义的类使用的加载器是默认的系统类加载器,而JDK中的核心类比如String类则是使用引导类加载器进行加载的

深入理解各类加载器

要说的都在图里的,自己看吧

image-20221210034343315

引导类加载器只加载包名为java、javax和sun等开头的类

image-20221210034712678

扩展类加载器只加载java.ext.dirs目录下的class文件,如果用户创建的jar也放在该目录下,其也会由该扩展器加载

image-20221210034803521

自定义类加载器

这一节的内容还是了解为主,到下一章我们会重点讲解这一节的相关内容

image-20221210035352348

然后我们来看看其实现步骤

image-20221210035540432

ClassLoader

ClassLoadoer是一个抽象类,其后所有的类加载器都继承该类

image-20221210040102731

其下当然也有各种方法,上图中有说明

ClassLoader其下有各个子类,全部都属于是自定义类加载器

image-20221210031519397

同时Launcher是Java虚拟机的一个入口应用

上图中说明了各种获取ClassLoader的方法

双亲委派机制

双亲委派机制指的是需要加载某个class文件时先将请求交给父类处理,若父类能处理则有父类进行处理

image-20221210041211981

下面是其工作原理

image-20221210041342252

假如我们创建一个java.lang包在我们的项目里并且在里面创建了一个String类,并写入了主方法并执行

image-20221210042448254

则此时会报出下面的异常

image-20221210042720730

报出该异常的原因是该类加载时请求被委托到引导类加载器,而引导类加载器会加载JDK内置的String的class,而该文件中又没有main方法,故会报出该异常

同时我们注意双亲委派机制不断向上尝试加载,加载与否的判断是由对应加载器实现设置的条件来判断的,比如我们的包名为java.lang,那么引导类加载器发现包名为java之后就会认为该类可由自己加载然后执行加载动作

双亲委派机制也有其优势,我们来看看下面的例子

image-20221210043115720

jdbc中SPI利用对应的接口实现了其自己的类,加载时引导类加载器会加载该核心jar包,而具体的实现类引导类加载器则无法加载,继续向下传递会由系统类加载器进行加载,这样就实现了接口和实现类由不同加载的效果,可以避免类的重复加载

image-20221210043349087

沙箱安全机制

双亲委派机制中还有一个沙箱安全机制,虽然听起来高大上,其实很好理解

image-20221210044732588

简单来说就是自定义的与核心类库相同的类会优先使用引导类加载器加载,其会优先加载jdk自带的文件,这样可以保证java核心源代码的保护,这就是沙箱安全机制

类的主动使用与被动使用

咋JVM中两个class对象为同一个类需要类名一致同时其加载器也是一致的

image-20221210045252332

如果一个类是由用户类加载器加载的,那么JVM会将该加载器的一个引用作为类型信息的一部分保存在方法区中,同时当解析一个类型到另一个类型引用是,JVM需保证两个类型的类加载器一致

image-20221210045241441

Jqava程序对类的使用方式分为主动使用与被动使用,被动引用不会导致类的初始化,而主动则会

image-20221210045627164

除了上图中的以上其中情况,其他使用Java类的方式都是被动引用

运行时数据区概述及线程

这里我们来学习运行时数据区的结构

image-20221210051119510

运行时数据区和内存息息相关,我们首先来讲解内存

image-20221210050916672

字节码文件翻译成的指令创造出对象都会进入到内存中给CPU使用,CPU通过读取内存内容来进行对应动作

image-20221210051222260

其具体的分类如下所示,这是jdk8中的结构

image-20221210051300605

同时Java虚拟机中方法区和堆区的生命周期与虚拟机一致,其他的则是与线程的生命周期一致

image-20221210051844445

最后每一个JVM都只有一个Runtime实例,也就是运行时环境,可以理解为整个运行时数据区

image-20221210051921839

接着我们来复习下线程

image-20221210054425811

线程是一个程序里的运行单元,JVM允许单个应用拥有多线程。在HotSpot JVM中每个线程都与操作系统的本地线程直接映射,两者的结束时间相同

image-20221210054632840

即使是一个普通的类,也会有许多的线程,上图中概述了那些线程

PC寄存器

PC Registers就是PC寄存器

image-20221210081947779

JVM中的PC寄存器是对物理寄存器的一种抽象模拟,不是说真的有这么一个寄存器硬件在里面

其作用可以简单理解为是指针,用于指向下执行引擎下一个需要执行的代码行

image-20221210082212862

栈中存有栈帧,每一个栈帧代表一个要执行的方法,每一个栈中内部都有必要的信息,下面是PC寄存器的介绍

image-20221210102035378

下面是PC寄存器的作用

image-20221210102141912

举个例子,我们下面是我们的代码在底层执行的过程

image-20221210105119940

左边的数字代表指令地址,右边则是具体的操作指令,PC寄存器存放对应的指令地址,执行引擎会通过该地址执行对应的操作指令,当然中间可能这个地址是指向另外一个地址,那么就继续找,直到找到具体的命令或者值为止

其他的内容就是补充内容,后面我们会深入讲解

然后我们来看看关于寄存器的两个问题

image-20221211131947732

PC寄存器集中路当前线程的执行地址,可以让CPU在多个线程中切换时仍然知道当前线程下一个要执行的动作

image-20221211132253705

PC寄存器要设置为私有的原因是这样才能准确记录各个线程的不同的要执行的指令,如果设置为公有的会有冲突问题

image-20221211132639303

CPU时间片是CPU分配给各个程序的时间,每个线程只能占有一段时间的时间片,但由于切换时间极快,所以宏观上看起来就是多个应用程序在同时执行

image-20221211132743927

并行指的是CPU的每个核执行一个线程,而并发指的是一个核执行多个线程

虚拟机栈概述

本节我们来学习虚拟机栈,首先我们来看看虚拟机栈出现的背景

image-20221211134053989

JVM中堆栈本身非常重要

image-20221211134148920

栈是负责运行的单位,其存储对应的方法调用和局部变量,而堆则是存储的单位,大部分的数据对象都是存储在堆区中的

image-20221211134236877

然后我们来看看关于虚拟机栈的介绍

image-20221211134643622

每个线程都会创建一个虚拟机栈,其内部保存一个个栈帧,生命周期与线程一致同时其主观Java程序的运行,能保存方法的局部变量,部分结果并参与方法的调用和返回

image-20221211135524548

上面的图是非常简略的图,因为栈帧中实际上还存在许多内容,后面我们会深入讲解

image-20221211135540028

栈只有进栈和出栈两个动作,效率仅次于程序计数器,不存在GC问题,但存在OOM(溢出)问题

常见异常和栈大小设置

栈中可能出下的异常有下面栈溢出异常和OOM异常

image-20221211140518281

使用参数-Xss选项可以设置线程的在最大栈空间

image-20221211140856440

栈的存储结构和运行原理

栈中数据以栈帧格式存在,每个线程执行中的每个方法各自对应一个栈帧,栈帧是一个数据集,维系方法执行过程中的各种数据信息

image-20221211142618070

一个时间点上只有当前正在执行方法的栈帧,也就是当前栈帧是有效的,当前真正的方法被称为当前方法,其类被称为当前类

image-20221211142633984

下面是调用其他方法时当前栈帧的变化图

image-20221211143013551

最后值得注意的是,不同线程中的栈帧是不允许相互引用的,其次是返回函数的方式有两种,一是产生异常并抛出,二是return,两种方法都是导致栈帧被弹出

image-20221211143347178

栈帧的内部结构

栈帧中存储局部变量表、操作数栈、动态连接和方法返回地址以及一些附加信息

image-20221211144637108

其中局部变量表和操作数栈的大小主要决定栈帧的大小

如果是多线程的情况,那么便是有多个虚拟机栈,当然,每个栈都被一个线程使用,内部同样存放栈帧

image-20221211145252492

局部变量表结构认知

局部变量表是一个数字数组,主要用于存储方法参数和方法体内的局部变量,局部变量包括给雷基本数据类型以及对象引用等

局部变量表建立在线程的栈上,为线程私有数据,不存在数据安全问题,且其大小是在编译器确定下来的

image-20221211145849624

方法嵌套的次数由栈大小确定且局部变量表中的变量只在当前方法的调用中有效,其生命周期与栈帧一致

image-20221212075010113

深入理解方法内部

接着我们来深入分析字节码来加深理解,首先字节码文件用记事本不可打开,必须要使用分析专用的反编译软件,IDEA里自己就有这个功能,因此我们可以在IDEA中正确打开字节码文件,我们也可以在idea中安装Jclasslib插件来实现对字节码文件的查看,我们这里就采用这种方式

首先我们来看看我们写入的代码

image-20221212090454351

点入之后我们就能看到其字节码文件,我们这里查看其方法下的main方法,由下到上显示的内容分别是方法的类型,方法的传入参数,L代表引用类型,这里为String,返回值为Void,简写为V,方法名为main

image-20221212085048941

然后可以键入Code,内部的ByteCode放入的具体的字节码内部指令的指令,Exception table代表发生异常时会存入的框目,我们这里没有发生异常,因此啥也没有

image-20221212085114427

Misc指的是方法内部的一些基本信息,从下往上分别是表示方法总长度为16,在代码中方法最高到达的行号为16,第二个是局部变量的最大容量,这里为3,很好理解,因为我们这里的确就存放了三个局部变量,最后是版本号,代表这个类被更新了几次

image-20221212085238686

Code下面第一个代表的是行号表,内部存放的是字节码内的行号到代码的行号的映射,Start PC是字节码中的行号,Line Number是对应的Java代码的行号

image-20221212085322641

第二个是本地变量表,内部存储了本地变量的各种信息,Start PC和Length结合起来代表对应变量的作用范围,这里三个变量的两者之和均为16,说明其作用域最大就到Java代码的第16行

image-20221212085414081

name代表是对应的局部变量的名字,最后则是对应的描述,描述下指明了变量的数据类型

Slot变量槽

局部变量表中存储单元为Slot(变量槽),可以简单理解为数组的下标,其存放各种数据类型,除Long和Double数据类型会占用两个slot之外,其他都占用一个

image-20221212093719079

每个局部变量表中每一个Slot都会分配一个访问索引,通过该索引可成功访问局部变量表中指定的变量值,其局部变量复制到slot上的顺序是按照调用顺序来设定的

值得一提的是,占用两位的变量,需要访问时需要使用其开头的索引

image-20221212094624315

在构造方法和非实例方法中,对象引用this会存放到index为0的slot处,比任何变量都快,此时我们就能在反编译的结果里看到文件夹,其就是代表this的引用存放,而在静态方法中则没有这个文件夹

也这是为什么静态方法中我们无法调用this,因为其局部变量表中没有存放this的对象引用,而非静态方法中则有,因此前者无法调用,后者可以调用

下面的图里我们可以看到Index代表的Slot下标,在遇上double类型时前进了两位

image-20221212095722067

同时栈帧中的局部变量表中的槽位可以复用

image-20221212100003029

比如下面中b变量占用槽位,但是出了这代码块之后b就没有意义了,因此c的位置就复用了b的槽位,可以看到两者的Index值是相同的

image-20221212100409206

静态与局部变量对比

首先我们来讲讲什么是静态变量和局部变量

image-20221212102155536

静态变量指的是类变量,也就是类中的属性且有static修饰的变量,该变量会在prepare阶段默认赋值,如果有设定值,那么后面还会进行显示赋值,相当于静态变量有显示

image-20221212101554412

而局部变量由于不存在系统初始化的过程,因此我们必须要对其进行初始化,认为指定数值,否则不可用

image-20221212102404739

局部变量表与性能调用关系密切,同时只要局部变量中的有直接引用或间接引用的对象,那么该对象就不会被回收

操作数栈

操作数栈是用数组结构形成的栈,其只能执行进栈出栈两个动作。

image-20221212104125732

操作数栈一般用于执行复制、交换、求和等操作。代码指令要被转换为字节码指令然后才能执行这些操作

image-20221212104352665

操作数栈也可以保存计算的中间结果,同样是只有long和double的数据类型占用两个单位深度

操作数栈在编译器会定义一个计算所需要的最大深度并保存在Code属性中,名为max_stack,注意这个和局部变量表的大小是不一样的

image-20221212105026844

最后如果被调用的方法有返回值,那么该返回值也会压入到当前栈帧的操作数栈中

字节码指令分析

接着我们从操作数栈的字节码指令来分析其内部的执行过程,下面是代码和字节码指令

image-20221212131421972

首先我们进行的bipush操作,将15的值压入到操作数栈中,然后执行istrore_1指令,将操作数栈的值存入到局部变量表中,存入1的原因是方法是非静态方法,0的位置保存了this引用

image-20221212131612069

接着压入8,然后存入8

image-20221212131910893

接着执行iload_1和iload_2指令,这两个执行会将上面存入到局部变量表中的15与8拷贝一份放到操作数栈中

image-20221212132219798

然后执行iadd执行,将操作数栈的中的两个值相加并保存到栈顶中,然后将值保存到局部变量表中,最后返回即可

image-20221212132246388

这里值得一提的是,如果我们的java代码中定义了一个int类型的数据,但是其值可以用byte表示,那么其在操作数栈中会用byte表示,会令其大小*2,以为int的字节比byte大一倍

但是如果超越了byte的范围,那么一开始就会用int类型进行表示,而且,最后到局部变量表时,他们都会用int来表示,即使是布尔类型也是

栈顶缓存技术

image-20221212134446440

动态链接与常量池

然后我们来讲栈帧中的动态链接

image-20221212135329885

动态连接指的是栈帧内部中存有指向运行时常量池的的所属方法的引用

image-20221212135412980

Java源文件被编译成字节码文件中时,所有的变量的方法引用都会作为符号引用保存在class文件的常量池里,当需要调用常量池内的内容时,动态链接会将这些符号引用转换为调用方法的直接引用

image-20221212135725938

如上图就是常量池,其中#123456等最左边的内容就是符号引用,而后面紧随的第二行内容是这个引用调用的其他引用,最后一行是引用的指向的内容的描述

下图则展示了字节码指令调用常量池方法的过程

image-20221212135750613

常量池是保存到方法区中的,而方法区是多线程共享的

image-20221212140044966

常量池的存在可以提供一些符号和常量,便于指令识别并提高效率

image-20221212140309800

静态绑定与动态绑定

JVM中符号引用转换为直接引用与方法的绑定机制有关,我们先来讲绑定相关知识

image-20221212142351655

JVM中的链接分为两种,分别是在编译期可以确定目标方法的静态链接和在运行时才能确定方法的动态链接,这两种链接也分为两种绑定机制,分别是早期绑定和晚期绑定

绑定指的是一个字段/方法/类在符号引用被替换为直接引用的过程

image-20221212142532257

早期绑定指的是被调用方法在编译器可知,且运行时保持不变的方法,比较有代表的例子是调用父类的方法或者是自己构造方法,这些方法不可能会被重写,因此在编译期就可以确定

而晚期绑定值得是在编译器无法确定,在运行期随着具体对象的不同而不同的方法,比如调用本类的方法(但是该类有子类)或者是接口的方法,这些方法都是可以重写的,因此编译期时无法确定

image-20221212143249674

Java中任何一个普通方法都具备虚函数特征,如果不希望其具备虚函数特征,可以用final关键字修饰该方法。

虚方法和非虚方法

方法的调用有两种方式,分别是虚方法和非虚方法,虚方法与非虚方法的区别于早期绑定和晚期绑定无异

image-20221212144510975

非虚方法有静态方法、私有方法、final修饰的方法、实例构造器和父类方法,其他的都是虚方法

image-20221212144743676

虚方法调用的命令是invokevirtual和incokeinterface,前者调用普通方法,后者调用接口的方法,但是这里值得注意的是,被final修饰的方法并不是虚方法,然而其调用时却会显示是invokevirtual字节码指令

image-20221212144754682

如果是调用静态方法,则是显示invokestatic,如果调用构造器方法、私有的或者父类方法,则会显示invokespecial

invokedynaic是动态调用执行,其作用是动态解析出需要调用的方法然后执行,这个方法在Java8Lambda表达式出现之后才有了直接的生成方式

image-20221212163824160

Java本质是静态类型语言,也就是强语言类型,但是由于Java是跨平台,为了满足跨平台的需要,其需要引入弱语言类型也就是动态类型语言的特性,因此增加了invokedynaic指令

image-20221212164354912

该指令在使用lambda表示时,调用其对应的方法时会在字节码的指令中展示出来

方法重写与虚方法表

Java的类实现了方法重写时,实际要找到对应的方法会从该类方法中寻找名称相同的方法,没有则不断向上查找,一直找不到就报异常

image-20221212165510910

同时这里会有一个权限异常,如果一开始访问对应类的权限校验不通过时,会报对应的异常

每次需要执行方法时都往上找的话,效率上就会变得很烂,为了解决这个问题,虚方法表应运而生

image-20221212165852822

每个类中都有一个虚方法表,虚方法表中保存对应方法和实际的方法地址的映射,这样能有效提升效率,虚方法表的创建时间是在类的变量初始化值准备完成之后

下面是虚方法表的例子

image-20221212170300689

方法返回地址

方法的结束有正常结束和异常结束两种方式,前者结束会将PC计数器的值作为返回地址,让执行引擎知道下一步的动作,而异常返回则需要通过异常表来确定,栈帧中通常不会保存这部分信息

image-20221212194945315

方法的退出就是当前栈帧出栈的过程

image-20221212195139813

正常退出和异常退出的区别在于,异常退出不会给上层调用者产生任何返回值

image-20221212195359898

在字节码指令中,有多个返回指令,使用哪个返回指令根据方法返回值的实际数据的类型而定

image-20221212195859124

方法发生异常时会存储异常处理表,能够在发生异常时找到用于处理的代码,from指的是异常从x行开始,to代表到第y行结束,target指的是对异常进行处理的行,而type描述异常的类型

简单来说,方法的返回地址的作用是让上一个方法完成之后,将PC寄存器的值返回给执行引擎,这样执行引擎就知道下一个需要继续执行的方法位置了

最后栈帧中还存在一些附加信息

image-20221213053551959

关于虚拟机栈的面试题

接着我们来看看关于虚拟栈的面试题

image-20221213055408484

  1. 栈溢出的情况是StackOverflowError,我们还可以通过-Xss设置栈的大小:OOM
  2. 挑战栈大小不能保证不发生栈溢出,但是降低其发生概率
  3. 栈内存越大,可以运行的线程就越少,因此栈内存并不是多多益善
  4. 方法区和堆区都存在OOM和GC,PC寄存器二者都不存在,本地方法栈和虚拟机栈只存在OOM
  5. 方法中定义的局部变量是否是线程安全的,需要具体问题具体分析。简单来说,如果变量在方法内部就被销毁了,那么其就是线程安全的,但如果方法是由外部传入或者是返回同样的变量的,那么就是线程不安全的

image-20221213055432081

本地方法接口

在讲解本地方法栈之前,我们先来讲解本地方法接口

image-20221213062037865

本地方法简单来说就是native修饰的方法,其代表的是去调用一个C/C++的函数

image-20221213062320707

native只不可以与abstract修饰符连用,其他均可,这是以为native是有方法体的,只是其实一个C/C++的函数而已,而abstract是没有方法体的,两个关键字互相矛盾,因此不可连用

image-20221213062519855

之所以要使用Native Method,是为了与Java外卖的环境交互

image-20221213062637234

其次是因为操作系统的底层是用C进行交互的,甚至JVM的一部分也是用C写的,而且Sun的解释器也是用C写的,这就导致在底层使用C时执行效率会更高,以上这些各种因素导致我们必须加入对应的本地方法

image-20221213063112710

现在本地方法的使用已经越来越少,除非是和硬件有关的应用,而且大部分需要使用本地方法的需求也有了替代品

image-20221213064647287

本地方法栈

本地方法栈用于管理本地方法的调用,其是线程私有的且允许实现成固定或者是可动态扩展的内存大小,在内存溢出方面的相同的

image-20221213071203127

本地方法使用C语言使用,其过程是在Native Method Stack中等级native方法,在Execution Engine执行时加载本地方法库

image-20221213071531329

当线程需要调用本地方法时,其会拥有和虚拟机同样的权限,可以做各种出格的动作

image-20221213071549660

值得注意的是,并不是所有的JVM都支持本地方法,且在Hotspot中,本地方法栈和虚拟机栈合二为一

堆空间概述

堆是运行时数据区中最大的一块空间

image-20221213100749102

一个JVM只存在一个堆内存,堆内存的大小是可以设置的,同时堆可以处于物理上不连续的内存空间中,但是在逻辑上必须是连续的。这里物理空间与逻辑空间简单来说就是在内存层面上造表来实现虚拟空间到实际空间的映射,通过这样映射可以实现空间逻辑上的连续,连续的空间可以让效率获得有效的提高

image-20221213211319718

同时所有的线程共享Java堆,但是堆中仍然可以划分线程私有的缓冲区,称之为TLAB

image-20221213213911708

几乎所有的对象实例都存放在堆中,数组和其他引用类型变量也是同理。方法结束之后,堆中对象并不是马上移除,而是在执行垃圾收集,也就是GC时进行移除

image-20221213214150533

上图是对应变量在栈堆方法区中的存放示意图

堆的细分内存结构

堆空间可以细分为新生区、养老区和永久区,其中永久区在Java8之后被更改为元空间

image-20221213222240214

新生区又还可以划分为Eden区和Survivor区,下图是对应的堆空间内部结构的详细展示图

image-20221213222431100

其中,我们在设置中指定的堆空间大小是只针对新生区和老年区的

image-20221213222656692

堆空间大小设置

堆空间的大小设置通过选项-Xmx和-Xms来设置,堆区内存大小超过指定最大内存时会发生OOM

下图中还列举了我们手动查看我们堆内存的具体使用信息的方式

image-20221214084359034

值得一提的是,堆空间内新生代内部结构为一个伊甸园和两个生存区,生存区二者只会有一者使用,另一者不会被计入空间内,因此我们的显示的空间大小总是会比指定大小小一些

在使用-XX:+PrintGCDetails中显示的新生代的空间总量中也是只计入了一个是生存区之后的结果

image-20221214084429438

OOM说明与举例

当我们的堆内存不足时,会发生OOM异常

image-20221214090237760

查看进程的情况可以使用Java VisualVM,其是Java内置的可视化工具,可以便于我们查看进程内部各种信息,不过这个玩意在Jdk8之后就无了,所以我们了解即可

我们伊甸园区首先会存放新放入的数据,伊甸园区一旦满了就全部存放到老年代区中,然后重置伊甸园区,同时生存区会换另一个生存区使用,当老年区慢了且伊甸园区也满了时,会发生OOM

年轻代与老年代

Java对象有两类,一类是生命周期较短的对象,另一类则是非常长的对象,为了适配者两种对象,因此我们的堆区划分除了年轻代和老年代,年轻代又划分出伊甸园和生存区

image-20221214091621746

年轻代和老年代的比例默认为1:2,在新生代中,伊甸园与两个生存区的比例为8:1:1,新生代中的比例不会改动

image-20221214091910702

尽管一般来说新生代中各个区的比例是指定的,但是由于Java中有内存自适应机制,因此其空间比例并不会严格遵守事先指定的比例

同时绝大部分对象的销毁和创建都是在伊甸园区进行的

image-20221214092227545

如果我们程序中的长存对象比较多,那么可以调用对应的指令将老年代的所占空间比例增加,反之也可减少

image-20221214092556137

伊甸园区满后,还存在的对象会转移到生存区中,生存区中的对象会转移到老年代中

image-20221214092827097

对象分配的两种过程

先来说说一般过程

一般来说对象先放在伊甸园区,伊甸园区有内存限制

image-20221214094113214

当伊甸园区满时,会触发Young GC,对该区进行垃圾回收,没有回收的对象将会放到幸存者1区,同时给放入的对象设置数量标识

image-20221214094241496

当再次触发YGC时,多的对象会放到幸存者2区,幸存者1区的内容也会移动到2区,但是其数量标识会自增

幸存者区也可以叫from区和to区,但是这并不是固定某一个幸存者区的名字,判断谁是to的方法在于看哪个区为空的,谁空谁是to区

image-20221214094414652

当幸存者区的数量标识到达15时,会将对象进行晋升,成为老年代中的对象,15是阈值,是可以设置的

image-20221214094649646

幸存者区如果满了,会直接将对应对象放到老年区,不会触发GC,也有可能对象直接放入老年区,连幸存者区都不经过

image-20221214094845531

当养老区执行了Major GC之后发现仍然无法进行对象保存时,就会抛出OOM异常

image-20221214094934188

最后垃圾回收频繁在新生区收集,但是很少会在养老区收集,几乎不再元空间收集

然后是特殊过程

特殊情况的内存分配如下图所示,这里要注意的是,此处演示的前提的是没有开启动态内存分配

image-20221214100951075

最后我们来看看常用的调优工具

三个GC的对比

JVM中的GC有三种,分别是YCG,MGC与FGC,要调优,我们主要需要避免后两者的GC

image-20221214103327683

YGC的触发条件是当伊甸园区满时,其进行垃圾收集同时会对幸存区进行垃圾收集

image-20221214104011771

下图是YGC的执行过程

image-20221214104304239

老年代GC有两种,分别是MGC和FGC,MGC触发时经常会伴随至少一次YGC,但并不绝对,简单来说就是当老年区空间不足时,会尝试YGC,若空间还不足再尝试MGC

image-20221214104327000

如果MGC后内存还不足,则会抛出OOM异常

FGC直接看下图吧,以后我们还会详细讲这个知识

image-20221214104440734

堆空间分代思想

堆空间即使不进行分代也能正常工作

image-20221214200545007

但是分代可以提高效率

image-20221214200627124

内存分配策略

分配内存时,按照下面的策略进行分配

image-20221214201046005

针对不同年龄段的空间分配原则如下图所示,这里值得一提的是如果幸存区中相同年龄的所有对象大小的总和大于其空间的一半,那么年龄大于等于其的对象可以直接进入老年代

image-20221214201125172

TLAB

TLAB是伊甸园区中的一个分配给线程的缓冲空间

image-20221214204917932

JVM为每个线程分配一个私有的缓存区域,其包含于Eden空间内,它就是TLAB

image-20221214205132138

使用TLAB可以避免一系列的线程安全问题同时还能提高内存分配的吞吐量,我们将这个内存分配方式称为快速分配策略

image-20221214205313511

上图是关于TLAB的空间分配过程

image-20221214205414761

JVM将TLAB作为内存分配的首选,如果分配TLAB失败,那么JVM就会尝试通过使用加锁机制来确保数据库的原子性

image-20221214211937886

堆空间中的参数设置

堆中有各种参数设置的方法,下图展示的对应的方法

image-20221214212334799

image-20221214212523742

还有一些我们之前使用过的方法

image-20221214214456409

下图关于空间分配担保方法的说明

image-20221214215043552

逃逸分析

如果经过逃逸分析之后对象并没有逃逸出方法的话,那么该对象可能被分配到栈上

image-20221215081858560

逃逸分析指的是分析一个对象是否可能会被外部方法所引用,若是则创建到堆中,反之则可能创建到栈中

image-20221215082357441

下图是没有发生逃逸的案例,对象V不会在方法外部被调用

image-20221215082708125

下图是将发生逃逸的对象改为不发生逃逸的方法

image-20221215082808743

下图是加深理解的例子

image-20221215083155831

判断是否发生了逃逸分析,就看最终的new的对象是否有可能在方法外被调用

image-20221215083252245

逃逸分析存在的主要目的就是为了减少GC,提高效率

image-20221215083442883

JVM默认开启逃逸分析,一般我们也不会关闭它

image-20221215083642569

代码优化

代码优化的方法有三,分别是栈上分配、同步省略和分离对象或标量替换

image-20221215083819465

栈上分配就是经过逃逸分析之后对象不存储在堆中,而是存储到栈帧中,随着栈帧的进出而一起释放,这样就省去了GC的功夫

image-20221215083946674

线程同步,也就是synchronized关键字的代价相当高,我们尽可能应该要避免使用。为此产生了同步省略

image-20221215090243261

同步省略指的是在动态编译时,其会判断同步区域的对象是否只能被一个线程访问,若是,则根本不需要锁,此时其会在编译时取消上锁

写入下图所示的代码,最后其执行的效果是和黄色区域的代码一样的

image-20221215090354015

这里值得一提的是,即使是字节码文件里也还是能看到其同步关键字,其进行动态编译的时候是在运行时的,是在字节码文件之后的

标量指的是基本数据类型,而聚合量可以理解为是引用数据类型,一个聚合量和分解为多个其他聚合量和标量

image-20221215091352396

JIT阶段经过逃逸分析发现一个对象不会被外界访问经过JIT优化,会将该对象拆解为多个标量进行代替,这个过程就是标量替换,并且最后的结果会存储在栈中

那么上面的代码在经过标量替换之后就是下面的样子

image-20221215091503039

标量替换可以减少堆内存的占用,且同时为栈上分配提供基础,存储于栈上的对象是会进行标量替换的,如果标量替换不进行,那么就不会存储到栈中

image-20221215091619087

标量替换可以用对应的命令进行关闭

image-20221215092226185

逃逸分析补充说明

使用逃逸分析时无法保证逃逸分析的性能消耗一定高于其自身消耗,因此其使用还有待考量。尽管其不成熟,但仍然是即时编译器优化技术中的一个重要手段

image-20221215134428817

可以确定的是Hotspot虚拟机中所有的对象实例都创建在堆中,其他的虚拟机就不一定了

最后来看看总结

image-20221215134713890

方法区概述

方法区是我们本节最后要讲述的一个章节

image-20221216134118921

堆和元空间都是线程共享且拥有GC的,而虚拟机栈和本地方法栈只有OOM,程序计数器则两者均无

image-20221216133051329

对于一个对象的创建,其对象的类型数据,也就是Class文件是会放到方法区中去的,具体的对象会放到堆区中,而这个方法则会在虚拟机栈中进行压栈

image-20221216133502521

基本理解与演进

方法区可以看作是独立于Java堆的内存空间

image-20221216135023563

方法区是被各线程共享的并且存在OOM,也可以扩展或者固定大小

image-20221216141655837

JDK7之前,方法区习惯称为永久代,用JVM的虚拟内存作为其大小,这样做的问题是容易发生OOM

image-20221216142248255

JDK8之后放弃了永久代的概念,而是将元数据区作为方法区,使用本地内存,有效避免OOM

image-20221216142557413

元空间与永久代并不只是名字不同,其内部结构亦进行了调整

image-20221216142631758

设置方法区大小

JKD7之前的方法设置

image-20221216144335600

JDK8之后

image-20221216144457565

为了提高效率,避免频繁GC,建议将方法区的值设置为一个相对较高的值

OOM的产生可能是因为内存泄露,内存泄露指的是对象在GC之后与GC Roots产生引用关联导致其无法被回收,此时需要定位泄露代码的位置并解决

image-20221216160512297

如果不存在内存泄露,那就单纯是类太多了,此时可以扩大内存或者减少类加载

方法区的内部结构

源代码编译成字节码文件后会经过类加载器将对应的信息加载到方法区中

image-20221219150010390

方法区中一般存入的信息如下所示,但这并不是所有的虚拟机都是一致的

image-20221219150301687

类型信息的主要内容如下图所示

image-20221219150551182

域信息如下如下图所示

image-20221219150632406

在方法信息里,构造器和其他方法都会统一被视为方法,方法信息下还有异常表,会记录异常处理的对应信息

image-20221219150854242

静态变量在类中会随着类的加载而加载,不需要new对象也可以调用

image-20221219152803027

全局变量,也就是被static final修饰的变量,会在编译器就确定其值,而静态变量则是会经过准备阶段初始化并通过初始化阶段确定对应的值

image-20221219153040191

常量池

在方法区中存在运行时常量池,而在字节码内部包含了常量池

image-20221219154401297

我们需要理解ClassFile字节码中的长岭吃才能明白方法区中的运行时常量池

image-20221219154435259

常量池在class文件位于字节码文件的中间位置,有效的字节码文件中包含类的版本信息等各种信息,并且还含有常量池表,其内部包括对各种字面量、类型、域和方法的引用

image-20221219155002315

二进制文件是字节码的真正形式,我们在idea的插件中看到的字节码文件都是反编译之后看到的,严格来说并不属于字节码文件,比如说字节码文件加载时同时还会记录其使用的类加载器的信息,但是我们在工具上反编译的字节码文件里就看不到这个信息,但在实际的字节码文件中这个记录是存在的

image-20221219155133078

字节码文件中存放各种指向常量池的符号引用,在动态链接时会使用到运行时常量池,这样可以通过这些符号引用来查找到所需要的具体的类,这样就不必将所有的类都加载到字节码文件中,能提高运行效率并将字节码文件的大小减少

image-20221219160216954

最后我们来看看总结

image-20221219160249924

运行时常量池是方法区的一部分,保存各种字面量和符号引用的常量池表就保存在其中

image-20221219161439619

加载类和虚拟机后就会创建对应的运行时常量池,每个类都会维护一个常量池,其中数据以索引访问。常量池中保存有运行期解析后才能过获得的方法或者字段引用,此时其不再是常量池中的符号地址,而是真实地址

同样,常量池存在OOM

关于常量池和字符串常量池的更多内容请参照blog.csdn.net/weixin_4406…

方法区的演进细节

首先,只有HotSpot虚拟机才有永久代的概念,而这个概念也在jdk8之后消逝了

image-20221219221252626

下面是各个版本JDK的结构分布

image-20221219221655459

image-20221219221705215

image-20221219221729586

之所以要用元空间取代永久代,这是因为永久代空间设置在虚拟机中,空间本身较小,容易发生OOM,且难以确定大小空间,容易出现GC,而且对永久代的调优也比较困难

image-20221219222501558

因此更是要尽量减少GC,所以用元空间取代永久代

字符串位置调整

image-20221219223511839

静态引用存放位置

静态引用的对象始终存在于堆空间

image-20221219224516365

使用JDK9之后提供的JHSDB工具可以分析每个对象具体存在虚拟机中的结构位置

image-20221219225051084

下面的代码我们想要得知静态变量与成员变量和局部变量的引用分别存在于什么位置

image-20221219225013853

它们三个都存在于Eden区内,也就是都存在于Java堆中

image-20221219224711288

得到结论是Java中对象都存在于堆中

image-20221219224906117

方法区的垃圾回收

方法区的GC主要包括常量池中废弃的常量和不再使用的类型,而且方法区的GC又总是不容易令人满意

image-20221220003442740

方法区常量池主要存放字面量和符号引用,只要常量池中的常量没有被任何地方引用,就可以被回收

image-20221219222918124

判断一个类型是否可以回收则比较苛刻

image-20221219222942769

在大量使用反射、动态代理、CGLib等字节码框架需要动态生成JSP以及OSGi这类频繁的自定义类加载器的场景中通常都需要Java虚拟机具备类型卸载的能力来保证不会对方法区造成过大的内存压力

总结

到此为止,我们的运行时数据区的内容就讲完了,下面是虚拟机栈的总结图

image-20221220005538892

下面是大厂的一些面试题

image-20221220005741461