type
status
date
slug
summary
tags
category
password

1、运行时内存整体结构

notion image
运行时数据区分为堆内存、方法区、jvm 栈、本地方法栈和程序计数器五部分。其中
  • 每个线程独享 jvm 栈、本地方法栈和程序计数器。
  • 所有线程共享堆内存、堆外内存(元空间或永久代、代码缓存)。
堆内存和方法区(元空间或永久代)会发生 GC 和 OOM,其中大部分 GC 都发生在堆内存(95%)
jvm 栈和本地方法栈会发生 OOM,不会发生 GC
程序计数器不会发生 GC 和 OOM。

2、程序计数器

程序计数器(Program Counter Register): 也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器
在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

2.1 查看字节码

例如下面 java 代码
查看字节码有两种方式:
方式1(控制台命令):
  1. 右键字节码文件打开控制台终端:
    1. notion image
  1. 在控制台输入命令:javap -v 字节码文件名称
    1. notion image
  1. 红框里的内容即为字节码文件反编译后的内容
    1. notion image
方式2(IDEA插件):
  1. 安装 jclasslib Bytecode Viewer  插件
    1. notion image
  1. 打开字节码文件 PCRegister.class -> 点击 View -> 点击 Show Bytecode with Jclasslib
    1. notion image
  1. 选中方法 -> 选中main -> 选中 Code ,即可查看字节码反编译后的内容
    1. notion image

2.2 分析字节码

notion image
如上图,字节码文件反编译后可以看到有一系列 指令地址操作指令
要想让计算机执行程序,需要让执行引擎中的解释器将字节码操作指令解释成CPU能够识别的机器指令
而选取哪一条操作指令进行解释并执行,这个时候就需要依赖于程序计数器了。可以把它想象成一个临时空间,用于存储字节码操作指令的指令地址
本图中,0 就是一个指令地址,通过指令地址就能够找到哪条指令,说明当前需要选取执行的操作指令是:iconst_1
如果执行完 0 后,需要执行指向 1 的这条指令,那么将程序计数器(PC计数器)中存储的指令地址改成 1 就行了。
PC寄存器的特点:
  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域

3、虚拟机栈

每个线程在创建的时候都会创建一个虚拟机栈(早期也叫 Java 栈),一个栈内有多个栈帧,每个栈帧对应一个方法,栈帧存储方法执行过程中的各种数据信息。方法开始执行对应入栈操作,方法执行结束对应出栈操作。虚拟机栈的特点:
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
  • 栈中可能出现的异常(Java 虚拟机规范允许 Java 虚拟机栈的大小是动态的或者是固定不变的):
    • 如果采用固定大小的 Java 虚拟机栈(可以通过参数-Xss来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度),那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。
    • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。
notion image
每个栈帧(Stack Frame)中存储着:
  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或称为表达式栈)
  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用
  • 方法返回地址(Return Address):方法正常退出或异常退出的地址
  • 一些附加信息

3.1 局部变量表

局部变量表实现形式为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,包括:
  • 编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)
  • 对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)
  • returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)。
注意:
  • 局部变量表的最小单位是 slot。
  • 局部变量表的变量只在当前方法调用中有效。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

3.2 操作数栈

操作数栈实现形式为一个数字数组,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
根据字节码指令往栈中写入数据或取出数据,一般用于复制、交换、求和等操作。
notion image

3.3 动态链接

动态链接指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
notion image

3.4 方法返回地址

方法返回地址存放调用该方法的 PC 寄存器的值
一个方法的结束,有两种方式
  • 正常执行完成
  • 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。

3.5 附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。

4、本地方法栈

一个 Native Method 就是一个 Java 调用非 Java 代码的接口。Native Method 带 native 修饰符。
  • 本地方法是使用 C 语言实现的。它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
  • 并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈
  • 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

5、堆内存

5.1 堆内存介绍

Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域用于存放对象实例,所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
  • 新生代:新对象和没达到一定年龄的对象都在新生代。新生代又分为 Eden 区S0 区(From)S1 区(To),默认比例是8:1:1
  • 老年代:存放许多 minor gc 次数后仍然存活的对象和大对象。一般新生代和老年代的比例为 1:2
JDK8 以后移除了永久代的概念,使用元空间替代了永久代,元空间已经不属于堆内存的一部分了。
notion image

