“合抱之木,生于毫末;千里之行,始于足下;九层之台,起于垒土。”
一、虚拟机运行时数据区
- 程序计数器
- 是一块较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。根据虚拟机的概念模型,字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令(分支、循环、跳转、异常处理、线程恢复都依赖于计数器)。
- Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻一个“处理器(多核处理器是一个内核)”只会执行一条线程中的指令。
- 为线程切换后恢复到正确的位置,每个线程都需要一个独立的程序计数器(各线程的计数器互不影响)。
- 线程执行Java方法时,计数器记录正在执行的虚拟机字节码指令的地址;如果是Native方法,计数器值为空(Undefined)。
- 是Java虚拟机规范中,没有规定任何OOM情况的区域。
- Java虚拟机栈
- 生命周期与线程相同,描述Java方法执行的内存模型:每个方法在执行时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 局部变量表存放了基本数据类型(boolean、byte、char、short、int、float、long、double、reference对象引用,其中long和double占用2个局部变量空间Slot)。局部变量表需要的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。
- 申请栈深度大于虚拟机允许值,抛出StackOverflowError;允许动态扩展栈深度时,如果扩展无法申请到足够的内存,会抛出OutOfMemoryError。
- 只存储对象object的内存地址(指针或引用),不直接保存对象。
- 本地方法栈
- 发挥的作用与虚拟机栈相似,本地方法栈为Native方法服务,虚拟机栈为Java方法(字节码)服务。会因与虚拟机栈同样的原因抛出SOF和OOM异常。
- HotSpot VM虚拟机将“本地方法栈”与“虚拟机栈”合二为一。
- Java堆
- Java堆(Java Heap)是Java虚拟机管理的内存中最大的一块。
- 被所有线程共享,在虚拟机启动时创建。
- 此区域唯一的目的是存放对象实例(包括数组)。
- 所有对象实例和数据都要在堆(Heap)上进行分配,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,导致了一些微妙的变化。
- Java堆是垃圾收集器管理的主要区域,通常被称为“GC堆”。由于垃圾收集器(GC)通常采用分代收集算法,通常又细分为:新生代和老年代;再细致可分为Eden空间、From Survivor空间、To Survivor空间等。
- 堆中可以划分出出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,即TLAB)。
- 根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间中,只需保证逻辑上的连续。即可以为固定大小,也可以为可扩展(通过-Xmx和-Xms控制)。
- 当堆中没有内存可以完成实例分配,且无法继续扩展,则抛出OOM异常。
- 方法区
- 方法区(Method Area)是各个线程共享的内存区域,用于存储已被虚拟机加在的类信息、常量、静态变量、即时编译器编译后的代码等数据。虚拟机规范把方法区描述为堆的逻辑部分,又名Non-Heap(为了与堆区分开)。
- 永久代(Permanent Generation)已经在JDK1.8+中移除,使用元空间(MetaSpace)作为新的内存区域实现方法区,而元空间的数据存放在本地内存(Native Memory)中。
- 当元空间的内存空间不足时,会触发Full GC进行内存空间的整理。
- 方法区无法满足内存分配需求时抛出OOM异常。
- 运行时常量池、字符串常量池
- 运行时常量池(Runtime Constant Pool)是“方法区”的一部分。Class文件中有类的版本、字段、方法、接口等描述信息,还有一项信息是“常量池”,用于存放编译器生成的各种字面常量和符号引用,“常量池”内容在类加载后进入方法区的运行时常量池中存放。
- Java虚拟机规范关于运行时常量池没有任何细节的要求,不同的虚拟机可以按自己的需求实现这个内存区域,通常“直接引用”也会存储在运行时常量池中。
- 字符串常量池(String Pool)存在于Java堆(Java Heap)中,Hotspot使用String Table记录内存中字符串对象的地址,并以此进行字符串的复用。
- 除使用String.intern()方法可以在运行期间将新的常量放入池中,其他情况都是预置入Class文件中常量池的内容才能进入方法区运行时常量池。
- 本地内存
- 本地内存(Native Memory)不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但这部分内存也被频繁使用,并会抛出OOM异常。
- 使用Native函数库可以直接分配堆外内存,通过Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,因为避免了Java堆和Native堆中来回复制数据,所以显著的提高了性能。
- 受到物理内存的限制,会在动态扩展时出现OOM异常。
二、内存中的对象
- 对象的创建
- 首先,检查new指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用的类是否已经被加载、解析和初始化过,如果没有就必须进行类加载过程。
- 类加载检查通过后,在虚拟机的堆中为新对象分配内存。因为对象的大小在类加载完成后便可以确定,因此为对象分配空间等同于在堆中划分一块确定大小的内存。
- 堆中划分内存分为两种方式,当堆中内存是绝对规整的(所有的用过的内存放在一边,空闲的内存放在一边,中间放着一个指针作为分界点),分配内存只需将指针挪动一段与对象大小相等的距离,称为“指针碰撞(Bump the Pointer)”;当堆中的内存不是规整的,虚拟机需要维护一个列表记录哪些内存可用,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新到列表的记录。
- Java堆是否规整由垃圾收集器是否带有压缩整理功能决定。
- 虚拟机采用CAS和失败重试的方式保证更新操作的原子性,或者把内存分配的动作按照线程划分在不同的空间进行(称为本地线程分配缓存Thread Local Allocation Buffer,TLAB,可以使用-XX:+/-UseTLAB参数设定)。使用TLAB分配时,会在分配TLAB空间时初始化空间为零值。
- 关于对象的信息,如对象是哪个类的实例、类的元数据、对象的哈希码、对象的GC分代年龄等信息,存放在对象的对象头(Mark Word)中。
- 对象的内存布局
- 对象在内存中存储的布局分为三个区域:对象头(Mark Word)、类型指针(Class Pointer)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头固定8字节,类型指针默认4字节(压缩指针失效时为8字节)。而对象在内存中存储,占用的内存空间必须为8字节的倍数,当大小不满足要求时,进行对齐填充。
- 如果对象是数组,则额外多出“数组长度”,固定4字节,最大值为Integer.MAX_VALUE=2^31-1=2147483647。
- 对象头(Mark Word)中存储了hashCode、分代年龄、锁信息。
- 对象的访问和定位
- Java程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有使用句柄和直接指针两种。
三、OutOfMemoryError异常
- 程序计数器
没有规定OOM异常的内存区域。 - 堆空间
堆内存的OOM异常是实际应用中常见的内存溢出异常情况,通过-XX:HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前内存堆转储快照以便事后进行分析,使用-Xms20m和-Xms20m设置最小和最大值。 - 栈空间
栈空间在单线程时栈空间内存不足时抛出SOF异常,在多线程时建立新的线程时会因内存不足抛出OOM异常。 - 方法区
方法区通常会在大量动态生成对象时(如Spring启动时)导致OOM异常,运行时常量池无限制增大同样会导致方法区OOM异常。 - 直接内存
向操作系统申请分配内存时,通过计算得知内存无法分配,会抛出OOM异常。通过-XX:MaxDirectMemorySize指定大小,默认值与堆空间-Xmx值相同。
说明
- 文中内容主要来自于《深入理解Java虚拟机》,更多内容请关注原著。
- 此文是读书时对重点知识的记录,如有错误请一定指出。