不定期补充、修正、更新;欢迎大家讨论和指正
本文主要根据尚硅谷的视频学习,建议移步观看,其他参考资料会在使用时贴出链接
尚硅谷宋红康JVM全套教程(详解java虚拟机)
由于JVM的知识是互相穿插的,比如学习字节码会接触到运行时数据区的知识,学习堆区又会接触到GC的知识,所以建议先看视频对JVM有个完整的概念,本文只是作为学习笔记来复习,不适合入门学习。
JVM官方文档
The Java® Virtual Machine SpecificationJava SE 8 Edition
JVM(一)_类加载系统和字节码
JVM(二)_运行时数据区
JVM(三)_执行引擎
JVM(四)_性能监控与调优
虚拟机(Virtual Machine)指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。在计算机中创建虚拟机时,需要将实体机的部分硬盘和内存容量作为虚拟机的硬盘和内存容量。每个虚拟机都有独立的CMOS、硬盘和操作系统,可以像使用实体机一样对虚拟机进行操作。
广义上来看,我们可以将具屏蔽底层细节,专注本层功能的都视为虚拟机,比如计算机组成原理的多级层次结构的计算机结构,我们可以把M4(高级语言机器)视为具有高级语言编译功能的机器,但M4并不是实际的机器,只是人们感到存在的一台高级语言功能的机器。同理,Word、Excel也可以视为处理文字、表格等的虚拟机。
狭义上,虚拟机可以分为系统虚拟机、程序虚拟机。
系统虚拟机是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,是对物理计算机的仿真,比如VMware、Visual Box等,这样就可以在Windows上运行Linux等系统,虽然看上去我们操作另一个系统,实际上是在操作软件。
程序虚拟机是专门为了某个计算机应用而设计的,比如要学的JVM。
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
如果问世界上最好的语言是什么,各语言的程序员肯定吵得不可开交,但是最好的虚拟机,毫无疑问是JVM。
JVM从Java7开始就不只是服务Java语言,其他语言只要符合JSR-292规范,由编译器编译成JVM规范的字节码文件,就都能在JVM上运行。
因此Java平台多语言混合编程正成为主流,各个领域使用不同的语言,比如一个项目中,并行处理用Clojure编写,展示层用JRuby/Ralis,中间层用Java,各语言的交互不成问题,最终都在JVM上执行。
(我也不太了解,都是照抄视频中的,以后有机会学习再详细讲讲)
Sun Classic VM
1996年在java1.0由sun公司发布,是世界上第一款商用的java虚拟机。
该虚拟机内部只提供了解释器,性能差(这也是造成Java运行效率比C/C++差固有印象的原因,现在的虚拟机一般是解释器和即时编译器(JIT)搭配执行),如果需要JIT,就需要外挂,但是解释器和即时编译器不能同时工作。该虚拟机在JDK1.4时候时被淘汰。
Exact VM
为了解决上一个虚拟机解释器和JIT不能同时工作的问题,JDK1.2时,sun提供了此虚拟机
该虚拟机主要提供了准确式内存管理功能(Exact Memory Management :虚拟机知道内存中某个位置的数据是什么类型)、热点探测、编译器与解释器混合工作模式,是现代高性能虚拟机的雏形。
但只在Solaris平台短暂使用,因为很快就被后来的HotSpot虚拟机取代。
Hotspot VM
最初由一家小公司Longview Technologizes设计,1997年被sun收购,2009年,sun公司被甲骨文oracle收购,JDK1.3时候,HotSpot成为默认虚拟机。是目前三大主流商用虚拟机之一,占绝对的主导地位,也是在此学习的虚拟机。
HotSpot的名字就是他的热点代码探测技术,通过计数器找到最具编译价值代码,触发即时编译或栈上替换通过编译器与解释器协同工作,在优化响应时间和最佳执行性能中取得平衡。
JRockit
三大商用虚拟机之一,由BEA公司开发,专注服务器端应用,因为不太关注程序启动速度,所以JRockit内部不包括解析器实现,全部代码靠即时编译器编译后执行,因此也是目前世界上最快的JVM(因为都是编译器工作),在2008年BEA被Oracle收购(世界五百强不是吹的,Java就不说了,旗下的Oracle,Mysql数据库都是它家的)。
IBM J9
三大商用虚拟机之一,IBM Technology for java Virtual Machine 简称IT4J,内部代号J9,市场定位与HotSpot接近,服务器端、桌面应用,嵌入式等多用途,广泛应用于IBM的各种Java产品。号称速度最快,因为在自家平台测试,和IOS一样,与自家产品高度契合,当然效率也高。2017年开源,命名位OpenJ9,交给Eclipse基金会管理。
Graal Vm
2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,从它的口号“Run Programs Faster Anywhere”就能感觉到一颗蓬勃的野心,这句话显然是与1995年Java刚诞生时的“Write Once,Run Anywhere”在遥相呼应。
Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。
Graal Vm野心极大,有一统所有虚拟机的目标,如果HotSpot被取代,最有可能的就是这款虚拟机,让我们拭目以待。
除了以上虚拟机,还有KVM、CDC、Azul VM、Liquid VM、Apache Harmony、Microsoft VM、TaobaoVM、Dalvik VM等
提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。
但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的;
甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM
而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,
Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。
HotSpot VM既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,
如它名称中的HotSpot指的就是它的热点代码探测技术(其实两个VM基本上是同时期的独立产品,HotSpot还稍早一些,HotSpot一开始就是准确式GC,
而Exact VM之中也有与HotSpot几乎一样的热点探测。
为了Exact VM和HotSpot VM哪个成为Sun主要支持的VM产品,在Sun公司内部还有过争论,HotSpot打败Exact并不能算技术上的胜利),
HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。
如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。
通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,
即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。
在2006年的JavaOne大会上,Sun公司宣布最终会把Java开源,并在随后的一年,陆续将JDK的各个部分(其中当然也包括了HotSpot VM)在GPL协议下公开了源码,
并在此基础上建立了OpenJDK。这样,HotSpot VM便成为了Sun JDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。
在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,这样Oracle就同时拥有了两款优秀的Java虚拟机:JRockit VM和HotSpot VM。
Oracle公司宣布在不久的将来(大约应在发布JDK 8的时候)会完成这两款虚拟机的整合工作,使之优势互补。
整合的方式大致上是在HotSpot的基础上,移植JRockit的优秀特性,譬如使用JRockit的垃圾回收器与MissionControl服务,
使用HotSpot的JIT编译器与混合的运行时系统。–摘自《深入理解Java虚拟机:JVM高级特性与最佳实践》
以下为HotSpot VM的大致结构,源代码编译成字节码文件(Class files,因此前面应还有一个编译过程),字节码文件通过类加载系统加载到JVM中,JVM所管理的内存为运行时数据区,执行引擎从运行时数据区获取数据,再通过执行引擎对这些数据进行处理,最终运行在操作系统上。
根据以上结构,对JVM分为四部分学习:
以下是更为详细的结构图
我们知道内存是硬盘和CPU的桥梁,程序要从硬盘中加载到内存中变成进程才能获取到CPU资源。同理,字节码文件也要加载到内存中才能被CPU执行,JVM在内存中的布局称为运行时数据区,其规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效运行,不同JVM的内存布局稍有差异,这里根据HotSpot虚拟机的来学习
运行时数据区主要分为五个结构
这些不同的区域有些会随虚拟机的启动而创建,随着虚拟机退出而销毁,有些则与线程一一对应。
即作用域不一样,因此这些资源也分为线程共享和线程私有
学过计组或汇编的对PC寄存器应该都不陌生,程序计数寄存器(Program Counter Register)的作用是存储下一条要执行指令的地址,通过取址执行就可以实现代码的分支、循环、跳转、异常处理等功能。
JVM中的PC寄存器虽然功能和CPU中的PC寄存器相同,但并不是硬件的物理概念,而是一种抽象模拟,也称为程序钩子(钩住一个个的字节码指令)
可以看看8086CPU汇编下的CS和IP是如何工作的,PC寄存器的工作也是类似
PC寄存器是十分小的内存空间,毕竟一个指令集很小,因此不会发生OOM(OutOfMemoryError,内存溢出,后面会经常看到)的错误,也是JVM规范中没有任何OOM情况的区域,当然更提不上垃圾收集了
PC寄存器是每个线程独有一份的,原因也很简单,我们知道线程是CPU时间分片轮流执行的,也就是一个线程不会独占资源执行完才切换线程,而是这个线程执行一会就得释放资源切换到其他线程。
在进行切换前,肯定要保存现场信息,以便时间片到自己时,根据保存的现场信息恢复到切换前的工作状态,最典型要保存的就是PC寄存器,这样才知道下一条指令要执行什么,如果PC寄存器是共享而不是各线程私有的话,那就无法保存各个线程下次要执行的指令。
现在我们来对一个简单程序进行反编译来看看字节码指令
在该类生成的字节码cmd路径下,输入javap -v 字节码class文件,就可以输出了反编译后的结果
也可以使用更加方便的工具jclasslib bytecode viewer工具查看,IDEA中直接在插件中安装即可
可以看到源码经过编译生成的字节码指令,旁边的数字就是指令的偏移地址,假如PC寄存器存放的值为4,那么下一条要交给执行引擎执行的指令就是iload_1
字节码详细的讲解在 JVM(一)_类加载系统和字节码中学习
栈区是存放Java虚拟机栈的空间(早期也叫Java栈),每个线程在创建时都会栈区创建一个虚拟机栈,用于保存方法的局部变量、部分结果、并参与方法的返回和调用,虚拟机栈的生命周期和线程的生命周期一致
对于栈来说,里面的数据都是程序执行所需要的,所以不存在垃圾收集问题,但存在StackOverflowError(栈溢出异常,Stack Overflow也是全球最大的技术问答网站),由于JVM允许栈的大小是动态或固定不变的,如果采用固定大小的栈,如果线程请求分配的栈容量超过虚拟机栈的最大容量就会抛出栈溢出异常;而如果是动态的,在尝试扩展时无法申请到足够的内存,或创建新线程没有足够的内存去创建对应的虚拟机栈就会抛出OOM异常,这两者要区分开。
栈溢出最常见的场景就是递归
JVM提供-Xss参数修改栈的大小(JVM的参数 - [ JVM(二)_运行时数据区] List item学习),在IDEA中编辑运行时的配置
在虚拟机选项添加参数,256k是设置栈的大小为256k
在执行时同时计数,可以看到超过和设置的大小差不多时就栈溢出了
虚拟机栈仍不是我们研究的对象,虚拟机栈内保存的一个个栈帧(Stack Frame)才是要研究的对象,每一个栈帧对应着一次次方法调用,例如现在有A()方法,A()方法中调用B()方法,这时该线程的虚拟机栈就存在两个栈帧,同时A()方法的栈帧在B()下面。
根据栈的先进后出的结构,以及在一个活动线程中,一个时间点只有一个活动的栈帧,即只有当前正在执行的方法的栈帧是有效的,这个栈帧称为当前栈帧,与该栈帧对应的方法则为当前方法,定义这个类的就是当前类。
执行引擎只对当前栈帧进行操作,如果当前方法调用了其他方法,被调用方法就会创建新栈帧,放到栈顶,称为当前栈帧,当当前方法返回时,就会将该方法执行结果返回给前一个栈帧,这个栈帧就会从虚拟机栈中移除,前一个栈帧称为当前栈帧(Java方法有两种函数返回,一种是正常返回,一种是抛出异常,无论哪种形式都会是当前方法的栈帧抛出)。
栈帧的内部结构如下:
虚拟机的大小决定可以放多少栈帧,栈帧的大小主要取决于局部变量表,操作数栈也占一些
局部变量表(Local Variable Table),也叫本地变量表,故名思意就是存放方法中局部变量的表,
局部变量表的变量只在当前方法调用中有效,在方法调用结束后,随着方法对应栈帧销毁,局部变量表也随着销毁
局部变量表中存放的变量包括8种基本数据类型、对象引用(reference,只是给出引用标记,对象实例在堆中)、以及returnAddress类型,变量最基础的存储单位是Slot(变量槽),在局部变量表中,32位以内的类型只占用一个slot(包括returnAddress类型,byte、short、char在存储前会转换为int),64位的类型(long和double)占两个slot。
局部变量表的大小在编译期就确定了下来,并保存在Code属性的maximum local variables数据项中,在方法运行区间是不会更改的。
下面还是通过jclasslib工具来分析以下实例
< init >是构造器方法,这里先看main()方法
每个栈帧都包含Code字段,包含了字节码指令(Bytecode)、异常表(Exception table)、杂项(Misc)
在杂项中我们可以看到该栈帧局部变量表的最大值(Maximum local variables),和字节码指令的长度(Code length)
Code字段下一般包含行号表(LindeNumberTable)和局部变量表(LocalVariableTable)
行号表所存储的是字节码指令偏移地址和源码中行数一一对应的关系
比如在源码中12行的int num = 10;
在字节码指令中对应的开始是8到10的地址
还是把重点放在局部变量表
局部变量表Start PC和行号表类似,代表这个变量编译后在字节码指令的偏移地址,Length表示的这个变量的作用域,很显然args的作用域就是main()方法的大小,main()的Code Length的大小刚好就是16;Index,数组的下标,从0开始到数组大小-1;Name就是变量的名字,上面的(cp_info#数字)是在常量池中的引用,这里先不深究,在动态链接会学习;Descriptor是变量的类型描述,int类型用I来表示
对于其他方法,如果不是静态方法,我们知道方法中隐含着一个变量this,用于指向调用该方法的对象,所以有this变量的话局部变量表第一个位置就会存放this变量
自然的,空参构造器中的局部变量表也只有this
上面讲过局部变量表存储单位为slot,Double和Long类型占两个slot
所以可以看到Double类型和Long类型后变量的下标都要+2
操作数栈(Operand Stack),有些地方也叫表达式栈(Expression Stack),操作数栈的作用就是在方法执行过程中,根据字节码指令,往栈中写入或读取数据,即入栈(push)和出栈(pop)。
与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈/压栈来访问。操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
如下
该源码生成的字节码指令
下面是指令执行的具体流程
PC寄存器指向偏移地址为0地址的指令,执行引擎执行0地址指令(其实个人觉得应该为2,因为PC存放的是下一条指令的地址,而图中0地址指令已经执行完成,后面都是这样不管了),bipush 向操作数栈存放数据,操作数栈中byte、short、char、boolean类型数据压栈前会被转为int。
PC寄存器执行下一条指令,istore_1,从操作数栈取出指令存放在局部变量表下标为1处的位置(为什么不放在0,因为该方法是非静态方法,所以0位置放的是this)
后面同理
iload_1和iload_2从局部变量表下标为1和2位置取出数据存放在栈中
iadd从栈中取出两个数据相加,得到的结果压到栈中
将栈中数据取出存到局部变量表
最后到return指令,方法执行完成返回
操作数栈的深度(大小)是在编译期就完成了,因为根据字节码指令就可以确定(在jclasslib没找到,这里通过javap来看了)
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
栈顶缓存技术
栈顶缓存技术:Top Of Stack Cashing
基于栈式架构的虚拟机所使用的零地址指令虽然更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
栈顶缓存(Top-of-Stack Cashing)技术
每一个栈帧都包含指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
在class文件格式的常量池中存有大量符号引用(如类的全限定名、字段名和属性、方法名和属性),字节码的方法调用指令就是以常量池中指向方法的符号引用为参数。这些符号引用一部分会在链接(类加载子系统的链接)的解析阶段转为直接引用(向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析。还有一部分引用会在运行期间转化为直接引用,这部分称为动态链接。
在分析局部变量表时,可以看到诸如cp_info #28的字符串,这些就是指向常量池的符号引用(cp,Constant Pool)
请看以下示例
这里用javap来分析方便些,可以看到有些指令旁边有#number的字符,这些就是符号引用,具体指向哪里要到其生成的字节码文件中的常量池查看(注释后面就告诉引用了什么)
以#6为例,又指向了#3和#36,#3又指向了#34
#34就是最终结果了,#36继续指向#28和#29,最后的结果如下
也可以看看methodB()的
#2和#9
在字节码文件中只是表示了这种关系,在运行时通过动态链接将这些符号引用转换为直接引用
比如引用到某一对象,从字节码文件的指令得到符号引用,在运行时常量池找到该符号引用(程序运行时,字节码文件的常量池加载到运行时数据区放置的位置是方法区中的运行时常量池),再在堆区找到对象实例,从而完成符号引用到直接引用。
那为什么不在栈帧中保存直接引用呢?这种实现也是可以的,但想想不同的方法,都可能调用同一个变量或方法,比如成员属性,方法A和方法B都调用同个类中的方法C,如果使用常量池就只需要保存符号引用即可
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。
静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定,最典型的就是多态(面向过程语言一般就不支持晚期绑定)
虚方法与非虚方法的区别
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
其他方法均为为虚方法。
方法返回地址存放调用该方法的PC寄存器的值,不然该方法结束后怎么跳转到调用方法的下一条指令对吧。一个方法的结束,有两种方式:正常地执行完成和出现未处理的异常非正常的退出。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息,通过异常完成的出口退出的不会给它的上层调用者产生任何的返回值。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常方式的返回
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常的完成出口。一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含ireturn(当返回值是Boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用,比如上个示例的return。
异常返回
在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,称为异常完成出口。方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
如下
方法中定义了try-catch,所以字节码会生成一个异常表
起作用的字节码指令地址为8-12,如果没异常就执行goto 20,就跳到return了;如果有异常,就根据Handler PC跳到15,执行异常相关的指令
再根据行号表找到8-12的字节码地址对应在源码中的行号
对应11-14行,都是一一对应的
本地方法(Native Method)简单来说就是一个Java调用非Java代码的接口,该方法的实现由非Java语言在JVM外部实现,比如C,其目的就是为了融合不同语言,一开始的初衷是融合C/C++。
本地方法在Java源码中以native修饰符修饰,比如Object类的clone()、hashCode()、getClass()方法,Thread类的start0()方法等,这说明这些类不是由Java来实现,Java在需要直接和操作系统交互或需要执行效率高的场合一般都由C/C++实现。
如果一个线程调用了本地方法,它就工作于更底层,从而不受JVM限制,可以通过本地方法接口来访问JVM内部运行时数据区,直接使用CPU的寄存器、从内存中分配内存等。
对于我们普通的Java工程师来说基本没有我们来使用本地方法的场合,所以大致了解以下就行
本地方法了解后,本地方法栈也就很容易了解。本地方法栈和虚拟机栈很多地方相同,线程私有,也可以设置固定或动态扩容的内存大小。
虚拟机栈用于管理Java方法的调用,本地方法栈就负责管理本地方法的调用,具体做法是登记native方法,在执行引擎执行时加载本地方法库。
并不是所有JVM都支持本地方法,所以JVM不支持本地方法,本地方法栈也没实现的必要。在HotSpot中直接将本地方法栈和虚拟机栈合二为一。
JVM规范对堆区的描述是:所有的对象实例以及数组都应当在运行时分配在堆上,因此堆区一般也是运行时数据区中最大的内存空间,堆区在JVM启动时就被创建,其空间大小也随之确定
一个JVM实例只存在一个堆区,被多个线程共享;JVM规范规定堆可以处于物理上不连续的内存空间,但在逻辑上应视为连续(想想链表)
在方法结束后,堆中的对象不会马上被删除,而是在垃圾收集时才被移除(简单的情况,一个对象没有任何引用指向它就是垃圾,在C/C++中,内存是程序员释放的,而Java是执行引擎的垃圾收集器来释放);堆区是垃圾收集的重点区域,还有一个可以垃圾收集的区域是方法区。
在学习堆区,我们需要用到一个监控工具,VisualVM。这个工具可以可视化分析垃圾收集的情况,顺便查看堆区的结构,该工具在JDK 7后是默认自带的,在JDK包下的bin路径打开jvisualvm.exe的可执行文件即可。
如果没有在其官网下载VisualVM
接下来安装解压就行,启动后就可以看到这样的页面
如果安装成功后,在IDEA中也可以下载相关插件
安装完让其生效就可以在工具栏看到图标(没有的话可能要重启IDEA,或者看清楚该插件有没有Enable)
配置好VisualVM可执行文件的路径,就是你安装VisualVM的路径
在此我们还需要安装其一个插件Visual GC,进入工具->插件
我根据它给的插件中心URL来找插件会报错,防火墙代理之类的,如果遇到这种情况需要修改插件中心URL
插件中心的URL还是其官网找
找到你JDK的版本复制下来就行,还是挺折腾的
更改后可用插件这边应该就能刷出来了,找到Visual GC下载(我下载了,所以这里找不到)
一个进程开启一个虚拟机实例,一个虚拟机只有一个堆区,我们来验证这一点
创建两个类Test和Test1,(代码一样,让其睡眠久一点)
分别配置这两个类的运行配置中的虚拟机选项,这里用到两个参数-Xms(堆初始大小 -X:虚拟机参数 m:memory s:start)、-Xmx(堆最大大小 x:maximum),一般初始大小和最大大小设置成一样就行
这里一个类设置为10M,一个设置为5M
分别将两个类运行起来,回到VisualVM的页面可以看到这生成的两个进程已被监控
通过Visual GC查看,把堆中区域Eden Space、Survivor 0、Survivor 1、Old Gen的大小相加刚好为10M(这些区域稍后就会学习)
另一个类刚好为5M,这就验证了每个进程/JVM都有独一份的堆区
根据Visual GC,我们可以看到Eden Space、Survivor 0、Survivor 1、Old Gen、Metaspace这几个区域
JVM逻辑上堆空间划分为
虽然逻辑上是这么划分,但在上面示例中也可以看到,设置堆的大小恰好等于新生区+老年区,是不包含永久区/元空间的,因此物理上我们不把永久区/元空间视为堆空间的结构,而是视作方法区的具体落地实现
现在我们来看看7版本的永久区(元空间在GC看到了,这就不试验了),在虚拟机选项上添加-XX:+PrintGCDetails参数,同时JRE版本改为7
Java官网各版本下载地址(老版本的下载需要注册):Java Archive
除了永久区,可以发现新生区并没有Survivor 0/1区,而是多出了两个from和to区,这其实就是Survivor 0/1区
这里有一个细节,假如我们设置的堆大小有600M
但实际上却只有575M
这里猜也猜不出来,直接看各区域的内存大小,Survivor0/1的大小刚好为600-575=25M,这是因为Survivor0/1只能有其中一个工作区域,另一个区域为空,所以实际大小要减去一个Survivor区域的大小。
这和垃圾收集的复制算法有关,简单来说,现在0区有一些对象是垃圾,复制算法就把不是垃圾的对象复制到1区,清空0区;随着程序的运行,又要垃圾收集,这时就把不是垃圾的对象复制到0区,清空1区。因此Survivor区必须有一个是空的,这也是为什么Survivor区也叫from区和to区的原因,有数据的就是from区——来的地方,空区域就是to区——要到的地方,当复制过一次,from区和to区也跟着变化,并不是一一对应某个区域。垃圾收集相关的学习可以看看另一篇文章JVM(三)_执行引擎
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
基本过程如下:
我们创建的new的对象一般会放在伊甸园中
当伊甸园的空间填满,程序又需要创建对象时,JVM的垃圾收集器将对伊甸园区进行垃圾收集(YGC/MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁(红色的)。再加载新的对象放到伊甸园区。
而没有被回收的对象会从伊甸园移动到幸存者区,并且在年龄计数器上+1
随着程序的运行,如果又触发一次YGC,伊甸园的幸存者会放到空的to区,在这里的话就是S1区;而from区,即S0的所有幸存者会通过复制算法移动到S1,同时年龄计数器在之前之上+1,这时S1就相对的成了from区,S0成了to区
在不断的创建新对象和垃圾收集中,当对象的年龄计数器到达阈值,默认是15(可以通过-XX:MaxTenuringThreshold=N设置),就会晋升到老年区,如果S区中相同年龄的所有对象大小总和大于S区空间的一半,年龄大于或等于该年龄的对象也可以进入老年区,无需到达阈值,这是动态对象年龄判断机制
老年区的对象都身经百战,所以很少进行垃圾收集,但如果老年区的内存不足会触发Old GC/Major GC,如果经过GC后仍无法进行对象的存储,就会产生OOM异常。
以上是普通情况,当对象大到伊甸园本身就放不下,就一步到位直接放到老年区,如果老年区还放不下就OOM
如下创建一个20M的byte数组,堆的大小为45M,新生区的大小为15M,老年区为30M(现在只用知道新生区:老年区的比例默认为1:2就行)
老年区刚好使用20M(20480/1024/1024=20M),验证了大对象直接放到了老年区
可以根据下图来帮助理解
对于垃圾收集,当伊甸园区空间不足时触发Young GC/Minor GC,S0/S1区自己不会触发GC,而是在伊甸园触发GC时跟着被收集,这点要特别注意。由于大多数的对象在新生区创建,而且很快被销毁,所以新生区是最频繁GC的区域;老年区空间不足时触发Old GC/Major GC,相较之新生区,老年区的对象GC的次数少很多;而永久区/元空间的数据就更少被GC了。
注意以上的GC是动词,垃圾收集的策略,落地实现的GC是名词——垃圾收集器,比如进行Old GC行为的CMS GC,进行Young GC行为的ParNew GC,前者是仅收集老年区的垃圾的一种策略,后者是具体垃圾收集器。
除了Young GC和Old GC,还有Mixed GC(收集整个新生区和部分老年区的垃圾收集)和Full GC(整堆收集,收集堆区所有区域和方法区的垃圾收集)
GC行为会进行STW(Stop the world),暂停用户的其他线程,等垃圾收集结束,用户线程才恢复工作。Young GC虽然频繁但是STW的时间短,而Old GC和Full GC会长很多,所以性能调优这块一般对这两者进行。
在HotSpot中,默认情况下新生区和老年区的大小比例为1:2,而新生区中Eden区和另两个Survivor的比例为8:1:1
从上面任意的示例都可以验证新生区和老年区的大小比例为1:2
虽然Eden区和另两个Survivor的比例默认为8:1:1,但由于自适应机制,实际上并不一定是该比例,比如下面就为6:1:1,如果需要严格的比例关系,需要显式使用-XX:SurvivorRatio=num参数
如果要修改新生区和老年区的比例可以加上-XX:NewRatio=N参数(新生区:老年区=1:N),比如设置为4
(90+15+15):480 = 1:4
如果要设置伊甸园区和幸存者区的比例则用-XX:SurvivorRatio=N(Eden:S0:S1 = N:1:1)
160:20:20 = 8:1:1
一般保持默认就好,设置成这样的比例是因为几乎所有Java对象都在Eden区被new出来,销毁在新生区进行。IBM公司专门研究表明,新生区80%的对象都是“朝生夕死”的。
根据以下代码不断生成对象,同时用Visual GC观察堆各区域的变化
/**
* -Xms600m -Xmx600m
*/
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(1024 * 1024)));
}
}
}
class Picture {
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
}
前面提过,堆区是线程共享的区域,因此会产生线程不安全的问题,对此很容易想到就是使用加锁机制保证线程安全,但锁机制会影响内存分配的吞吐量。所以JVM采用了另一种机制,在伊甸园为每个线程划分一个私有缓冲区(Thread Local Allocation Buffer,TLAB),几乎OpenJDK衍生的JVM都采用这种设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间,默认是开启的。
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
在之前很多学习资料都说创建好的对象都是放在堆中的,这是普遍的常识,但随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
如果一个对象经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾收集了。这也是最常见的堆外存储技术。
此外,基于OpenJDK深度定制的TaoBao VM( 淘宝虚拟机 ),其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
首先了解什么是逃逸分析
逃逸分析(Escape Analysis)本质上是即时编译器(JIT)的一种分析对象作用域的算法,或者可以称之为是一种用于优化JVM的分析技术。逃逸分析不是直接用来优化代码的技术,它为JVM编译器其他优化技术提供必要的分析依据。逃逸分析的基本行为 —— 分析对象作用域,逃逸分析(Escape Analysis)算是目前JVM中比较前沿的优化技术了。
逃逸的方式有
比如下面的代码
sb对象可以通过返回值传递到方法外部,这样就发生了逃逸
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
若不想让sb对象发生逃逸,可以修改成以下代码
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
如何判断一个对象是否发生逃逸,直接的方法就是看new的对象实体是否有可能在其方法外被调用
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的? 仍然会发生逃逸。
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance(); //这个e对象,本身就是从外面的方法逃逸进来的
//getInstance().xxx()同样会发生逃逸
}
}
在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析
如果使用的是较早的版本,开发人员则可以通过:-XX:+DoEscapeAnalysis显式开启逃逸分析,通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
使用逃逸分析后,如果对象没有逃逸,编译器就可以对代码做出一些优化,目前的技术有
栈上分配
将对象在堆分配转化为栈分配,我们知道栈区是不存在垃圾收集的,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。
我们通过以下代码来测试堆分配和栈分配的差距
/**
* 栈上分配测试
* -Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User(); //未发生逃逸
}
static class User {
}
}
先看看堆分配的情况,关闭逃逸分析
对象在堆分配中发生GC
开启逃逸分析
可以看到栈上分配相比于堆分配的巨大优势,同时没有垃圾收集
同步省略
如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。
据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
Oracle Hotspot JVM是通过标量替换实现逃逸分析的
目前很多书籍还是基于JDK 1.7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久区上,而永久区已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
每日一道面试题-什么是逃逸分析?
方法区是运行时数据区最后一个区域了,方法区主要存放的是class文件的信息,而堆放是实例化的对象
可以根据下图大致了解虚拟机栈、堆、方法区三者的关系。
《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。
在堆中说过,尽管永久区/元空间(方法区的具体落地实现,方法区只是规范的接口)在逻辑上是属于堆的,但是实际实现上是另一个独立区域,在HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
但尽管如此,逻辑上属于堆,就有一些和堆一样的地方
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:
java.lang.OutofMemoryError:PermGen space(JDK7之前)或者 java.lang.OutOfMemoryError:Metaspace(JDK8之后)
比如加载大量的第三方jar包、Tomcat部署的工程过多(30~50个)、大量动态的生成反射类就容易导致方法区OOM
就下面简单的代码
在 JDK7 及以前,习惯上把方法区,称为永久区。JDK8开始,使用元空间取代了永久区。JDK 1.8之后,元空间存放在堆外内存中
本质上,方法区和永久区并不等价,仅是对Hotspot而言的可以看作等价,不过元空间与永久区最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久区的概念。
现在来看,当年使用永久区,不是好的idea。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)
而到了JDK8,终于完全废弃了永久区的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
在JDK7前,永久区的大小可以用-XX:PermSize来设置,默认是21M;-XX:MaxPermSize来设定永久区最大可分配空间(32位机器默认是64M,64位机器模式是82M)
设置永久区大小
如果把JDK改成8以上版本,是识别不到这两参数的
JDK8之后,元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定
默认值依赖于平台,Windows下,-XX:MetaspaceSize 约为21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
与永久区不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OOM
-XX:MetaspaceSize:设置初始的元空间大小。对于一个 64位 的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。
现在通过下面这个类来加载大量的自定义类,使方法区产生OOM
/**
* jdk6/7中:
* -XX:PermSize=10m -XX:MaxPermSize=10m
*
* jdk8中:
* -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class OOMTest extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
OOMTest test = new OOMTest();
for (int i = 0; i < 10000; i++) {
//创建ClassWriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号,修饰符,类名,包名,父类,接口
classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
//返回byte[]
byte[] code = classWriter.toByteArray();
//类的加载
test.defineClass("Class" + i, code, 0, code.length); //Class对象
j++;
}
} finally {
System.out.println(j);
}
}
}
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。除了JIT代码缓存,其他的内容都是字节码文件内容,也就是一个类的元数据。
我们可以通过以下代码反编译的字节码文件来看这些信息所包含的内容(字节码文件的内容在上篇学习JVM(一)_类加载系统和字节码,这里只大致了解)
/**
* 测试方法区的内部构成
*/
public class MethodInnerStructTest extends Object implements Comparable<String>, Serializable {
//属性
public int num = 10;
private static String str = "测试方法的内部结构";
//空参构造器
//方法
public void test1() {
int count = 20;
System.out.println("count = " + count);
}
public static int test2(int cal) {
int result = 0;
try {
int value = 30;
result = value / cal;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(String o) {
return 0;
}
}
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全类名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
-这个类型的修饰符(public,abstract,final的某个子集)- 这个类型直接接口的一个有序列表
域(Field)信息(类的成员变量)
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的类为 void.class
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
方法信息的东西就比较多了,截图不方便,用文本显示
public static int test2(int);
descriptor: (I)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 30
4: istore_2
5: iload_2
6: iload_0
7: idiv
8: istore_1
9: goto 17
12: astore_2
13: aload_2
14: invokevirtual #12 // Method java/lang/Exception.printStackTrace:()V
17: iload_1
18: ireturn
Exception table:
from to target type
2 9 12 Class java/lang/Exception
LineNumberTable:
line 19: 0
line 21: 2
line 22: 5
line 25: 9
line 23: 12
line 24: 13
line 26: 17
LocalVariableTable:
Start Length Slot Name Signature
5 4 2 value I
13 4 2 e Ljava/lang/Exception;
0 19 0 cal I
2 17 1 result I
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 12
locals = [ int, int ]
stack = [ class java/lang/Exception ]
frame_type = 4 /* same */
运行时常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。
常量池在这里做简单了解,可以看做是一张符号引用表,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等信息。
因此运行时常量池就很好理解了,就是方法区存放字节码常量池的区域,在加载类和接口到虚拟机后,就会创建对应的运行时常量池,池中的数据项像数组项一样,是通过索引访问的(早期运行时常量池内还包括字符串常量池,现在将其移到堆中了)
常量池能有以下这些信息
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
比如下面这条指令,就会通过符号引用常量池找到要执行的方法,然后再动态链接在堆中找到真正的实例对象
为什么要有常量池这种结构呢,回想之前一个简单的类就要加载上千个类,如果要将用到的类信息、方法信息都记录在当前字节码文件,那需要的空间显然比自身大许多,所以有了常量池只需要给出符号引用,真正使用时通过动态编译转换为直接引用即可。
首先要明确永久区这概念HotSpot才有,对BEA JRockit、IBMJ9等来说,是不存在永久区的概念,所以这里的演变过程是针对HotSpot的
JDK 6前,静态变量和字符串常量表都在永久区
JDK 7,永久区仍存在,但已经逐步 “去永久区”(因为此时oracle收购了JRockit)字符串常量池、静态变量从永久区中移除,保存在堆中
JDK 8及以后,方法区由元空间实现,并且不再使用运行时数据区的内存,而是直接使用本地内存
那为什么要用元空间取代永久区呢,(官方给的原因比较扯,直接就是说JRockit没有永久区,所以就不设置了)原因有:
一个Java类的创建,相信很多人都停留在new的阶段,实际上对象的创建要复杂很多
1.在常量池找到该类的符号引用,并判断该类是否加载,即经过类加载的加载、链接、初始化阶段。如果没有通过双亲委派机制,通过类加载器加载相应的.class文件,如果找不到抛出ClassNotFoundException异常。如果找到,则进行类加载,并生成对应的Class类对象。
2.为该对象分配内存
如果内存空间比较规整,可以使用指针碰撞(Bump The Pointer)来分配空间,该方法将堆区抽象为两块区域,一块是已经被其他对象占用的区域,另一块是空白区域,中间通过一个指针进行标注,这时只需要将指针向空白区域移动相应大小空间,就完成了内存的分配,当然这种划分的方式要求内存是地址连续的,同时虚拟机带有内存压缩机制,可以在内存分配完成时压缩内存,形成连续地址空间。
如下图
这种方式也存在一个比较严重的问题,那就是多线程创建对象时,会导致指针划分不一致的问题,例如A线程刚刚将指针移动到新位置,但是B线程之前读取到的是指针之前的位置,这样划分内存时就出现不一致的问题,解决这种问题,虚拟机采用了循环CAS操作来保证内存的正确划分;如果内存不规整,已使用的内存和未使用的内存相互交错,则使用空闲列表(Free List)来为对象分配内存。简单来说就是维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象,并更新列表上的内容。
参考文章
一张图解释指针碰撞和空闲列表
java对象的创建过程
3.处理并发安全问题,在上面采用指针碰撞来分配内存就会发生并发安全。可以采用CAS失败重试、区域加锁保证更新的原子性。也可以为每个线程分配一块TLAB来处理。
4.初始化空间,即为变量设置默认值,注意对于常量一般在编译期就决定了值,静态变量在链接阶段的准备环节就设置了默认值,在初始化阶段就赋上显式的值,所以这里是为普通变量设置默认值。
5.设置对象头,该过程的具体设计方式取决于JVM实现,这涉及到Java对象在JVM的布局
在Hotspot中,对象在内存中存储的布局可以分为三块区域:对象头(Header)(其中对象头又分为Mark Word和Klass Word两个字段)、实例数据(Instance Data)和对齐填充(Padding),如果对象是数组类型对象头还会有Length字段
- 对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
- 对象头中的Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
- 数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
- 实例数据是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
- 对齐字是为了确保整个对象所占的内存空间是8的整数倍,不到的用0补齐。
普通对象对象头所占空间大概为16字节
我们可以使用jol-core工具来查看对象头信息
这里klass word信息显示的不全,这是因为JVM指针压缩的缘故,实际这部分是没有的,也就说klass word所占字节是两字节,只是为了表示被压缩了
我们可以在虚拟机添加-XX:-UseCompressedOops选项关闭指针压缩,这时就能看到klass word完整内容了。开启指针压缩,理论上来讲,大约能节省百分之五十的内存,所以jdk 8及以后版本已经默认开启指针压缩。
对于Mark Word主要是记录线程锁状态(以下锁的学习可以看Java_多线程编程(上))
由于这就是普通的类,就是无锁,所以锁标志位就是1了
参考资料:JAVA对象布局之对象头(Object Header)
6.最后一步,就是执行对象的init构造方法了