JVM内存空间详解&实例分析

1、介绍

    Java不需要开发人员来显示分配内存,而是由JVM来自动管理内存的分配和回收(垃圾回收GC),但由此带来的负面影响有可能是在不知不觉中浪费了很多内存或者造成内存泄漏。因此,作为开发人员而言,不能因为JVM自动内存管理机制就不掌握内存分配和回收的知识了。

2、内存空间

    分析JVM的内存结构,主要是分析JVM运行时数据区,具体划分成5个部分:方法区、堆、虚拟机栈、本地方法栈和程序计数器。

2.1 方法区

    方法区是各个线程共享的内存区域,可以被描述为堆的一个逻辑部分。

    方法区主要存放了需要加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的field信息、类中的方法信息、即时编译器编译后的代码等数据。在HotSpot中采用永久代的方法来实现方法区,而其他虚拟机(比如IBM J9等)是不存在永久代的。

    Java7中已经将运行时常量池从永久代移除,在堆中专门开辟了一块区域存放运行时常量池。

    Java8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域,这个区域叫做元空间。

什么是元空间?

    元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的区别在于:元空间并不在虚拟机中而是直接使用本地内存。因此在默认情况下,元空间的大小仅受本地内存的限制,但是可以通过以下参数来指定元空间的大小:

-XX:MetaspaceSize,初始空间大小。-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

2.1.1运行时常量池

    运行时常量池是方法区的一部分,也是线程共享的。

    Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面常量和符号引用,这部分内容在类加载后存放到方法区的常量池中。

    static修饰的静态变量也存放在方法区中,但是不再常量池中(不能修饰局部变量),不能再一个方法内部定义static变量(final可以),只能定义为成员变量。

 

2.2 虚拟机栈

    Java虚拟机栈是线程私有的。虚拟机栈描述的是Java方法执行时的内存模型,即:每个方法被执行的时候都会同时创建一个栈帧,用于存放局部变量表、操作数栈、动态链接、返回地址等信息。每个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    Java虚拟机栈不存在垃圾回收的问题,只要线程一结束该栈就释放,其生命周期和线程相同

    Java虚拟机规范中对该区域规定了两种异常情况:

    1)如果线程请求的深度大于虚拟机所允许的深度,栈溢出。比如递归的时候,可能会抛出StackOverflowError异常。

    2)虚拟机栈动态拓展无法申请到足够的内存时,会抛出OutOfMemoryError异常。

注意:

    当方法传递参数时实际上是一个方法将自己栈帧中局部变量表的副本传递给另一个方法栈帧中的局部变量表(注意是副本,不是其本身),不管数据类型是什么(基本类型、引用类型)。

2.2.0栈帧

    栈帧是用于支持Java虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存,不会受到程序运行期间变量数据的影响,而仅仅取决于具体虚拟机的实现。

2.2.1局部变量表

    局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

    这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。

    局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(这个大小是指变量槽的数量,至于每个变量槽真正占用多少个bit,比如32bit、64bit,这完全是由具体的虚拟机自行决定的事情)。

    JVM通过索引定位的方式使用局部变量表,之前我们知道,局部变量表存放的是方法参数和局部变量。当调用非static方法时,局部变量表中第0位索引默认是用于传递方法所属对象实例的引用,即“this”关键字指向的对象。

    Slot复用:

    为了节省栈帧空间,局部变量表中的Slot是可以重用的。当离开了某些变量的作用域之后,这些变量对应的Slot就可以交给其他变量使用,比如:

public void test(boolean flag){
    if(flag){
        int a = 1;
    }
    
    int b = 2;
}

    当虚拟机运行test方法时,就会创建一个栈帧,并压入到当前线程的栈中。当运行到int a=1时,JVM会在当前栈帧的局部变量表中创建一个Slot存储变量a,当运行到int b=2时,此时已经超出变量a的作用域了,a失效,变量a占用的Slot就可以交给b来使用,这就是Slot复用。

