1、Java运行时数据区域

Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。

  1. 程序计数器
  2. Java虚拟机栈
  3. 本地方法栈
  4. Java堆
  5. 方法区
  6. 运行时常量池
  7. 直接内存
    在这里插入图片描述

示例类

public class Math {
    
    public static final int initData = 666;
    public static User user = new User();

    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();
        // compute是在常量池中的
        math.compute();
        System.out.println(111);
    }

}

当一个类被编译成class文件后,会通过类加载子系统(C++实现)把字节码转入到运行时数据区中,字节码执行引擎(C++实现)会加载执行程序代码
在这里插入图片描述

1.1、程序计数器

程序计数器(Program Counter Register)是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,就是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等都是依托程序计数器完成的。也就是说处理器在时间片切换时,为了线程切换后能恢复到(找到)正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间互不影响,独立存储,线程私有的内存

白话理解:

  • 程序正在运行或马上运行代码内存的位置(行号) ,代码运行时,字节码执行引擎会跟随运行代码行修改计数器的值。
  • 线程独有

1.2 Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,生命周期与线程相同;

白话理解:

  • 栈(Java虚拟机栈):例如示例中,线程运行主方法,java虚拟机会在线程栈(Java虚拟机栈)内分配一个独立的内存空间,用来存放线程运行过程中局部变量的内存空间
  • 线程独有

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法在执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表,操作数帧,动态链接,方法出口等信息。即使是递归调用,也是在栈中继续分配栈帧

一个方法的调用到执行结束,就对应着一个栈帧在虚拟机中从入栈到出栈的过程,先进后出,和程序调用过程相符,例如main方法运行时,main方法先入栈,main方法调用compute方法,伴随着compute方法入栈,compute运行结束,compute出栈,main结束,main出栈。

执行javap -c Math.class,对代码进行反汇编将字节码转为指令码

  public int compute();
    Code:
       # 将int类型常量1压入操作数栈,数字0一般指this
       0: iconst_1
       # 将int类型值存入局部变量1
       1: istore_1
       # 将int类型常量2压入栈
       2: iconst_2
       # 将int类型值存入局部变量2
       3: istore_2
       # 从局部变量1中装载int类型值
       4: iload_1
       # 从局部变量2中装载int类型值
       5: iload_2
       # 执行int类型的加法
       6: iadd
       # 将一个8位带符号整数压入栈
       7: bipush        10
       # 执行int类型的乘法,跳过8 是因为,10也会占内存位置
       9: imul 
       # 将int类型值存入局部变量3
      10: istore_3
      # 从局部变量3中装载int类型值
      11: iload_3
      # 从方法中返回int类型的数据 
      12: ireturn

查看compute方法的指令码可知:

  • 变量 int a = 1; 的赋值过程
    • 1先压入操作数栈
    • 1出栈赋值给局部变量表中局部变量下标1(或索引)的变量a(0一般指this)

局部变量表

  • 局部变量表存放的是编译期可知的各种Java虚拟机基本类型(boolean、byte、char、short、int、float、long、double)、对象的引用(reference类型,指的是对象地址的引用或句柄或其他于此对象相关的地址)、returnAddress(指向了一条字节码指令的地址)。
  • 这个数据类型在局部变量表中的存储空间以局部变量槽(Slot)表示,64位的长度(long,double)占用2个变量槽,其余都是1个。
  • 局部变量表所需的内存空间,在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部面量表的大小(大小指的是变量槽的数量)
  • 虚拟机一个变量槽占用的比特(例如32比特,64比特或者更大),是由具体的虚拟机决定的
  • 例子中:main方法中math在局部变量表中对应的值是堆中math地址的引用

操作数栈

  • 程序在运行过程中,操作数在运算的过程中,做操作的一块临时中转的内存空间。个人理解:如下代码在运算过程中,从局部变量表中分别取出1和2压入操作数栈中,分别出栈1和2 ,求和后(CPU操作),在压入操作数栈,10压入操作数栈,出栈 3和10,得到结果(CPU操作)后再入栈,30出栈,赋值给局部变量表中变量c
    int a = 1;
    int b = 2;
    int c = (a + b) * 10;
    

动态链接

  • 程序运行过程中,将符号引用转化成直接引用, 直接引用地址存放到动态链接中
  • 例如上面例子中,main方法在运行时,compute方法是不会被加载的,只会先加载静态方法。程序在运行到compute方法时,会解析这个符号 (compute),找到这个符号在内存中的位置,存放到动态链接中