5.2 堆内存分配策略

5.2.1 对象内存分配过程

  1. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区。此时 JVM 会给对象定义一个对象年龄计数器。
  1. Eden 区被放满之后,会触发一次 minor gc,通过 gc 算法收集 Eden 区的存活对象,把存活的对象放在 From 区,并且对象年龄 +1,此时 To 区为空。
  1. Eden 区再次被放满之后,会再触发一次 minor gc,通过 gc 算法收集 Eden 区和 From 区的存活对象,把存活的对象放在 To 区,并且对象年龄 +1,然后清空 From 区。清空后 From 区和 To 区的角色互换,空的区域变成 To 区。当存活对象的年龄达到阈值(-XX:MaxTenuringThreshold 控制,默认 15 次),对象会晋升到老年区。
  1. Eden 区如果 minor gc 完后也放不下该对象,就尝试把对象放到老年区。如果老年区也放不下该对象,会触发一次 full gc。full gc 后老年代还是放不下,就触发 OOM。

5.2.2 对象什么时候进入老年代

  • 对象存活次数超过阈值(默认15)。阈值可以通过参数 -XX:MaxTenuringThreshold=15 设置。
  • 超大对象。对象大小超过了 -XX:PetenureSizeThreshold 参数设置的值,对象就不会分配在 Eden 区,而是直接被分配到老年代此参数只对 Serial 及 ParNew 两款收集器有效。
  • 动态对象年龄判定。Survivor 区中如果有相同年龄的对象所占空间大于 Survivor 区的一半,那么年龄大于等于该年龄的对象就可以直接进入老年代。
  • 空间分配担保。Minor GC 后,当 Eden 和一个 Survivor 区中依然存活的对象无法放入到另一个 Survivor 区,则通过分配担保机制提前转移到老年代中。

5.2.3 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。动态对象年龄判断是指:如果在 Survivor 空间从年龄 0开始累加,当累加到某个年龄时,对象总大小超过了 Survivor 区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
动态对象年龄判定的作用是当某年龄段对象较多时提前晋升,避免长期存活对象在 Survivor 区反复复制。
假设:
  • Survivor 区大小 = 100MB
  • 年龄分布:年龄1 = 30MB,年龄2 = 30MB,年龄3 = 20MB
计算:
  • 年龄1(30MB) < 50MB(一半)
  • 年龄1 + 年龄2(60MB) > 50MB
则年龄2 及以上的对象(年龄2和年龄3)都会被晋升到老年代。
相关参数:
  • 大对象直接进入老年代。那多大的对象是大对象?这个阈值通过 -XX:PretenureSizeThreshold 参数来配置。
  • 年龄大于阈值,进入老年代。这个阈值通过 -XX:MaxTenuringThreshold 来配置,默认是 15。
  • 动态年龄判断涉及到一个值,就是 survivor 区域的一半,其实也不一定是一半,可以通过 -XX:TargetSurvivorRatio 来配置。

5.2.4 空间担保机制

空间分配担保机制(Space Allocation Guarantee Mechanism)是指在 Minor GC 前预先检查老年代是否有足够空间,从而决定是否允许进行 Minor GC 或需要直接触发Full GC。这是一种确保老年代有足够空间容纳从新生代晋升的对象的策略。
空间担保机制主要解决以下问题:当新生代进行 Minor GC 时,如果存活对象太多而 Survivor 区无法容纳,这些对象需要晋升到老年代。但如果老年代空间也不足,就会导致晋升失败。
空间担保机制的过程:发生 Minor GC 之前,JVM 虚拟机会检查老年代最大可用的连续空间是否大于新生代所有空间总和
  • 如果大于,那么此次 Minor GC 是安全的。
  • 如果小于,则 JVM 查看 -XX:HandlePromotionFailture 是否允许担保失败。
    • 如果 HandlePromotionFailture=true ,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小
      • 如果大于,则尝试进行一次 Minor GC,但此次 Minor GC 仍然是有风险的。
      • 如果小于,则改为进行一次 Full GC。
    • 如果 HandlePromotionFailture=false ,则改为进行一次 Full GC。
为什么需要空间分配担保机制?
因为新生代采用复制收集算法,假如大量对象在 Minor GC 后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而 Survivor 空间是比较小的,这时就需要老年代进行分配担保,把 Survivor 无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行 Full GC 来让老年代腾出更多空间。

