《深入理解Java虚拟机》(第二版)学习1:JVM的内存划分

运行时数据区

先来一张图描述一下 JVM 的内存划分

请添加图片描述

PS:自己画的,丑是难免丑了点…

程序计数器(Program Counter Register)

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。

特点:

  1. 线程私有,各个线程之间的计数器互不影响;
  2. 它是在Java虚拟机规范中唯一没有规定任何内存溢出(OutOfMemoryError)情况的区域。

使用:如果线程当前执行的是Java方法,则计数器记录的是当前执行的虚拟机字节码指令的地址;如果当前执行的是 Native 方法,则计数器为空(Undefined)。

用途:使线程切换之后能恢复到正确的执行位置。

虚拟机栈(Virtual Machine Stack)

虚拟机栈描述的是 Java 方法执行的内存模型,内部的基本单位是栈帧,一个栈帧对应着一个方法,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

特点:

  1. 线程私有,生命周期与线程相同

虚拟机栈有两种异常情况:

  1. 栈溢出(StackOverflowError):线程请求的栈深度大于虚拟机所允许的深度,多出现于方法递归中;
  2. 内存溢出(OutOfMemoryError)

栈帧(Stack Frame)

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的的栈元素。

在这里插入图片描述

如上图,栈帧内部包括局部变量表、操作数栈、动态连接、方法返回地址和额外信息等内容。

在活动栈中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method),虚拟机执行引擎的所有字节码指令都是针对当前栈帧进行操作的。

局部变量表(Local Variable Table)

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

局部变量表的最大容量在编译期间就写入到 Code 属性的 max_locals 数据项中(编译期间确定大小,不存在溢出现象)。

局部变量表的基本单位

局部变量表的容量以变量槽(Variable Slot,简称 Slot)为最小单位,每一个 Slot 都能存放一个 boolean、byte、char、short、int、float、reference(对象实例的引用)或者 returnAddress 类型的数据,它们在 Java 中都占用小于或者等于32位的内存长度。

对书上的“只要保证即使在64位机中使用64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让 Slot 在外观上看起来与32位虚拟机中的一致”这句话存疑。
照这句话的意思岂不是64位 JVM 里 int 虽然占了64位,但剩下32位是一堆空白?
在网上暂时没找到这个问题的答案,先余着。

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 中明确的(reference 类型可能是32位的也可能是64位的)64位的数据类型只有 long 和 double 两种,这里引出了一个问题,就是** long 和 double 的非原子性协定**,这个我们留到后面再说。

对书上的“reference类型可能是32位的也可能是64位的”存疑,那如何判断reference是32位的还是64位的呢?
也暂时没找到这个的答案,余着。

局部变量表的使用

虚拟机通过索引定位的方式使用局部变量变,索引范围从0开始至局部变量表最大的 Slot 数量。如果访问的是32位数据类型的变量,索引 n 就代表了使用第 n 个 Slot ,如果是64位数据类型的变量,则说明会同时使用 n 和 n+1 两个 Slot 。

对于两个相邻的共同存放一个64位数据的两个 Slot ,不允许采用任何方式单独访问其中的某一个!!!

在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法(非 static 方法),那局部变量表中第0位索引的 Slot 默认用于传递方法所属对象实例的引用,在方法中可以通过 this 关键字来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量 Slot ,参数表分配完之后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot 。

那如果是类方法(static方法)呢?那局部变量表中第0位索引的 Slot 值是多少?
如果是类方法,那局部变量表中第0位索引的 Slot 值是 0 ,这从虚拟机层面解释了为什么在类方法里面使用不了this关键字。

Slot 的复用机制

为了尽量节省栈帧空间,局部变量表中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。

Slot 复用的副作用:某些情况下,Slot 的复用会影响垃圾回收。

例:

/**
 * @author 小关同学
 * @create 2021/9/26
 */
public class Test1 {
    
    
    public static void main(String[] args) throws Exception{
    
    
        {
    
    
            byte[]placeholder = new byte[64 * 1024 *1024];
        }
        System.gc();
    }
}

内存数据:
[GC 69468K->66368K(249344K), 0.0012025 secs]
[Full GC 66368K->66091K(249344K), 0.0098479 secs]

我们可以看到 placeholder 变量并没有被回收,这是因为原来 placeholder 变量占用 Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。

修改过后

/**
 * @author 小关同学
 * @create 2021/9/26
 */
public class Test1 {
    
    
    public static void main(String[] args) throws Exception{
    
    
        {
    
    
            byte[]placeholder = new byte[64 * 1024 *1024];
        }
        int i = 0;
        System.gc();
    }
}

内存数据:
[GC 69468K->66320K(249344K), 0.0011144 secs]
[Full GC 66320K->555K(249344K), 0.0100487 secs]

我们可以明显看到内存被回收了,因为变量 i 对变量 placeholder 原先占用的 Slot 进行复用,所以变量 placeholder 占用的内存被回收了。

操作数栈(Operand Stack)

操作数栈(Operand Stack)也叫操作栈,是一个后进先出(Last In First Out,LIFO)栈。

同局部变量表一样,操作数栈的最大深度也在编译期间就写入到 Code 属性的 max_stacks 数据项中(编译期间确定大小,不存在溢出现象)。

32位数据类型所占的栈容量为1,64位数据类型所占容量为2。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。

