JVM 系列(二)、 JVM 内存结构(下)方法区、栈


1. 方法区

方法区(Method Area)与Java堆一样,是各种线程共享的内存区域,它用于存储已被虚拟机加载的类的信息常量静态变量即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开。

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的存储和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是较少出现的。但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

所有的对象在实例化后的整个运行周期内,都被存放在堆内存中。堆内存又被分成不同的部分,Eden、Survivor Sapce、Old Generation Space。

方法的执行都是伴随着线程的。原始类型的本地变量以及引用都存放在线程栈中。而引用的对象比如String,都存在堆中,为了更好的解释这段话,我们可以看一个例子。


package com.hnbian. JVM ;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.logging.Logger;

public class HelloWorld {
    private static Logger logger = Logger.getLogger(HelloWorld.class.getName());

    public static void main(String[] args) {
        HelloWorld hm = new HelloWorld();
        hm.sayHello("你好");
    }
    public void sayHello(String message){
        SimpleDateFormat formatter = new SimpleDateFormat("YYYY-MM-dd");
        String today =formatter.format(new Date());
        logger.info(today + ":" +message);

    }
}

上面这段程序的数据在内存中的存放如下:

1.1 方法区与永久代的关系?

方法区在虚拟机启动的时候创建,方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。在JDK1.8后,永久代被移除,永久代中的信息存放在了元空间。

方法区中存放着类信息,常量静态变量等。但是在这种情况下有一个问题,如果类的元数据大小超过了应用的可分配内存,那么就会出现内存溢出问题。

1.2 元空间

元数据空间(metaspace),专门用来存元数据的,它是jdk8里特有的数据结构用来替代perm。
元空间中有两大部分组成:

  • Klass Metaspace
  • NoKlass Metaspace

Klass Metaspace就是用来存klass的,klass是我们熟知的class文件在 JVM 里的运行时数据结构,不过有点要提的是我们看到的类似A.class其实是存在heap里的,是java.lang.Class的一个对象实例。这块内存是紧接着Heap的,和我们之前的 perm 一样,这块内存大小可通过 -XX:CompressedClassSpaceSize 参数来控制,这个参数默认是1G,但是这块内存也可以没有,假如没有开启压缩指针就不会有这块内存,这种情况下klass都会存在NoKlass Metaspace里,另外如果我们把-Xmx设置大于32G的话,其实也是没有这块内存的,因为会这么大内存会关闭压缩指针开关。还有就是这块内存最多只会存在一块。

NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool(常量池) 等,这块内存是由多块内存组合起来的,所以可以认为是不连续的内存块组成的。这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容,上面已经提到了对应场景。

Klass Metaspace和NoKlass Mestaspace都是所有classloader共享的,所以类加载器们要分配内存,但是每个类加载器都有一个SpaceManager,来管理属于这个类加载的内存小块。如果Klass Metaspace用完了,那就会OOM了,不过一般情况下不会,NoKlass Mestaspace是由一块块内存慢慢组合起来的,在没有达到限制条件的情况下,会不断加长这条链,让它可以持续工作。

元空间更多内容可以查看文档: JVM 源码分析之Metaspace解密

1.2.1 永久代与元空间的区别

两者最大的区别是永久代在虚拟机中,而元空间使用本地内存。因此,默认情况下元空间是不受虚拟机内存大小的限制的,只受到系统内存的限制。

1.2.2 元空间替换永久代的原因

  1. 字符串存在永久代中,容易出现性能问题和内存溢出。
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(因为堆空间有限,此消彼长)。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。Oracle 可能会将HotSpot 与 JRockit 合二为一。

1.2.3 相关参数

参数 说明
-XX:MetaspaceSize 初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize 最大空间,默认是没有限制的。
-XX:MinMetaspaceFreeRatio 在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio 在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

1.3 运行时常量池

运行时常量池是方法区的一部分。用于存放编译期生成的各种字面量和符号引用

当 Java 文件被编译成 class 文件之后,也就是生成 class 常量池,那么运行时常量池又是什么时候产生的呢?
JVM 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析(resolve)三个阶段。而当类加载到内存中后, JVM 就会将 class 文件的常量的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。class 常量池中存的是字面量和符号引用,也就是说它们存的并不是对象的实例,而是对象的符号引用值。而经过解析之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

运行时常量池是方法区的一部分,当运行时常量池无法申请到内存时,将抛出OutOfMemoryError异常。

/**
 * @Author haonan.bian
 * @Description 运行时常量池
 * @Date 2020-01-08 11:35
 **/
public class RunTimeConstantPool {
    public static void main(String[] args) {
        String a = "12";// 在方法区中 StringTable  HashSet 结构
        String b = "12";// 在方法区中 指向 StringTable 中的 key
        System.out.println( a == b); // true
        String c = new String("12"); // 保存在堆中
        System.out.println( a == c); // false
        System.out.println( a == c.intern()); // true
        // c.intern 将c使用的内存移动到方法区的运行时常量池里面
    }
}

1.3.1 运行时常量池与Class文件常量池的全局字符串常量池区别?

  1. 全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
  2. class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
  3. 运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

2. 栈(Stack)

2.1 Java虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,它的生命周期与线程相同。虚拟机栈是用于方法执行的一块内存区域:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)。

引用类型不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置。

其中 64 位长度的 long 和 double 类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间完全是确定在,在方法运行期间不会改变局部变量表的大小。
 JVM  栈示意图

public class JavaStack {

