JVM之内存管理
java中内存的管理是由JVM负责的,程序员new了一个对象后无需处理对象的释放删除等工作,较C/C++来说,开发轻松了不少。但是JVM不是万能的,一旦内存出现泄漏等问题,程序员需要排查原因,所以有必要好好理解JVM的内存管理机制。
1.2 jvm线程
jvm线程
- 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
- 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收
- 操作系统负责将线程安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法
- 如果一个线程抛异常,并且该线程是进程中最后一个守护线程,那么进程将停止
jvm系统线程
又叫后台线程,它不包括调用public static void main(String [])的main线程以及所有这个main线程自己创建的线程。
后台线程在HotSpot中主要有以下几个:
- 虚拟机线程:jvm达到安全点时出现的线程(安全点是和垃圾回收相关的一个概念,后续会讲到)。
- 周期任务线程:用于周期性操作的调度执行
- GC线程:专门负责垃圾回收
- 编译线程:运行时会将字节码编译成到本地代码
- 信号调度线程:接收信号并发送给JVM,在它内部通过调用适当的方法进行处理
1. jvm的内存布局
jvm内存布局又叫做运行时数据区域,主要是Java虚拟机在运行时,对不同部分的内存划分。
jvm定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会被销毁。其他数据区域是每个线程的。每线程数据区域在创建线程时创建,在线程退出时销毁。主要分为程序计数器、虚拟机栈、堆、方法区和运行常量池。
每个JVM只有一个Runtime实例,即为运行时环境,也就是上图中间蓝色的框框。
不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,本文主要探讨经经典的JVM(HotSpot)内存布局。
灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 线程独有:程序计数器、虚拟机栈、本地方法栈
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
1.1 程序计数器(pc寄存器)
程序计数器是一块很小的内存区域,可以看作是当前线程所执行字节码的行号指示器。
pc寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令,并执行该指令。程序的控制流,分支、循环、异常跳出等都是通过改变这个计数器来实现的。
java虚拟机支持多线程,cpu在不同的线程之间切换,在每个线程中指令是顺序执行的,某个确定的时刻,只会执行一条指令,因此为了保证多线程里面,指令执行的正确性,程序计数器必须是独立的,因此这块区域被称为“线程私有”内存
上图栈帧其实对应一个方法,方法1中调用2方法,方法2再调用……一直到调用n方法,调用就压入栈,红色区域是指当前方法。方法里面具体的指令都有行号标识,pc寄存器就相当于行号标识,记录了下一条要执行的指令的地址。
如果程序正在执行的是一个java方法,则计数器的值为虚拟机字节码指令的地址。如果正在执行的是本地方法,则计数器的值为空。此区域是唯一一个不会发生OOM的内存区域。
举例
使用javap -v xxx.class得到反编译代码,如上图所示,左边的行号即为指令地址(或者偏移地址),右边的为jvm操作指令,pc寄存器存储的是下一条要执行的指令地址(5),执行引擎会取得该地址,操作对应的局部变量表、操作数栈等,然后把jvm指令翻译成机器指令,供cpu执行。
1.2 虚拟机栈
以前叫java栈,是java虚拟机内存中的一块区域。
栈概述
栈是运行时的单位,堆是存储的单位。
- 栈解决程序的运行问题,即程序如何执行,如何处理数据.
- 堆解决的是数据存储的问题,即数据怎么放,放哪里.
栈的运行原理:
虚拟机栈存储的是java方法相关的数据和操作。线程被创建时,虚拟机会同步创建一个虚拟机栈,其内部保存的是一个个的栈帧,栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
方法的调用代表一个栈帧入栈,方法调用结束代表栈帧出栈,方法从开始调用到执行完毕,对应着入栈到出栈的过程。如下图,执行引擎执行methodA时,methodA的栈帧入栈,然后调用methodB,methodB栈帧入栈(简画,实际栈帧存储的东西不止局部变量)。栈顶的栈帧代表的是当前栈帧,栈帧对应的方法叫当前方法,方法对应的类叫当前类。
不同线程中所包含的栈帧是不允许存在相互引用的,即一个栈帧之中不可以引用另外一个线程的栈帧。
java方法有两种返回函数的方式,但不管使用哪种方式,都会导致栈帧被弹出
- 正常的函数返回,使用return指令。
- 抛出异常。如果当前方法A发生了异常但是没有捕获,则当前栈帧会被弹出,如果调用A的方法B的栈帧也没有捕获异常,则B栈帧被弹出,继续往外层执行,假设直至main方法均没有捕获异常,则程序会报错中断。
java虚拟机栈属于线程私有内存,生命周期和线程相同。
虚拟机栈作用:
主管Java程序的运行,它保存方法的局部变量(8 种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
虚拟机栈优点:
1、快速有效的内存分配方式,访问速度仅次于程序计数器。
2、jvm对栈的操作简单:入栈和出栈。
3、不存在垃圾回收的问题(GC、OOM,但会发生栈溢出)。
java虚拟机规范对这块内存区域定义了2种异常:
- 如果线程申请的栈深度大于虚拟机允许的深度,抛出StackOverflowError栈溢出异常。
- 如果栈可以动态扩展,扩展时无法申请到足够的内存,则抛出OutOfMemoryError内存溢出异常。
使用-Xss来设置线程的最大栈空间,栈的大小决定了函数可达的最大深度。
栈帧的内部结构
每个栈帧的内部存储这以下数据:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
栈帧的大小取决于内部结构的大小。在编译java源程序的时候,栈帧中需要多大的局部变量表、需要多深的操作数栈都已经被计算出来,并且写入到了方法表的Code属性中。
- 局部变量表
- 局部变量表又被称之为局部变量数组或本地变量表。
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型(8种)、对象引用(reference),以及returnAddress类型。
- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 局部变量表中的变量只在当前方法调用中有效。方法调用结束,方法栈帧出栈,局部变量表也会销毁。
实例:
1 | |
执行反汇编javap -v LocalVariablesTest.class得到字节码指令
1 | |
由上可知,locals=3代表局部变量表大小为3,分别为args参数、test对象和num变量。代码指令一共12行,LineNumberTable表示pc指令地址和源码所在行的对应关系。LocalVariableTable局部变量表,start表示当前变量的作用域,变量都是定义之后生效,比如test的start是8,从LineNumberTable表可知,8对应line16,也就是从16行开始test变量生效。length表示变量作用域的长度,从表中可看到三个变量起始位置+长度=12,因此所有变量作用域的结束都是方法的}。
关于Slot的理解
- 1、参数值的存放总是从局部变量数组索引 0 的位置开始,到数组长度-1的索引结束。
- 2、局部变量表,最基本的存储单元是Slot(变量槽),局部变量表中存放编译期可知的各种基本数据类型(8种)、引用类型、returnAddress类型的变量。
- 3、在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型占用两个slot(1ong和double)。byte、short、char和boolean在存储前都会被转化成int。
- 4、JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。占两个slot的变量使用起始索引访问。
- 5、当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上。
- 6、如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。如下图jclasslib插件中可以看到字节码文件信息,test2位实例方法,它的this占了index=0的插槽。由于静态方法中栈帧局部变量表没有存储this变量,因此静态方法中无法使用
this.xxx。
Slot的重复利用
局部变量槽是可以重复利用的,如果一个局部变量过了其作用域被销毁了,则下一个局部变量可以利用这个局部变量槽。比如以下代码:
1 | |
局部变量表和性能调优密切相关,局部变量表占了栈帧的主要空间,因此它和栈溢出息息相关。局部变量表中的变量是垃圾回收的根节点,被局部变量表中的直接或间接引用的对象不会被回收。
- 操作数栈
栈帧中的操作数栈Operand Stack采用数组来具体实现,也可被称之为表达式栈。操作数栈在方法的执行过程中,根据字节码指令,往栈中写入(入栈)或者提取(出栈)数据。
操作数栈是栈(虚拟机栈)中栈(栈帧的操作数栈),它主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。局部变量表中的变量被操作的时候(比如执行i+j),会被压入操作数栈,经过一些指令(iadd)后再推出去,存储到局部变量表中。上述反汇编代码中的stack=2表示的是操作数栈的深度。
栈中的任何一个元素都是可以任意的Java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
代码追踪实例
1 | |
javap -v xxx.class之后得到字节码指令
1 | |
以上代码的具体执行如下图,指令类型采用所能包含的值的最小整数类型指令:
- 当执行main函数时,创建好栈帧。pc寄存器的值为0,栈帧中初始化了局部变量表和操作数栈,值为空
- 执行地址为0的指令,bipush,pc=2,15入栈
- 执行地址为2的指令,istore_1,pc=3,15出栈,局部变量表slot1=15(实例方法,slot0存储的是this变量)
- 执行地址为3的指令,sipush(由于800超过了byte的范围,所以会使用sipush,short类型),pc=5,8入栈
- 执行地址为5的指令,istore_2,pc=6,8出栈,局部变量表slot2=8
- 执行地址为6的指令,iload_1,pc=7,从slot1中加载15,15入栈
- 执行地址为7的指令,iload_2,pc=8,从slot2中加载8,8入栈
- 执行地址为8的指令,iadd,pc=9,8和15出栈并执行相加操作,23入栈
- 执行地址为9的指令,istore_3,pc=10,23出栈,局部变量表slot3=23
- 执行地址为10的指令,return。函数结束,栈帧销毁。

