JVM系列(一)-Java虚拟机运行时数据区域详解

文章目录

    • 一、程序计数器(pc Register)
    • 二、虚拟机栈
    • 三、本地方法栈
    • 四、堆
    • 五、方法区
    • 六、运行时常量池

Hello,大家好:
今天咱们来聊聊Java运行时数据区域,在我们学习Java的时候,很多人将Java的内存区域都笼统的分为堆和栈,只知道new的对象都存储在堆中,基本数据类型保存在栈中。这种想法对于初学者很合适,但是对于有一定Java编程经验的大佬们来说,可就错了。下面我们来看看Java运行时数据区域。
JVM系列(一)-Java虚拟机运行时数据区域详解_第1张图片

一、程序计数器(pc Register)

Java虚拟机规范是这样定义的:Java虚拟机可以同时支持多个线程执行。每个Java虚拟机线程都有自己的pc(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,也就是该线程的当前方法。如果该方法不是native的,则pc寄存器包含当前正在执行的Java虚拟机指令的地址。如果当前线程正在执行的方法是native的,则Java虚拟机的pc寄存器的值是undifine的(注:这儿是因为pc寄存器只关注虚拟机层面的字节码,不会管底层的C或者C++代码)。
我们来详细理解一下程序计数器的作用:

public class ProgramCounter {
     
    public void methodA() {
     
       int i = 1;
       int j = 2;
       int m = i+j;
       System.out.println(m);
    }
}

使用javac命令将上述类编译成class文件,使用javap命令进行反编译:
javap -v ProgramCounter.class
得到的字节码部分截图如下:
JVM系列(一)-Java虚拟机运行时数据区域详解_第2张图片
图中红色圈出的值就是字节码的行号,程序计数器的作用就是要保存这个行号,然后解释器通过程序计数器中的值取出对应的字节码,转为机器指令,交给CPU去执行。大致流程如下:
JVM系列(一)-Java虚拟机运行时数据区域详解_第3张图片
为什么程序计数器是线程私有的?
其实道理很简单:我们可以想象一下,两条线程如果共享一个程序计数器,比如A线程执行字节码指令位置为6,B线程还未执行,那么CPU切换到B线程就直接从6号位置开始执行了,这程序就乱套了。也就是说,将程序计数器设置为私有的目的是,CPU切换后,线程能够知道上次执行的位置,以便让线程继续执行。

二、虚拟机栈

Java虚拟机规范定义如下:
每个Java虚拟机线程都有一个私有的Java虚拟机堆栈,与线程同时创建。用于存储栈帧。Java虚拟机堆栈类似于传统语言(如C)的堆栈:它保存局部变量和一些尚未算好的结果,并在方法调用和返回中发挥了重要的作用。因为除了栈帧的出栈和入栈之外,Java虚拟机堆栈从来不会直接操作,所以帧可能会被堆分配。Java虚拟机堆栈的内存不需要是连续的。

在Java®虚拟机规范的第一版中,Java虚拟机堆栈被称为Java堆栈。

该规范允许Java虚拟机栈的大小固定,也可以根据计算的需要动态扩展和收缩。如果Java虚拟机堆栈的大小是固定的,那么每个Java虚拟机堆栈的大小可以在创建堆栈时独立选择。

Java虚拟机实现可以为程序员或用户提供对Java虚拟机堆栈初始大小的控制,在动态扩展或收缩Java虚拟机堆栈的情况下,还可以提供对最大和最小大小的控制。以下异常情况与Java虚拟机栈关联:

  • 如果在一个线程中计算请求分配的容量大于Java虚拟机允许的最大容量,那么Java虚拟机会抛出一个StackOverflowError。
  • 如果Java虚拟机栈可以动态地扩展,但是在扩展过程中无法申请到足够的内存,或者在创建新的线程,但是没有足够的内存去创建与之对应的虚拟机栈,那么Java虚拟机也会抛出一个OutOfMemoryError。
    针对以上两种情形,我们来分别进行实验区验证和理解它们.
    虚拟机参数:-Xss参数:是指设定每个线程的堆栈大小。
    本文只介绍需要使用的虚拟机参数,在后面的博文中,我将详细的介绍虚拟机参数。
public class StackOverFlow {
     
    private int stackDepth = 1;
    public void method() {
     
        stackDepth += 1;
        method();
    }
    public int getStackDepth() {
     
        return stackDepth;
    }
    public static void main(String[] args) {
     
        StackOverFlow stackOverFlow = new StackOverFlow();
        try {
     
            stackOverFlow.method();
        } catch (Throwable e) {
     
            System.out.println("stack depeth = "+stackOverFlow.stackDepth);
            throw e;
        }
    }
}

JVM系列(一)-Java虚拟机运行时数据区域详解_第4张图片

测试结果:
JVM系列(一)-Java虚拟机运行时数据区域详解_第5张图片
第二种OutOfMemoryError异常我不建议大家去尝试,因为我已经踩坑结束,电脑因为创建太多线程卡死,所以代码就不贴出来了,如果有小伙伴非要去尝试的,可以自行编写代码,思路是,不断的创建线程,然后每个线程在run方法中执行死循环不退出。强烈不建议去尝试。
JVM系列(一)-Java虚拟机运行时数据区域详解_第6张图片

此外虚拟机栈存储的栈帧的内部结构,由于本文篇幅的原因,此处就不再细说,在后续的博文中,博主会仔细详解,感兴趣的小伙伴们请耐心等待几天。目前咱们就只需要知道方法的执行就是一个个栈帧入栈和出栈的过程。

三、本地方法栈

本地方法栈的含义和作用和虚拟机栈相似,只是虚拟机栈是为Java方法服务,而本地方法栈是为本地方法服务的,所以在此就不进行赘述了。

四、堆

Java虚拟机规范这样写道:
Java虚拟机有一个堆,在所有Java虚拟机线程之间共享。堆是运行时数据区域,为所有类实例和数组分配内存。(注意:堆的作用是为所有类实例和数组分配内存这句话是正确的,但是如果说所有类实例和数组都分配在堆上,栈上分配、标量替换优化手段让这句话显得不那么准确。)
堆在虚拟机启动时创建。对象的堆存储由一个自动存储管理系统(称为垃圾收集器)回收;对象永远不会显式地释放。Java虚拟机没有特定类型的自动存储管理系统,可以根据实现者的系统需求选择存储管理技术。堆的大小可以是固定的,也可以根据计算的需要扩展,如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。
Java虚拟机实现可以让程序员或用户控制堆的初始大小,如果可以动态地扩展或收缩堆,还可以控制堆的最大和最小容量。
Java堆可能发生如下异常情况:
如果需要的堆容量超过了自动管理系统所能提供的最大堆容量,Java虚拟机将抛出OutOfMemoryError异常。
至于堆的详细内存结构,我们留在讲解GC算法的时候再进行解析。
下面我们来体验一下堆内存溢出:
使用到的虚拟机参数如下:
-Xms 设置初始 Java 堆大小
-Xmx 设置最大 Java 堆大小
JVM系列(一)-Java虚拟机运行时数据区域详解_第7张图片
-Xms20m -Xmx20m
Java代码如下:

public class HeapOOMDemo {
     
    public static void main(String[] args) {
     
        ArrayList<HeapOOM> arrayList = new ArrayList<HeapOOM>();
        try {
     
            while (true) {
     
                arrayList.add(new HeapOOM(new byte[1024*1024]));
            }
        } catch (Throwable e) {
     
            System.out.println("list size = " + arrayList.size());
            throw e;
        }
    }
}
class HeapOOM{
     
    private byte[] b;
    public HeapOOM(byte[] bytes) {
     
        this.b = bytes;
    }
}

测试结果:
在这里插入图片描述

五、方法区

Java虚拟机的规范定义如下:
方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的正文段。它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区域在虚拟机启动时创建。尽管方法区域在逻辑上是堆的一部分,但简单的实现可以选择不进行垃圾收集或压缩它。本规范没有强制规定用于管理已编译代码的方法区域或策略的位置。方法区域可以是固定大小的,也可以根据计算的需要扩展,如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的。Java虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,而且,对于可以动态扩展和收缩的方法区来说,还应该提供对最大和最小方法区域容量的控制。
以下异常条件与方法区相关:
如果方法区域中的内存无法满足分配请求,Java虚拟机将抛出一个OutOfMemoryError异常。

对于主流的HotSpot虚拟机,在JDK1.8之前采用永久代的思想来实现方法区。这是因为HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

但是这样的设计在实际运行过程中遇到了很多性能问题,比如JVM加载的class的总数,方法的大小等都很难确定,因此对永久代大小的指定难以确定。太小的永久代容易导致永久代内存溢出,太大的永久代则容易导致虚拟机内存紧张。

此处注意一点:可能很多小伙伴们都不清楚方法区和永久代的区别和联系,方法区是Java虚拟机中定义的一个规范,所有的虚拟机都需要实现这个规范,而永久代仅仅是HotSpot虚拟机对方法区的一个实现而已,对于其他的很多虚拟机,比如BEA的JRockit就不存在永久代的概念。

在Oracle收购了BEA获得了JRockit的使用权后,为了HotSpot也可以具备JRockit许多优秀的特征,将二者进行融合,但是二者对方法区的实现差异使得融合过程困难重重。考虑到HotSpot的未来,HotSpot设计团队从JDK1.6之后就开始逐步废弃永久代,采用本地内存来实现方法区,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,(字符串常量池移动到了Java堆的新生代和老年代中),而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
在OpenJDK的官网我们也能找到关于移除永久代动机,以及永久代中内容的去向。
JVM系列(一)-Java虚拟机运行时数据区域详解_第8张图片

六、运行时常量池

运行时常量池(Runtime Constant Pool)是class文件中每一个类或接口的常量池表的运行时表示形式,它包括了若干种不同的常量,从编译期可知的数值字面量到必须在运行期解析后才能获得的方法或字段引用。运行时常量池类似于传统语言中的符号表(symbol table),不过它存储数据的范围比通常意义上的符号表要更为广泛。
此处要注意的是:一个class文件就对应一个运行时常量池。不要将它与字符串常量池混淆。它们二者的联系,在分享类加载阶段再进行讲解。

每一个运行时常量池都在Java虚拟机的方法区中分配(此处即是说运行时常量池是方法区的一部分),在加载类和接口到虚拟机后,就创建对应的运行时常量池。在创建类和接口的运行时常量池时,可能会发生如下异常情况:
当创建类或接口时,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,那么Java虚拟机将会抛出一-个OutOfMemoryError异常。
注意:每一个类或接口的常量池表主要存放字面量和符号引用。字面量比较接近于常量的概念,比如文本字符串,或者被final修饰的常量,符号引用细节我们在分享字节码文件结构时再进行讲解。

通过上文的对方法区的分析,我们知道字符串常量池在JDK1.7之前是存放在永久代的,也就是HotSpot虚拟机实现的方法区中,那么我们分别通过JDK1.6和JDK1.8来运行相同的程序看看会得到怎样的效果。

前置知识介绍:

  1. String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用。
  2. -XX:PermSize :设置初始永久代初始容量。-XX:MaxPermSize:设置永久代最大容量。
public class MethodAreaOOM {
     
    public static void main(String[] args) throws Throwable {
     
        try{
     
            ArrayList<String> arrayList = new ArrayList<String>();
            for (int i = 0; i < Integer.MAX_VALUE; i++) {
     
                arrayList.add(String.valueOf(i).intern());
            }
        }catch (Throwable e){
     
            throw e;
        }
    }
}

虚拟机参数:-XX:PermSize=4M -XX:MaxPermSize=4M
设置方法同上文,使用JDK1.6运行得到的效果如下:
在这里插入图片描述
使用JDK1.8运行却很久不会抛出异常,但是修改虚拟及参数为:-Xms6m -Xmx6mJDK1.8运行却很快抛出堆移除异常:
JVM系列(一)-Java虚拟机运行时数据区域详解_第9张图片
在上文中就提过,在JDK1.7的时候,HotSpot的开发团队就已经将字符串常量池移除永久代,而移到了Java堆中,请大家不要把永久代认为是Java堆的一部分,仅仅是因为HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,从而让很多人以为永久代是堆的一部分,记住,它是HotSpot虚拟机对方法区的一个实现。
好啦,Java虚拟机运行时内存区域就讲解到此啦,本文若有不足之处,还请大家多多指正。如果这篇文章帮助到您,请您点赞支持。若有疑问,请小伙伴们私信我,或者评论区留言。谢谢大家。

你可能感兴趣的:(JVM,jvm,java)