    public static void main(String[] args) {
        final String cons = "final String";// 指向运行时常量池的引用
        int a = 10;// 局部变量表
        int b = 5;// 局部变量表
        int sum = a*b; // 操作数栈
        method_b();

        String c = method_c(); // 方法返回地址
        System.out.println(c);

        System.out.println("mechod main");
        B b1 = new B(); // 动态链接
        System.out.println(b1.b);
    }
    public static String method_c(){
        return "c";
    }
    public static void method_a(){
        System.out.println("mechod a");
    }
    public static void method_b(){
        method_a();
        System.out.println("mechod b");
    }

    public static class B{
        int b = 10;
    }
}

在 Java 虚拟机规范中,对这个区域规定了两种异常情况:

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常

  • 模拟栈溢出
public class JavaStack {
    public static void main(String[] args) {
       method_a();     
    }
    //递归 模拟栈溢出 
    public static void method_a(){
        method_a();
        System.out.println("mechod a");
    }
}
//报错信息
Exception in thread "main" java.lang.StackOverflowError
    at com.hnbian.runtime.JavaStack.method_a(JavaStack.java:25)
    at com.hnbian.runtime.JavaStack.method_a(JavaStack.java:25)
    at com.hnbian.runtime.JavaStack.method_a(JavaStack.java:25)
    at com.hnbian.runtime.JavaStack.method_a(JavaStack.java:25)
    at com.hnbian.runtime.JavaStack.method_a(JavaStack.java:25)
......

如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可以动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

2.2 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。

下面代码中会调用到native方法。

public class NativeMethodStacks {
    public static void main(String[] args) {
        method_b();
        //乐观锁
        AtomicInteger atomicInteger = new AtomicInteger(1);
        //当多个线程访问时只有一个线程能够更新成功
        atomicInteger.compareAndSet(1,2);
        /**
         *  public final boolean compareAndSet(int expect, int update) {
         *         return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
         *     }
         *  会调用native方法
         *  public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
         *
         */

        System.out.println("main");
    }
    public static void method_a(){
        System.out.println("mechod_a");
    }

    public static void method_b(){
        method_a();
        System.out.println("mechod_b");
    }
}

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

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

2.3 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核心处理器来说是一个核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”内存。

程序计数器图示

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址,如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

此内存区域是唯一一个在Java虚拟机中没有规定任何OutOfMemoryError情况的区域。

查看编译后的class字节码对应的行号

先定义User一个类里面有两个方法

public class User {
    private int id;

    public User() {
    }

    public int getId() {
        return this.id;
    }

    public void setId(int Id) {
        this.id = this.id;
    }
}
hnbiandeMacBook-Pro:runtime hnbian$ javap -l User.class
Compiled from "User.java"
public class com.hnbian.runtime.User {
  public com.hnbian.runtime.User();
    LineNumberTable: //该属性用于调试,它将源码和字节码匹配类起来。
      line 3: 0 // 这句话代表该函数字节码 0 哪一个操作对应代码的第 7 行。
    LocalVariableTable: //该属性用于调试,它用于描述函数执行时的变量信息。
      Start  Length  Slot  Name   Signature
          0       5     0  this   Lcom/hnbian/runtime/User;
//start = 0 :表示从code[] 第0 个字节开始,
//length = 5:表示从start = 0 到start+5个字节
//Slot = 0 表示这个变量在本地变量表locals中第一个元素
//Signature =Lcom/hnbian/runtime/User;:表示该行代码所在的位置


  public int getId();
    LineNumberTable:
      line 6: 0
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       5     0  this   Lcom/hnbian/runtime/User;

  public void setId(int);
    LineNumberTable:
      line 9: 0
      line 10: 8
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       9     0  this   Lcom/hnbian/runtime/User;
          0       9     1    Id   I
//Length = 9 表示到从 start=0 到 start+9 个字节
// (不包含第 9 个字节,因为 code 数组一共就 8 个字节)
}

3、常见的内存溢出

对内存结构清晰的认识同样可以帮助理解不同OutOfMemoryErrors:

  • Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space
    原因:对象不能被分配到堆内存中

  • Exception in thread “main”: java.lang.OutOfMemoryError: PermGen space
    原因:类或者方法不能被加载到持久代。它可能出现在一个程序加载很多类的时候,比如引用了很多第三方的库;

  • Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    原因:创建的数组大于堆内存的空间

  • Exception in thread “main”: java.lang.OutOfMemoryError: request bytes for . Out of swap space?
    原因:分配本地分配失败。JNI、本地库或者Java虚拟机都会从本地堆中分配内存空间。

  • Exception in thread “main”: java.lang.OutOfMemoryError: (Native method)
    原因:同样是本地方法内存分配失败,只不过是JNI或者本地方法或者Java虚拟机发现

扩展阅读:
Class文件中的常量池详解(上)
JVM 字符串常量池同运行时常量池关系理解
JVM -String常量池与运行时常量池
Java中几种常量池的区分


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
 上一篇
JVM系列(三)、GC算法 JVM系列(三)、GC算法
1. 介绍垃圾收集器( Garbage Collection ) 通常被称为 “GC”,它诞生于1960年 MIT 的 Lisp 语言,经过半个多世纪,目前已经十分成熟了。Jvm中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,
2020-01-10
下一篇 
spark-sql Required-field-'filesAdded'-is-unset spark-sql Required-field-'filesAdded'-is-unset
1. 背景使用sparkSQL计算数据向一个已经存在数据的分区中写数据报错 使用版本: Spark2 2.3.2 Hive 3.1.0 错误信息如下: org.apache.spark.sql.AnalysisException: or
2019-12-26
  目录