- 执行地址为10的指令,return。函数结束,栈帧销毁。
栈顶缓存(Top-of-Stack-Cashing)技术
jvm简单介绍文章里面提到过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候需要使用更多的入栈和出栈指令,操作数栈存储在内存中,因此会导致频繁的内存读写,影响执行速度。为了解决这个问题HotSpot的提出了栈顶缓存技术。
栈顶缓存是指将栈顶元素全部缓存在物理cpu的寄存器中,以此降低对内存的读写次数,提升执行引擎执行效率。
- 动态链接
栈帧里面的动态链接、方法返回地址和一些附加信息有些地方把它们统称为帧数据区。
动态链接又叫指向运行时常量池的方法引用。
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用区域
包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令
java源文件被编译为字节码文件后,所有的变量和方法的引用都会被保存到class文件的常量池(映射着的内存结构为方法区的运行时常量池)里面
动态链接的作用是:把这些方法的符号引用转换为直接引用
如下图所示,栈帧中有一个区域专门用来存储方法引用。方法区内存是多线程共享的,假设方法A和方法B都调用方法C,那么在运行时常量池会有方法C的方法引用,方法A和方法B对应的栈帧中的区域都会存储该方法引用。
以下代码,methodB()调用methodA(),反汇编之后的结果如下。从结果中我们可以看到,方法b中有指令5: invokevirtual #5,invokevirtual代表调用方法,#5为方法引用,表示常量池中的#5 = Methodref #28.#29
1 | |
方法的调用
- 方法返回地址
- 一些附加信息
《java虚拟机规范》中没有描述到的一些信息也可以保存在栈帧之中。比如调试、性能搜集等相关信息,取决于虚拟机具体的实现。
1.3 本地方法栈
本地方法栈和虚拟机栈几乎一样,只不过它是为调用本地方法服务的。比如:调用本地的C、C++方法等。
1.4 堆
堆是虚拟机管理的最大的一块内存区域,此区域用于存放对象实例,几乎所有对象的内存分配都在java堆这里。
堆是垃圾收集器管理的内存区域,因此堆也被叫做“GC堆”。大多数虚拟机对这一块内存采用“分代管理”的方式,因此这块内存在分代虚拟机(比如HotSpot虚拟机)里面又被分为新生代、老年代、永久代等,这块后面会具体讲。
java堆属于多个线程共享的一块区域,但是如果具体划分也可以划分出线程私有的缓冲区(Thread Local Allocation Buffer, TLAB),用于提升对象分配效率。java虚拟机规范规定,java堆在物理上可以不连续,但是在逻辑上必须是连续的。
java堆可以是固定大小的,也可以动态扩展,取决于具体虚拟机的实现。当前主流的虚拟机设计都是可动态扩展的(-Xmx、-Xms)。当堆中实例分配内存不足,且无法申请新的内存时,会报OutOfMemoryError。
1.5 方法区
方法区(只有HotSpot才有这个概念)是各个线程共享的一块区域,java方法区类似于常规语言编译代码的存储区,存储编译器编译后的代码缓存等数据,它存储每个类的结构,比如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。
JVM规范对方法区的约束很松,和堆一样,可以物理区间不连续、内存大小可选择固定和可扩展的外,还可以选择不实现垃圾回收。垃圾收集器在方法区很少出现,如果出现收集行为,主要针对常量池的回收和类型的卸载,这部分的回收很麻烦,但是有时候又很有必要。
如果内存分配不足,会报OOM异常。
由于这一块区域几乎不会发生GC,因此HotSpot把这一块区域称为“永久代”(Permanent Generation),jdk1.8之后称为元空间(metespace)。
1.6 运行时常量池
运行时常量属于方法区的一部分,.class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池表(Constant Pool Table),用于存放编译器生成的各种字面量与符号引用,这部分的内容将在类加载后存放到方法区的运行时常量中池。
规范没有对运行时常量池作任何要求,但是通常,除了保存.class中的符号描述引用外,还会把符号引用翻译出来的直接引用也存储在这里。
运行时常量池具有动态性,除了编译产生的内容会进入,运行期间新产生的常量也可以进入常量池。比如String的intern()方法。当内存不够时,会报OOM异常。
1.7 直接内存
直接内存不属于虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存经常被使用,也可能导致OOM。
jdk1.4新加入了了NIO(New Input/Output)类,引入了一种基于通道和缓存区的I/O方式,它可以使用Native函数库直接分配对外内存,然后通过一个存储在java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了再Java堆和Native堆中来回复制数据。
直接内存的分配不受java堆大小的限制,但是机器的限制。
参考文档:https://docs.oracle.com/javase/8/docs/index.html
参考文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5
2. HotSpot对象内存布局
HotSpot是目前最经典也最常用的虚拟机,本节以HotSpot为例,进一步解释说明上一节的运行时内存区域。
2.1 对象的创建
1、类加载
当java虚拟机碰到new指令时,会执行以下步骤:

