JVM与Java体系结构
Java以及JVM简介
JVM是跨语言的平台,许多平台的代码都会通过编译器转化为字节码文件被Java虚拟机识别并装载并运行
Java发展中的重大事件
JVM的位置
无聊的知识点度过之后,我们现在正式来进行JVM的学习,首先我们要了解JVM的位置
JVM运行在操作系统之上,与硬件没有直接的交互,用户的高级语言必须通过编译器翻译成字节码文件之后才能被JVM读取并加载
JVM位于JRE中的最底层
我们这里讲解的JVM结构是HotSpot VM的结构,其他的虚拟机可能结构有所不同
字节码文件首先要经过类装载器子系统,根据字节码的内容在内存中生成真正的对象,运行时数据区有很多内容,这些内容我们后面会讲解,操作系统只能接受机器指令,而我们的字节码指令必须要经过执行引擎才能够翻译成机器指令,最后被操作系统识别
java代码执行流程
下面是Java代码的执行流程图
更加具体的图如下所示,编译器为JVM前端编译器内容,我们看看即可,总之其令源码变为字节码,然后字节码进入虚拟机最后会进入执行引擎,执行引擎内有翻译字节码和JIT编译器,前者翻译字节码为机器指令,后者负责编译指令内的内容并生成对象
最后我们看一下语言转换的流程图
JVM的架构模型
JVM的架构模型总共有两种,一种是基于栈式架构 ,一种是基于寄存器架构,两者的优缺点都在上图中
一般来说,我们的JVM都是栈式架构的,最起码我们的JVM就是栈式架构的
JVM的生命周期
JVM的生命周期分为启动、执行和退出
启动是通过引导类加载器创建一个初始类来完成,该类是由虚拟机具体实现指定的
我们JVM中的运行时数据区其实就是Runtime类,该类是一个单例类
JVM的发展历程
了解为主的内容,最主要的是前三个虚拟机,这才是大头,后面则是自己看的内容
第一款虚拟机只能在解释器和JIT上二选一,这就导致效率上不尽人意
第二款虚拟机具有现代化雏形,但是很短命
第三款虚拟机则是我们本章需要介绍的虚拟机,也是目前的占有率最高的虚拟机
下面都是些了解为主的内容
内存结构概述
接下来我们来学习JVM内部的内存结构,首先我们来学习第一个结构,也就是字节码文件的编译的结构
首先字节码文件要经过类加载器子系统(Class Loader),将字节码文件加载到内存当中并生成对应的对象,当需要真正执行指令时,则是执行引擎在发挥作用,需要在虚拟机栈中取出数据
然后我们来看看详细的流程
字节码文件进行编译首先需要进入加载环节,加载环节中有三个加载器,分别是引导类加载器、扩展类加载器和应用类加载器,当然,我们也可以自定义加载器。然后是连接,连接又分为验证、准备和解析三个环节,最后经过初始化后创建所需要的对象到内存中
内存中有许多内容,首先是PC寄存器,其下存有多个线程,中间是栈,栈下每个线程占据一个空间,线程包含栈帧以及LV OS DL RA等内容,这些内容我们后面会演示。右边是本地方法栈,调用本地方法时需要用到。左边是方法区和堆区,后者是内存中最大的一块空间,同时堆区是共享的,前者则主要存放类信息,常量,域信息和方法信息。
代码的执行需要使用执行引擎,其中包括解释器、JIT即时编译器以及垃圾回收器,其可以将代码翻译成机器指令,从而令操作系统执行对应的动作,下面的本地方法接口和本地方法库则是我们需要使用的时候才会调用的东西
类加载器概述及类加载过程
需要讲解的内容上面都说过了,直接看图吧
然后来看看下面的例子
这是执行过程,其中之所以严重是靠字节码文件中的标识来进行验证,验证依靠的是字节码文件内的标识,字节码文件翻译之后自然是一串二进制的内容,里面的二进制的一些位数就是代表标识
比如Java虚拟机使用的字节码会在开头有CA FE BA BY这八位代表其是用于Java虚拟机的使用的字节码,若不同则不是
后面我们会更加详细地提及
这是执行的流程图
执行时先判断是否装载对应类,若无则进行装载,装载完成之后对其进行初始化并调用其下的主方法,这就是类加载并执行的过程
Loading(加载)
字节码进入编译器中首先要进行加载,加载的过程如下图所示
这里我们最主要记住生成大的Class对象是在加载时生成的即可,下面是补充加载.class文件的方式的图片
Linking(连接)
连接分为验证,准备和解析三个过程,验证主要是为了确保加载类的正确性
准备阶段会给类中所有变量设置为默认的0值,不同的数据类型会有不同的零值。同时被final修饰的变量会在编译时就分配零值,准备阶段不会再次分配,同时也不会给实例变量进行初始化,类变量会分配在方法区中,实例变量会随着对象一起分配到Java堆中
后续的解析最主要要记住的是,我们一个普通的类实际在底层时是会加载出许多其他的类和符号引用的,解析会将这些符号引用转换为直接引用
Initialization(初始化)
接着我们来讲前端编译器的最后一个过程,初始化
初始化的阶段其实是执行类构造器方法()的过程,这个方法是由java编译器自动收集类中的所有类变量的父值动作和静态代码块中的语句合并而来,当然,如果类中不存在赋值动作和静态代码块,那么该方法也就不会存在
上图中的例子之所以可以在静态代码块中赋值,是因为在prepare过程中就会先给num赋值为0,所以可以进行赋值,但是这里值得一提的是,我们是不可以在静态代码块中调用外面定义的变量的,会报非法的前向引用异常。这是当然的,因为静态代码块最先执行,而此时如果调用前面的变量,此时由于前面的变量还没完成赋值,会出现预期之外的内容,因此会报错
同时在这个过程中还存在()方法,该方法其实是类的构造器方法,任何一个类都一定有该方法,换言之,任何一个类的编译都会有()方法执行
()方法还有两点,一是若类有父类,则会保证父类的()方法先执行,同时会给类中的()方法加锁,一个线程进入之后没有执行完毕时另一个线程就不可进入
上图中的例子,子类Son的值为2,因为先执行父类,会令A为2导致的
类加载器分类
JVM引导类加载器和自定义加载器,引导加载器特指Bootstrap ClassLoader,而自定义加载器只要实现了ClassQLoader的类则都为自定义类加载器
类加载器有很多,最底层的是引导类加载器,其由C++实现,往下是扩展器加载类、系统类加载器再往下则是用户的自定义加载器
这里四者的关系是包含关系,下面的类是上面的类的内部类,不是继承关系或者是上下层关系
同时根据定义,第一个是类加载器,其他都属于是自定义类加载器
同时用户自定义的类使用的加载器是默认的系统类加载器,而JDK中的核心类比如String类则是使用引导类加载器进行加载的
深入理解各类加载器
要说的都在图里的,自己看吧
引导类加载器只加载包名为java、javax和sun等开头的类
扩展类加载器只加载java.ext.dirs目录下的class文件,如果用户创建的jar也放在该目录下,其也会由该扩展器加载
自定义类加载器
这一节的内容还是了解为主,到下一章我们会重点讲解这一节的相关内容
然后我们来看看其实现步骤
ClassLoader
ClassLoadoer是一个抽象类,其后所有的类加载器都继承该类
其下当然也有各种方法,上图中有说明
ClassLoader其下有各个子类,全部都属于是自定义类加载器
同时Launcher是Java虚拟机的一个入口应用
上图中说明了各种获取ClassLoader的方法
双亲委派机制
双亲委派机制指的是需要加载某个class文件时先将请求交给父类处理,若父类能处理则有父类进行处理
下面是其工作原理
假如我们创建一个java.lang包在我们的项目里并且在里面创建了一个String类,并写入了主方法并执行
则此时会报出下面的异常
报出该异常的原因是该类加载时请求被委托到引导类加载器,而引导类加载器会加载JDK内置的String的class,而该文件中又没有main方法,故会报出该异常
同时我们注意双亲委派机制不断向上尝试加载,加载与否的判断是由对应加载器实现设置的条件来判断的,比如我们的包名为java.lang,那么引导类加载器发现包名为java之后就会认为该类可由自己加载然后执行加载动作
双亲委派机制也有其优势,我们来看看下面的例子
jdbc中SPI利用对应的接口实现了其自己的类,加载时引导类加载器会加载该核心jar包,而具体的实现类引导类加载器则无法加载,继续向下传递会由系统类加载器进行加载,这样就实现了接口和实现类由不同加载的效果,可以避免类的重复加载
沙箱安全机制
双亲委派机制中还有一个沙箱安全机制,虽然听起来高大上,其实很好理解
简单来说就是自定义的与核心类库相同的类会优先使用引导类加载器加载,其会优先加载jdk自带的文件,这样可以保证java核心源代码的保护,这就是沙箱安全机制
类的主动使用与被动使用
咋JVM中两个class对象为同一个类需要类名一致同时其加载器也是一致的
如果一个类是由用户类加载器加载的,那么JVM会将该加载器的一个引用作为类型信息的一部分保存在方法区中,同时当解析一个类型到另一个类型引用是,JVM需保证两个类型的类加载器一致
Jqava程序对类的使用方式分为主动使用与被动使用,被动引用不会导致类的初始化,而主动则会
除了上图中的以上其中情况,其他使用Java类的方式都是被动引用
运行时数据区概述及线程
这里我们来学习运行时数据区的结构
运行时数据区和内存息息相关,我们首先来讲解内存
字节码文件翻译成的指令创造出对象都会进入到内存中给CPU使用,CPU通过读取内存内容来进行对应动作
其具体的分类如下所示,这是jdk8中的结构
同时Java虚拟机中方法区和堆区的生命周期与虚拟机一致,其他的则是与线程的生命周期一致
最后每一个JVM都只有一个Runtime实例,也就是运行时环境,可以理解为整个运行时数据区
接着我们来复习下线程
线程是一个程序里的运行单元,JVM允许单个应用拥有多线程。在HotSpot JVM中每个线程都与操作系统的本地线程直接映射,两者的结束时间相同
即使是一个普通的类,也会有许多的线程,上图中概述了那些线程
PC寄存器
PC Registers就是PC寄存器
JVM中的PC寄存器是对物理寄存器的一种抽象模拟,不是说真的有这么一个寄存器硬件在里面
其作用可以简单理解为是指针,用于指向下执行引擎下一个需要执行的代码行
栈中存有栈帧,每一个栈帧代表一个要执行的方法,每一个栈中内部都有必要的信息,下面是PC寄存器的介绍
下面是PC寄存器的作用
举个例子,我们下面是我们的代码在底层执行的过程
左边的数字代表指令地址,右边则是具体的操作指令,PC寄存器存放对应的指令地址,执行引擎会通过该地址执行对应的操作指令,当然中间可能这个地址是指向另外一个地址,那么就继续找,直到找到具体的命令或者值为止
其他的内容就是补充内容,后面我们会深入讲解
然后我们来看看关于寄存器的两个问题
PC寄存器集中路当前线程的执行地址,可以让CPU在多个线程中切换时仍然知道当前线程下一个要执行的动作
PC寄存器要设置为私有的原因是这样才能准确记录各个线程的不同的要执行的指令,如果设置为公有的会有冲突问题
CPU时间片是CPU分配给各个程序的时间,每个线程只能占有一段时间的时间片,但由于切换时间极快,所以宏观上看起来就是多个应用程序在同时执行
并行指的是CPU的每个核执行一个线程,而并发指的是一个核执行多个线程
虚拟机栈概述
本节我们来学习虚拟机栈,首先我们来看看虚拟机栈出现的背景
JVM中堆栈本身非常重要
栈是负责运行的单位,其存储对应的方法调用和局部变量,而堆则是存储的单位,大部分的数据对象都是存储在堆区中的
然后我们来看看关于虚拟机栈的介绍
每个线程都会创建一个虚拟机栈,其内部保存一个个栈帧,生命周期与线程一致同时其主观Java程序的运行,能保存方法的局部变量,部分结果并参与方法的调用和返回
上面的图是非常简略的图,因为栈帧中实际上还存在许多内容,后面我们会深入讲解
栈只有进栈和出栈两个动作,效率仅次于程序计数器,不存在GC问题,但存在OOM(溢出)问题
常见异常和栈大小设置
栈中可能出下的异常有下面栈溢出异常和OOM异常
使用参数-Xss选项可以设置线程的在最大栈空间
栈的存储结构和运行原理
栈中数据以栈帧格式存在,每个线程执行中的每个方法各自对应一个栈帧,栈帧是一个数据集,维系方法执行过程中的各种数据信息
一个时间点上只有当前正在执行方法的栈帧,也就是当前栈帧是有效的,当前真正的方法被称为当前方法,其类被称为当前类
下面是调用其他方法时当前栈帧的变化图
最后值得注意的是,不同线程中的栈帧是不允许相互引用的,其次是返回函数的方式有两种,一是产生异常并抛出,二是return,两种方法都是导致栈帧被弹出
栈帧的内部结构
栈帧中存储局部变量表、操作数栈、动态连接和方法返回地址以及一些附加信息
其中局部变量表和操作数栈的大小主要决定栈帧的大小
如果是多线程的情况,那么便是有多个虚拟机栈,当然,每个栈都被一个线程使用,内部同样存放栈帧
局部变量表结构认知
局部变量表是一个数字数组,主要用于存储方法参数和方法体内的局部变量,局部变量包括给雷基本数据类型以及对象引用等
局部变量表建立在线程的栈上,为线程私有数据,不存在数据安全问题,且其大小是在编译器确定下来的
方法嵌套的次数由栈大小确定且局部变量表中的变量只在当前方法的调用中有效,其生命周期与栈帧一致
深入理解方法内部
接着我们来深入分析字节码来加深理解,首先字节码文件用记事本不可打开,必须要使用分析专用的反编译软件,IDEA里自己就有这个功能,因此我们可以在IDEA中正确打开字节码文件,我们也可以在idea中安装Jclasslib插件来实现对字节码文件的查看,我们这里就采用这种方式
首先我们来看看我们写入的代码
点入之后我们就能看到其字节码文件,我们这里查看其方法下的main方法,由下到上显示的内容分别是方法的类型,方法的传入参数,L代表引用类型,这里为String,返回值为Void,简写为V,方法名为main
然后可以键入Code,内部的ByteCode放入的具体的字节码内部指令的指令,Exception table代表发生异常时会存入的框目,我们这里没有发生异常,因此啥也没有
Misc指的是方法内部的一些基本信息,从下往上分别是表示方法总长度为16,在代码中方法最高到达的行号为16,第二个是局部变量的最大容量,这里为3,很好理解,因为我们这里的确就存放了三个局部变量,最后是版本号,代表这个类被更新了几次
Code下面第一个代表的是行号表,内部存放的是字节码内的行号到代码的行号的映射,Start PC是字节码中的行号,Line Number是对应的Java代码的行号
第二个是本地变量表,内部存储了本地变量的各种信息,Start PC和Length结合起来代表对应变量的作用范围,这里三个变量的两者之和均为16,说明其作用域最大就到Java代码的第16行
name代表是对应的局部变量的名字,最后则是对应的描述,描述下指明了变量的数据类型
Slot变量槽
局部变量表中存储单元为Slot(变量槽),可以简单理解为数组的下标,其存放各种数据类型,除Long和Double数据类型会占用两个slot之外,其他都占用一个
每个局部变量表中每一个Slot都会分配一个访问索引,通过该索引可成功访问局部变量表中指定的变量值,其局部变量复制到slot上的顺序是按照调用顺序来设定的
值得一提的是,占用两位的变量,需要访问时需要使用其开头的索引
在构造方法和非实例方法中,对象引用this会存放到index为0的slot处,比任何变量都快,此时我们就能在反编译的结果里看到文件夹,其就是代表this的引用存放,而在静态方法中则没有这个文件夹
也这是为什么静态方法中我们无法调用this,因为其局部变量表中没有存放this的对象引用,而非静态方法中则有,因此前者无法调用,后者可以调用
下面的图里我们可以看到Index代表的Slot下标,在遇上double类型时前进了两位
同时栈帧中的局部变量表中的槽位可以复用
比如下面中b变量占用槽位,但是出了这代码块之后b就没有意义了,因此c的位置就复用了b的槽位,可以看到两者的Index值是相同的
静态与局部变量对比
首先我们来讲讲什么是静态变量和局部变量
静态变量指的是类变量,也就是类中的属性且有static修饰的变量,该变量会在prepare阶段默认赋值,如果有设定值,那么后面还会进行显示赋值,相当于静态变量有显示
而局部变量由于不存在系统初始化的过程,因此我们必须要对其进行初始化,认为指定数值,否则不可用
局部变量表与性能调用关系密切,同时只要局部变量中的有直接引用或间接引用的对象,那么该对象就不会被回收
操作数栈
操作数栈是用数组结构形成的栈,其只能执行进栈出栈两个动作。
操作数栈一般用于执行复制、交换、求和等操作。代码指令要被转换为字节码指令然后才能执行这些操作
操作数栈也可以保存计算的中间结果,同样是只有long和double的数据类型占用两个单位深度
操作数栈在编译器会定义一个计算所需要的最大深度并保存在Code属性中,名为max_stack,注意这个和局部变量表的大小是不一样的
最后如果被调用的方法有返回值,那么该返回值也会压入到当前栈帧的操作数栈中
字节码指令分析
接着我们从操作数栈的字节码指令来分析其内部的执行过程,下面是代码和字节码指令
首先我们进行的bipush操作,将15的值压入到操作数栈中,然后执行istrore_1指令,将操作数栈的值存入到局部变量表中,存入1的原因是方法是非静态方法,0的位置保存了this引用
接着压入8,然后存入8
接着执行iload_1和iload_2指令,这两个执行会将上面存入到局部变量表中的15与8拷贝一份放到操作数栈中
然后执行iadd执行,将操作数栈的中的两个值相加并保存到栈顶中,然后将值保存到局部变量表中,最后返回即可
这里值得一提的是,如果我们的java代码中定义了一个int类型的数据,但是其值可以用byte表示,那么其在操作数栈中会用byte表示,会令其大小*2,以为int的字节比byte大一倍
但是如果超越了byte的范围,那么一开始就会用int类型进行表示,而且,最后到局部变量表时,他们都会用int来表示,即使是布尔类型也是
栈顶缓存技术
动态链接与常量池
然后我们来讲栈帧中的动态链接
动态连接指的是栈帧内部中存有指向运行时常量池的的所属方法的引用
Java源文件被编译成字节码文件中时,所有的变量的方法引用都会作为符号引用保存在class文件的常量池里,当需要调用常量池内的内容时,动态链接会将这些符号引用转换为调用方法的直接引用
如上图就是常量池,其中#123456等最左边的内容就是符号引用,而后面紧随的第二行内容是这个引用调用的其他引用,最后一行是引用的指向的内容的描述
下图则展示了字节码指令调用常量池方法的过程
常量池是保存到方法区中的,而方法区是多线程共享的
常量池的存在可以提供一些符号和常量,便于指令识别并提高效率
静态绑定与动态绑定
JVM中符号引用转换为直接引用与方法的绑定机制有关,我们先来讲绑定相关知识
JVM中的链接分为两种,分别是在编译期可以确定目标方法的静态链接和在运行时才能确定方法的动态链接,这两种链接也分为两种绑定机制,分别是早期绑定和晚期绑定
绑定指的是一个字段/方法/类在符号引用被替换为直接引用的过程
早期绑定指的是被调用方法在编译器可知,且运行时保持不变的方法,比较有代表的例子是调用父类的方法或者是自己构造方法,这些方法不可能会被重写,因此在编译期就可以确定
而晚期绑定值得是在编译器无法确定,在运行期随着具体对象的不同而不同的方法,比如调用本类的方法(但是该类有子类)或者是接口的方法,这些方法都是可以重写的,因此编译期时无法确定
Java中任何一个普通方法都具备虚函数特征,如果不希望其具备虚函数特征,可以用final关键字修饰该方法。
虚方法和非虚方法
方法的调用有两种方式,分别是虚方法和非虚方法,虚方法与非虚方法的区别于早期绑定和晚期绑定无异
非虚方法有静态方法、私有方法、final修饰的方法、实例构造器和父类方法,其他的都是虚方法
虚方法调用的命令是invokevirtual和incokeinterface,前者调用普通方法,后者调用接口的方法,但是这里值得注意的是,被final修饰的方法并不是虚方法,然而其调用时却会显示是invokevirtual字节码指令
如果是调用静态方法,则是显示invokestatic,如果调用构造器方法、私有的或者父类方法,则会显示invokespecial
invokedynaic是动态调用执行,其作用是动态解析出需要调用的方法然后执行,这个方法在Java8Lambda表达式出现之后才有了直接的生成方式
Java本质是静态类型语言,也就是强语言类型,但是由于Java是跨平台,为了满足跨平台的需要,其需要引入弱语言类型也就是动态类型语言的特性,因此增加了invokedynaic指令
该指令在使用lambda表示时,调用其对应的方法时会在字节码的指令中展示出来
方法重写与虚方法表
Java的类实现了方法重写时,实际要找到对应的方法会从该类方法中寻找名称相同的方法,没有则不断向上查找,一直找不到就报异常
同时这里会有一个权限异常,如果一开始访问对应类的权限校验不通过时,会报对应的异常
每次需要执行方法时都往上找的话,效率上就会变得很烂,为了解决这个问题,虚方法表应运而生
每个类中都有一个虚方法表,虚方法表中保存对应方法和实际的方法地址的映射,这样能有效提升效率,虚方法表的创建时间是在类的变量初始化值准备完成之后
下面是虚方法表的例子
方法返回地址
方法的结束有正常结束和异常结束两种方式,前者结束会将PC计数器的值作为返回地址,让执行引擎知道下一步的动作,而异常返回则需要通过异常表来确定,栈帧中通常不会保存这部分信息
方法的退出就是当前栈帧出栈的过程
正常退出和异常退出的区别在于,异常退出不会给上层调用者产生任何返回值
在字节码指令中,有多个返回指令,使用哪个返回指令根据方法返回值的实际数据的类型而定
方法发生异常时会存储异常处理表,能够在发生异常时找到用于处理的代码,from指的是异常从x行开始,to代表到第y行结束,target指的是对异常进行处理的行,而type描述异常的类型
简单来说,方法的返回地址的作用是让上一个方法完成之后,将PC寄存器的值返回给执行引擎,这样执行引擎就知道下一个需要继续执行的方法位置了
最后栈帧中还存在一些附加信息
关于虚拟机栈的面试题
接着我们来看看关于虚拟栈的面试题
- 栈溢出的情况是StackOverflowError,我们还可以通过-Xss设置栈的大小:OOM
- 挑战栈大小不能保证不发生栈溢出,但是降低其发生概率
- 栈内存越大,可以运行的线程就越少,因此栈内存并不是多多益善
- 方法区和堆区都存在OOM和GC,PC寄存器二者都不存在,本地方法栈和虚拟机栈只存在OOM
- 方法中定义的局部变量是否是线程安全的,需要具体问题具体分析。简单来说,如果变量在方法内部就被销毁了,那么其就是线程安全的,但如果方法是由外部传入或者是返回同样的变量的,那么就是线程不安全的
本地方法接口
在讲解本地方法栈之前,我们先来讲解本地方法接口
本地方法简单来说就是native修饰的方法,其代表的是去调用一个C/C++的函数
native只不可以与abstract修饰符连用,其他均可,这是以为native是有方法体的,只是其实一个C/C++的函数而已,而abstract是没有方法体的,两个关键字互相矛盾,因此不可连用
之所以要使用Native Method,是为了与Java外卖的环境交互
其次是因为操作系统的底层是用C进行交互的,甚至JVM的一部分也是用C写的,而且Sun的解释器也是用C写的,这就导致在底层使用C时执行效率会更高,以上这些各种因素导致我们必须加入对应的本地方法
现在本地方法的使用已经越来越少,除非是和硬件有关的应用,而且大部分需要使用本地方法的需求也有了替代品
本地方法栈
本地方法栈用于管理本地方法的调用,其是线程私有的且允许实现成固定或者是可动态扩展的内存大小,在内存溢出方面的相同的
本地方法使用C语言使用,其过程是在Native Method Stack中等级native方法,在Execution Engine执行时加载本地方法库
当线程需要调用本地方法时,其会拥有和虚拟机同样的权限,可以做各种出格的动作
值得注意的是,并不是所有的JVM都支持本地方法,且在Hotspot中,本地方法栈和虚拟机栈合二为一
堆空间概述
堆是运行时数据区中最大的一块空间
一个JVM只存在一个堆内存,堆内存的大小是可以设置的,同时堆可以处于物理上不连续的内存空间中,但是在逻辑上必须是连续的。这里物理空间与逻辑空间简单来说就是在内存层面上造表来实现虚拟空间到实际空间的映射,通过这样映射可以实现空间逻辑上的连续,连续的空间可以让效率获得有效的提高
同时所有的线程共享Java堆,但是堆中仍然可以划分线程私有的缓冲区,称之为TLAB
几乎所有的对象实例都存放在堆中,数组和其他引用类型变量也是同理。方法结束之后,堆中对象并不是马上移除,而是在执行垃圾收集,也就是GC时进行移除
上图是对应变量在栈堆方法区中的存放示意图
堆的细分内存结构
堆空间可以细分为新生区、养老区和永久区,其中永久区在Java8之后被更改为元空间
新生区又还可以划分为Eden区和Survivor区,下图是对应的堆空间内部结构的详细展示图
其中,我们在设置中指定的堆空间大小是只针对新生区和老年区的
堆空间大小设置
堆空间的大小设置通过选项-Xmx和-Xms来设置,堆区内存大小超过指定最大内存时会发生OOM
下图中还列举了我们手动查看我们堆内存的具体使用信息的方式
值得一提的是,堆空间内新生代内部结构为一个伊甸园和两个生存区,生存区二者只会有一者使用,另一者不会被计入空间内,因此我们的显示的空间大小总是会比指定大小小一些
在使用-XX:+PrintGCDetails中显示的新生代的空间总量中也是只计入了一个是生存区之后的结果
OOM说明与举例
当我们的堆内存不足时,会发生OOM异常
查看进程的情况可以使用Java VisualVM,其是Java内置的可视化工具,可以便于我们查看进程内部各种信息,不过这个玩意在Jdk8之后就无了,所以我们了解即可
我们伊甸园区首先会存放新放入的数据,伊甸园区一旦满了就全部存放到老年代区中,然后重置伊甸园区,同时生存区会换另一个生存区使用,当老年区慢了且伊甸园区也满了时,会发生OOM
年轻代与老年代
Java对象有两类,一类是生命周期较短的对象,另一类则是非常长的对象,为了适配者两种对象,因此我们的堆区划分除了年轻代和老年代,年轻代又划分出伊甸园和生存区
年轻代和老年代的比例默认为1:2,在新生代中,伊甸园与两个生存区的比例为8:1:1,新生代中的比例不会改动
尽管一般来说新生代中各个区的比例是指定的,但是由于Java中有内存自适应机制,因此其空间比例并不会严格遵守事先指定的比例
同时绝大部分对象的销毁和创建都是在伊甸园区进行的
如果我们程序中的长存对象比较多,那么可以调用对应的指令将老年代的所占空间比例增加,反之也可减少
伊甸园区满后,还存在的对象会转移到生存区中,生存区中的对象会转移到老年代中
对象分配的两种过程
先来说说一般过程
一般来说对象先放在伊甸园区,伊甸园区有内存限制
当伊甸园区满时,会触发Young GC,对该区进行垃圾回收,没有回收的对象将会放到幸存者1区,同时给放入的对象设置数量标识
当再次触发YGC时,多的对象会放到幸存者2区,幸存者1区的内容也会移动到2区,但是其数量标识会自增
幸存者区也可以叫from区和to区,但是这并不是固定某一个幸存者区的名字,判断谁是to的方法在于看哪个区为空的,谁空谁是to区
当幸存者区的数量标识到达15时,会将对象进行晋升,成为老年代中的对象,15是阈值,是可以设置的
幸存者区如果满了,会直接将对应对象放到老年区,不会触发GC,也有可能对象直接放入老年区,连幸存者区都不经过
当养老区执行了Major GC之后发现仍然无法进行对象保存时,就会抛出OOM异常
最后垃圾回收频繁在新生区收集,但是很少会在养老区收集,几乎不再元空间收集
然后是特殊过程
特殊情况的内存分配如下图所示,这里要注意的是,此处演示的前提的是没有开启动态内存分配
最后我们来看看常用的调优工具
三个GC的对比
JVM中的GC有三种,分别是YCG,MGC与FGC,要调优,我们主要需要避免后两者的GC
YGC的触发条件是当伊甸园区满时,其进行垃圾收集同时会对幸存区进行垃圾收集
下图是YGC的执行过程
老年代GC有两种,分别是MGC和FGC,MGC触发时经常会伴随至少一次YGC,但并不绝对,简单来说就是当老年区空间不足时,会尝试YGC,若空间还不足再尝试MGC
如果MGC后内存还不足,则会抛出OOM异常
FGC直接看下图吧,以后我们还会详细讲这个知识
堆空间分代思想
堆空间即使不进行分代也能正常工作
但是分代可以提高效率
内存分配策略
分配内存时,按照下面的策略进行分配
针对不同年龄段的空间分配原则如下图所示,这里值得一提的是如果幸存区中相同年龄的所有对象大小的总和大于其空间的一半,那么年龄大于等于其的对象可以直接进入老年代
TLAB
TLAB是伊甸园区中的一个分配给线程的缓冲空间
JVM为每个线程分配一个私有的缓存区域,其包含于Eden空间内,它就是TLAB
使用TLAB可以避免一系列的线程安全问题同时还能提高内存分配的吞吐量,我们将这个内存分配方式称为快速分配策略
上图是关于TLAB的空间分配过程
JVM将TLAB作为内存分配的首选,如果分配TLAB失败,那么JVM就会尝试通过使用加锁机制来确保数据库的原子性
堆空间中的参数设置
堆中有各种参数设置的方法,下图展示的对应的方法
还有一些我们之前使用过的方法
下图关于空间分配担保方法的说明
逃逸分析
如果经过逃逸分析之后对象并没有逃逸出方法的话,那么该对象可能被分配到栈上
逃逸分析指的是分析一个对象是否可能会被外部方法所引用,若是则创建到堆中,反之则可能创建到栈中
下图是没有发生逃逸的案例,对象V不会在方法外部被调用
下图是将发生逃逸的对象改为不发生逃逸的方法
下图是加深理解的例子
判断是否发生了逃逸分析,就看最终的new的对象是否有可能在方法外被调用
逃逸分析存在的主要目的就是为了减少GC,提高效率
JVM默认开启逃逸分析,一般我们也不会关闭它
代码优化
代码优化的方法有三,分别是栈上分配、同步省略和分离对象或标量替换
栈上分配就是经过逃逸分析之后对象不存储在堆中,而是存储到栈帧中,随着栈帧的进出而一起释放,这样就省去了GC的功夫
线程同步,也就是synchronized关键字的代价相当高,我们尽可能应该要避免使用。为此产生了同步省略
同步省略指的是在动态编译时,其会判断同步区域的对象是否只能被一个线程访问,若是,则根本不需要锁,此时其会在编译时取消上锁
写入下图所示的代码,最后其执行的效果是和黄色区域的代码一样的
这里值得一提的是,即使是字节码文件里也还是能看到其同步关键字,其进行动态编译的时候是在运行时的,是在字节码文件之后的
标量指的是基本数据类型,而聚合量可以理解为是引用数据类型,一个聚合量和分解为多个其他聚合量和标量
JIT阶段经过逃逸分析发现一个对象不会被外界访问经过JIT优化,会将该对象拆解为多个标量进行代替,这个过程就是标量替换,并且最后的结果会存储在栈中
那么上面的代码在经过标量替换之后就是下面的样子
标量替换可以减少堆内存的占用,且同时为栈上分配提供基础,存储于栈上的对象是会进行标量替换的,如果标量替换不进行,那么就不会存储到栈中
标量替换可以用对应的命令进行关闭
逃逸分析补充说明
使用逃逸分析时无法保证逃逸分析的性能消耗一定高于其自身消耗,因此其使用还有待考量。尽管其不成熟,但仍然是即时编译器优化技术中的一个重要手段
可以确定的是Hotspot虚拟机中所有的对象实例都创建在堆中,其他的虚拟机就不一定了
最后来看看总结
方法区概述
方法区是我们本节最后要讲述的一个章节
堆和元空间都是线程共享且拥有GC的,而虚拟机栈和本地方法栈只有OOM,程序计数器则两者均无
对于一个对象的创建,其对象的类型数据,也就是Class文件是会放到方法区中去的,具体的对象会放到堆区中,而这个方法则会在虚拟机栈中进行压栈
基本理解与演进
方法区可以看作是独立于Java堆的内存空间
方法区是被各线程共享的并且存在OOM,也可以扩展或者固定大小
JDK7之前,方法区习惯称为永久代,用JVM的虚拟内存作为其大小,这样做的问题是容易发生OOM
JDK8之后放弃了永久代的概念,而是将元数据区作为方法区,使用本地内存,有效避免OOM
元空间与永久代并不只是名字不同,其内部结构亦进行了调整
设置方法区大小
JKD7之前的方法设置
JDK8之后
为了提高效率,避免频繁GC,建议将方法区的值设置为一个相对较高的值
OOM的产生可能是因为内存泄露,内存泄露指的是对象在GC之后与GC Roots产生引用关联导致其无法被回收,此时需要定位泄露代码的位置并解决
如果不存在内存泄露,那就单纯是类太多了,此时可以扩大内存或者减少类加载
方法区的内部结构
源代码编译成字节码文件后会经过类加载器将对应的信息加载到方法区中
方法区中一般存入的信息如下所示,但这并不是所有的虚拟机都是一致的
类型信息的主要内容如下图所示
域信息如下如下图所示
在方法信息里,构造器和其他方法都会统一被视为方法,方法信息下还有异常表,会记录异常处理的对应信息
静态变量在类中会随着类的加载而加载,不需要new对象也可以调用
全局变量,也就是被static final修饰的变量,会在编译器就确定其值,而静态变量则是会经过准备阶段初始化并通过初始化阶段确定对应的值
常量池
在方法区中存在运行时常量池,而在字节码内部包含了常量池
我们需要理解ClassFile字节码中的长岭吃才能明白方法区中的运行时常量池
常量池在class文件位于字节码文件的中间位置,有效的字节码文件中包含类的版本信息等各种信息,并且还含有常量池表,其内部包括对各种字面量、类型、域和方法的引用
二进制文件是字节码的真正形式,我们在idea的插件中看到的字节码文件都是反编译之后看到的,严格来说并不属于字节码文件,比如说字节码文件加载时同时还会记录其使用的类加载器的信息,但是我们在工具上反编译的字节码文件里就看不到这个信息,但在实际的字节码文件中这个记录是存在的
字节码文件中存放各种指向常量池的符号引用,在动态链接时会使用到运行时常量池,这样可以通过这些符号引用来查找到所需要的具体的类,这样就不必将所有的类都加载到字节码文件中,能提高运行效率并将字节码文件的大小减少
最后我们来看看总结
运行时常量池是方法区的一部分,保存各种字面量和符号引用的常量池表就保存在其中
加载类和虚拟机后就会创建对应的运行时常量池,每个类都会维护一个常量池,其中数据以索引访问。常量池中保存有运行期解析后才能过获得的方法或者字段引用,此时其不再是常量池中的符号地址,而是真实地址
同样,常量池存在OOM
关于常量池和字符串常量池的更多内容请参照blog.csdn.net/weixin_4406…
方法区的演进细节
首先,只有HotSpot虚拟机才有永久代的概念,而这个概念也在jdk8之后消逝了
下面是各个版本JDK的结构分布
之所以要用元空间取代永久代,这是因为永久代空间设置在虚拟机中,空间本身较小,容易发生OOM,且难以确定大小空间,容易出现GC,而且对永久代的调优也比较困难
因此更是要尽量减少GC,所以用元空间取代永久代
字符串位置调整
静态引用存放位置
静态引用的对象始终存在于堆空间
使用JDK9之后提供的JHSDB工具可以分析每个对象具体存在虚拟机中的结构位置
下面的代码我们想要得知静态变量与成员变量和局部变量的引用分别存在于什么位置
它们三个都存在于Eden区内,也就是都存在于Java堆中
得到结论是Java中对象都存在于堆中
方法区的垃圾回收
方法区的GC主要包括常量池中废弃的常量和不再使用的类型,而且方法区的GC又总是不容易令人满意
方法区常量池主要存放字面量和符号引用,只要常量池中的常量没有被任何地方引用,就可以被回收
判断一个类型是否可以回收则比较苛刻
在大量使用反射、动态代理、CGLib等字节码框架需要动态生成JSP以及OSGi这类频繁的自定义类加载器的场景中通常都需要Java虚拟机具备类型卸载的能力来保证不会对方法区造成过大的内存压力
总结
到此为止,我们的运行时数据区的内容就讲完了,下面是虚拟机栈的总结图
下面是大厂的一些面试题