方法出口

  • 记录方法运行的位置等信息
  • 例如compute方法调用结束后,要在回到main中继续执行System.out.println(111),所以需要记录执行方法的位置等信息
    在这里插入图片描述
    虚拟机栈中会引用堆中对象的地址

《Java虚拟机规范》中这个内存区域规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError
    • 每次方法调用都会有一个栈帧压入虚拟机栈。操作系统给JVM分配的内存是有限的,JVM分配给“虚拟机栈”的内存是有限的。 如果方法调用过多,导致虚拟机栈满了就会溢出。 这里栈深度就是指栈帧的数量
  • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存空间会抛出OutOfMemoryError
  • HotSpot虚拟机栈容量不支持动态扩展,所以只要线程申请到栈空间就不会出现OOM,如果申请失败则会出现OOM异常

1.3、本地方法栈

本地方法栈(Native Method Stack):本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。

  • 线程独有

  • Native Method就是一个java调用非java代码的接口。该方法的实现由非java语言实现,比如C或C++。

    • 例如Object类中
      在这里插入图片描述

1.4、Java堆

Java堆(Java Heap)是虚拟机所管理内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例。Java堆是垃圾回收器管理的内存区域也称为GC堆(字节码执行引擎会 进行垃圾收集)。

一般new出来的对象都放在堆中,一般存放在Eden区

  • 线程共享

Java堆可以处于物理上不连续的内存区域,但在逻辑上应该被视为连续的,但对于大对象(典型的如数组对象),多数虚拟机实现处于简单实现,存储高效的考虑,很可能会要求连续的内存空间
在这里插入图片描述
在这里插入图片描述
Java虚拟机的堆内存分为新生代、老年代、永久代、Eden、Survivor,在当前的HotSpot中上述提法就有很多需要商榷的地方。Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存

JDK8之后,无永久代,改为元空间(Metaspace),使用的是直接内存

Java堆既可以被实现成固定大小,也可以是可扩展的,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 设定),如果Java堆中没有内存完成实例分配,并且堆也无法再扩展,Java虚拟机将会抛出OutOfMemoryError异常

1.5、方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。

  • 线程共享

方法区会引用堆中对象的地址

// 静态对象会存在方法区中,而new出来的对象是放在方法区中的,方法区中存的是地址的引用
public static User user = new User();

JDK 8 HotSpot完全放弃了永久代的概念,改用本地内存中实现的元空间(Meta-space)来代替

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或可扩展外,甚至可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确比较少出现,,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标最主要是针对常量池的回收和对类型的卸载,但是回收效果比较难令人满意,尤其是类型的卸载。

方法区无法满足新的内存分配需求时将会抛出OutOfMemoryError异常

1.6、运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table)用于存放编译期生成的各种字面量与符号引用,在类加载后存放到方法区运行常量池中还会把由符号引用翻译出来的直接引用也存储在运行时常量池中

  • 字面量:等号右边的八种基本类型的值、字符串值、声明为final的常量的值

    //a为常量,10为字面量
    final int a = 10; 
    // b 为变量,hello world!为字面量
    string b = "hello world!"; 
    
  • 符号引用:可以是任意类型的字面量。只要能无歧义的定位到目标。在编译期间由于暂时不知道类的直接引用,因此先使用符号引用代替。最终还是会转换为直接引用访问目标。符号引用就是某个变量,在编译的时候,无法确定其内存地址。

    String str = "Hello World!"
    // str在编译的时候就会编译为符号引用。
    System.out.println(str);
    
  • 直接引用:程序运行时可以定位到引用的东西(类, 对象, 变量或者方法等)的地址

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

  • intren方法:通俗的讲,是将字符串放入常量池中。

    /**
    * 表达式右边是纯字符串常量,则存放在常量池中
    * 表达式右边存在字符串引用,则存放在堆中
    */
    public class test {
        public static void main(String[] args) {
            String s1="aaa";
            String s2="bbb";
            String s3="aaabbb";
            String s4=s1+s2;
            String s5="aaa"+"bbb";
            String s6=new String("aaabbb");
            // false
            System.out.println(s3==s4);
            // true
            System.out.println(s3==s4.intern());
            // true
            System.out.println(s3==s5);
            // false
            System.out.println(s3==s6);
            // true
            System.out.println(s3==s6.intern());
        }
    }
    

    说明:s1,s2,s3,s5均存放在常量池中,s4,s6存放在堆中。

当常量池无法再申请到内存时会抛出OutOfMemoryError异常

1.7、直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但是这部分内存也被频繁的使用,也可能导致OutOfMemoryError异常

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、及SWAP区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理 上的和操作系统 级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

参考《深入理解Java虚拟机》-周志明

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