JVM 内存模型

JVM体系结构
JVM 内存模型
1.classfile 字节码文件的部分
2.Classloader
3.内存结构
4.执行引擎的部分 解释器和JIT即时编译器 还差GC

类的对象存储在方法区中,堆中存储对象和成员变量,而堆中的对象在方法的执行过程中,需要用到虚拟机栈,程序计数器,以及本地方法栈,方法在执行的时候,它的每行代码都是由执行引擎中的解释器逐行解释的,对于热点代码则通过JIT即时编译器进行编译,在执行引擎中还有一个比较重要的模块,垃圾回收GC,它会对堆中的不再引用对象进行垃圾回收,还会有一些java不太方便实现的功能, 调用底层操作系统的功能,通过本地方法接口来进行实现。

JVM系统线程

有很多的线程其实是在后台运行的,这些后台线程与主函数的主线程以及主线程创建的一些其他线程在一起运行 HotSpotVM 后台运行的系统线程有以下几个:

五大内存区域

JVM 内存模型

程序计数器

程序计数器 Program Counter Register
指的是当前指令(操作码)的地址 本地指令除外,如果当前的方法native方法 PC的值为undefined。 所有的CPU 都有一个PC 每执行一条指令PC都会自增 因此PC存储了指向下一条要被执行的指令
为什么需要程序计数器

我们知道对于一个处理器(如果是多核cpu那就是一核),在一个确定的时刻都只会执行一条线程中的指令,一条线程中有多个指令,为了线程切换可以恢复到正确执行位置,每个线程都需有独立的一个程序计数器,不同线程之间的程序计数器互不影响,独立存储。

注意:如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址。如果为native【底层方法】,那么计数器为空。这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域。

程序计数器在这里记住的JVM下一条指令的执行地址,当第一条指令执行完,那么解释器就会到程序计数器中取寻找下一条指令。在物理上来讲的话 程序计数器是通过寄存器来实现的。

特点:

  • 线程私有的

多个线程在执行代码的时候,CPU会用一个调度系数,给线程分配时间片,线程在现有的时间片,没有执行完,那么会让它执行一个暂存的指令,然后切换到其他线程,其他线程也是如此。那么线程在切换的过程中,如果想知道下一个指令执行到哪了,会使用程序计数器来进行记录。

每条线程里都有自己的程序计数器,来记录自己线程执行到的指令 当线程切换后 又切换到原有的线程那么它就从自己的程序计数器中取出要执行的下一条指令。

  • 程序计数器 不存在内存溢出 OOM
  • 是很小的空间 ,可以把它看成字节码行号的指示器

虚拟机栈

Java中每个线程在运行的时候都需要划分内存空间,虚拟机栈是指每个线程在运行时需要的内存空间,所以说每条线程中都会存在一个虚拟机栈,而虚拟机栈中的每一块空间都是一个栈帧,一个栈帧对应着一次方法的调用。

栈帧: 每个方法在执行的时候需要的内存
方法在调用的时候,需要参数 局部变量 返回地址 都是需要地址
一个虚拟机栈是由多个栈帧组成
JVM 内存模型
栈帧存储 局部变量表 操作栈 动态链接 方法出口等信息 后进先出 LIFO
总结:
每个线程运行时需要的内存 -------虚拟机栈
每个栈都由多个栈帧组成 每一个栈帧又对应着方法调用的内存
每个线程只能有一个活动的栈帧 对应着当前正在执行的方法
1.垃圾回收是否涉及到虚拟机栈
不需要 因为每一次方法的执行完之后都会被弹出栈空间 ,根本就不需要垃圾回收
2.栈内存分配越大越好嘛

可以通过-Xss来设置栈空间的大小,栈的空间越大 并不会提升程序的运行的速度,反而会让程序的线程数量变少, 因为物理内存是固定的。栈的内存越大 提升方法进行递归调用的次数。

3.方法内的局部变量是否是线程安全的
这个主要看 局部变量被多个线程共享
JVM 内存模型
如果两个线程中的局部变量互补干扰 那么局部变量是对线程私有的,不会存在线程的安全的问题。
当这个变量一旦被static关键字修饰了,那么它就不安全了。
JVM 内存模型