2、内存分配
类加载之后,对象所需要的内存大小可以完全确定下来。为对象分配内存实际上是从java堆上划分一块内存,存储对象相关信息。内存分划分有2种方式:
- 如果内存是规整的(使用过的在一边,空闲的在另一边),则中间放一个指针,分配内存时,把指针向空闲的那边挪动与对象大小相等的距离。这种分配方式叫”指针碰撞“。
- 如果内存不是规整的,则需要维护一个列表,记录哪些内存块可用,哪些不可用。这种方式叫做”空闲列表“。
选择何种分配方式取决于内存是否规整,而内存是否规整又取决于垃圾收集器是否带有空间压缩站整理(Compact)功能。比如Serial、ParNew带压缩功能,使用指针碰撞方式,高效简单。CMS基于清楚算法的收集器,使用复杂的空闲列表方式。
并发情况下,简单的”指针碰撞“会出现安全问题,有可能对象A分配内存指针还没来得及修改,对象B修改指针了。解决这个问题,有2种方式:
- 虚拟机采用CAS+失败重试的方式,保证更新的原子性。
- 预先给线程划分一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB ),哪个线程需要分配内存,在自己的TLAB中进行。TLAB使用完毕之后,分配新的缓冲区则需要同步锁定。
-XX:+/-UseTLAB设定是否使用TLAB
3、赋零值
内存分配之后,虚拟机必须将分配到的内存空间(除了对象头)都初始化为零值。如果使用了TLAB,也可在在TLAB分配时赋零值。这一步操作保证了java代码中对象不赋初始值就可以直接使用。(比如 Integer i;)
4、对象其他设置
java虚拟机对象进行必要的设置,比如对象属于哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息。这些信息会放到对象头(Object Header)之中。偏向锁、对象头设置方式等。
以上步骤结束后,从虚拟机角度看,新的对象已经产生了。但是从程序员角度看,对象创建才和刚刚开始,因为还没开始执行init方法。
2.2 对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的布存储布局划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头(Mark Word)
HotSpot对象头包括两部分信息:1、存储自身的运行时数据 2、类型指针
对象自身的运行时数据
比如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位虚拟机(未开启指针压缩)中,分别为**4个字节(32bit)和8个字节(64bit)**。
对象头的结构是动态的,根据对象的状态复用存储空间。以便在极小的空间内存存储尽量多的数据,节省存储成本。
在不同的状态下(5种状态)对象头中所存储的内容会有所不同,如下所示:
1 | |

