分享

JVM入门教程(四)

 夜猫速读 2022-05-05 发布于湖北

JVM 方法区

1. 前言

本节主要讲解运行时数据区的方法区。本节主要知识点如下:

  • 了解方法区的作用及意义,为本节的基础知识;

  • 了解方法区存放数据类型,为本节重点内容之一;

  • 了解运行时常量池,我们在学习Class文件结构的时候,也学习过常量池结构,那么运行时常量池本节课程会进行讲解;

  • 了解方法区与堆内存结构的关系,以JDK 1.8 版本为分界线,进行对比讲解,为本节重点内容之一。

2. 什么是方法区

定义:方法区,也称非堆(Non-Heap),是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据对象、static-final 常量、static 变量、JIT 编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域 “运行时常量池”。

Tips:对于运行时常量池,后文会有讲解。

对于习惯在 HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

3. 方法区存放的数据

在讲解方法区内存放的数据之前,我们先通过示意图来直观的看下,方法区存放的数据与堆内存之间的关系。如下图所示:

从图中可以看到,方法区存放了 ClassLoader 对象的引用,也存放了一个到类对象的引用,这两个引用的对象实例会存放到堆内存中。从上图我们就可以简单的了解到方法区存放的数据是什么,接下来,我们对存放的数据类型进行解释。

  • 类型全限定名:全限定名为 package 路径与类名称组合起来的路径;

  • 类型的直接超类的全限定名:父类或超类的全限定名;

  • 类型是类类型还是接口类型:判定当前类是 Class 还是接口 Interface;

  • 类型的访问修饰符:判断修饰符,如 pulic,private 等;

  • 类型的常量池:这部分会在下文进行讲解;

  • 字段信息:类中字段的信息;

  • 方法信息:类中方法的信息;

  • 静态变量:类中的静态变量信息;

  • 一个到类 ClassLoader 的引用:对 ClassLoader 的引用,这个引用指向对内存;

  • 一个到 Class 类的引用:对对象实例的引用,这个引用指向对内存。

4. 运行时常量池

我们先来回顾下Class 文件结构中的常量池的相关知识。

Class 文件中的常量池
在 Class 文件结构中,最头的 4 个字节用于存储 Megic Number,用于确定一个文件是否能被 JVM 接受,再接着 4 个字节用于存储版本号,前 2 个字节存储次版本号,后 2 个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个 u2 类型的数据 (constant_pool_count) 存储常量池容量计数值。

常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References)。更加具体的知识,同学们可以翻看之前相关的小节内容。

运行时常量池:我们回到正题,来看下运行时常量池。

Tips:其实 Class 文件中的常量池与运行时常量池的关系非常容易理解,Class 文件中的常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。简单总结来说,编译器使用 Class 文件中的常量池,运行期使用运行时常量池。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是 String 类的 intern() 方法。

5. 常量池的优势

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。

例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

  • 节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。

  • 节省运行时间:比较字符串时,==equals () 快。对于两个引用变量,只用  == 判断引用是否相等,也就可以判断实际值是否相等。

6. 方法区内存变更

方法区的实现,虚拟机规范中并未明确规定,目前有 2 种比较主流的实现方式:

HotSpot 虚拟机 1.8之前:在 JDK1.6 及之前版本,HotSpot 使用 “永久代(permanent generation)” 的概念作为实现,即将 GC 分代收集扩展至方法区。这种实现比较偷懒,可以不必为方法区编写专门的内存管理,但带来的后果是容易碰到内存溢出的问题(因为永久代有 - XX:MaxPermSize 的上限)。

在 JDK1.7,HotSpot 逐渐改变方法区的实现方式,如 1.7 版本移除了方法区中的字符串常量池,但为发生本质的变化。

HotSpot 虚拟机 1.8之后:1.8 版本中移除了方法区并使用 metaspace(元数据空间)作为替代实现。metaspace 占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。

7. 小结