package com.openlab;

public class Demo02 {

    public static void main(String[] args) {

//        StringBuilder sb = new StringBuilder();
//        sb.append(4);
//        sb.append(5);
//        sb.append(6);
//
//        new Thread(()->{
//           m2(sb);
//        }).start();

    }

    public static void m1(){
        StringBuffer sb = new StringBuffer();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());

    }

    public static void m2(StringBuilder sb){

        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());

    }

    public static StringBuilder m3(){
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;

    }
}

M1方法 线程安全的 StringBuffer 本身就是一个局部变量
M2 方法 StringBuilder 变成了一个参数 所以它线程不安全, 有可能会有其他线程访问到它 sb对象 不是线程私有的了。
M3 方法 将StringBuilder 做成了返回值 那么它线程不安全, 其他线程是可以拿到这个方法的返回值的 并且去修改它。

局部变量表

存放了哪些东西
1.编译期 基本数据类型
2.对象的引用 reference 类型 引用指针
3.ReturnAddress 类型 -----指向了一条字节码指令

64位的long double 会占用两个局部变量的空间(slot) 其余的类型都只占用1个slot

在局部变量表中包含了方法在执行过程中的所有的变量 甚至包括this的引用,方法的参数,其他局部变量 静态方法

在虚拟机规范中,对这个区域规定两种异常情况
1.如果线程请求的栈的深度大于虚拟机所允许的深度
*Error
2.虚拟机的栈是可以进行动态扩展的,当扩展时申请不到足够的内存时
OutOfMemoryError

JVM栈帧

方法调用的和方法执行的一个数据结构 一个方法从调用到执行完成。

每一个栈帧都包括
局部变量表
操作数栈
动态链接
方法返回地址
额外的信息
JVM 内存模型

操作数栈

类似局部变量表, 操作栈 也是后进先出
操作数栈的最大深度 class文件中的Code属性max_stacks 数据项

操作数栈可以存储任意类型
32位的数据类型 栈容量为1
64位的数据类型 栈容量为2
栈容量的单位 ----字宽
对32位的虚拟机 一个字宽是 4个字节
对64位的虚拟机 一个字宽是8个字节

在进行算术运算上就是通过操作数栈来进行的。
JVM 内存模型

动态链接

每个栈帧都包含 一个指向运行时常量池中这个栈帧属性和方法的引用,持有这个引用是为了支持方法调用过程的动态连接。
在class文件的常量池中有大量的符号引用的,方法在调用的时候调用指令的时候会以符号引用作为参数。
符号引用的一部分是在类的加载阶段或是在第一次使用的时候转化为直接引用
静态解析
另一部分在每次运行期间转化的直接引用
动态链接。

方法的返回地址

方法的退出方式有两种

正常完成出口
执行引擎遇到return的指令返回的字节码指令 返回值和返回类型

异常完成出口
方法在执行时遇到了异常 异常没有在方法体内得到处理 一种是虚拟机内部异常,代码中athrow字节码的异常。当出现异常时是不会产生任何返回值的。

只要是方法的退出,都要回到方法的调用位置 这个位置是由程序计数器来记录的可以作为返回的地址 栈帧中会保存这个计数器的值,异常退出的话,返回的地址是通过异常处理器来确定的。异常信息是不会保存栈帧中嘚。

附加信息

栈帧的信息

本地方法栈 Native栈

和虚拟机栈类似 发挥的作用基本相同。
虚拟机栈是为虚拟机执行字节码服务
本地方法栈是为虚拟机使用到native方法服务

提供支持的JVM一般都会为每个线程创建本地方法栈

C-linkage 模型实现 JNI java native Invocation 这个本地栈就被称为C栈 本地方法栈参数的顺序,返回值和C程序相同的。

Hotspot 直接将虚拟机栈和本地方法栈合二为一。

