《探索JVM内存区域》

一、为什么要了解JVM内存区域

      对于C/C++程序员,它们使用对象时既要先使用new新建对象,又要在使用完对象之后free或delete掉对象。这样不仅辛苦,而且容易导致以后忘记释放掉对象所占的内存空间。而Java则采用了比较自动化式的机制,将最后的“对象销毁”操作交由JVM自动完成,使得我们使用对象时只需通过new新建一个对象即可使用,完全不用担心以后会忘记释放对象所占的内存空间。

     如果是满足日常的学习需要,使得自己编写的Java程序得以在自己的机器上顺利运行,则JVM提供的这种自动的垃圾回收机制足矣,何况,目前商用的高性能JVM都已经提供了相当多的优化特性和调节手段;但是,如果是在生产开发环境下,则经常会发生内存泄漏(Memory Leak)内存溢出(Memory Overflow)的情况,这时我们就需要借助JVM的内存结构来排查问题了。



二、结识JVM中的“内存”成员

研究JVM所管理的内存时,我们主要研究的是其中的运行时数据区(一个“大家庭”)。这个大家庭由下面5个成员组成。
1

1. 程序计数器(PC=Program Counter Register)
  • 属于”线程私有“的内存,生命周期与线程一致。
  • 该区域是一块较小的内存空间,是当前线程所执行的字节码的行号指示器。在JVM的概念模型中,字节码解释器就是通过改变这个计数器的值来取出下一条将要执行的字节码指令。
  • 在多线程的使用环境下,基于CPU的时间片轮转调度算法原理,为了实现线程切换后能恢复到正确的执行位置(也就是在线程被挂起之前,记住线程已经执行到哪行字节码,在线程恢复执行之后,从之前记住的位置开始继续往下执行),每条线程都需配备一个相互独立的程序计数器,这类内存区域属于”线程私有“的内存。


2. JVM栈(Java Virtual Machine Stacks)
  • 属于”线程私有“的内存,生命周期与线程一致。
  • 该区域描述了Java方法执行时的内存模型:每个方法在执行的同时均会创建一个栈帧(Stack Frame)
    ,用以存放局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从被调用到执行完成,对应着一个栈帧在JVM栈中的入栈到出栈过程。
  • 人们经常提及JVM中的”堆“和”栈“,而其中的”栈“其实就是JVM栈,主要指的是JVM栈中的局部变量表。
  • 局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型。
  • 在JVM规范中,对该区域规定了两种异常:
    • SOF异常:线程申请的栈深度超过虚拟机所允许的栈深度。
    • OOM异常:可以动态拓展的虚拟机进行拓展时无法申请到足够的内存。


3. 本地方法栈(Native Method Stack)
  • 属于”线程私有“的内存,生命周期与线程一致。
  • 该区域为JVM使用到的Native方法服务,而前面的JVM栈为JVM使用到的Java方法服务。
  • JVM规范中没有强制规定本地方法栈的实现方式,故具体的虚拟机可以自由实现它。
  • 其中,Sun HotSpot虚拟机直接将本地方法栈和JVM栈合二为一。
  • 在JVM规范中,该区域也有SOF、OOM异常。


4. Java堆(Java Heap)
  • 属于”被所有线程共享“的内存。
  • 该区域为JVM所管理的内存中最大的一块内存,在JVM启动时便创建。
  • 该区域用以存放几乎所有的对象实例,JVM规范中提到:The heap is the runtime data area from which memory for all class instances and arrays is allocated(所有的对象实例以及数组都要在堆上分配)。但是随着各种JVM技术的发展,就目前来说,”所有对象实例都在堆上分配“也变得不那么绝对了。
  • 该区域为垃圾收集器管理的主要区域,故又名”GC堆”(Garbage Collected Heap)。从内存回收的角度看,基于JVM分代搜集算法,该区域可以分为新生代和老年代;从内存分配的角度看,该区域可以划分出多个线程私有的分配缓冲区(TLAB)。
  • 该区域的内存空间可以物理不连续,逻辑上连续即可。
  • 在JVM规范中,若该区域中已没有内存可以进行对象实例分配,且无法再拓展时,将产生OOM异常。