2.2.2操作数栈

    操作数栈的元素可以是任意Java数据类型,和局部变量表不同的是,操作数栈不是通过索引来访问的,而是通过标准栈的操作——压栈和出栈来访问。方法开始执行时,操作数栈是空的,在方法执行过程中,通过字节码指令对操作数栈进行入栈和出栈的操作。通常在进行算数运算的时候是通过操作数栈进行的,又或者在调用其他方法的时候通过操作数栈进行参数传递。操作数栈可以理解为栈帧中用于计算的临时数据存储区。

2.2.3动态链接

    每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用中的动态链接。符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成静态解析。另一部分在每一次运行运行期间转化为直接引用,这部分称为动态链接

2.2.4返回地址

    当一个方法开始执行后,只有两种方式可以退出这个方法。

    第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。

    另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。

2.3 本地方法栈

    本地方法栈和虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用本地方法(native)服务。native方法是JDK中直接与操作系统交互,以本地语言(C或C++)实现的方法。

    本地方法栈是线程私有的,有的Java虚拟机比如HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。

    Java虚拟机规范中对该区域规定了两种异常情况:

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

    2)虚拟机栈动态拓展无法申请到足够的内存时,会抛出OutOfMemoryError异常。

2.4 Java堆(Heap)

    Java堆是Java虚拟机管理内存中最大的一块,是所有线程共享的内存区域,在虚拟机启动的时候就已经创建

    该区域的唯一功能是存放对象实例,几乎所有对象的实例都在堆里面分配。

    根据《Java虚拟机规范》的规定,Java堆可以处于物理层面上不连续的内存空间中,但在逻辑上它应该被视为连续的。

    Java堆既可以被实现成固定大小的,也可以是可拓展的,不过当前主流的Java虚拟机都是按照可拓展来实现的。

    Java对堆采用分代管理的方式(新生代New Generation和老年代Old Generation),具体划分如下:

    

    大多数情况下,Java程序中新建的对象都从新生代分配内存,新生代又分为Eden Space和Survivor Space(S0和S1),新创建的对象存储在新生代中,当新生代内存占满后触发Minor GC(清理新生代的内存)。新生代中存储的对象经过多次GC后仍然存活的对象会移动到老年代中进行存储,老年代空间占满后,会触发Full GC(清理整个堆空间),如果Full GC后,堆中仍然无法存储对象,就会抛出OOM异常。

    由于新生代中约98%的对象都是“朝生夕死”,所以对于New Generation不需要按1:1划分。HotSpot默认的Eden和Survivor的比利是8:1,也就是说Eden:S0(From):S1(To)=8:1:1。在GC开始的时候,对象只会存在于survivor的“From”区和Eden区,也就是说,在新生代中可用空间为90%(除去survivor的“To”区),紧接着进行GC,此时Eden区中所有存活的对象都会被复制到“To”区域里,而“From”区中,仍存活的对象会根据年龄来决定它们的去向,如果年龄达到阈值,将被移动到老年代中,没有达到阈值的对象会被复制到“To”区域里。经过这次的GC后,“From”区和Eden区已经被清空,此时“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

    由于堆是所有线程共享的。因此在堆上分配内存时需要进行加锁(这就导致了创建对象的开销比较大),所以JDK为了提升内存分配的效率,会为每个新线程在EdenSpace上分配一块独立的空间TLAB(默认占Eden的百分之一),在TLAB分配内存时不需要加锁,所以JVM在给线程中的对象分配内存时会尽量在TLAB上分配。

   更多详细关于Java虚拟机垃圾回收请移步另一篇博客:正在写@_@

2.5 程序计数器

    程序计数器是线程私有的,并且此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码(class文件)行号指示器。字节码解释器就是通过改变该计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器来完成。

3、实例分析

实例1:

package sicau.noname;

/**
 * @Author: Sicauxjh
 * @Date: 2021/2/10 11:12
 */
public class Math {
    public static final Integer CONSTANT_1 = 666;