比如:在64位的HotSpot虚拟机中,如对象处于无锁状态下时,对象头的64个字节中的26个bit处于空闲状态,HashCode占了31个bit,4个bit用来描述分代年龄,1个bit固定为0表示不是偏向锁,2个bit用于存储锁标志位。
类型指针
对象头的另一部分是类型指针,即对象指向它的类型原数据的指针,Java虚拟机通过这个指针来确定对象是哪个Class的实例。但是并不是所有的虚拟机实现都需要在对象数据上存储类型指针,后面会讲到这一点。
如果对象是数组时,在对象头中还需要有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是数组的长度不确定,就无法推断出整个数组的大小,无法分配内存。
类型指针占用大小:-XX:+UseComparessedClassPointers 开启的话是4个字节,不开启则为8个字节
实例数据
实例数据部分是对象真正存储的有效信息。也就是我们在代码中定义的各种成员变量,父类子类的都必须记录起来。字段存储的顺序满足以下规则(按顺序满足):
- HotSpot虚拟机默认分配顺序为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),即相同宽度的字段会分配到一起存放。
- 父类中定义的变量会出现在子类之前。除非
+XX:CompactFields=true,子类窄变量可以插入父类变量空隙之中。
对齐填充
HotSpot要求对象起始地址必须是8字节的整数倍,如果不是则会占位。实际上对象头的结构已经被定为6的整数倍(31或者64),如果实例数据部分不是,则会对齐填充。
实例分析:分析new Object()对象大小
对象头:8个字节 + 类指针4个字节(默认开启了-XX:+UseComparessedClassPointers,因此为4字节)
实例数据:成员变量没有,所以为0
对齐填充:8 + 4 = 12,需要对齐 + 4
因此在64位下,SizeOf(Object) = 8 + 4 + 4 = 16字节。以下代码也可以验证:
1 | |
2.3 对象的访问定位
建立对象是为了使用对象,我们通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机规范中只规定了一个指向对象的引用,并没有定义引用应该通过什么方式定位、访问堆中的对象具体位置,所以对象的访问方式取决于虚拟机的实现。
主流的方法分为句柄访问和直接指针访问两种,就HotSpot而言主要使用的是第二种直接指针访问进行对象访问(如果使用了ShenandoahGC收集器时会有额外的转发)
句柄访问
Java堆中将可能会划分出一块内存在作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自具体的地址信息。

直接指针访问
在使用直接指针访问时,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象实例地址,如果只是访问对象本身的话,就不需要多一次间接的定位开销

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!