5. 方法区(Method Area)
  • 属于”被所有线程共享“的内存,别名”Non-Heap“。
  • 该区域用以存放已被JVM加载的类信息(类的名称、方法信息、字段信息)、常量、静态变量、JIT编译器编译后的代码等数据。
  • HotSpot虚拟机将GC分代收集拓展至方法区,使用永久代来实现方法区,这样JVM的垃圾收集器可以像管理Java堆那样管理这个区域,省去专门为该区域编写内存管理代码的工作。这使得很多使用HotSpot虚拟机进行开发的人习惯将该区域称为”永久代“(Permanent Generation)。
  • JVM规范中没有规定该区域的实现形式,故具体的虚拟机可以自由实现它。
  • HotSpot虚拟机使用永久代来实现该区域,目前来看不是那么好,容易导致内存溢出,因为永久代有-XX:MaxPermSize的上限,且导致一些方法(如String.intern())在不同的JVM上表现不同。因此,在jdk1.7的HotSpot中,将原本放在永久代的字符串常量池移至Java堆中(官方原文:In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. )。在jdk1.8中移除整个永久代,使用元空间(Metaspace)来代替永久代实现方法区。而方法区就是用来存放一些描述性信息的,即元数据。所以这个”元空间“至少比之前的”永久代“来得更加形象和见名知意了。

