Java基础知识专题5-JVM内存解析

Java基础知识专题5-JVM内存解析

前言

前面几章我们讲解了JVM的构成、运行原理,以及类的加载过程。其中在类加载过程中就涉及到对JVM内存中方法区的分配。

下图是JVM主要包含的模块回顾:
在这里插入图片描述
可以看出整个JVM部件中运行时内存区占了非常大的一部分,而且它也像JVM对于整个Java架构一样,处在中心位置,起到承上启下的作用。

实际工作中JVM的内存也确实是与我们代码联系最紧密的部分,因为内存使用合理的程序比一般的程序会更加安全、健壮,具体表现就是会更快、更节省空间、更少的遇到OutOfMemory问题。

JVM内存为什么存在?

在具体讲JVM内存前,我们需要先简单了解一下为什么内存如此重要,JVM为什么要花这么大力气去处理这个异常复杂的问题,直接用物理内存不好吗?

大家都应该知道,电脑中CPU的运算速度是非常非常快的,而且其他硬件如:I/O、网络、内存的读写速度和CPU的处理速度根本不在一个量级。所以就会出现一个问题,CPU跑的太快、其他硬件跟不上的情况,又不能总是让CPU等待其他硬件,而浪费了CPU的速度优势。所以为了协调两者速度上的差异,设计者们为CPU设计了多线程和高速缓存来协调CPU与各硬件的速度差异,保证CPU处理过程中能够依靠高速缓存来解决直接与硬件交互的速度问题。

而JVM也是利用这个原理,对部分物理内存进行封装成为JVM的运行时内存区,保证了多线程场景下的速度,同事通过MSI、MESI等协议保证了多线程数据一致性。同时由于JVM对物理内存的直接封装,从而屏蔽不同平台的内存使用情况,依然是由JVM适配不同平台,我们的代码无需关注。

JVM内存的各个区块

首先咱们看一下JVM内存个区块的示意图:
在这里插入图片描述
从上图可以看出,JVM内存按照其线程访问特性分为两种:线程共享、线程独占。在两种类型下共分为五块主要区域:方法区、堆、栈区、本地方法栈、PC计数器。

接下来我们对各部分通过:是什么?有什么特点?保存什么数据?这三个问题进行逐一解答分析。

方法区

什么是方法区?

方法区是Java虚拟机规范中定义的一个概念上的区域,它是堆的逻辑组成部分,但它又被与堆区分开来,别名称为:Non-Heap。它并不需要一段连续的内存空间来保存数据,而是可以动态进行扩展的。由于类加载后的类信息基本全部放在这个区域,所以它的大小决定了系统可以包含多少个类,如果系统中类太多,而方法区内存不够也会导致方法区的内存溢出,JVM一样会抛出OutOfMemory错误。

JVM规范对方法区的限制比较宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不是实现垃圾收集。垃圾收集行为在方法区也比较少出现,当方法区无法满足内存分配时,会抛出OutOfMemoryError。

值得注意的是:
方法区在JDK的升级过程中做过重要的变更,JDK1.8以后:
1、移除了永久代(PermGen),替换为元空间(Metaspace);
2、永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
3、永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
4、永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

下面引入两个概念:

永久代(PermGen)
在工作中我们多多少少都遇到过“java.lang.OutOfMemoryError:PremGen space”。这里的PermGen space指的就是方法区。不过方法区和PermGen space还是有区别的。方法区是JVM的规范定义,而PermGen space可以说是一种实现,并且只有HotSpot才这么实现,而如JRockit(Oracle)、J9(IBM)并没有将方法区实现为PermGen space。由于方法区存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代内存溢出。所以在JDK1.8中替换了永久代。(Oracle还是牛X的!)

元空间
元空间的本质和永久代是类似的,都是对JVM规范中方法区定义的实现。不过元空间与永久代的最大区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受到本地内存限制。
这个改变其实从JDK1.7就已经逐渐开始了。在JDK1.7中,存储在永久代的部分数据已经转移到Java Heap或者Native Heap中了。但是永久代在JDK1.7中依然存在,并没有完全移除,如符号引用(Symbols)转移到了Native Heap;字面量(interned strings)转移到了Java Heap;类的静态变量(class statics)转移到了Java Heap中,但是类的元信息、常量池等还是在方法区中的。
但是JDK1.8对JVM架构的改造则将类元数据放到本地内存中去了,另外,将常量池和静态变量放到了Java堆里。HotSpot VM将会为类的元数据明确分配和释放本地内存。在这种架构下,类原信息就突破了原来方法区大小的限制,可以使用更多的本地内存。这样从一定程度上解决了如使用反射、代理等需要动态运行生成大量类而造成的方法区拉满导致的GC问题。同时由于很多内容都到了Java堆中,堆空间的消耗势必会增加。

