深入理解Java虚拟机
第2章 Java内存区域与内存溢出异常2.2运行时数据区域2.1.1 程序计数器2.1.2 Java虚拟机栈变量的分类:局部变量表操作数栈动态链接方法返回地址 2.1.3 本地方法栈本地方法接口 2.1.4 Java堆2.1.5 方法区2.1.6 运行时常量池 2.3 HotSpot虚拟机对象探秘2.3.1 对象的创建2.3.2对象的内存布局对象头实例数据对齐填充 2.2.3 对象的访问定位 2.4 实战:OutOfMemoryError异常第2章 Java内存区域与内存溢出异常
根据《Java虚拟机规范》的规定,JVM管理的内存包括如图的几个运行时数据区域:
其中白色的是线程私有,灰色的线程共享。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F7FSWj3H-1627355600122)(D:\cs-book\笔记\jvm\img\image-0721211013184.png)]
2.2运行时数据区域
2.1.1 程序计数器
程序计数器是一块较小的内存空间,它是线程的私有内存(随着线程的创建和结束而分配和回收),可以看作时当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-upH5QgVG-1627355600125)(D:\cs-book\笔记\jvm\img\image-0721212537463.png)]
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一 内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响, 独立存储,称之为“线程私有”内存。
如果线程正在执行一个Java方法,这个计数器记录的正是正在执行的虚拟机字节码指令的地址;如果线程正在执行一个Native方法,这个计数器则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
2.1.2 Java虚拟机栈
Java虚拟机栈是线程私有的,它的生命周期和线程相同。其描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Java方法运行时的基础数据结构),用于存储局部变量表,操作数栈,动态链接,==方法出口(方法返回地址)==等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
其中最重要的是局部变量表和操作数栈,其余的动态链接、方法出口(方法返回地址)和一些附加信息一般统称“帧数据区”。
栈是运行时的单位,堆是存储的单位。
以下面这个做菜为例,栈管的是怎么运行,堆是实际存放对象的地方。
一个方法对应一个栈帧。不同线程的栈帧(也即一个方法)不能相互引用。
变量的分类:
按照数据类型分类:1基本数据类型 2引用数据类型按照在类中的位置分类: 成员变量:在使用前都默认初始化赋值(赋0值) 类变量:linking(链接)阶段的prepare阶段会给类变量默认赋值,initial阶段给类变量显式赋值。实例变量:随着对象的创建会在堆空间中分配示例变量空间,并进行默认赋值。 局部变量:使用前必须显示赋值,否则编译不通过。
局部变量表
局部变量表:局部变量表是 Java 虚拟机栈的一部分,存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,它不等同于对象本身,可能是一个指向对象地址的引用指针,也可能是执行一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
通俗说就是:局部变量表存放的是当前栈帧对应的方法中的方法参数和方法内的局部变量,如果是基本类型就直接存值,如果是引用类型则存地址。
这些数据类型在局部变量表中的存储空间一局部变量槽(Slot)表示:其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。构造方法和示例方法的局部变量表中第一个slot存的是this,是这个对象的引用。而且slot是可重用的,即如果一个变量的作用域结束,其slot位置可被下一个使用。
在Java虚拟机规范中,对这个区域规定了两种异常。
如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将会抛出 StackOverflowError 异常(在虚拟机栈不允许动态扩展的情况下);如果扩展时无法申请到足够的内存空间(栈扩展失败),就会抛出 OutOfMemoryError 异常。
局部变量表中的变量是重要的垃圾回收根节点(GC Roots),直接或间接被局部变量表中引用的对象都不会被回收。
操作数栈
操作数栈(operand stank):也叫做“表达式栈”,方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的(这个时候数组是有长度的,只是操作数栈为空)
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为stack的值。
栈中的任何一个元素都是可以任意的Java数据类型。
32bit的类型占用一个栈单位深度64bit的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
采用数据实现的栈。
操作数栈的具体示例看宋红康老师的的ppt示意图,清晰易懂。
动态链接
运行的时候将字节码中的常量池复制一份放到方法区的运行时常量池中,里面包含了字面量和各种符号引用。运行时常量池又常量池不具备的动态性,可以运行时动态添加新的常量。
这里注意,虚方法和非虚方法的定义。只要是能够被重写的都叫做虚方法,其它都是非虚方法。例如静态方法、构造方法、final修饰的方法等。
操作数栈执行invokevirtual 指令时会去找符合的方法,为了简化这个寻找过程,为每个类维护一个虚方法表,存放各个方法的实际入口。该表再类加载的链接阶段创建并初始化,初始化完毕后会把该类的方法也初始化完毕。
方法返回地址
存放该方法的pc寄存器的值。
一个方法的结束,有两种方式:
正常执行完成;
出现未处理的异常,非正常退出。
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
面试题:
举例栈溢出的情况? (StackOverflowError)
通过-Xss设置栈的大小;O0M
调整栈大小,就能保证不出现溢出吗?不能
分配的栈内存越大越好吗?不是!总空间有限,会挤压其它区域空间。
垃圾回收是否会涉及到虚拟机栈?不会的!只有简单的入栈出栈操作。
方法中定义的局部变量是否线程安全?具体问题具体分析
2.1.3 本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
本地方法接口
本地方法:简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如c。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "c"告知C++编译器去调用一个c的函数。
"A native method is a Java method whose implementation isprovided by non-java code . "
在定义一个native method时,并不提供实现体(有些像定义一个Javainterface),因为其实现体是由非java语言在外面实现的。
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合c/C++程序。现在比较少自己编写了。
2.1.4 Java堆
对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有对象实例以及数组都要在堆上分配,但是随着 JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对” 了。
Java堆是垃圾收集器管理的主要区域,因此也被称为"GC堆"。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代,再细致一点的有Eden空间,From Survivor空间,To Survivor空间等,但是这些只是设计风格,不是其内部的固定内存布局。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。可以通过-Xmx和-Xms控制堆内存大小。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
2.1.5 方法区
方法区与Java堆一样,是所有线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。对于习惯在HotSpot虚拟机上开发,部署程序的开发者来说,很多人愿意把方法区称为**“永久代”,本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代技术扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机(如:BEA JRockit,IBM J9等)来说是不存在永久代的概念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,现在看来并不是一个好 主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上线,J9和JRockit只要没有触及到可用内存的上限,例如32 系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因这个原因导致不用虚拟机下有不同的表现。因此,对于 HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代逐步改为采用Native Memory**来实现方法区的规划了,在目前已经发布的JDK 7的HotSpot中,已经把原本放在永久代的字符串常量池、静态变量移出,到了JDK 8完全放弃了永久代的概念,改用和JRockit、IBM J9一样的在本地内存中实现的元空间(Metaspace)来代替,并把JDK7中永久代剩余的内容移到了元空间中。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
2.1.6 运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
Java虚拟机对Class文件每一部分(自然包含运行时常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可,装载和运行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存 Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是String类的 intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量无法再申请到内存时会抛出OutOfMemoryError异常。
2.3 HotSpot虚拟机对象探秘
2.3.1 对象的创建
当以Java字节码遇到new指令来创建对象时,会有以下步骤
1、首先将会去检查这个指令的参数是否已经能够在常量池中定位到这个类的符号引用,并且检查这个符号引用所指向的类是被已被加载、解析、初始化过。如果没有,则开始进行相应的类加载的过程。
2、在类加载检查完后,虚拟机会将新创建的对象在堆上面进行内存的分配,该对象所要占的内存大小在类加载完后就已经可以知道了。这里分配内存方式会根据Java堆是否规整选择指针碰撞或空闲列表方式,并且还要考虑并发创建对象时的并发问题(同步处理或者TLAB方式解决)。
3、内存分配完后,将分配的内存空间(不包括对象头)都进行初始化零值,这步操作保证对象的实例字段在java代码中可以不赋初始值就可以直接使用,访问到的时这个数据类型所对应的初始值。
4、接下来对对象进行必要的设置,比如这个对象时哪个类的实例,类的元数据信息等,这些都存在对象头中。
5、最后,执行init方法,开始调用类的构造函数,来对对象进行初始化,初始化完后,才算是一个真正可用的对象了。
2.3.2对象的内存布局
HotSpot
虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
对象头
在HotSpot虚拟机中,对象头有两部分信息组成:运行时数据 和 类型指针,如果是数组对象,还有一个保存数组长度的空间。
Mark Word(运行时数据):用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄、线程持有的锁、偏向线程ID 等信息。在32位系统占4字节,在64位系统中占8字节;
Class Pointer(类型指针):用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;
实例数据
实例数据 是对象真正存储的有效信息,无论是从父类继承下来的还是该类自身的,都需要记录下来,而这部分的存储顺序受虚拟机的分配策略和定义的顺序的影响。
默认分配策略:
long/double -> int/float -> short/char -> byte/boolean -> reference
如果设置了-XX:FieldsAllocationStyle=0(默认是1),那么引用类型数据就会优先分配存储空间:
reference -> long/double -> int/float -> short/char -> byte/boolean
结论:
分配策略总是按照字节大小由大到小的顺序排列,相同字节大小的放在一起。如果是父类继承的,则继承的数据放子类前,除非设置了+XX:CompactFields为true,则不用这样。
对齐填充
无特殊含义,不是必须存在的,仅作为占位符。HotSpot虚拟机要求每个对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(32位为1倍,64位为2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
2.2.3 对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象。而对象的访问方式取决于JVM
虚拟机的实现。目前主流的访问方式有句柄和直接指针两种方式。
句柄访问:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。 直接指针访问:Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是 一项非常可观的执行成本。Sun HotSpot虚拟机使用的是第二种方式进行对象访问的。
2.4 实战:OutOfMemoryError异常
内存异常是我们工作当中经常会遇到问题,但如果仅仅会通过加大内存参数来解决问题显然是不够的,应该通过一定的手段定位问题,到底是因为参数问题,还是程序问题(无限创建,内存泄露)。定位问题后才能采取合适的解决方案,而不是一内存溢出就查找相关参数加大。
概念
内存泄露:代码中的某个对象本应该被虚拟机回收,但因为拥有GCRoot引用而没有被回收。
内存溢出:虚拟机由于堆中拥有太多不可回收对象没有回收,导致无法继续创建新对象。
在分析问题之前先给大家讲一讲排查内存溢出问题的方法,内存溢出时JVM虚拟机会退出,那么我们怎么知道JVM运行时的各种信息呢,Dump机制会帮助我们,可以通过加上VM参数-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时生成dump文件,然后通过外部工具(VisualVM)来具体分析异常的原因。
除了程序计数器外,Java虚拟机的其他运行时区域都有可能发生OutOfMemoryError的异常。
本章实验主要示例了Java堆溢出、虚拟机栈和本地方法栈溢出、以及方法去和运行时常量池溢出、本机直接内存溢出等案例。
本章学习明白了虚拟机内部如何划分,各区域可能会因为上面操作导致内存溢出异常。下一章则介绍垃圾收集器机制为避免内存溢出所作的努力。