(关于元空间可以参考这篇博文:Java 8: 从永久代(PermGen)到元空间(Metaspace)

  • 该区域还包含了一个很重要的区域–运行时常量池 (Runtime Constant Pool)。Class文件中有一项常量池信息,记录了编译期生成的各种字面量和符号引用,当Class文件被加载时,这部分信息将被存放到方法区的运行时常量池中。
  • JVM规范对Class文件中的每一部分(包括常量池)的格式均有严格规定,而对运行时常量池则没有任何细节要求,故具体的虚拟机可以自由实现它。
  • Class文件中的常量池与运行时常量池,两者最大的不同在于后者具备动态性,因为Java不要求常量只有在编译期才能产生,也就是在运行期间也可以产生新的常量并置入运行时常量池中(如String.intern()方法)。
  • 在JVM规范中,当运行时常量池无法再申请到内存时,将产生OOM异常。
  • 在JVM规范中,当该区域无法满足内存分配需求时,将产生OOM异常。


6. 需要知道的一个“邻居“–直接内存
  • 除了以上5个成员之外,其实还有一个我们经常“拜访”(频繁使用)的邻居–直接内存(Direct Memory)。但他不属于运行时数据区这个大家庭,也不是Java虚拟机规范中定义的内存区域。
  • 直接内存常被称为堆外内存,自从JDK1.4引入NIO之后,使得我们也经常和这个邻居打交道了。我们可以通过Native方法分配直接内存,然后通过DirectByteBuffer对象操作这部分内存。这部分内存如果使用不当,也会发生OOM异常。在配置虚拟机参数时,设置-Xmx等参数时应考虑到这部分内存。



三、HotSpot JVM的Java堆中的对象

以下讨论中的“对象“仅限于普通Java对象,不包括数组和Class对象等。

1. 对象的创建
  • JVM遇到一条new指令时,先检查new指令的参数是否能在运行时常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化。若没有,则先执行相应的类加载过程。
  • 类加载的检查通过后,JVM将为新生对象分配相应的内存,对象所需的内存大小在类加载完成之后便可完全确定了,给对象分配内存空间其实就是在Java堆中分出一块确定大小的内存。此时,有两种情况。若Java堆中的内存是绝对规整的,用过的内存和空闲的内存各放一边,中间放置一个指针作为分界点的指示器,此时分配内存就是把指针向空闲的内存那边移动一段与对象大小相等的距离,这种内存分配方式称之为”指针碰撞(Bump the Pointer)“;若Java堆中的内存并不是规整的,用过的内存和空闲的内存相互交错,此时JVM会维护一个列表,记录所有内存块的使用情况,在分配内存时从列表中找出一块足够大的内存块分配给对象实例,并更新列表上的记录,这种内存分配方式称之为”空闲列表(Free List)“。
  • Java堆是否规整,与JVM采用的垃圾收集器是否带有压缩整理功能有关。
  • 为了解决关于内存分配的多线程并发的安全问题,JVM有两个方法:
    • 对分配内存空间的动作进行同步处理;
    • 把内存分配的动作按线程划分在不同的空间中进行,也就是Java堆为每个线程预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,则先在其TLAB上进行分配。当TLAB用完并分配新的TLAB时,才需要进行同步锁定。(其中,设置JVM是否使用TLAB,可以通过 -XX:+/-UseTLAB参数设定)
  • 内存分配完成后,JVM将分配出的内存初始化为零值(对象头除外),若使用TLAB,则此工作可以提前到TLAB分配时进行。
  • 内存初始化后,JVM将对象的相关信息(类的元数据信息、所属类、哈希码、GC分代年龄等信息)存放于对象的对象头中。而且根据JVM的当前运行状态,对对象头会有不同的设置方式。
  • 最后,一般地(由字节码中是否跟随invokespecial指令决定),执行new指令后会紧接着执行<init>方法,将对象按照我们的意愿进行初始化,这样一个真正可用的对象才算诞生了。


2. 对象在内存中存储的布局
  • 对象在内存中存储的布局分为三部分:对象头(Head)、实例数据(Instance Data)和对齐填充(Padding)。
  • 对象头,分为两部分信息

    • 第一部分:存储对象自身的运行时数据(如哈希码、GC分代年龄、锁状态标志、线程持有的锁等),这部分数据官方称它为”Mark Word”。其被设计成一个非固定的数据结构以便在极小的空间中存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
      4

    • 第二部分:类型指针,即对象指向它的类元数据的指针,JVM通过该指针确定对象是哪个类的实例。(注意:不是所有的JVM实现都必须在对象数据上保留类型指针,即查看对象的类元数据信息并非要经过对象本身)
      5


    • 若对象是一个数组,则对象头中还需配备一块存储数组长度值的内存,因为JVM无法从数组的元数据信息中获取数组的长度,但是对于普通Java对象则可借助其元数据信息确定其所占内存大小。


  • 实例数据

存储关于对象的有效信息,即代码中定义的各种类型字段的内容,包括父类继承下来的和子类中定义的字段。这部分信息的存储顺序与JVM的分配策略参数(FieldsAllocationStyle)和字段在Java源代码中的定义顺序有关。

7



  • 对齐填充

该部分不是必然存在的,仅起着占位符的作用。因为HotSpot JVM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分恰为8字节的整数倍(1或2倍),故当对象实例数据部分没有对齐时,需通过对齐填充来对齐补全。

8


3. 对象的访问定位

我们在使用对象时,其实是通过JVM栈上的reference数据来操作Java堆上的对象实例。而JVM规范中没有规定

reference数据(一个指向对象的引用)应该用哪种方式去定位和访问Java堆中的对象实例,故对象访问方式取决于具体的JVM实现。目前常用的有以下两种实现方式:

  • 使用句柄访问

一个对象实例表现为两部分:在Java堆中的对象实例数据和在方法区中的对象类型数据。在Java堆中分出一块内存作为句柄池,reference数据存储着对象实例的相应句柄地址。而每个句柄中都包含着两个指针:指向对象实例数据的指针和指向对象类型数据的指针。其中对象实例数据存储在Java堆中的实例池中,对象类型数据存储在方法区中。
2


  • 使用直接指针访问

一个对象实例在Java堆中表现为两部分:指向对象类型数据的指针和对象的实例数据。reference数据存储着对象实例的地址。其中对象类型数据存储在方法区中。
3




    第一种方式使得reference中存储的是稳定的句柄地址,即使以后对象实例的地址改变了,也只需改变句柄中的指向对象实例数据的指针,reference本身无需改变。
    第二种方式加快了访问速度,因为节省了一次指针定位的时间消耗。而且对象访问是频繁发生的事件,因此这类开销积少成多之后执行成本也很可观。HotSpot JVM就是采用这种方式进行对象访问的。









参考资料:《深入理解Java虚拟机》、莫枢(撒迦)的《JVM分享》PPT

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值