JVM的运行时内存区域

jvm-memory

# 程序计数器

程序计数器是线程私有的, 它可以看作是当前线程所执行字节码的行号指示器, 执行引擎通过改变这个计数器的值来选取下一条需要执行的字节码指令. 它用于多线程切换后能够恢复到正确的执行位置.

# 虚拟机栈

虚拟机栈描述的是java方法执行的内存模型, 栈是线程私有的, 线程中每个方法执行的时候会压入一个栈帧, 用于存储局部变量表, 操作数栈, 动态连接(指向运行时常量池的方法引用), 方法出口(正常退出或异常推出)等信息.

stack

# 1. 局部变量表

定义为一个数字数组, 用于存储方法参数和定义在方法体内部的局部变量, 这些数据类型包括各类基本数据类型, 对象的引用以及returnAddress类型.

局部变量表的大小在编译器确定下来, 并保存在方法的Code属性的maximum local variables数据项中.

局部变量表数组中的每个格子称之为Slot, 每个局部变量都会按照顺序复制到每一个Slot上, 每个Slot都有一个访问索引, 通过这个索引可以访问到局部变量表中的局部变量值.

32位以内的类型只占用一个slot, 64位的占用两个.

非静态方法的局部变量表的index为0的位置会放置this引用.

lvt

在以可达性算法实现的垃圾回收器中, 局部变量表中的变量也是垃圾回收的根节点之一. 被直接或间接引用的对象都不会被回收.

局部变量表的大小会直接影响整个栈帧的大小, 进而也会影响栈可容纳栈帧的数量


# 2. 操作数栈

操作数栈是基于数组实现的.

操作数栈在方法执行过程中, 根据字节码指令, 往栈中写入或提取数据, 即入栈和出栈.

操作数栈主要用于保存计算过程的中间结果, 同时作为计算过程中变量的临时的存储空间, 如果被调用的方法带有返回值, 返回值将会被压入当前栈帧的操作数栈中, 并更新程序计数器中下一条需要执行的字节码指令.


# 3. 动态链接

每个栈帧都是一个方法, 而方法是保存在方法区中的运行时常量池中的. 动态链接就是栈帧中指向运行时常量池中对应方法的引用. 这个引用的作用是为了支持当前方法的代码能够实现动态链接. 比如invokedynamic指令.

Tip

当java文件编译为字节码文件时, 所有的变量和方法引用都作为符号引用保存在class文件的常量池中, 在类加载的解析过程中, 这些符号引用就被解析为了直接引用. 当一个方法调用另一个方法时, 就是通过常量池中指向方法的符号引用来表示的. 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用.

除动态链接以外, 还有静态链接.

当一个字节码文件被加载到JVM内部时, 如果被调用的方法在编译器可知且运行期间保持不变时, 
这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接. 

如果被调用的方法在编译器无法确定下来, 也就是说, 只能够在程序运行期间将调用方法的符号
应用转换为直接引用, 由于这种引用转换过程具备动态性, 因此称之为动态链接. 

动态链接与静态链接的方法绑定机制也不同, 绑定是一个字段, 方法或类在符号引用被替换为直接引用的过程. 动态链接的方法绑定为晚期绑定, 而静态链接的方法绑定为早期绑定.

早期绑定指被调用方法在编译器可知, 且运行期间不变即可将这个方法与所属类型绑定. 晚期绑定指被调用方法编译器无法确定, 只能在程序运行期间根据实际类型绑定相关方法. 这类方法也被称为虚方法. 与之相对的早期绑定的方法即非虚方法.


# 4. 虚拟机中提供的方法调用指令

invoke

  • 普通调用指令:

    1. invokestatic: 调用静态方法
    2. invokespecial: 调用<init>方法, 私有方法, 父类方法(super)
    3. invokevirtual: 调用所有虚方法, 包括final修饰方法
    4. invokeinterface: 调用接口方法
  • 动态调用指令:

    1. invokedynamic: 动态解析出需要调用的方法, 然后执行

普通调用指令固化在虚拟机内部, 不可人为干预, 动态调用指令则可以由程序员使用, 如使用ASM

# 5. 方法返回地址

方法返回地址保存的是程序计数器的值, 也就是调用该方法的指令的下一条指令的地址

# 本地方法栈

本地方法栈与虚拟机栈相似, 也是线程私有的, 里面的栈帧是虚拟机使用到的native方法服务

#

jvm的堆内存是线程共享的, 用于存放对象实例, 是垃圾收集器管理的主要收集区域, 由于垃圾收集器采用分代算法, 所以堆又分为新生代与老年代.

heap

新生代的Eden与From和To(两个Survivor)之间的大小比例默认为8:1:1, 新生代与老年代的大小比例默认为1:2.

# 对象内存分配

对象分配

新对象大部分 分配到新生代的Eden区, 当新生代的Eden没有足够空间时会触发一次youngGC, 回收在Eden与From区中可达性分析中判断为可回收的对象, 如果对象本次不可回收将会移动到To区中. 然后对象的GC年龄+1. 同时 回收Eden与From区中的垃圾对象. 如果在迁移到To区时, To区空间不足, 则会直接进入老年代. 老年代空间不足时会触发FullGC