本节主要讲解了运行时数据区里边的方法区,方法区是一块共享内存区域,在运行时数据区占据着十分重要的位置。我们了解了方法区里边存储的数据类型,也了解到了方法区的作用,同时了解了方法区内存的版本变更,通篇皆为重点知识,学习者需要用心学习。

JVM 堆内存

1. 前言

本节主要讲解运行时数据区的堆内存。本节主要知识点如下:

  • 掌握堆内存空间结构图,从总体层面认识堆内存,为本节重点内容之一;

  • 了解 JVM 堆空间的基本概念,为本节的基础知识点;

  • 了解堆内存的分代概念,年轻代,eden区,from/to,幸存者及老年代,为本节核心知识点,后续对垃圾回收讲解时,大部分的回收都是发生在堆内存中,掌握分代概念是学习垃圾回收机制的必要前提。

2. 堆内存结构

堆内存是运行时数据区中非常重要的结构,实例对象会存放于堆内存中。在后续小节中,我们讲解 GC 垃圾回收器,绝大多数的垃圾回收都发生在堆内存中,因此对于 JVM 来说,堆内存占据着十分重要的且不可替代的位置。

我们先来看下堆内存的结构图,初步了解堆内存的整体内存划分。

从上图可以看到如下几个要点:

  • 堆内存从结构上来说分为年轻代(YoungGen)和老年代(OldGen)两部分;

  • 年轻代(YoungGen)又可以分为生成区(Eden)和幸存者区(Survivor)两部分;

  • 幸存者区(Survivor)又可细分为 S0区(from space)和 S1区 (to space)两部分。

从图中,我们能够大体了解堆内存的结构划分,后文在讲解分代概念时,我们会提供更加直观,更加清晰的内存结构图。

3. 什么是堆内存

物理层面:从物理层面(硬件层面)来说,当 Java 程序开始运行时,JVM 会从操作系统获取一些内存。JVM 使用这些内存,这些内存的一部分就是堆内存。

Java层面:从开发层面来说,堆内存通常在存储地址的底层,向上排列。当一个对象通过 new 关键字或通过其他方式创建后,对象从堆中获得内存。当对象不再使用了,被当做垃圾回收掉后,这些内存又重新回到堆内存中。

总结来说,堆内存是JVM启动时,从操作系统获取的一片内存空间,他主要用于存放实例对象本身,创建完成的对象会放置到堆内存中。

4. 堆内存的分代概念

从上文堆内存的结构图中,我们看到了比较多的JVM堆内存中的专有名词,比如:年轻代,老年代。那么对于堆内存来说,分代是什么意思呢?为什么要进行分代呢?

分代:将堆内存从概念层面进行模块划分,总体分为两大部分,年轻代和老年代。从物理层面将堆内存进行内存容量划分,一部分分给年轻代,一部分分给老年代。这就是我们所说的分代。

分代的意义:易于堆内存分类管理,易于垃圾回收。类似于我们经常使用的 Windows 操作系统,我们会将物理磁盘划出一部分存储空间作为用户系统安装盘(如 C 盘),我们还极大可能将剩余的磁盘空间划分为 C, D, E 等磁盘,用于存储同一类型的数据。

  • 易于管理:对于堆空间的分代也是如此,比如新创建的对象会进入年轻代(YoungGen)的生成区(Eden),生命周期未结束的且可达的对象,在经历多次垃圾回收之后,会存放入老年代(OldGen),这就是分类管理;

  • 易于垃圾回收:将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及 GC 频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。

Tips:关于上文提到的垃圾回收部分的知识,我们会在后边的章节做专门的、详细的讲解,此处我们先做了解即可。

5. 堆内存结构详解

讲解完分代的概念,我们来对堆内存中的不同的代,不同的内存空间的作用进行更加详细的讲解。讲解之前,我们来看下如下示意图,更加直观的了解堆内存结构。

