1、什么是运行时数据区
恩......如果我说什么是Java的内存模型,估计大家都会脱口而出,程序计数器、方法栈、本地方法栈、元空间、堆。而且很自信的觉得自己说的一点都没错。are you really?
其实对于java的内存模型和运行时数据区很多同学都搞不清楚。java的内存模型是JMM(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。可以见我的并发编程专题文章,并发编程十二-Java内存模型以及底层实现原理 了解JAVA的内存模型。
程序计数器、方法栈、本地方法栈、元空间、堆这些统称为java运行区域,也叫运行时数据区。
2、运行时数据区介绍
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
2.2虚拟机栈
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。如下图
1. 局部变量表
局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。
虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
2. 操作栈
操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。
我们可以通过Javap -v 命令,查看每个函数执行的顺序和指令。
如以下这个程序,我们看下i++和++i在程序里具体是怎么进行执行的。
public class test { public static int sum(int i) { i = i++; return i; } public static int sum2(int i) { i = ++i; return i; } public static void main(String[] args) { System.out.println(sum(1)); System.out.println(sum(2)); } }
将程序使用javac变异为class文件,然后在执行
javap -v test.class >test
j将文件导出,然后查看test文件内容。大家肯定一脸懵逼。。。。哈哈,不要急,拿出武林秘籍,JAVA指令集,所有的反编译指令都已经总结道那篇博客里了。大家可以当做字典使用。然后我们对照着指令,查看具体执行流程吧。
Last modified Jun 1, 2020; size 549 bytes MD5 checksum 2aaae1ab4ce8152ade04b4ca65c34a2f Compiled from "test.java" public class com.dahua.test.controller.test minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#18 // java/lang/Object."<init>":()V #2 = Fieldref #19.#20 // java/lang/System.out:Ljava/io/PrintStream; #3 = Methodref #5.#21 // com/dahua/test/controller/test.sum:(I)I #4 = Methodref #22.#23 // java/io/PrintStream.println:(I)V #5 = Class #24 // com/dahua/test/controller/test #6 = Class #25 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 sum #12 = Utf8 (I)I #13 = Utf8 sum2 #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 SourceFile #17 = Utf8 test.java #18 = NameAndType #7:#8 // "<init>":()V #19 = Class #26 // java/lang/System #20 = NameAndType #27:#28 // out:Ljava/io/PrintStream; #21 = NameAndType #11:#12 // sum:(I)I #22 = Class #29 // java/io/PrintStream #23 = NameAndType #30:#31 // println:(I)V #24 = Utf8 com/dahua/test/controller/test #25 = Utf8 java/lang/Object #26 = Utf8 java/lang/System #27 = Utf8 out #28 = Utf8 Ljava/io/PrintStream; #29 = Utf8 java/io/PrintStream #30 = Utf8 println #31 = Utf8 (I)V { public com.dahua.test.controller.test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 16: 0 public static int sum(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iload_0 //2、从局部变量0中装载int类型值 为1 1: iinc 0, 1 //3、把一个常量值加到一个int类型的局部变量上 为2 4: istore_0 //4、将int类型值存入局部变量0 将2存在0的位置上 5: iload_0 //5、从局部变量0中装载int类型值 读取2 6: ireturn //返回 LineNumberTable: line 18: 0 line 19: 5 public static int sum2(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iinc 0, 1 //2、把一个常量值加到一个int类型的局部变量上 3: iload_0 //3、从局部变量0中装载int类型值 4: istore_0 //4、将int类型值存入局部变量0 5: iload_0 //5、从局部变量0中装载int类型值 6: ireturn LineNumberTable: line 23: 0 line 24: 5 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: iconst_1 //1、将int类型常量1压入栈 4: invokestatic #3 // Method sum:(I)I 7: invokevirtual #4 // Method java/io/PrintStream.println:(I)V 10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 13: iconst_2 14: invokestatic #3 // Method sum:(I)I 17: invokevirtual #4 // Method java/io/PrintStream.println:(I)V 20: return LineNumberTable: line 28: 0 line 29: 10 line 30: 20 }
i++ 和 ++i 的区别:
- i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。
- ++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。
之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。
对于操作数栈中的指令,大家可以通过javap 命令将java class程序编译下,就可以看到实际中程序的每个指令的执行顺序。
3. 动态链接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
4.方法返回地址
方法执行时有两种退出情况:
- 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;
- 异常退出。
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- PC计数器指向方法调用后的下一条指令。
栈指向堆
当我们局部变量表中存在Object对象的时候,因为对象存储是在堆中的,所以就会出现栈指向堆的场景
2.3本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。
JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性。
2.4 方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然
Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
为什么要使用元空间取代永久代的实现?
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- 将 HotSpot 与 JRockit 合二为一。
类信息、常量、静态变量、JIT
2.5 heap(堆)
对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
本章主要介绍了java运行时数据区的作用,下章将给大家解析堆的内存结构~