ps: 这里有个问题算是扩展思考, youngGC的时候, Eden和From区的不可回收对象要迁移到To区, 如果To区空间不足就要直接晋升到老年代, 这个晋升时Eden和From的不可回收对象全部晋升, 还是部分晋升? 如果是部分晋升的话是依据什么条件, 对象年龄排序吗?

其实这个问题, JVM已经提前解决了, 详见下面的空间分配担保

若对象长期存活(对象的GC年龄达到15)会晋升入老年代, 大对象分配时则会直接进入老年代(大对象应该尽量避免).

大对象

youngGC后, Eden区为空, 此时分配对象大小若大于Eden区大小, 则会判定为大对象, 直接进入老年代.

动态对象年龄判定

若Survivor中相同年龄的对象大小总和达到了Survivor空间的一半, 那么大于等于这个年龄的对象将直接进入老年代. 若老年代还是不够, 则FullGC, 若还不够则OOM.

空间分配担保

youngGC之前会检查老年代最大可用连续空间是否大于新生代所有对象总空间, 若条件成立, 直接GC; 不成立, 会检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小, 条件成立时, 尝试进行youngGC, 不成立则进行FullGC

# TLAB

由于堆空间是线程共享的, 且对象创建的操作是非常频繁的, 因此在并发情况下在堆中分配对象空间是线程不安全的, 为了避免多线程操作同一个地址, 就需要使用加锁等机制来保证线程安全, 但同时也影响了分配的速度.

TLAB(Thread Local Allocation Buffer)是java内存模型中的一部分, JVM在Eden中为每个线程分配一个线程的私有缓存区域, 当多线程并发分配内存时使用TLAB来避免并发问题. 如果TLAB分配失败, 则使用加锁机制确保内存分配的原子性

# 逃逸分析与栈上分配

TLAB技术并不是只解决多线程并发分配内存的问题, 还可以搭配逃逸分析与栈上分配来降低GC的回收频率和提高GC的回收效率.