堆内存每个模块之间的关系及各自的特点概述如下:

  • JVM 内存划分为堆内存和非堆内存,堆内存分为年轻代(YoungGen)、老年代(OldGen);

  • 年轻代又分为 Eden 和 Survivor 区。Survivor 区由 FromSpace 和 ToSpace 组成。Eden 区占大容量,Survivor 两个区占小容量,默认比例是 8:1:1;

  • 堆内存存放的是对象,垃圾收集器就是收集这些对象,然后根据 GC 算法回收;

  • 新生成的对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代;

  • 老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。

Tips:关于上文提到的垃圾回收部分的知识,我们会在后边的章节做专门的、详细的讲解,此处我们主要关注在堆内存的每个模块的概念,特点及作用。对于垃圾回收部分的知识,我们后续再进行学习。

6. 小结

本节主要讲解了运行时数据区里边的堆内存,堆内存是一块共享内存区域,在运行时数据区占据着十分重要的位置。我们了解了堆内存里的分代概念,并从示意图中直观的感受了堆内存的结构。我们了解了堆内存中不同内存空间模块的作用、特点及意义。这都是非常重要的知识点。

由于垃圾回收绝大多数都是发生在堆内存中,因此在课程讲解的过程中,多少会涉及到垃圾回收的一些概念,此处如果不能理解的学习者,可以在学习完垃圾回收器后再次理解目前不能够掌握的知识。

JVM 中堆的对象转移与年龄判断

1. 前言

上节课程我们讲解了堆内存中不同内存空间模块的作用、特点及意义,本节主要讲解堆内存中对象的转移与年龄判断。本节主要知识点如下:

  • 理解并掌握对象优先在 Eden 区分配的实验案例,为本节重点内容之一;

  • 理解并掌握对象直接在老年代分配的触发条件,理解什么是大对象,为本节重点内容之一;

  • 掌握堆内存对象转移的完整流程图及触发机制,为本节核心知识点,其它所有知识点都是围绕这一知识点展开的;

  • 理解并掌握年龄判断的定义,作用及默认年龄值,为本节重点内容之一。

通篇皆为重点内容,其核心是围绕堆内存对象转移的完整流程图及触发机制,本节课程的内容会涉及到垃圾回收的相关概念,此处我们先做了解即可,后续会对垃圾回收进行专门的讲解。

2. 对象优先在Eden 区分配

Tips:标题中“优先”一次需要学习者认真品味,“优先” 意味着首先考虑,那么在一些特殊情况下,新创建的对象还是有可能不在Eden区分配的。这种特殊情况我们在讲解老年代(OldGen)的时候再进行说明。

上节课程我们学习了,Eden 区属于年轻代(YoungGen)。在创建新的对象时,大多数情况下,对象先在 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

那我们如何进行证明,新创建的对象优先在Eden 区分配呢?为了对这个结论进行验证,我们来设计如下实验。

实验设计

  • 创建了一个类,类名称可自定义,并在类中实现一个 main 函数,为后续测试做前提准备;

  • 在运行main函数之前,通过设置 JVM 参数,设置堆内存初始大小为 20M,最大为 20M,其中年轻代大小为 10M,不需要特殊设置 Eden 区的大小;

  • 除了设置堆内存参数之外,还需要设置JVM 参数跟踪详细的垃圾回收日志,以便于观察年轻代(YoungGen)的内存使用情况;

  • 设置完成后,main 函数不写任何代码,运行空的 main 函数观察打印日志;

  • 在main函数中创建一个 2M 大小的对象,运行 main 函数观察打印日志。

Tips:实验中会用到两种JVM的参数配置,一种是配置堆内存的参数,另外一种是配置跟踪垃圾回收的参数。这两部分参数我们在之前的章节都有详细描述过。

