写点什么

Jvm 专讲之内存结构

作者:java易二三
  • 2023-08-24
    湖南
  • 本文字数:3978 字

    阅读完需:约 13 分钟

Jvm 内存模型

1.JDK 体系架构



从上面 JDK 体系架构图可以看出来:

  • JDK 包含了 java 的常用工具开发包和 jre

  • jre 是 java 运行时环境,包括了 jvm 和 Java 核心类库

  • jvm 负责把字节码文件解释为操作系统可以识别的机器码

2.Java 语言的跨平台性



我们的 java 文件经过编译形成 class 文件后,为什么可以放在装有 jdk 的操作系统上就可以运行呢?这是因为针对不同的操作系统有不同的 jvm 实现,而 jvm 负责把我们编译好的字节码文件翻译为操作系统可以识别的机器码并运行。

3.Jvm 的内存模型

声明一个 Math 类

java复制代码public class Math {    public static final int initData = 10;    public static User user = new User();    public static String s = new String();    public int compute(){        int a = 1;        int b = 2;        int c = (a + b) *10;        return c;    }    public static void main(String[] args) {        Math math = new Math();        math.compute();        System.out.println(s.getClass().getClassLoader());    }}
复制代码



Jvm 的内存模型如上图,主要分为方法区,堆空间,虚拟机栈,本地方法栈,程序计数器,接下来为大家详细描述每一块区域:

3.1 虚拟机栈

栈空间是我们线程私有的,当我们每次创建一个新的线程的时候,jvm 都会为它分配一块私有的栈内存,当线程执行方法的时候,每执行一个方法会形成一个栈帧,最先进入的方法被压到栈底,遵循先进后出的原则。

在栈帧中都有什么呢?它主要存放的是局部变量表,操作数栈,动态链接,方法出口,我们先把 math.class 文件生成反汇编文件来了解这些内存区域

javascript复制代码javap -c Math.class > Math.txt
复制代码

经过这个指令,我们的 Math 类生成的反汇编文件 compute 方法内容如下:



主要看我们的 compute 方法这里的指令,iconst_1 是把我们的 1 放在操作数栈空间中,istore_1 是把 1 这个数存入局部变量表索引 1 的这个位置,也就是 a=1,iconst_2 是把 2 压入操作数栈,istore_2 把 2 存入局部变量表索引 2 的位置,也就是 b=2,iload_1 是从局部变量表索引 1 位置取出操作数,iload_2 从局部变量索引 2 位置取出操作数,iadd 就是把两个数相加,bipush 10 将一个 8 位带符号整数 10 压入操作数栈,imul 是进行一次乘法运算,istore_3 是将 i 相乘的结果存入局部变量表索引 3 的位置,

iload_3 从局部变量索引 3 的位置中中装载取值,ireturn 将结果进行返回

iconst_1 将 int 类型常量 1 压入操作数栈

istore_1 将 int 类型值存入局部变量 1

iconst_2 将 int 类型常量 2 压入操作数栈

iload_1 从局部变量 1 中装载 int 类型值

iload_2 从局部变量 2 中装载 int 类型值

iadd 执行 int 类型的加法

bipush 将一个 8 位带符号整数压入栈

imul 执行 int 类型的乘法

istore_3 将 int 类型值存入局部变量索引 3 位置

ireturn 从方法中返回 int 类型的数据

3.2 本地方法栈

本地方法栈和虚拟机栈一样,是当线程执行本地方法的时候虚拟机开辟的一片空间,只不过这里的方法是本地方法,方法实现是通过 c 或者 c++实现的。

3.3 程序计数器

程序计数器也是线程私有的,主要目的是当线程发生上下文切换的时候,记录一下我们代码的运行位置,字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

3.4 方法区

方法区在 JDK1.8 和 JDK1.7 的概念是不同的,在 JDK1.7 中我们的方法区被称作永久代,其内存区域是由 JVM 提供的,而 JDK1.8 中被称作元空间,它使用的是我们物理机的直接内存,在方法区主要存储的是我们的常量,静态变量,类信息

3.5 堆

堆在虚拟机启动时创建,是 Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意 Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做 GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

3.6 常量池

3.6.1 Class 常量池和运行时常量池