Java Heap java虚拟机中管理内存的最大一块 堆是被所有线程共享的内存区域。在虚拟机启动的时候创建。
所有的对象实例以及数组都要在堆上分配。
栈帧只能存储指向堆中的对象和数组的引用。 对象只能由垃圾回收器移除。

Java堆是收集器管理的主要区域, GC堆 采用的分代收集的算法。

新生代
经常被分为 Eden空间和Survivor空间
老年代
永久代
1.8 的jdk之后,就不存在永久代了。 被metaspace

在细致的去分
Eden空间
FromSurvivor空间
ToSurvivor 空间

进一步划分的目的是为了更好的回收内存或者更快的分配内存。

分代策略

JVM 内存模型
1.8 的jdk之后,就不存在永久代了。 被metaspace替代了, 采用永久代的方式来实现方法区,永久代 用于放常量 类的信息 静态变量等数据 这些数据和垃圾回收的关系不是很大,
和垃圾回收关系比较大的两个区域分别是新生代和老年代。
JVM 内存模型

内存管理

所有的对象和数组都会在堆内存中进行分配,并且永远不会显示回收,而是由垃圾回收器自动回收。通常新的对象和数组被创建并放入老年代。
Minor 垃圾回收器 回收将发生在新生代,依旧存活的对象将从eden区域移到Survivor区
Major 垃圾回收 一般会导致应用进程的暂停,它将在三个区内移动对象, 仍然存活的对象,被会从新生代移动到老年代。

每次老年代回收时也会进行永久代的回收,它们中任何一个变满了,都会进行回收。

新生代

新生成的对象优先的放在新生代中,存活率很低
新生代中的常规应用进行一次垃圾回收, 可以回收到70%-95% 效率很高

HotSpot 将新生代划分为三个区

一块比较大的 Eden 伊甸
和两块较小的 Survivor 幸存者
默认比例为:8:1:1
为什么用这个比例 原因是因为hotspot 采用的复制算法来回收新生代。
大的对象是不会进入新生代的 而是直接进入老年代

当Eden区没有足够的空间进行分配,虚拟机将发起一次MinorGC
GC开始的时候 对象会存在Eden区 。 这个时候FormSurvivor区和ToSurvivor 是空的,
作为保留区域。
GC进行时 Eden区的所有存活的对象都会被复制到ToSurvivor 区中 而在FormSurvivor区,扔存活的对象,根据他们的年龄值决定去向。年龄值达到年龄的阀值(15 新生代中的对象每熬过一次GC 年龄就+1)的对象会被移动到老年代中,没有达到阀值的对象会被复制的ToSurvivor区,接着FormSurvivor和ToSurvivor 两者交换角色。都要保证ToSurvivor 区在一轮GC后是空的 GC时 当ToSurvivor没有足够的空间存放上一次新生代收集下来的存活对象,需要依赖老年代进行分配担保,将这些对象放入到老年代。

老年代

在新生代经历了多次GC(具体要看虚拟机的配置的情况)后仍然存活了下来的对象就会进入老年代,对象的生命周期长,存活率高,在老年代进行GC的频率比较低,而且回收速度也比较慢。

永久代 PermanentGeneration — metaspace

类的信息 常量 静态变量 JIT即时编译器编译后的代码等数据。这个区域是不进行垃圾回收的。

非堆内存

在逻辑是是JVM的一部分对象,但实际是哪个不在堆上创建
非堆内存:
永久代:
方法区
驻留字符串
代码缓存 Code Cache 用于编译和存储哪些被JIT即时编译器编译的原生代码方法。

栈内存溢出

栈内存溢出情况
1.栈帧过多的时候容易造成溢出
2.栈帧过大的时候容易造成溢出

堆内存的溢出

堆内存诊断

1.命令行工具 jps
查看当前系统中有哪些java进程

2.命令行工具 jmap jmap -heap 进程id
查看堆内存中的占用情况

3.图形界面的工具 多功能的检测工具 可以连续监控。
jconsole

方法区MethodArea

