JVM系列之内存布局
概述
JVM的体系结构,
JVM包含两个子系统和两个组件,分别为
- ClassLoader:类装载子系统,根据给定的全限定类名来装载class文件到运行时数据区的方法区中,参考JVM系列之ClassLoader;
- Execution Engine:执行引擎子系统,执行class文件的指令;
- Runtime Data Area:运行时数据区组件;
- Native Interface:本地接口组件。
JVM内存布局,也有叫Java内存分区,内存区域划分,运行时数据区域,内存数据模型等。
JVM内存布局规定Java在运行过程中内存申请、分配、管理的策略,保证JVM的稳定高效运行。不同的JVM对于内存的划分方式和管理机制存在部分差异。有些区域跟随JVM创建或销毁,而有些区域则是线程独有的,线程独有的区域会跟随线程的创建与销毁。
程序计数器
Program Counter Register,PC寄存器,PCR。CPU只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,多线程在并发执行过程中,任何一个确定的时刻,一个处理器或多核处理器中的一个内核,只会执行某个线程中的一个指令。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
每个线程都有自己的程序计数器,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。
程序计数器既能持有一个本地指针,也能持有一个returnAddress。当线程执行某个方法时,程序计数器的值总是下一条被执行指令的地址。这里的地址可以是一个本地指针,也可以是方法字节码中相对该方法起始指令的偏移量。如果正在执行Native方法,则程序计数器的值是undefined,即为空。
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制;
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来时能够知道该线程上次运行位置。
特点:
- 存储空间较小
- 线程私有,每条线程都有一个程序计数器
- 唯一一个不会出现OOM的内存区域
- 生命周期随着线程的创建而创建,随着线程的结束而死亡
Java虚拟机栈
JVM Stack。JVM是基于栈结构的运行环境,栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入帧到出帧的过程。JVM为每一个即将运行的方法创建一块叫做栈帧的区域,用于存储该方法在运行过程中所需要的一些信息:局部变量表(存放基本数据类型变量、引用类型的变量、returnAddress类型的变量)、操作栈、动态链接、方法返回地址等。
在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。当方法在运行过程中需要创建局部变量时,就将局部变量的值存入栈帧的局部变量表中。当方法执行完毕后,方法所对应的栈帧将会出栈,并释放内存空间。
特点
- 局部变量表的创建是在方法被执行时,随栈帧的创建而创建。局部变量表的大小在编译时期即确定,在创建时只需分配事先规定好的大小即可。在方法运行的过程中局部变量表的大小不会发生改变。
- Java虚拟机栈会出现两种异常:StackOverFlowError(SOF)和OOM。
- SOF:
若Java虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前Java虚拟机栈的最大深度时,抛出SOF异常。 - OOM:
若Java虚拟机栈的内存大小允许动态扩展,当线程请求栈时内存用完,无法再动态扩展,抛出OOM异常。
- SOF:
- 线程私有。
局部变量表
局部变量表是存放方法参数和局部变量的区域。类属性变量一共要经历两个阶段,分为准备和初始化,而局部变量只有初始化阶段,而且必须是显示的。如果是非静态方法,则在index[0]
位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。
在编译期已经完成内存分配,这样栈帧在用局部变量时,它占用的内存是已经确认的,不需要再分配,这就一定程度上减少栈帧的工作量。
操作栈
操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎基于(操作)栈,字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的stack属性中。
动态连接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。
方法返回地址
方法执行时有两种退出情况:
- 正常退出:正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等;
- 异常退出:执行出现异常,需进行异常回溯,返回地址通过
异常处理表
确定。
无论何种退出情况,都将返回方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
- 返回值压入上层调用栈帧;
- 异常信息抛给能够处理的栈帧;
- PC计数器指向方法调用后的下一条指令。
本地方法栈
Native Method Stack,与虚拟机栈功能类似,本地方法栈是在调用本地方法(Native方法)时使用的栈,每个线程都有一个本地方法栈。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会抛出SOF和OOM异常。如果没有本地方法,也就没有本地方法栈。
线程开始调用本地方法时,会进入一个不再受JVM约束的世界。本地方法可通过JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限。当大量本地方法出现时,会削弱JVM对系统的控制力,因为它的出错信息都比较黑盒,难以捉摸。内存不足时,本地方法栈还会抛出native heap OutOfMemory
。
JNI
Java本地接口,为可移植性准备。JNI允许本地方法完成以下工作:
- 传递或返回数据
- 操作实例变量、类变量或调用类方法、数组
- 对堆的对象加锁、装载新的类、抛出异常
- 捕获本地方法调用Java方法抛出的异常、虚拟机抛出的异步异常
- 指示垃圾收集器某个对象不再需要
方法区
Method Area,主要是用来存放已被虚拟机加载的线程类信息、常量、静态变量、即时编译器编译后的代码等数据。属于堆的逻辑部分。
方法区是规范
,永久代
Pern是Hotspot针对该规范的实现。JDK8前,方法区都是永久代实现的。JDK8后,永久代变为元空间Metaspace。
永久代代表的方法区,和堆使用的物理内存是连续的。
可通过两个参数配置大小:
-XX:PremSize
:设置永久代的初始大小;-XX:MaxPermSize
: 设置永久代的最大值,默认64M。
永久代在启动时就已确定大小,空间配置有限,容易出现java.lang.OutOfMemoryError: PermGen space
报错。难以进行调优,只有FGC时会移动类元信息,因为它比新生代和老年代拥有更长的生命周期,永久代存在频率较低的GC。
元空间
方法区存在于元空间。物理内存不再与堆连续,而是直接存在于本地内存中,理论上机器内存有多大,元空间就有多大
。
运行时常量池
Runtime Constant Pool,方法区的一部分,class文件除有类的版本、字段、方法接口等描述信息外,还有一项信息是常量池,用于存储编译期生成的各种字面量和符号引用
,这部分内容将在类加载后进入方法区运行时常量池中存放。此区域同样会发生OOM异常。
字面量:Literal,如文本字符串、final常量值;
符号引用:与编译相关的一些常量,因为Java不像C++那样有连接的过程,因此字段方法这些符号引用在运行期就需要进行转换,以便得到真正的内存入口地址。
class文件中的常量池,也称为静态常量池,JVM虚拟机完成类装载操作后,会把静态常量池加载到内存中,存放在运行时常量池。
堆
Heap,堆是用来存放几乎所有对象实例和数组的内存空间,堆的内存空间是可自定义大小的,支持运行时动态修改,通过-Xms
、-Xmx
两个参数调整堆的初始值和最大值。ms
即memory start,代表最小堆容量,mx
即memory max,代表最大堆容量。包括TLAB。
特点:
- 线程共享:整个JVM内只有一个堆,所有线程都访问同一个堆。程序计数器、Java虚拟机栈、本地方法栈都是线程私有。
- 在虚拟机启动时创建;
- 垃圾回收的主要场所;
- 可进一步细分为:新生代、老年代;新生代又可被分为:Eden、From Survior、To Survior。不同区域存放具有不同生命周期的对象。这样可根据不同区域使用不同的GC算法;
- 堆的大小既可固定也可扩展,当线程请求分配内存,但堆已满,且内存已满无法再扩展时,抛出OOM;
- 堆是逻辑上连续不间断,物理上可以不连续的内存空间
总结
其他
逃逸分析
面试问题:对象一定分配在堆中吗?
不一定,JVM通过逃逸分析,那些逃不出方法的对象会在栈上分配。
逃逸分析:Escape Analysis,一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象引用的使用范围,从而决定是否要将这个对象分配到堆上。仅存在于server模式的JDK。
在编译期间,JIT会对代码做很多优化,目的是减少内存堆分配压力。
一种确定指针动态范围的静态分析,它可分析在程序的哪些地方可访问到指针;同编译器优化原理的指针分析和外形分析相关联。在JVM的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译判断对象是否逃逸的依据:一种是对象是否被存入堆中(静态字段或堆中对象的实例字段),另一种就是对象是否被传入未知代码。
当变量(或对象)在方法中分配后,其指针有可能被返回或被全局引用,这样就会被其他方法或线程所引用,这种现象称作指针(引用)的逃逸。
局部对象user未被外部调用:
public static String returnStr() {User user = new User();user.setUserId(1);user.setUserName("me");return user.toString();
}
局部对象user可能会被外部调用:
public static User returnUser() {User user = new User();user.setUserId(2);user.setUserName("me");return user;
}
除此之外,如果对象还有可能被外部线程访问到,例如赋值给可在其它线程中访问的实例变量,这种就被称为线程逃逸。
逃逸分析的好处
- 对象栈上分配:如果确定一个对象不会逃逸到线程之外,就可考虑将这个对象在栈上分配(栈分配可快速地在栈帧上创建和销毁对象),而不是分配到堆空间,对象占用的内存随着栈帧出栈而被销毁,降低GC压力;
- 同步锁消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个对象不会逃逸出线程,无法被其他线程访问(只能从一个线程访问),则这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可安全地消除掉,即synchronized同步锁。JVM并不能消除Lock锁。要开启同步消除,需加上
-XX:+EliminateLocks
参数。因为这个参数依赖逃逸分析,所以同时要打开-XX:+DoEscapeAnalysis
选项。另外,-XX:+PrintEliminateLocks
参数可打印锁消除信息。 - 分离对象标量替换:如果一个数据是不可拆分的基本数据类型,则称之为
标量
。把一个Java对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换
。假如逃逸分析能够证明一个对象不会被方法外部访问,且可被拆散,则可以不创建对象,用创建若干个基本类型的成员变量来代替,让对象的成员变量在栈上分配和读写。不用生成对象头减少内存使用;降低GC频率。
示例代码:
public void drink() {Water lock = new Water();synchronized (lock) {log.info(lock);}
}
如上代码中对lock对象进行加锁,如果lock对象的生命周期只在drink()
方法中,并不会被其他线程所访问到,则在JIT编译阶段会被优化成:
public void drink() {Water lock = new Water();log.info(lock);
}
内存分配
内存分配有两种方式:
- 指针碰撞:Bump The Pointer,假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的实例;
- 空闲列表:Free List,如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,虚拟机必须维护一个列表,记录哪些内存块可用,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
选择使用哪种方式,由Java堆是否规整决定,更进一步,由垃圾收集器是否具有压缩整理能力决定。
对象创建在虚拟机中是非常频繁的行为,可能存在线性安全问题:如果一个线程正在给A对象分配内存,指针还没来得及修改,同时另一个为B对象分配内存的线程,仍引用着之前的指针指向,即发生抢占。
有两种方案:
- 采用CAS分配重试方式来保证更新指针操作的原子性;
- TLAB,先在TLAB中分配,TLAB用完才需要同步锁定。
TLAB
Thread Local Allocation Buffer,线程本地分配缓冲。把内存分配的动作按照线程划分在不同的空间之中进行,每个线程在Java堆中预先分配一小块内存,即TLAB,位于年轻代Eden区。在堆上分配内存需锁定整个堆,在TLAB上不需要进行同步锁定,JVM在分配对象时会尽量在TLAB上分配,以提高效率。
能减少多线程下并发创建对象造成的性能下降,可通过-XX:UseTLAB
设定:
堆内结构
站在垃圾收集器的角度来看,可把内存分为新生代与老年代。内存的分配规则取决于当前使用的GC组合,以及内存相关的参数配置。对象优先分配在新生代Eden区,而大对象直接进入老年代。
第一,对象优先分配在新生代Eden区,JVM为每个线程分配一个私有TLAB,其结构如下:
// ThreadLocalAllocBuffer: a descriptor for thread-local storage used by the threads for allocation.
// It is thread-private at any time, but maybe multiplexed over time across multiple threads. The park()/unpark() pair is used to make it avaiable for such multiplexing.
class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {friend class VMStructs;
private:HeapWord* _start; // address of TLABHeapWord* _top; // address after last allocationHeapWord* _pf_top; // allocation prefetch watermarkHeapWord* _end; // allocation end (excluding alignment_reserve)size_t _desired_size; // desired size (including alignment_reserve)size_t _refill_waste_limit;// hold onto tlab if free() is larger than this
}
本质上,TLAB的管理是依靠三个指针:start、end、top。start与end标记Eden中被该TLAB管理的区域,该区域不会被其他线程分配内存所使用,top是分配指针,开始时指向start位置,随着内存分配的进行,慢慢向end靠近,当撞上end时触发TLAB refill。因此内存中Eden的结构大体为:
第二、新生代的Survivor区域。当Eden区域内存不足时会触发YGC,在YGC存活下来的对象,会被复制到Survivor区域中。Survivor区的作用在于避免过早触发FGC。如果没有Survivor,Eden区每进行一次YGC都把对象直接送到老年代,老年代很快便会内存不足引发FGC。新生代中有两个Survivor区,任何时候总有一个Survivor是空的,在发生YGC时,会将Eden及另一个的Survivor的存活对象拷贝到该Survivor中,从而避免内存碎片的产生。
第三、老年代。老年代放置长生命周期的对象,通常是从Survivor区域拷贝过来的对象,不过当对象过大的时候,无法在新生代中用连续内存的存放,那么这个大对象就会被直接分配在老年代上。一般来说,普通的对象都是分配在TLAB上,较大的对象,直接分配在Eden区上的其他内存区域,而过大的对象,直接分配在老年代上。
第四、Vritual空间。可以使用Xms与Xmx来指定堆的最小与最大空间。如果Xms小于Xmx,堆的大小不会直接扩展到上限,而是留着一部分等待内存需求不断增长时,再分配给新生代。Vritual空间便是这部分保留的内存区域。
Java堆内的内存结构大体为:
直接内存
Direct Memory,不是虚拟机运行时数据区的一部分,不由JVM管理,堆外内存。内存分配不受Java堆大小的限制但受整个内存大小的限制。
在NIO里,JVM通过堆上的DirectByteBuffer操作直接内存,利用本地方法库直接在Java堆之外申请的内存区域。
好处:避免在Java堆和native堆之间同步数据的步骤。
code cache
字节码缓存。Non-heap区域用于存储由JIT编译期生成的编译后代码。由内存直接分配;由Code Cache清理器管理。
Compressed Class Space
MetaSpace默认会将元数据和类信息放在同一个区域,当UseCompressedClassesPointers启用后会将类信息和元数据分为两部分存储,MaxMetaspaceSize将会设置两部分空间的上限。
启用前的存储形式:
启用后的存储形式:
参数-XX+UseCompressedOops
的作用,当从32位虚拟机迁移到64位虚拟机上,JVM会将部分指针进行压缩,防止在64位系统中占用更大内存。
空间分配担保
当发生YGC时,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么YGC可以确保是安全的。当大量对象在YGC后仍然存活,就需要老年代进行分空间分配担保,把Survivor无法容纳的对象直接进入老年代。如果老年代判断剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为判定值)进行FGC。
内存对齐
内存是由若干个黑色的内存颗粒构成的。每一个内存颗粒叫做一个chip。每个chip内部,是由8个bank组成的。bank是一个二维矩阵,矩阵中每一个元素中都保存1个字节,即8个bit。
OOM
OOM可能发生在哪些区域上?
- 堆内存。最常见,如果在堆中没有内存完成对象实例的分配,并且堆无法再扩展时,将抛出OOM异常。可以通过-Xmx和-Xms来控制堆内存的大小,发生堆上OOM的可能是存在内存泄露,也可能是堆大小分配不合理。
- Java虚拟机栈和本地方法栈,这两个区域的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,在内存分配异常上是相同的。对Java虚拟机栈规定两种异常:
- 如果线程请求的栈大于所分配的栈大小,则抛出SOF错误,比如一个不会停止的递归调用;
- 如果虚拟机栈是可动态拓展,拓展时无法申请到足够的内存,则抛出OOM错误。
- 直接内存。直接内存虽然不是虚拟机运行时数据区的一部分,但既然是内存,就会受到物理内存的限制。NIO使用Native函数库在堆外内存上直接分配内存,但直接内存不足时也会导致OOM。
- 方法区。随着Metaspace元数据区的引入,方法区的OOM错误信息也变成
java.lang.OutOfMemoryError:Metaspace
。
对于旧版本的Oracle JDK,永久代大小有限,而JVM对永久代的垃圾回收并不积极,如果往永久代不断写入数据,例如String.Intern()
调用,在永久代占用太多空间导致内存不足,也会出现OOM的问题,错误信息为java.lang.OutOfMemoryError:PermGen space
内存区域 | 是否线程私有 | 是否可能发生OOM |
---|---|---|
程序计数器 | 是 | 否 |
虚拟机栈 | 是 | 是 |
本地方法栈 | 是 | 是 |
方法区 | 否 | 是 |
直接内存 | 否 | 是 |
堆 | 否 | 是 |
SOF和OOM
SOF表示当前线程申请的栈超过事先定好的栈的最大深度,但内存空间可能还有很多。OOM是指当线程申请栈时发现栈已满,且内存也全都用光。
工具
查看堆内存的工具,两类:图形化工具和命令行工具。
- 图形化工具:直观,连接到Java进程后,可以显示堆内存、堆外内存的使用情况。如:JConsole,VisualVM、MAT等。
- 命令行工具:可在运行时进行查询,包括jstat,jmap等,可对堆内存、方法区等进行查看。用于定位线上问题。jmap也可以生成堆转储文件(Heap Dump)文件,如果是在Linux上,可以将堆转储文件拉到本地来,使用Eclipse MAT进行分析,也可使用jhap进行分析。
参考
- JVM经典五十问
- JVM内存布局详解