实验要点准备

  • 设置堆内存大小为 20M,最大为 20M,其中年轻代大小为 10M,并设置垃圾跟踪日志打印。需要通过JVM参数  -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails 进行设置;

  • 不需要特殊设置 Eden 区的大小,那么年轻代中 Eden 区、from space 和 to space 将会以默认的 8:1:1进行空间分配;

  • 创建一个 2M 大小的对象,我们可以通过语句 byte[] obj = new byte[2*1024*1024] 来实现。

空运行main函数代码演示

public class DemoTest {    public static void main(String[] args) {    }}

空运行mian函数日志

Heap PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000) Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们主要关注 PSYoungGen(年轻代)下的内存分配。空运行情况下,我们看到 Eden 区的大小为 8192K,已使用 28%。为什么空运行下还会有 28% 的内存使用呢?这 28% 的内存使用,包括了支持main函数运行的对象实例。

新建 2M 对象的代码演示

public class DemoTest {    public static void main(String[] args) {        byte[] obj = new byte[2*1024*1024];    }}

新建 2M 对象的运行日志:此处我们只展示年轻代的运行日志。

PSYoungGen      total 9216K, used 4418K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)  eden space 8192K, 53% used [0x00000000ff600000,0x00000000ffa50ac8,0x00000000ffe00000)

结果分析:我们看到,新建 2M 的对象之后,Eden 区使用的空间从之前的 28% 增长到了 53%,净增长 25%。那么我们来进行简单的计算 Eden 区的总内存大小 8192K * 25% = 2048K = 2M。

看到这里我们应该明白了,新创建的对象确实是优先存储于年轻代(YoungGen)中的Eden区的。

3. 大对象直接进入老年代

我们在进行上一知识点讲解时提到过,新创建的对象是优先存放入 Eden 区的,那么对于新创建的大对象来说,会直接进入老年代码。

什么是大对象:2M 的对象算大吗?10M 的对象算大吗?100M 的对象呢?什么是大对象,大对象的标准是什么?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。

那么如果不能够设置 -XX:PretenureSizeThreshold 参数,那什么是大对象呢?Eden 区容量不够存放的对象就是所谓的大对象。

为了验证“大对象直接进入老年代”这一结论,我们依然通过实验进行验证。

实验设计

  • 沿用上一个实验的 JVM 参数设置,并在此基础上增加参数设置 -XX:PretenureSizeThreshold = 3m

  • 将新建的 2M 对象修改为新建 6M对象;

  • 运行 main 函数,观察日志结果。

实验要点准备:本实验所需的 JVM 参数为 -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails

代码示例

public class DemoTest {    public static void main(String[] args) {        byte[] obj = new byte[6*1024*1024];    }}

运行结果

Heap PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) ParOldGen       total 10240K, used 6020K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)  object space 10240K, 58% used [0x00000000fec00000,0x00000000ff1e1010,0x00000000ff600000) Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们先来看下老年代(OldGen),total 10240K, used 6020K,说明我们新创建的对象是直接进入了老年代。然后我们来看下 Eden区 为什么不能存储 6M 大小的对象,我们进行简单的计算。

Eden 区剩余内存空间 = 总空间 8192K  * (1-28%)= 5898 K < 6M。这就是我们所说的,大对象直接进入老年代。

4. 对象转移流程

上文我们学习了 Eden 区优先存放新建的独享,新建大对象不会经过Eden区,直接进入老年代,那么还剩两个区域没有进行讲解:幸存者区 from space 和 幸存者区 to space。我们在对流程图进行讲解时,会对这两块内存区域进行说明。

从上图中可以看出,新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到 Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代。

如果新生成的是大对象,会直接将该对象存放入老年代。

老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。

5. 对象年龄判断

对象年龄判断的作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成从对象从年轻代到老年代的转移。

对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。

年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。

年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

6. 小结

本节我们学习了堆内存对象的转移过程以及 JVM 是如何通过判断对象年龄来决定是否将对象从年轻代转移至老年代的。通篇皆为重点内容,学习者需认真对待,本节内容与垃圾回收也息息相关,学好本节课程,也能为后续垃圾回收部分打下良好的基础。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多