方法区 Methodarea 与java中堆一样的,方法区中的数据可以被每个线程共享,用于存储已经被加载的类的信息 ,常量 静态变量 ,以及JIT即时编译器的代码数据 等等。JVM虚拟机的规范中,把方法区描述为堆的一个逻辑部分,也有一个名字来对应 Non-heap 是为了与java中的堆进行分区。

很多人把方法区称为永久代, 本质上其实是不一样,按照HotSpot设计团队选择把GC分代收集扩展至方法区,永久代知识方法区的实现而已 目前永久代被废除 叫metaspace
JVM 内存模型
Java的虚拟机 对方法区的限制是比较宽松的,除了和java堆一样不需要连续的内存 也可以选择固定的大小或者可以扩展,还可以选择不实现垃圾回收。垃圾回收在方法区中出现的比较少。
方法区 内存回收目标是对常量池的回收和对类型的卸载。

方法区结构

JVM 内存模型

JDK1.6版本 JDK1.7版本 JDK1.8版本

常量池主要分成以下几种:

1.静态常量池 就是.class文件中的常量池, 包含字符串数字这些字面量,还包含 类的方法的信息, 占用了classfile的大部分空间,这个常量池主要用于放两大类的常量:字面量和符号引用,包含了三种类型的常量 类和接口的全限定名 字段名称和描述符,方法名称和描述符。

2.运行时常量池
虚拟机会将各个的class文件中常量池载入到运行时常量池中 即编译期间 生成字面量 符号引用,装载class文件

3.字符串常量池 StringTable
可以把它理解为运行时常量池分出来的一部分内容,加载时 对于class的静态常量池 字符串会被装载到字符串常量池中。

4.整型常量池 Integer 类似StringTable 可以把它理解为运行时常量池分出来的一部分内容 在加载时如果是整型的数据被装到整型的常量池中。

在永久代移出之后,字符串的常量池也不再放在永久代了, 也没有放入到元空间里,而是继续留在了堆空间了Heap-? 方便回收?
运行时常量池搬到了元空间里, 它是装静态变量 字节码的信息 有它的地方才被称为方法区。
JVM 内存模型
所有的线程共享同一个方法区,因为访问方法区的数据和动态链接的进程必须是线程安全的,
方法区存储了每个类的信息,比如:

Classloader 引用
通过this.getClass().getClassLoader()进行获取
通过上图可以看出来,对象中含有字节码和加载器的地址引用。

类型信息
修饰符(public final)
是类还是接口(class,interface)
类的全限定名(Test/ClassStruct.class)
直接父类的全限定名(java/lang/Object.class)
直接父接口的权限定名数组(java/io/Serializable)

运行时常量池
数值型常量
字段引用
方法引用
属性
字段数据
针对每个字段的信息
字段名
类型
修饰符
属性(Attribute)
方法数据
每个方法
方法名
返回值类型
参数类型(按顺序)
修饰符
属性
方法代码
每个方法
字节码
操作数栈大小
局部变量大小
局部变量表
异常表
每个异常处理器
开始点
结束点
异常处理代码的程序计数器(PC)偏移量
被捕获的异常类对应的常量池下标
常量池

直接常量:
1.1CONSTANT_INGETER_INFO整型直接常量池public final int CONST_INT=0;

1.2CONSTANT_String_info字符串直接常量池
public final String CONST_STR=“CONST_STR”;
1.3CONSTANT_DOUBLE_INFO浮点型直接常量池
等等各种基本数据类型基础常量池、方法名、方法描述符、类名、字段名,字段描述符的符号引用

参考博客内容

方法区内存溢出

1.8版本以前 导致永久代的内存溢出
1.8版本之后 导致元空间的内存溢出
1.8jdk 版本之后永久代被移出 由metaspace来替代,元空间是没有上限的,所以这段代码在不调整JVM的虚拟机参数的情况下是不会溢出的。
如果测试永久代溢出 把jdk版本修改成1.6的版本 并且需要修改一下虚拟机的参数

-XX:MaxPermSize=8m;

这里应该出现的error OutofMemoryError 异常信息。

场景: 在生产环境中 这种动态生成class的方法 是非常常用的方式

