JAVA|JAVA jvm系列--java内存区域

目录

  • 一、JVM的组成
  • 二、JVM运行流程
  • 三、java内存区域详解(运行时数据区域)
    • (一)程序计数器
    • (二)java虚拟机栈
    • (三)本地方法栈
    • (四)java堆
    • (五)方法区
    • 运行时常量池
    • (六)直接内存
  • 总结
    JVM: Java Virtual Machine,Java虚拟机,包括处理器、堆栈 、寄存器等,是用来执行java字节码(二进制的形式)的虚拟计算机。

    一、JVM的组成 JVM由以下四部分组成(两个子系统和两个组件):
    类加载器(ClassLoader)
    执行引擎(Execution Engine)
    运行时数据区(Runtime Data Area)
    本地库接口(Native Interface)

    结构如图:

    JAVA|JAVA jvm系列--java内存区域
    文章图片


    (1)运行时数据区域我们在本文进行详解;

    (2)类加载机制会在后续文章中依次分析,本文主要介绍运行时数据区域;

    (3)执行引擎:

    JIT编译器:编译执行;将字节码指令变成机器指令。将机器指令放在方法区缓存。

    解释器:逐行解释字节码。

    垃圾回收器:内存回收的具体实现。

    (4)本地方法库:

    有时java应用需要与java外面的环境、操作系统交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。

    jre大部分是用java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread 的 setPriority()方法是用java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 SetPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

    本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。

    二、JVM运行流程 JAVA|JAVA jvm系列--java内存区域
    文章图片


    (1)程序在执行之前先要把java代码转换成字节码(class文件);

    (2)jvm首先需要把字节码通过类加载器(ClassLoader) 把文件加载到 运行时数据区(Runtime Data Area) ;

    (3)字节码文件不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU去执行;

    (4)第三步过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能。

    注:Java 虚拟机与 Java 语言没有什么必然的联系,它只与特定的二进制文件.Class 文件有关 。 因此无论任何语言只要能编译成.Class 文件,就可以被 Java 虚拟机识别并执行,比如Groovy、Kotlin。

    三、java内存区域详解(运行时数据区域) 我们说的Java内存区域,一般都指运行时数据区域,其组成如图所示:

    JAVA|JAVA jvm系列--java内存区域
    文章图片


    JDK1.8之后的内存区域布局如下:

    JAVA|JAVA jvm系列--java内存区域
    文章图片


    参考文章:Java内存区域(运行时数据区域)和内存模型(JMM)


    (一)程序计数器

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

    对于一个单核cpu(或者是一个内核)来说,只能同时执行一条指令,而JVM通过快速切换线程执行指令来达到多线程的,真正处理器就能同时处理一条指令,只是这种切换速度很快,我们根本不会感知到。为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。——线程私有,多线程的实现

    如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。——无内存溢出

    (二)java虚拟机栈

    线程私有:Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同,与线程同时创建。线程的生命周期请参考我的另一篇文章:线程的生命周期。

    虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

    在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。

    JAVA|JAVA jvm系列--java内存区域
    文章图片


    (1)局部变量表

    局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量要显示初始化,没有默认值。
    存放了编译期间可知的基本数据类型、对象引用类型(引用指针)和returnAddress类型(程序就是存储在方法区的字节码指令,指向特定指令内存地址的指针)。

    32位的数据类型占用一个局部变量空间(Slot),64位的long和double占2个。

    在Java程序被编译为Class文件时,就在方法的Code属性(Java程序方法中的代码经过javac编译之后形成字节码存在了Code属性内)的max_locals数据项中确定了方法所需的分配的局部变量表的最大容量。

    (2)操作数栈

    操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操作栈。

    虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
    i++ 和 ++i 的区别:i++:从局部变量表取出 i 并压入操作栈(load memory),然后对局部变量表中的 i 自增 1(add&store memory),将操作栈栈顶值取出使用,如此线程从操作栈读到的是自增之前的值。++i:先对局部变量表的 i 自增 1(load memory&add&store memory),然后取出并压入操作栈(load memory),再将操作栈栈顶值取出使用,线程从操作栈读到的是自增之后的值。

    (3)动态链接

    每个栈帧中包含一个在运行时常量池中对所在方法的引用, 目的是支持方法调用过程的动态连接。

    现有动态链接,再有栈帧。

    (1)每一个栈帧当中都包含指向运行时常量池栈帧所属方法的引用(invokedynamic指令);

    (2)在java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里;
    比如:描述一个方法调用的另外的其它方法时,就是通过常量池中指向该方法的符号引用来表示,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

    参考:https://www.zhihu.com/question/347395101
    知乎上参考到的理解:

    比如类里有个a方法,加载到了元空间的内存地址:0x0000 0001号单元 然后运行时常量池里把这个方法的符号引用转换为直接引用: a — 0x0000 0001。

    然后调用a方法,创建栈帧,里面保存了常量池里指向a方法的这个直接引用 0x0000 0001。就可以从这个直接引用找到a方法代码的入口执行a方法。

    线程切换恢复后也可以根据程序计数器(偏移量)结合这个引用,再次找到a方法在内存中上次执行到的位置,继续执行代码。
    什么是符号引用:

    符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。
    (4)方法返回地址

    方法出口。

    方法执行时有两种退出情况:
    正常退出,即正常执行到任何方法的返回字节码指令;异常退出。

    无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
    返回值压入上层调用栈帧。异常信息抛给能够处理的栈帧。PC计数器指向方法调用后的下一条指令。


    (三)本地方法栈

    本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

    (四)java堆

    对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。——线程共享

    jdk1.8之后,字符串常量池从方法区移到了堆中。

    堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。

    (1)从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。

    (2)从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
    Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。——内存溢出


    (五)方法区

    作用:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    回收:垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。

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

    JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配,元空间的大小取决于本地内存的大小。


    运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

    一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

    运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。(当调用 intern() 方法时,编译器会将字符串添加到常量池中(stringTable维护),并返回指向该常量的引用。)
    既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。


    (六)直接内存

    直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

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

    显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

    总结 如图所示:

    JAVA|JAVA jvm系列--java内存区域
    文章图片


    【JAVA|JAVA jvm系列--java内存区域】本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!

      推荐阅读