1、运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
JVM启动时,会在内存中开辟空间,并按职能划分为不同的区域。
- 堆:用来分配java对象和数组的空间;
- 方法区:存储类元数据;
- 栈:线程栈;
- PC寄存器:存储执行指令的内存地址;
堆与方法区是所有线程共享的公共区域。堆与方法区所占的内存空间,是由JVM负责管理维护的的,而内存的释放工作则由垃圾收集器自动完成。栈和PC是线程的私有区域,是线程执行程序的工作场所。每个线程都关联着唯一的栈和PC寄存器,并仅能使用属于自己的栈空间和PC寄存器来执行程序。在HotSpot虚拟机实现中,Java栈与本地栈合一为,是在本地内存空间中分配的。
此外,为支持对JVM的监测,系统还要准备额外的空间来记录虚拟机自身状态,并允许外界程序读取这些信息,这部分运行时空间并不属于JVM的主要逻辑区域。
1.1、堆(Heap)
在Java中,内存是由虚拟机自动管理的。虚拟机在内存中划出一片区域,作为满足程序的内存分配请求的空间。堆是虚拟机所管理的内存中最大的一块,其是被所有线程共享的一块内存区域,在虚拟机启动时创建。所有的对象实例及数组都要在堆上分配内存。
虚拟机中内存空间按内存用途,可以划分以下几个区域:
堆:用于对象的分配空间。按照对象的年龄,分为新生代和老年代区域;
非堆:包括方法区和Code Cache;在JConsole工具中,将内存空间分为堆与非堆,非堆再划分为PermGen和Code Cache,在Visual VM工具中,将内存空间分为堆与PermGen。
1.1.1、分代
随着系统的运行,不同对象的生命周期会有很大差别。实践表明,JVM中存在着大量的生命周期短暂的对象,它们快速生成,快速死亡;还有另外一些对象,生命周期很长,甚至能够伴随着应用程序或JVM的整个运行期。
因此,对不同类型的对象,应采取不同的收集策略。分代收集(generational collection)是指在不同的内存空间分配不同的区域,分别存储不同年龄的对象,各自区域可根据自身的特点灵活采取收集策略。
这些区域根据存储对象年龄的不同,可以分为以下3种分代(generation);
新生代(YangGen),位于堆空间;
老年代(OldGen),位于堆空间;
永久代(PermGen),位于堆空间;
1.2、线程私有区域
除了像堆这样的共亨空间以外,系统还为每个线程准备了独亨空间:PC寄存器和栈。这部分内存空间是为线程的函数调用栈服务的。在JVM运行期间,每个线程的PC和栈都只能由所属线程独自支配。栈反映了程序运行位置的变化,PC寄存器反映的是所执行指令的变化情况。
1.2.1、PC(程序计数器)
线程启动时,JVM会为每个线程分配一个PC寄存器(Program Coumer,即程序计数器)。
为了模拟线程结构,虚拟机的设计者们必需提供一套能够保存指令的地址的机制。 在真实机器中,往往提供个PC寄存器专门用来保存程序运行的指令在内存中的地趾。在HotSpot实现中,为每个线程分配一个字长的存储空间,以实现类似硬件级的PC寄存器,沿用了硬件中的术浯,也称为PC寄存器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
JVM可以支持多条线程同时执行,每一个JVM线程都有自己的PC寄存器。任意时刻,一个线程只能执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法。
PC寄存器的大小应能保存一个returnAddress类型的数据或一个与平台相关的本地指针的值。如果当前执行方法不是本地方法,则PC寄存器中的值是未定义的,这是因为本地方法的执行依赖硬件PC寄存器,其值是由操作系统来维护的,虚拟机实现的PC寄存器对本地方法不会产生任何影响。
1.2.2、JVM栈
JVM栈(Java Virtual Machine Stacks)是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、中间演算结果、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
JVM规范允许JVM栈被实现为固定大小,或根据允许状况动态扩展或收缩。如果采用固定大小的Java虚拟机栈,那么每一条线程的栈容量在线程创建时就是应该明确。JVM实现应当提供配置栈初始容量的方法。对可动态扩展和收缩的JVM栈,还应当提供调节其最大、最小容量的手段。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short. int、tioat、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相应的位置)和returnAddress类型(指向了一条字节码指令的地址)。
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个,局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java虚拟机栈可能的异常:
- 如果线程请求分配的栈容量超过Java虚拟机允许的最大容量时,JVM将会抛出一个StackOverflowError异常;
- 如果JVM栈可以动态扩展,在扩展的栈空间中仍然无法满足内存分配请求,或者在建立新的线程时没有足够内存区创建JVM栈,那么将会抛出一个OutOfMemoryError异常。
1.3、方法区
方法区由虚拟机的所有线程共享。方法区类似传统语言编译后的代码存储区或Unix进程的正文段,它存储每个类的结构信息;
- 常量池:
- 域;
- 方法数据;
- 方法或构造函数的字节码;
- 类、实例、接口初始化时用到的特殊方法;
方法区在虚拟机启动时创建。虚拟机规范对方法区实现的位其并没有明确要求,在HotSpot虚拟机实现中,方法区仅是逻辑上的独立区域,在物理上并没有独立于堆而存在,而是位于永久代中。此外,虚拟机规范对这个区域是否实现垃圾收集,以及编译代码采用何种管理方式也没有特别规定,这些都可以由JVM自由实现。在HotSpot实现中,垃圾收集器会收集此区域,回收过程主要关注对常量池的收集及对类的卸载。
Java虚拟机规范允许方法区的容量是固定的或是动态扩展的,方法区在实际内存空间中是可以不连续的。但要求JVM实现应当提供调节方法区初始容量的手段,对于可以动态扩展和收缩方法区来说,则应当提供调节最大、最小容量的手段。
方法区可能发生如下异常:
如果方法区的内存空间不能满足内存分配请求,那么JVM将抛出一个OutOfMemoryError异常。
1.3.1、类型信息
JVM不仅要以特定格式准确无误地描述一个类型,还要以某种合理的方式组织存储,以支持运行时的快速高效访问。HotSpot在永久代内存空问中存储这些信息,这块职能区域也被称为方法区(method area)。方法区承载了Java类的字段和字节码,通过引用堆中对象以及围绕栈进行操作
的JVM指令,将各个内存逻辑区域有机地联系起束,成为联系各区域进行协作的纽带。
对于给定的类型(类或接口),在方法区中需要存储的信息至少应包含两大类数据:类型基本描述信息和域信息。
类型描述,主要包括:
- 类型的全限定名;
- 类型的直接超类的全限定名。接口或java.lang.Object类除外,因为他们都没有超类;
- 是一个类类型还是接口类型;
- 类型修饰,如public,abstract、final等java关键字;
- 一个已排序的接口列表。
其次,是类型的主体信息,主要包括:
- 常量池;
- 字段信息;
- 方法信息;
- 除常量外的所有static类型变量,又称为类变量;
- 指向该类的引用。
为了在方法区中更好地存储和管理Java类中的字段,就必须存储字段的如下信息:
- 字段名称:
- 字段类型
- 字段描述,由Java关键字public,pfivate,protected,staLhic,final, volatile,transIent等描述。
类似的,为了在方法区中更好地存储和管理Java类中的方法,就必须存储方法的如下信息:
- 方法名:
- 返回类型:
- 方法参数的个数和类型,
- 方法的描述,由Java关键字public、prlvate、protected、static.final、synchronized 、native、abstract等描述。
除抽象或本地方法以外类型的方法,还应包含:
- 方法的字节码;
- 操作数栈的大小和本地变量;
- 异常表。
1.3.2、常量池
在每个Java类文件中,都定义了一组数据结构用来表示类中出现的符号信息,这种数据结构就是常量池。常量池中每项都能表示一个符号,每个符号都拥有一个在Class文件中唯一的索引号。字节码指令就是通过常量池索引号定位方法或字段的全限定名的。常量池项间允许相互引用。
JVM规范规定,虚拟机在创建一个类或接口时,将按照类或接口在Class文件中的定义创建相应的常量池。在Class文件中定义constant_pool表,用作类的常量池。Class文件中的constant_pool表是常量池的静态描述,虚拟机在对类进行解析和连接之后,将在内存中为该类生成一套运行时常量。运行时常量池是类文件中constant_pool表的运行时动态描述。JVM规范规定,常量池在运行期间在方法区中进行分配。
常量池的作用类似C语言中的符号表,Java利用常量池实现类加载和链接阶段对符号的引用的定位。但与C中符号表不同的是,常量池并没有对程序员完全开放控制权。
运行时常量池(Runtime Constant Pool)也是方法区的一部分。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编泽期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放人池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
既然运行时常量池是方法区的一部分,白然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
1.4、性能监控数据区:Perf Data
为支持虚拟机性能监控,在虚拟机中开辟了一块共享内存,专门存放一些关于性能统计的计数器,称为Perf Data计数器。PerfData指的就是有关JVM性能的统计数据。有些Perf Data是一成不变的,而有些PerfData的值则随系统运行不断变化着。虚拟机使用共享内存方式向外部进程提供了一种通信手段,允许外部监控进程attach至虚拟机进程,并从共享内存中读取这些Perf Data。
1.5、转储
虚拟机提供转储技术,能够将运行时刻的程序快照保存下来,为调试、分析或诊断提供数
据支持。转储类型包括以下3种:
- 核心转储(core dump);
- 堆转储(heap dump);
-
线程转储(thread dump)。
转储文件为我们对故障进行离线分析提供了可能。
核心转储(core dump),也称为崩溃转储(crash dump),是一个正在运行的进程内存快照。它可以在一个致命或未处理的错误(如:信号或系统异常)发生时由操作系统自动创建。另外,也可以通过系统提供的命令行工具强制创建。核心转储文件可供离线分析,往往能揭示进程崩溃的原因。
一般来说,核心转储文件并不包含进程的全部内存空间数据,如.text节(或代码)等内存页就没有包含进去,但是至少包含堆和栈信息。
有如下因素可导致JVM崩溃或出现致命错误:
- HotSpot自身的bug,
- 系统库的bug;
- JDK库或API的bug,
- 应用程序自身的本地代理库代码的bug,
- 操作系统引起的错误
- VM内存资源枯竭或操作系统资源耗尽等。
2、虚拟机对象
2.1、对象的创建过程
-
检测类是否被加载
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 -
为新生对象分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那么分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在java堆中预先分配一小块内存,称为本地内存分配缓冲(Thread Local Allocation Buffer,TLAB)。那个线程要分配内存,就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
-
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。 -
进行必要的设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄、偏向锁等信息。这些信息存放在对象的对象头之中。 -
执行init方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚开始,<init>方法还没有执行,所有的字段都还为零。所以一般来说,执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
2.2、对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
- 对象头
- 实例数据
- 对齐填充
2.2.1、对象头
对象头包括两部分信息:
MarkWord:
第一部分用于存储对象自身的运行时数据 ,如:
- 哈希码(HashCode)
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
这部分数据的长度在32和64位虚拟机中分别为32bit和64bit,官方的名称是 “MarkWord”。考虑到虚拟机的空间效率,MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于来被锁定的状态下,那么MarkWord的32bit空问中的25bit用于存储对象啥希码.4bit用丁存储对象分代年龄,2bit用于存储锁标志位.1bit固定为0。而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
类型指针:
指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机都必须实现在对象数据上保存类型指针,就是确定对象的元数据不一定经过对象本身。
2.2.2、实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段的字段内容。无论是从父类继承下来的,还是在子类中的定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中的定义顺序的影响。
2.2.3、对齐填充(Padding):
对齐填充不是必要的,由于HotSpot虚拟机要求对象起始地址必须是8字节的整数倍,所以对象的大小必须是8字节的整数倍。所以就有了对齐填充。
2.3、对象的访问定位
java是通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式取决于虚拟机的实现,目前主流访问方式为句柄和直接指针两种。
- 如果使用句柄的话,那么reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 使用直接指针访问,则堆中保存类型的相关信息,而reference中存储的是对象的地址;
使用句柄访问好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改;
使用直接访问好处是速度快,它节省了一次指针定位的时间开销,由于对象访问非常频繁,因此这类开销积少成多也是一项非常可观的执行成本。
Hotspot使用第二种方式进行对象访问。
3、堆栈溢出
在 Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生 OutOfMemoryError(下文成 OOM)异常的可能。
3.1、Java 堆溢出
Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
通过参数-XX:+HeapDumpOnOutOfMemoryError 即可让虚拟机在出现内存溢出异常时 Dump 出当前的内存堆转储快照以便事后进行分析。
Java 堆内存的 OOM 异常是实际应用中常见的内存溢出异常情况。当出现 Java 堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”。
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否可以调大,从代码上检查是否存在某些对象生命周期过长,持有状态时间个遇的情况,尝试减少程序运行期的内存消耗。
3.2、虚拟机栈和本地方法栈溢出
由于在 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于 HotSpot 来说,虽然 -Xoss 参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只有 -Xss 参数设定。关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
- 如果虚拟机在扩展时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。
在实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生 OutOfMemoryError 异常,尝试的结果都是获得 StackOverflowError 异常。 - 使用 -Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
- 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出 StackOverflowError 异常时输出的堆栈深度相应缩小。
3.3、方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到 JDK 1.7 开始逐步“去永久代”的事情,再次就以测试代码观察一下这件事对程序的实际影响。
String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量。
3.4、本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,如果不指定,则默认与 Java 堆最大值(-Xmx 指定)一样,通过返回获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe() 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory()。
由DirectMemory导致内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。
网友评论