5.3 堆内存常用参数

  • -Xms :设置堆初始化内存大小,等价于 -XX:InitialHeapSize ,默认为物理内存的 1/64。
  • -Xmx :设置堆最大内存大小,等价于 -XX:MaxHeapSize ,默认为物理内存的 1/4。一般设置 -Xmx-Xms 相同的值,目的是为了防止堆内存频繁伸缩造成额外系统消耗。
  • -Xmn :设置新生代内存大小,如果同时设置了 -XX:NewRatio-Xmn ,则以 -Xmn 的值为准。
  • -XX:+PrintGCDetails :打印 GC 细节。
  • -XX:NewRatio :设置新生代和老年代的比例。默认为 2,表示新生代和老年代的比例为 1:2,新生代占整个空间比例为 1/3。如果改成 4,表示新生代和老年代的比例为 1:4。
  • -XX:SurvivorRatio :设置新生代中 Eden 区和单个 Survivor 区的比例。默认为 0,表示 eden:s0:s1 的比例为 8:1:1。
  • -XX:MaxTenuringThreshold :新生代的 Survivor 区的对象需要晋升到老年代的年龄阈值,默认为15。
  • -XX:HandlePromotionFailture :是否设置空间担保。

5.4 GC垃圾回收

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
  • Partial GC:收集整个 Java 堆的 GC,其中又分为:
    • 新生代收集(Minor GC/Young GC):只收集新生代区的 GC。会触发 STW。因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
    • 老年代收集(Major GC/Old GC):只收集老年代区的 GC。只有 CMS 的 concurrent collection 是这个模式。会触发 STW,Major GC 的速度一般比 Minor GC 慢 10 倍以上,所以 STW 的时间更长。
    • Mixed GC:收集整个新生代区以及部分老年代区的 GC。只有 G1 有这个模式。
  • Full GC:收集整个 Java 堆和方法区的 GC。
许多 Major GC 是由 Minor GC 触发,所以很多情况下将这两种 GC 分离是不太可能的。所以很多时候 Major GC 会和 Full GC 混合使用,我们不用去关心到底是叫 Major GC 还是 Full GC,大家应该关注当前的 GC 是否停止了所有应用程序的线程,还是能够并发的处理而不用停掉应用程序的线程。
Minor GC 的触发机制:
  • 当年轻代空间不足时就会触发 Minor GC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。
Major GC 的触发机制:
  • 当老年代空间不足的时候会触发 Major GC。
  • 出现了 Major GC,经常会伴随至少一次的 Minor GC,也就是老年代空间不足时,会先尝试触发 Minor GC,如果还是空间不足,则触发 Major GC。但非绝对的,ParallelScavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程 。
  • Major GC 后,如果老年代还是空间不足,就报 OOM 了。
Full GC 的触发机制:
  • 调用 System.gc() 。系统建议执行 Full GC,但是不必然执行,具体执行时机由 JVM 控制。
  • 老年代空间不足
  • 方法区空间不足
  • 空间担保失败。通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存。
  • GC 晋升失败(Promotion Failed):通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存时。由 Eden 区、From Survior 区向 To Survior 区复制时,对象大小大于 To Survior 区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小时。
  • Concurrent Mode Failure。如果老年代使用了 CMS 垃圾回收器,因为 CMS 垃圾回收器的垃圾回收线程和用户线程是并发执行的,在 CMS GC 的过程中,如果新生代 Survivor 空间放不下,需要放入老年代,而老年代也放不下,或者用户直接把对象放入老年代放不下,就会报 Concurrent Mode Failure。
  • 执行 jmap -histo:live 或者 jmap -dump:live

5.5 GC日志分析

Minor GC 日志分析

notion image

Full GC 日志分析

notion image

5.6 TLAB

TLAB 是堆内存(Eden 区)为单个线程单独分配的私有内存区域,每个线程在创建对象时,优先在自己的 TLAB 分配内存,从而避免多线程竞争获取全局堆内存。
为什么有TLAB (Thread Local Allocation Buffer) ?
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
TLAB开启和配置:
  • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
  • 在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启TLAB空间。
  • 默认情况下, TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 -XX:TLABSize 设置 TLAB 空间大小。
  • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