Spring Aop
动态代理 ----cglib Aop的核心
Mybatis
也应用cglib 比如mapper接口的生成

StringTable 串池

String 字符串这个类 不可变的 底层是字符数组
当String 类 String s=”hello” 是在StringTable当中的“hello”字符串引用给s这个变量

String拼接的底层使用的StringBuilder 通过append的方法进行底层的拼接,再把StringBuilder转换成String类型
JVM 内存模型
为什么要把StringTable放入到堆空间中?
在1.6的JDK中永久代,很少去做垃圾回收的,降低了它的效率 FullGC垃圾回收时才会回收StringTable,但是FullGC的触发条件 老年代空间不足,在永久代空间也不足时才会触发FullGC。
会导致StringTable的回收效率不高。 研发的时候 String变量是经常使用的,所以在1.8版本中 放在堆空间里,提升StringTable的回收效率。

StringTbale的底层数据结构是HashTable,每个元素都是key-value结构,采用了数组+单向链表的实现方式
Sting中的intern() 这个方法 就是将字符串对象放入的StringTable

直接内存

直接内存 DirectMemory 并不是虚拟机运行时数据区的一部分,也不是规范中定义的内存区域,但它也会OOM。
JDK1.4 加入了NIO new Input/Output 引入了一种基于通道 Channel 与缓冲区buffer的IO的方式。可以使用native函数库直接分配堆外的内存,然后通过Java的对象DirectByBuffer对象作为这块内存的引用而进行操作。在某些场景中是可以提高性能的,因为它避免了在java堆和native堆中来回的复制数据。
直接内存分配是不受java的堆的大小控制 ,但他还是内存,只要是内存就有限制,
一般情况下我们使用-Xmx 等一些参数的收,会忽略直接内存。 从而导致动态扩展的时候内存总和大于物理内存限制,而出现OOM。

Object obj=new Object()占用字节?????
我们来分析下,以64位操作系统为例,new Object()占用大小分为两种情况:

未开启指针压缩 占用大小为:8(Mark Word)+8(Class Pointer)=16字节
开启了指针压缩(默认是开启的) 开启指针压缩后,Class Pointer会被压缩为4字节,最终大小为:8(Mark Word)+4(Class Pointer)+4(对齐填充)=16字节
结果到底是不是这个呢?我们来验证一下。首先引入一个pom依赖:

<dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-core</artifactId>
      <version>0.10</version>
    </dependency>

然后新建一个简单的demo:

package com.zwx.jvm;
 
import org.openjdk.jol.info.ClassLayout;
 
public class HeapMemory {
  public static void main(String[] args) {
    Object obj = new Object();
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
  }
}

输出结果如下:
JVM 内存模型
最后的结果是16字节,没有问题,这是因为默认开启了指针压缩,那我们现在把指针压缩关闭之后再去试试。
-XX:+UseCompressedOops 开启指针压缩
-XX:-UseCompressedOops 关闭指针压缩
JVM 内存模型
再次运行,得到如下结果:
JVM 内存模型
可以看到,这时候已经没有了对齐填充部分了,但是占用大小还是16位。

下面我们再来演示一下如果一个对象中带有属性之后的大小。

新建一个类,内部只有一个byte属性:

package com.zwx.jvm;
 
public class MyItem {
  byte i = 0;
}

然后分别在开启指针压缩和关闭指针压缩的场景下分别输出这个类的大小。

package com.zwx.jvm;
 
import org.openjdk.jol.info.ClassLayout;
 
public class HeapMemory {
  public static void main(String[] args) {
    MyItem myItem = new MyItem();
    System.out.println(ClassLayout.parseInstance(myItem).toPrintable());
  }
}

开启指针压缩,占用16字节:
JVM 内存模型
关闭指针压缩,占用24字节:
JVM 内存模型
这个时候就能看出来开启了指针压缩的优势了,如果不断创建大量对象,指针压缩对性能还是有一定优化的。

上一篇:剑指offer-二进制加法


下一篇:2021年11月21日