Class 常量池可以理解为是 Class 文件中的资源仓库。 Class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table) ,用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)

字面量:就是等号右边的数字、字符串,如:int a=1 这里的 1 为就是字面量

符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的,主要包括了以下三类常量:

  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

    比如 a=1 中 a 就是一个符号引用,比如一个类的全路径 com.lx.Math,包含类中的方法比如 main、test 等,()是一种 UTF8 格式的描述符,这些都是符号引用。

    这些常量池现在是静态信息,只有到运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接了。例如,main()这个符号引用在运行时就会被转变为 main()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。

3.6.2 字符串常量池

3.6.2.1 字符串常量池的设计思想

  1. 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能

  2. JVM 为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化

    为字符串开辟一个字符串常量池,类似于缓存区

    创建字符串常量时,首先查询字符串常量池是否存在该字符串

    存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

3.6.2.2 字符串常量池位置

Jdk1.6 及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池

Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里

Jdk1.8 及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里

如何证明,jdk1.6 之后我们的字符串常量池是在堆中的,请看一下程序和结果

arduino复制代码//-Xms6M -Xmx6M public class StringConstantPoolOOM {    public static void main(String[] args) {        ArrayList<String> list = new ArrayList<String>();        for (int i = 0; i < 10000000; i++) {            String str = String.valueOf(i).intern();            list.add(str);        }    }}
复制代码

运行结果:堆空间不足



接下来我们通过案例一起更进一步分析一下字符串常量池

三种字符串操作:

ini复制代码String s = "luoxue";  // s指向常量池中的引用
复制代码

这种方式创建的字符串对象,只会在常量池中,因为有"luoxue"这个字面量,创建对象 s 的时候,JVM 会先去常量池中通过 equals(key) 方法,判断是否有相同的对象,如果有,则直接返回该对象在常量池中的引用,如果没有,则会在常量池中创建一个新对象,再返回引用。

javascript复制代码String s1 = new String("luoxue");  // s1指向内存中的对象引用
复制代码

这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。解析:因为有"qianyue"这个字面量,所以会先检查字符串常量池中是否存在字符串"qianyue",不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象"qianyue",存在的话,就直接去堆内存中创建一个字符串对象"qianyue",最后,将内存中的引用返回。

ini复制代码        String s1 = new String("luoxue"); //s1->指向堆中的对象                String s2 = s1.intern();//s2->常量池中的对象        System.out.println(s1);//luoxue        System.out.println(s2);//luoxue        System.out.println(s1==s2);//false        
复制代码

String 中的 intern 方法是一个 native 的方法,当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(用 equals(oject)方法确定),则返回池中的字符串。否则,将 intern 返回的引用指向当前字符串 s1

ini复制代码        //堆中创建了一个字符串hello,但是没有在字符串常量池中创建,s1指向堆中hello对象的地址        String s1 = new String("he")+new String("llo");        //发现常量池没有,直接返回堆中hello的地址        String s2 = s1.intern();        System.out.println(s1);//hello        System.out.println(s2);//hello        System.out.println(s1==s2);//true
复制代码

4.JVM 内存参数设置



Spring Boot 程序的 JVM 参数设置格式(Tomcat 启动直接加在 bin 目录下 catalina.sh 文件里):

ini复制代码java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eureka‐server.jar
复制代码

关于元空间的 JVM 参数有两个:-XX:MetaspaceSize=N 和 -XX:MaxMetaspaceSize=N

  • -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。

  • -XX:MetaspaceSize: 指定元空间触发 Fullgc 的初始阈值(元空间无固定初始大小), 以字节为单位,默认是 21M,达到该值就会触发 full gc 进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期 jdk 版本的-XX:PermSize 参数意思不一样,- XX:PermSize 代表永久代的初始容量。

由于调整元空间的大小需要 Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量 Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在 JVM 参数中将 MetaspaceSize 和 MaxMetaspaceSize 设置成一样的值,并设置得比初始值要大, 对于 8G 物理内存的机器来说,一般会将这两个值都设置为 256M。

用户头像

java易二三

关注

还未添加个人签名 2021-11-23 加入

还未添加个人简介

评论

发布
暂无评论
Jvm专讲之内存结构_Java_java易二三_InfoQ写作社区