TLAB 中的对象分配过程
notion image

5.7 逃逸分析

逃逸分析是分析对象是否会在方法或线程之外被引用,当一个对象在方法中被定义后,只在方法内部使用,没有被外部方法或其他线程所引用,则认为没有发生逃逸。通过逃逸分析,Java Hotspot 编译器可以进行以下优化:
  • 栈上分配。对于未逃逸的对象,可以直接在栈上分配内存,而不必在堆上分配。栈分配可以快速地在栈帧上创建和销毁对象,减少 JVM 垃圾回收的压力。
  • 标量替换。标量替换是指可以将对象打散,拆解成基本类型的成员变量(标量),将这些标量直接存储在寄存器或者 JVM 栈上,而非堆内存里面。可以减少对象的内存分配和垃圾回收成本。
  • 消除同步锁。如果一个对象只被一个线程访问到,则访问这个对象时,可以不加同步锁。如果程序中使用了 synchronized 锁,则 JVM 会将 synchronized 锁消除。注意:
    • 这种情况针对的是 synchronized 锁,而对于 Lock 锁,则 JVM 并不能消除。
    • 要开启同步消除,需要加上 -XX:+EliminateLocks 参数。因为这个参数依赖逃逸分析,所以同时要打开 -XX:+DoEscapeAnalysis 选项。
逃逸分析案例一:
在 ObjectEscape 类中,存在一个成员变量 user,我们在 init() 方法中,创建了一个 User 类的对象,并将其赋值给成员变量 user。此时,对象被复制给了成员变量,可能被外部使用,此时的变量就发生了逃逸。
逃逸分析案例二:
另一种典型的场景就是:对象通过 return 语句返回。如果对象通过 return 语句返回了,此时的程序并不能确定这个对象后续会不会被使用,外部的线程可以访问到这个变量,此时对象也发生了逃逸。

6、方法区

方法区是线程共享的一块内存空间,类似于堆内存,用来存储以下信息:
  • 类的元数据:被虚拟机加载的类信息,包括类的名称、访问标志、父类、接口、字段、方法等信息。
  • 运行时常量池:在 Java 代码中,常量可以被直接定义在类或接口中,这些常量在编译后被存储在 Class 文件的常量池中,而运行时常量池则是从 Class 文件中加载的。
  • 静态变量:类的静态变量和常量都存储在方法区中,它们在类加载的时候被初始化并分配内存空间。
  • 方法字节码:在 Java 中,方法的字节码被编译成 Class 文件并存储在方法区中。
  • 即时编译器(JIT)编译后的代码:为了提高程序的执行效率,JIT会将热点代码编译成本地机器码并存储在方法区中。
注意:
  • 方法区是 JVM 规范中定义的一个概念。只有 HotSpot 才实现了方法区,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有方法区。
  • 方法区(Method Area) 与 Java 堆一样,是各个线程共享的内存区域。
  • 方法区在 JVM 启动的时候创建,并且它的实际的物理内存空间和 Java 堆区一样都可以是不连续的。
  • jdk7 及以前,习惯上把方法区称为永久代。jdk8 开始,使用元空间取代了永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
    • jdk7 以上:-XX:PermSize=100m -XX:MaxPermSize=100m
    • jdk8 及以后:-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区的溢出,虚拟机同样会抛出内存溢出错误: java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError:Metaspace
  • 关闭 JVM 就会释放这个内存区域。
  • 在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 oSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

7、字节码执行过程

notion image
首先将操作数 500 放入到操作数栈中
notion image
然后存储到局部变量表中
notion image
然后重复一次,把 100 放入局部变量表中,最后再将变量表中的 500 和 100 取出,进行操作
notion image
将 500 和 100 进行一个除法运算,在把结果入栈
notion image
在最后就是输出流,需要调用运行时常量池的常量
notion image
最后调用 invokevirtual(虚方法调用),然后返回
notion image
返回时
notion image
程序计数器始终计算的都是当前代码运行的位置,目的是为了方便记录 方法调用后能够正常返回,或者是进行了CPU切换后,也能回来到原来的代码进行执行。
JVM系列:JVM类的初始化之类加载的时机和步骤JVM 内存分析工具 MAT 的深度讲解与实践——进阶篇
Loading...