jdk8中做法:
把永久代从Java堆中移除了, 并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间.
好处:
而在JDK8中,类的元数据保存在本地内存中,元空间的最大可分配空间就是系统可用内存空间,可以避免永久代的内存溢出问题,不过需要监控内存的消耗情况,一旦发生内存泄漏,会占用大量的本地内存。
ps:JDK7之前的HotSpot,字符串常量池的字符串被存储在永久代中,因此可能导致一系列的性能问题和内存溢出错误。在JDK8中,字符串常量池中只保存字符串的引用。

方法区有什么特点?

  1. 方法区是线程安全的,由于所有的线程都共享方法区,所以方法区里的数据访问必须被设计成线程安全的。如:同时两个线程试图访问方法区中的同一个类,而这个类还没有加载如JVM,那么只允许一个线程去加载它,而其他线程则必须等待;
  2. 方法区的大小不必是固定的,JVM可根据应用情况动态调整,同时方法区也不一定是连续的内存空间,方法区可以在一个堆(甚至是JVM自己的堆)中自由分配;
  3. 方法区也可以被垃圾收集,当某个类不再被使用时,JVM将卸载这个类,进行垃圾收集。

方法区存放的内容

常量池

常量池也称为运行时常量池(Runtime Constant Pool),用于存放编译期生成的各种字面量和符号引用,它是这个类型用到的常量的一个有序集合,包括实际的常量(String, Integer, 和Floating point常量)和类型,域和方法的符号引用。
池中的数据项像数组项一样,是通过索引访问的。 因为常量池存储了一个类类型所使用到的所有类型,域和方法的符号引用,所以它在java程序的动态链接中起了核心的作用。

域(Field)信息

域的相关信息包括:域名; 域类型; 域修饰符(public, private, protected,static,final volatile,transient的某个子集)。

方法(Method)信息

方法的相关信息包括:方法名, 方法的返回类型(或 void), 方法参数的数量和类型(有序的),方法的修饰符(public, private, protected, static, final, synchronized, native, abstract的一个子集),除了abstract和native方法外,其他方法还有保存方法的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小。

堆区

什么是堆区?

Java堆是JVM管理内存中最大的一块,它与方法区都是被所有线程共享的内存区域,在JVM启动时便创建,与GC关系紧密!

堆区的特点是什么?

  1. 是JVM内存块中最大的一块内存区域;
  2. 与方法区一样,都是所有线程共享的;
  3. JVM规范规定,Java堆可以是物理上不连续的的内存空间,但是逻辑上必须是连续的;
  4. 对既可以是固定大小的,也可以是扩展的;
  5. 如果堆中没有内存来完成实例分配,并且堆也无法再扩容时,将会抛出OutOfMemoryError;
  6. 由于堆存放的的主要内容是运行时创建的对象信息,所以它是垃圾收集(GC)主要处理的内存块。

堆区存放什么

这个区域是用来存放对象实例的,几乎所有对象实例都会在这里分配内存。

Java堆可以细分为:新生代和老年代,新生代又可以分为Eden空间,From Survivor空间,To Survivor空间等。后面在讲到GC的章节中会对它进行专门的讲解

栈区

什么是栈区?

JVM会为每一个线程创建自己的栈(运行栈),它的生命周期与线程相同,这个栈是每一个线程私有的,当线程执行一个方法的时候,则会在栈中压入栈帧(含有:局部变量表、操作数栈、动态链接、返回地址和附加信息),当方法完成后会弹出栈帧。而整个栈会在线程结束后被销毁、释放占用的栈区内存空间。

栈帧

