Java虚拟机内存管理

和C/C++语言不同,Java通过虚拟机来对内存进行自动管理,避免了手动申请和释放内存的繁琐以及容易出错的问题,Java虚拟机把内存分为几个不同的数据区,如下:
50aac95423ea62a79cd61e571a48d8f9.png

Java栈

JVM规范要求:每个Java线程拥有自己私有独享的JVM栈,JVM栈随着线程启动产生,线程结束而消亡。栈区内存由编译器自动分配释放,线程在执行一个方法时会创建一个对应的栈帧(Stack Frame),栈以帧为单位保存线程的状态,栈帧负责存储局部变量变量表、操作数栈、动态链接和方法返回地址等信息。
>栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态连接、方法返回值和异常分派。

对于一个Java程序来说,它的运行就是通过对栈帧的操作来完成的 。一个方法被调用时,当前方法的栈帧会被压到当前线程的Java栈的栈顶,调用结束后弹出,JVM对栈只进行两种操作:以帧为单位的压栈和出栈(销毁)操作。基于这个特点,方法中的局部变量因此可以在不同的调用过程中具有不同的值,这个是递归调用的基础。

f2b1e72e57d3629b4c7baac24ccf7d41.png

1.局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。

在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量)
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。

虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

2. 操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中。

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

3.动态连接

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。

这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。

4.方法返回

当一个方法开始执行时,可能有两种方式退出该方法:

  • 正常完成出口
  • 异常完成出口

正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。

方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

JVM栈的限制

虽然JVM栈拥有存取速度快,无并发等优势,但它更多的是一种运行时的单位,代表的是程序处理的逻辑。但也存在一些缺陷。
1. 由于JVM栈的压栈入栈操作是LIFO(Last-In-First-Out)的,导致的一个问题是:限制了某一个方法的栈帧的生命周期不能超过其调用者。这样对于多线程共享的变量无法在栈中实现。
2. 另外的一个问题是:栈帧中的数据大小在编译期必须确定,对于需要大小动态变化的对象无法很好支持。因此JVM栈中主要存放一些大小确定的基本类型的变量(int, short, long, byte, float, double, boolean, char)和对象句柄。

因此需要新的内存管理策略-堆管理。

Java堆

Java堆是被所有线程共享的一块内存区域,负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放,在Java中,堆的内存由JVM来自动管理。
一个典型的java对象的创建过程和在堆中分配过程如下:
502fe2a6834a7bd3aa1838105538100a.jpeg

堆内存分配方式

堆内存

堆内存分配方式分为“指针碰撞”与“空闲列表”两种,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

指针碰撞(Bump the Pointer)

如果堆内存是完全工整的,用过的内存和没用的内存各在一边每次分配的时候只需要将指针向空闲内存一方移动一段和内存大小相等区域即可。比如ParNew、Serial、G1这种带整理的垃圾回收器,由它们负责回收的区域就采用“指针碰撞”方式来进行内存分配。
493cc75a18069d45a26c3a7faa1a8861.png

空闲列表(free list)

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。像CMS垃圾回收器回收后并不对内存进行整理,因此老年代会存在较多的碎片,这样当需要在老年代进行内存分配是,采用的是“空闲列表”的方式。
225721a7373d96c3b321651df6270fc0.png

堆内存分配并发问题

由于堆内存是多线程共享的,因此不管哪种堆分配方式,都不可避免的存在并发问题。JVM主要采用两种方案来解决并发问题:

同步处理

JVM采用CAS(Compare and Swap)乐观锁 + 重试机制 来保证多线程更新free list的原子性。

TLAB(Thread Local Allocation Buffer)

JVM运行中,内存分配是一个比较频繁的动作,同步处理带来的失败重试会对分配性能有较大影响,因此,从java 1.6开始引入了TLAB技术,可以通过-XX:+/-UseTLAB来开启或者关闭(默认是开启的)。TLAB线程的一块私有内存,在线程初始化时,从堆中申请一块指定大小的内存。当前线程需要申请内存时,先从自己的TLAB中分配,容量不够时再通过同步处理从Eden区申请。这样从TLAB中分配时没有并发问题,能大幅提升分配效率。

TLAB上分配过程

TLAB的管理是依靠三个指针:start、end、top。start与end标记了Eden中被该TLAB管理的区域,该区域不会被其他线程分配内存所使用,top是分配指针,开始时指向start的位置,随着内存分配的进行,慢慢向end靠近,当撞上end时触发TLAB refill。每次

内存中Eden的结构大体为:
1c03f28f3bf5c70d42c8f9fbe551cfc4.jpeg
TLAB中分配规则(开启UseTLAB情况下):

  1. TLAB剩余空间大于待分配对象大小,直接在TLAB上分配返回。
  2. 如果TLAB剩余空间放不下对象,同时TLAB剩余空间大小 大于允许的浪费阈值(refill_waste),那么本次放弃在TLAB上分配,直接在eden区进行慢分配,TLAB剩余空间留着下次继续使用。
  3. 如果TLAB剩余空间放不下对象,同时TLAB剩余空间大小 小于允许的浪费阈值,那么进入到“慢分配”过程:
    a. 丢弃当前TLAB。
    b. 从eden区裸空间重新分配一个新TLAB,然后对象再在新TLAB上分配。(这里从eden区新申请TLAB时,如果eden区空间不够,会触发YGC。)