Java虚拟机的执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

现代虚拟机一般会对操作数栈进行一些优化处理,如下图:
在这里插入图片描述
如上图,虚拟机会令两个栈帧发生部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起(主要体现在方法中有参数传递的情况),这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。

动态链接(Dynamic Linking)

动态链接主要就是指向运行时常量池的方法引用。每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有这个引用是为了支持方法调用中的动态链接(Dynamic Linking)。

动态链接跟方法调用息息相关,关于方法调用我们后面再讲。

方法返回地址(Return Address)

当一个方法开始执行以后,只有两种方式可以退出这个方法。
第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
第二种退出方式是,在方法执行过程中遇到异常,并且这个异常没有在方法内部得到处理(在本地方法的异常表中没有搜索到匹配的异常处理器),就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采取哪种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。
方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中可能会保存这个计数器值。
方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法返回地址和PC计数器的区别:

  • 程序计数器指的是处理器在来回切换线程执行的时候,记录当前线程执行到什么地方的;
  • 方法的返回地址是记录当前方法指令执行完之后,下一步要执行的方法指令的地址。

本质上:方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压调用者栈帧的操作数栈、设置 PC 计数器值等,让调用者方法继续执行下去。

附加信息

这部分信息取决于虚拟机的具体实现,如:调试相关的信息。

本地方法栈(Native Method Stack)

本地方法栈作用和虚拟机栈类似,区别在虚拟机栈是为 Java 方法服务的,而本地方法栈则是为虚拟机使用到的 Native 方法服务

与虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

Java堆(Java Heap)

Java 堆是虚拟机所管理的内存中的最大的一块,随虚拟机启动而被创建。它的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

注意前面这个“几乎”,原来所有对象实例的确都在堆上分配的,但是随着 JIT 编译器的发展和逃逸分析技术逐渐成熟,栈上分配标量替换优化技术会导致一些变化,一些对象不会再被分配到堆上。

特点:线程共享;

Java堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器基本采用分代算法,所以从内存回收的角度Java堆也可以被细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)

Java堆可以处于物理上不连续的内存空间中,只要逻辑上使连续的即可。

方法区(Method Area)

方法区(Method Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

特点:线程共享;

在 Hotspot 虚拟机上,方法区也被称为 “永久代”(Permanent Generation),原因是 Hotspot 虚拟机的设计团队把 GC 分代收集扩展至方法区,这样垃圾收集器就可以像管理 Java 堆一样管理这部分内存(现在看来不是个好主意),省去专门为方法区编写内存管理代码的工作(感觉像是在偷懒)。

注意:其他虚拟机上是不存在永久代这个概念的,这个是 Hotspot 虚拟机特有的。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载。一般来说,方法区的回收率并不高,尤其是类型的卸载,条件相当苛刻。

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References),字面量相当于 Java 语言层面常量的概念,如文本字符串,声明为 final 的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:

  • 类和接口的全限定名
  • 字段名称和描述符
  • 方法名称和描述符

这部分内容在类加载之后就进入到方法区的运行时常量池中存放。一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另一个重要特征是具备动态性,Java 语言不要求常量一定是编译期才能产生,运行期间也可能将新的常量放入池中,如 String 类的 intern()方法。

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

直接内存(Direct Memory)

直接内存(Direct Memory)并不是 Java 虚拟机规范中定义的内存区域。在 JDK 1.4 中新加入了 NIO(New Input / Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样避免了在 Java 堆和 Native 堆中来回复制数据,在一些场景中提高性能。

这部分区域虽然使用的是本机直接内存,但是也会出现 OutOfMemoryError 异常。

直接内存的回收

需注意堆外内存并不直接控制于JVM,这些内存只有在 DirectByteBuffer 回收掉之后才有机会被回收,而 Young GC 的时候只会将年轻代里不可达的 DirectByteBuffer 对象及其直接内存回收,如果这些对象大部分都晋升到了年老代,那么只能等到 Full GC 的时候才能彻底地回收 DirectByteBuffer 对象及其关联的堆外内存。因此,堆外内存的回收依赖于 Full GC

  • Full GC 一般发生在老年代垃圾回收或者代码调用 System.gc 的时候,依靠老年代垃圾回收触发 Full GC,进而实现堆外内存的回收显然具有太大的不确定性。如果老年代一直不进行垃圾回收,那么堆外内存就得不到回收,机器的物理内存可能就会被慢慢耗光。为了避免这种情况发生,可以通过参数 -XX:MaxDirectMemorySize 来指定最大的直接内存大小,当其使用达到了阈值的时候将调用 System.gc 来做一次 Full GC,从而完成可控的堆外内存回收。这样做的问题在于,堆外内存的回收依赖于代码调用 System.gc,而 JVM 参数 -XX:+DisableExplicitGC 会导致 System.gc 等于一个空函数,根本不会触发 Full GC,这样在使用 Netty 等 NIO 框架时需注意是否会因为这个参数导致直接内存的泄露。

  • -XX:MaxDirectMemorySize 参数没有指定的话,那么根据 directMemory = Runtime.getRuntime().maxMemory(),最大直接内存的值和堆内存大小差不多

PS:也可以到我的个人博客查看更多内容
个人博客地址:小关同学的博客

猜你喜欢

转载自blog.csdn.net/weixin_45784666/article/details/120584553
今日推荐