上面描述中提到了栈帧:可以理解为当线程执行方法时,方法局部变量、中间数据以及结果的存放空间,栈帧的生命周期就是方法的声明周期,方法开始执行则生成、方法执行完成则销毁。

  1. 局部变量表:是一组变量值的存储空间,用于存放方法参数和局部变量。它的大小在编译的时候已经确定,有class文件的方法表的Code属性的max_locals指定;它的最小单位是变量槽(Variable Slot),可以存放boolean、byte、char、short、int、float、reference和returnAddress这8中类型。
    其中要解释的是:reference和returnAddress
    reference表示一个对象实例的引用,通过它可以得到对象在Java堆中存放的起始地址和索引和该数据所属数据类型在方法区的类型信息。通俗说就是可以找到一个对象的所需信息。
    returnAddress则指向一条字节码指令地址。
    为了节省栈帧的空间,局部变量表中的Slot是可以重用的。

  2. 操作数栈:它与局部变量表可以说几乎一样,但是它存放的内容是调用过程中计算的临时结果,而不是声明的变量,也就是可以理解为:当声明好的局部变量或者掺入的参数进入局部变量表后,我们下一步要使用他们了,会先把他们压入操作数表,然后再弹出进行运算,当运算完成后则存入局部变量表中等待下一步;它可以存放的类型与局部变量表一致。

  3. 动态链接:动态链接的作用主要是是当方法需要调用某个类是,JVM将解析运行时常量池的接口符号,而这个符号就存在动态链接中,根据符号引用查找具体的是提,再把符号引用替换成直接引用。(简单说就是保存指向运行时常量池的引用)

  4. 返回地址:当一个方法开始执行以后,只有两种方法可以退出当前方法:

    • (1)当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
    • (2)当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

    无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

    当方法返回时,可能进行3个操作:

    • 恢复上层方法的局部变量表和操作数栈
    • 把返回值压入调用者调用者栈帧的操作数栈
    • 调整 PC 计数器的值以指向方法调用指令后面的一条指令

    5.附加信息:虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

栈区的特点是什么?

  1. 栈区是一片内存区域,每一个线程会在这个内存区域中创建一个栈,线程每运行一个方法,则会在栈中压入一个栈帧、每执行完成一个方法则会弹出这个方法对应的栈帧;
  2. 栈区中的每一个栈都是线程独占的,栈中的数据只可以被其自己的线程查看、使用;
  3. 与数据结构中的栈不同,JVM的栈是先进先出的,也就是说先进的栈帧会放在栈顶,后进的栈帧依次往后排;
  4. 在java虚拟机栈中,规定了两种异常状况:
    StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,将抛出该异常。
    OutOfMemoryError:如果虚拟机栈可以动态扩展,当无法申请到足够的内存,就会抛出该异常。

栈区存放的内容

每个线程的栈,每个栈中是一系列的栈帧,每个栈帧中则是方法运行过程中所需的各类中间数据。

程序计数器

什么是程序计数器?

程序计数器(Program Counter Register),也叫PC寄存器(注意PC可不是Personal Computer的意思!)。它是一块较小的内存空间,用来存放当前线程所执行的字节码的行号指示器,其中每个一个线程会有一个计数器,与线程栈是一一对应的。
代码逻辑中的分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的的方式来实现的,统一时刻CPU内核只能执行一个线程中的指令。因此线程轮换后需要恢复到之前正确的执行位置,所以每个线程都会有一个自己的程序计数器,来保证个线程之间的计数器相互不影响,独立存储。所以程序计数器与线程栈一样是线程私有的。

程序计数器的特点是什么?

  1. 在内存区域中是非常小的一块,毕竟只存每个线程的执行行号,基本不占用太多空间;
  2. 它是线程私有的,每个线程工作时都有属于自己的独立计数器;
  3. 执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是Java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给Java的一个接口,Java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是Java进行实现的。那么自然无法产生相应的字节码,也无法管理器内存了;
  4. 程序计数器还是唯一一个在JVM规范中没有规定任何OutOfMemoryError的区域,缺失也不需要。

程序计数器存放的内容

当前线程所执行的字节码的行号指示器(也可以说是指令栈的指令代号)。

本地方法栈

什么是本地方法栈?

该区域与JVM栈所发挥的作用非常相似,只是虚拟机栈为JVM执行Java方法服务,而本地方法栈则为使用到的本地操作系统(Native)方法服务。

本地方法是非java语言实现,比如C语言实现。而我们需要在java程序中使用这个C语言的方法的功能。

本地方法栈的特点是什么?

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。当C程序调用一个C函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。

很可能本地方法接口需要回调Java虚拟机中的Java方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个Java栈。

本地方法栈存放的内容

调用本地方式是产生的临时数据。

总结&对比

在这里插入图片描述

结语

至此,对JVM内存的解析基本就完成了,通过以上内容,我们了解了JVM内存各部分的概念、特点和作用。接下来我将详细结构JVM的最后一个重头戏垃圾回收(GC)。

参考资料:

https://www.jianshu.com/p/15106e9c4bf3
https://blog.csdn.net/laomo_bible/article/details/83067810
https://blog.csdn.net/niunai112/article/details/80878907
https://blog.csdn.net/niunai112/article/details/80879197

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值