    public static Object obj = new Object();

    public int math(){
        int a = 1;
        int b = 2;
        int c = (a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        System.out.println(math.math());
    }
}

以该程序为例,运行该程序,jvm会分配给该程序一个线程,总体图示如下:

 

该线程在运行时候,java虚拟机会分配给该线程独立的java栈,而栈帧存在于栈中,存放的是 每一个方法运行时候需要的数据(每一个方法都有一个栈帧,栈帧存的是 局部变量表,操作数栈,动态链接,方法出口),上图有两个方法,即 jvm会分配两个栈帧。

首先入栈的是main方法的栈帧,当main方法new一个math对象时候,该栈帧当中存放了math的引用,而math对象是放在堆中,方法区中会放置Math类的.class文件,一些类的静态变量和常量也会放入方法区,比如:

 

然后main方法中调用了math方法(new了一个math对象),从而math方法的栈帧入栈,当math方法执行完毕之后,它的栈帧会弹出栈。

 

(使用javap指令反编译一下 Math.class)

 

查询jvm指令可知:iconst_1的含义是将int类型常量1压入栈

(栈即使 操作数栈),istore_1的含义是

iconst_2,istore_2也一样。;int b=2;

 

程序计数器也是每一个线程私有的,每个方法运行的时候,都有一个程序计数器,作用是告诉jvm接下来该运行哪一行代码,即是一个指针,如反编译后图的0,1,2,3......前四行代码都执行了,现在该运行4, 程序计数器放的内容是4

 

然后,执行int c=(a+b)*10,对应 iload_1,iload_2 ,含义如下,

对应结果(从局部变量表 装载到 操作数栈,PS:局部变量表中的值还在,只是复制到操作数栈中,下图显示不正确)

接着计算a+b,对应的是将b=2,a=1都弹出栈,进行+运算,然后将算出的结果3,放入操作数栈中,然后需要10,所以将10也压入栈

 

执行3*10的操作,需要将3和10均弹出栈进行乘的计算,计算的出30,再压回操作数栈中,然后将30弹出栈,进入局部变量表:

 

最后math方法执行方法执行完毕,会通过方法出口返回给main方法,并且,math方法的栈帧主动弹出栈销毁

 

本地方法栈是存放程序调用的native方法,(或者程序底层的native方法)

 

有个结论:java栈,本地方法栈,程序计数器是每一个线程私有的,而堆和方法区是所有线程所共享的。(堆和方法区共享是因为,其他线程也能创建相同的对象,比如math,也要用到方法区的一些内容)

 

实例2:

/**
 * @Author: Sicauxjh
 * @Date: 2021/2/12 18:54
 */
//Test.java
public class Test {     //JVM把Test的信息都放到方法区

    public static void main(String[] args) {    //main成员方法本身放入方法区
        Sample test1 = new Sample("test1");     //test1是引用,所以放到java虚拟机栈局部变量表
        Sample test2 = new Sample("test2");     //test2同理

        test1.printName();
        test2.printName();
    }
}
/**
 * @Author: Sicauxjh
 * @Date: 2021/2/12 18:54
 */
//Sample.java
public class Sample {       //运行时,JVM把sample的信息都放入方法区

    private String name;    //使用new Sample后,name引用(即test1、test2)放入java虚拟机栈局部变量表里,name对象放入堆里

    public Sample(String name) {
        this.name = name;
    }

    public void printName(){    //printName()方法本身放入方法区里
        System.out.println(name);
    }
}

以上代码段各个板块在JVM中的分配如下:

4、小结

    到此为止,我们明白了虚拟机里面的内存是如何划分的,虽然Java有垃圾收集机制,但是内存溢出离我们并不遥远。

内容参考/来源:

《深入理解Java虚拟机》(第三版).周志明

https://www.cnblogs.com/chengwu1996/p/10466977.html

猜你喜欢

转载自blog.csdn.net/qq_41834553/article/details/113275842