4f6219eb1a004554135484c442fcb12f.jpeg

几点需要注意的地方:

  1. 开启TLAB情况下,TLAB占用eden区空间比较小,默认只有Eden空间的1%,可以通过 _-XX:TLABWasteTargetPercent_来调整。
  2. 默认情况下,TLAB大小和refill_waste阈值都是由JVM在运行时根据“历史统计信息”动态计算出来的,比如一个线程内存分配申请很频繁,可能该线程的TLAB就会更大。如果不想让系统自动调整,可以通过JVM参数来控制:

    • -XX:-ResizeTLAB (禁用TLAB size的动态调整策略)
    • -XX:TLABSize (指定固定的TLAB size)
    • TLABRefillWasteFraction (可浪费阈值,默认是64:可浪费1/64的TLAB空间)

栈上分配

以上可知,一般情况下JVM中对象都是在堆中进行内存分配,但是在堆中分配内存虽然有TLAB类似的技术能降低同步带来的开销,但还是存在较多在TLAB上分配不了的情况,另外,堆中分配的对象在生命周期结束后需要专门的垃圾回收器来清理,和栈中内存“方法退出即可销毁”的分配回收相比效率较低。

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段编译是把.class转换成机器指令的过程。为提升第二阶段的执行速度,引入了JIT技术(Just-in-time Compilation),JIT其中主要的工作包括:

  • “热点代码检测”;
  • 热点代码编译优化(逃逸分析、锁消除、锁膨胀、方法内联、空值检查消除等);
  • 缓存热点代码编译后的机器指令;

其中编译优化中采用的一种重要技术“逃逸分析”就是优化部分堆上分配为“栈上分配”的基础优化。

逃逸分析

逃逸分析(Escape Analysis)是一种代码分析,通过动态分析对象的作用域,可以分析出某个对象是否永远只在某个方法、线程的范围内,并没有“逃逸”出这个范围,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。

  • 方法逃逸 方法中定义的对象被外部方法引用(如:作为调用参数或者返回值被其他方法引用到);
  • 线程逃逸 对象可以被其他线程访问到(如:实例变量、类变量等)

通过 -XX:+DoEscapeAnalysis 参数来控制“逃逸分析”是否开启,jdk 1.8下默认开启。

对于“无法逃逸”的对象,JIT编译器可以对代码进行优化,主要有以下几种优化方式:

同步消除

线程同步的消耗较大,通过“逃逸分析”,如果确定一个对象不会被其他线程访问到,那对这个对象的读写就不会存在并发问题,因此可以清除该对象的同步锁。

如下示例:该对象的同步锁在编译优化阶段去掉了,提升执行性能。

public void sample() {
    Object lock = new Object();
    synchronized(lock) {
        System.out.println(lock);
    }
}
public void sample() {
    Object lock = new Object();
    System.out.println(lock);
}

通过 -XX:+EliminateLocks 参数来控制“同步消除”是否开启,jdk 1.8下默认开启。

标量替换

JIT经过“逃逸分析”发现一个对象只在方法内部使用不会被外界访问时,会在编译优化过程中把这个对象的成员变量拆解成若干个原始数据类型的标量来进行替代,这个过程就是“标量替换”。通过这种方式就能间接实现堆上对象不用在堆上分配而是通过替代的标量在栈上分配了。

如下示例:经过标量替换后,User对象被替换成两个标量了,从而避免在堆上进行分配。

public void sayHi() {
    User user = new User(1, 14100L);
    System.out.println("Say Hi to:" + user.uid + "," + user.staffNum);
}
class User {
    private int uid;
    private long staffNum;
}
public void sayHi() {
    int uid = 1;
    long staffNum = 14100L;
    System.out.println("Say Hi to:" + uid + "," + staffNum);
}

通过 -XX:+EliminateAllocations 参数来控制“标量替换”是否开启,jdk 1.8下默认开启。

栈上分配

如果确定一个对象不会逃逸出方法之外,那么让对象在栈上分配内存,对象所占用的内存空间就可以随着栈帧的出栈而销毁。目前JVM没有实现真正的“栈上分配”,而是通过“标量替换”来间接实现的。

总结

由以上整理可知,虚拟机对象分配流程大概如下:首先如果开启栈上分配,JVM会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行TLAB分配,如果TLAB分配不成功,再尝试在eden区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。在eden区和老年代分配主要通过“指针碰撞”和“空闲列表”两种方式实现,通过CAS解决堆上“非TLAB方式分配”的并发问题。
b4c73d5e632937bc8e9a1b986203cc00.png


参考:
Java虚拟机—栈帧、操作数栈和局部变量表
HotSpot JVM参数 -XX:+DoEscapeAnalysis 有什么影响
逃逸分析为何不能在编译期进行?