判断发生逃逸的依据有两种, 一种是相对于栈帧的: 判断new的对象是否有在方法的外部使用. 这种情况是对象在一个栈帧的生命周期中进行实例化操作之后要依靠堆去共享给其他栈帧使用. 另外一种是相对于线程的: new的对象被赋值给了堆中的对象的成员变量或类的成员变量. 因为对象被放入堆中后, 可以被其他线程访问. 以上两种情况对于new的对象的之后的使用情况, 编译器是无法追踪的. 所以视为逸出.

    public void test1(){

        // obj没有返回出去, 所以没有发生逃逸
        Object  obj = new Object();
        obj = null;
    }
    
    public Object test2(){

        // obj被return出去, 发生了逃逸
        return new Object();
    }

    Object obj;
    
    public void setObj(){
        
        // new出来的obj可能会在外部调用, 发生了逃逸
        this.obj = new Object();
    }
    
    public Object test3(){

        // obj被return出去, 发生了逃逸
        return obj == null? new Object() : obj;
    }
    
    public void test4(){
        
        //也是发生了逃逸, 判断逃逸的标准并不是只看当前方法, 
        //如果使用了已逃逸都算做发生逃逸
        Object o = test3();
    }
    
    public void test5(){
    
        // 锁仅当前线程可见, 对象是没有逃逸的
        synchronized (new Object()){
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 同步省略

当逃逸分析一个对象没有逸出方法时, 可能会被优化为栈上分配. 同时如果JIT编译器在通过逃逸分析判断同步代码块所使用的监视器对象只被一个线程访问, 上面实例代码中的test5, 那么JIT会在编译时取消 这个同步代码块来提高并发性能, 因为它是没有意义的加锁. 这个取消的过程就叫做同步省略.

# 标量替换

对于没有逃逸的对象来说, 如果满足特定条件, JIT编译器还会进行标量替换来进一步优化.

标量指的是不可以在分解成更小的数据的数据. java中的标量是基本类型. 与标量相对的是聚合量. 也就是还可以再拆分的数据. java中的对象就是聚合量. 标量替换就是将聚合量拆分为标量的过程.

    
    class Obj{
        int a;
        int b;
    }
    
    // 替换前
    public void beforeScalarReplice(){
    
        Obj obj = new Obj();
        obj.a = 1;
        obj.b = 2;
        System.out.println(obj.a);
        System.out.println(obj.b);
    }
    // 替换后
    public void beforeScalarReplice(){
    
        int a = 1;
        int b = 2;
     
        System.out.println(a);
        System.out.println(b);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

对象分配过程

# 对象创建过程

  1. 虚拟机遇到new命令时, 首先检查这个指令的参数是否能在常量池定位到一个类的符号引用, 并检查这个符号引用代表的类是否已经被加载, 解析和初始化过.
  2. 分配内存. 对象所需内存的大小在类加载时就已经确定
  3. 初始化分配的内存空间为零值.
  4. 虚拟机对对象进行必要的设置. 例如这个对象是哪个类的实例, 如何才能找到对象的元数据信息, 对象的哈希码, 对象的GC分代年龄等信息. 这些信息保存在对象头中
  5. 对于虚拟机来说, 前四步已经完成了对象的创建. 执行init方法, 也就是下面的六七两步
  6. 进行对象属性的显式初始化, 包括构造代码块中的初始化, 这两个的顺序是代码编写的前后顺序
  7. 执行构造方法

# 对象的内存布局

  • 对象头

存储对象自身的运行时数据. 如哈希码, GC分代年龄, 锁状态标志, 偏向线程ID, 偏向时间戳等.

存储类型指针, 即对象指向他的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例(并不是所有虚拟机都这么做), 若对象是数组, 对象头中还需要一块用于记录数组的长度

  • 实例数据

对象真正存储的有效信息, 字段内容, 包括继承的.

  • 对齐填充

由于hotspot的自动内存管理系统要求对象的大小必须是8字节的整数倍, 而对象头部分正好是8字节的整数倍, 因此, 当实例数据没有对齐时, 需要对齐填充来补全.

# 对象的访问定位

java程序通过栈上的reference数据来操作堆上的具体对象, 但虚拟机规范并没有定义这个引用应该通过何种方式去访问堆中的对象, 现在的访问方式有两种:

  • 句柄

堆中划分出一块内存作为句柄池, reference中存储的是对象句柄的地址.

句柄中包含对象实例和类型数据各自的具体地址信息.

好处是当对象被移动时, 只会改变句柄中的实例数据指针, reference本身不需要修改.

  • 直接指针

reference中直接存储对象地址, 好处是访问速度快.

# 方法区

方法区域与堆一样是线程共享的, 用于存储虚拟机加载的Class信息, 常量, JIT编译器编译后的代码.

方法区又称为永久代, 原因是hotspot虚拟机将GC分代回收扩展至方法区, 或者说使用永久代实现方法区, 目的是让垃圾回收器可以向管理堆一样管理方法区.

在jdk7中, 永久代中的字符串常量池和静态变量迁移到了堆中, 在jdk8开始, 永久代被元空间替换, 所在位置转移到了本地内存中.

# 为什么替换为元空间

  1. 永久代大小很难确定不好设置

某些场景下, 如果动态加载类过多, 容易产生永久代的oom, 比如某个web工程中, 因为功能点比较多, 在运行过程中, 要不断动态加载很多类, 会出现永久代的oom

而元空间因为不在虚拟机中, 使用的是本地内存, 因此默认情况下, 元空间的大小仅受本地内存限制.

  1. 永久代调优困难

针对jvm的调优一般都是针对于full gc的.

针对于方法区的回收, 主要是回收常量池中废弃的常量和不再使用的类型. 回收类型之前首先要判断这个类是否已经不再使用, 而判断类型是否不再使用的条件比较苛刻, 且比较耗时.

将方法区替换到本地内存中也能够减少full gc的次数。

# 字符串常量池为什么要迁移到堆中

字符串常量池是在jdk7中和静态变量一块迁移到堆空间中的, 那时还没有替换为元空间。 之所以迁移到堆中也是因为永久代的回收效率太低, 放到堆中方便及时回收.

# 运行时常量池与字符串常量池的区别

运行时常量池位于元空间内, 用于存放编译器生成的各种字面量和符号引用, 这部分内容在类加载后进入方法区的运行时常量池.

运行时常量池在jdk1.7之前是包含字符串常量池(StringTable)的, jdk7开始, 由于字符串常量池迁移到堆中, 两者分开.

字面量

文本字符串, 声明为final的常量值

符号引用

  • 类和接口的完全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

# 内存溢出

内存溢出与内存泄露的区别: 内存溢出程序运行过程中申请的内存大于系统能够提供的内存, 导致无法申请到足够的内存, 于是发生内存溢出. 内存泄露指程序运行过程中分配内存给临时变量, 用完之后却没有GC回收, 始终占用着内存, 既不能被使用也不能分配给其他程序.

下面记录了几种内存溢出的排查与解决方法

  • 堆上的内存溢出

使用内存映像工具对dump出的堆转储快照进行分析, 确认内存中的对象是否必要, 也就是先分清楚是出现了内存泄露还是内存溢出.

如果是内存泄露, 查看泄露对象到GC Roots的引用链.

如果不存在内存泄露, 检查堆参数与物理机器对比是否还可以调大, 再检查代码是否某些对象的生命周期过长, 持有状态时间过长. https://github.com/roncoo/roncoo-education-web.git

  • 栈上的内存溢出

主要检查多线程时的溢出, 虚拟机内存排除掉堆和方法区后, 每个线程分配到的栈容量越大, 可以创建的线程数量越少, 创建线程时越容易把剩下的内存耗尽.

  • 方法区的内存溢出

在经常动态生成大量class的应用中, 需要特别注意类的回收状况.

如果是JSP或动态生成JSP的应用同上.

上次更新: 2022/3/11 15:12:48