深度剖析JVM内存模型与对象分配机制及相关案例
JVM内存模型深度剖析与对象分配机制

一、背景铺垫
/**
0. cd 文件所在目录
使用 javac 命令可以将 Java 文件编译成字节码文件,例如将 Math.java 编译后会生成 Math.class 。
2. 运行 java Math
*/
public class Math {
定义一个方法 add,该方法接收两个整数参数 a 和 b,方法返回一个整数类型的值。
return a + b;
}
公共静态的 void 主函数(字符串数组参数 args){
Math math = new Math();
System.out 会输出 math 类中 add 方法的结果,该方法接收两个参数 1 和 2 并进行相加操作。
}
复制代码
我们知晓 java 语言的一个重要特性为跨平台。我们平时所写的代码会被编译成字节码文件,然后在 jvm 虚拟机中运行。在开始 JVM 内存模型之前,先简单运行此案例,并且要记住它。
二、JVM整体结构及内存模型
如图所示,JVM运行时一共分为如下几块数据区
我们都清楚,堆是用来存放对象的。堆被分成不同的区域,包括新生代和老年代。其中,新生代又分为 Eden、s0 和 s1 区域。当再次触发 gc 时,新生代会触发 minor gc,老年代会触发 full gc。
方法区 - 常量 + 静态变量 + 类信息
栈 - 线程运行在栈上
本地方法栈 - 运行 native 修饰的方法
程序计数器用于记录当前线程正在执行的字节码指令的地址。它能够用来对线程的恢复以及继续执行提供支持。当线程被切换至另一个线程时,当前线程的程序计数器的值会被保存起来。而当该线程再次被切换回来时,它的程序计数器的值会被恢复。通过这个过程,线程能够在被中断之后继续执行。
字节码执行引擎负责执行 java 代码。每执行一行代码,字节码执行引擎就会修改线程栈中程序计数器的值。
我们知晓,java 线程是在栈上运行的。每个线程启动时,会在栈上划分出一块区域,以供该线程使用。当一个方法被调用时,Java 虚拟机就会为该方法创建一个栈帧,并且把这个栈帧压到栈顶。当方法执行结束后,虚拟机又会弹出这个栈帧,把控制权交还给调用该方法的方法。栈帧包含局部变量表、操作数栈、动态链接以及方法出口。
局部变量表 — 用于存放方法运行过程汇总的局部变量
方法执行时,会读取字节码中的指令。根据指令类型,会从操作数栈中弹出相应数量的操作数。执行指令后,将结果压回操作数栈中。
动态链接,具体而言,在程序调用一个方法时,虚拟机需要在类的方法表中去查找该方法的符号引用,然后把它转化为直接引用。
方法出口是因为在 Java 调用中,栈上的变化表现为栈帧的进栈和出栈。当一个方法调用结束后,需要返回到调用该方法的地方,然后继续执行后续的操作。
三、java 中的对象
我们都知晓,在日常使用 java 时,通过 new 关键字创建的对象,在大部分情况下是存储在堆中的(有一些情况会触发栈上分配,后续会讲解)。那么,我们的对象具有什么样的结构呢?并且对象创建的流程又是怎样的呢?
对象的结构
对象结构包含两个主要部分:对象头和实例数据。
对象头是一个数据结构,它的大小是固定的。这个数据结构用于存储对象的元数据信息。这些元数据信息包含对象的哈希码、类元数据指针以及锁状态标志等。不同的 JVM 实现,对象头的大小和内容可能会有所不同。
实例数据是对象存储的重要部分,其中包含对象的属性或字段的值,并且可能还包含一些对象的方法以及其他操作。
对象创建流程
当使用 new 关键字创建对象时,JVM 会首先进行类加载检查。它会检查对应的类是否已经被加载进 JVM 内存。如果该类尚未被加载,JVM 会先进行类的加载操作。
内存分配方面,我们知晓对象是有一定大小的,并且是存储在堆里面的。当我们要在堆中创建一个新的对象时,就需要一小块连续的区域来放置该对象。这样一来,就存在两个小问题了。
jvm分配内存有哪些方法
指针碰撞法
指针碰撞是一种内存分配算法。它通常被用于基于物理地址的内存管理系统。这种算法借助指针在内存里的连续性,把内存划分成两个区域,一个是已分配的,另一个是未分配的。当要分配一块新的内存时,指针会径直指向未分配区域的空闲内存。接着把指针向前移动所需的内存块大小。之后再返回指针的旧值,此旧值即为分配的内存的起始地址。
空闲列表
空闲列表是内存分配算法中常用的数据结构。它用于管理可用的内存块,也就是那些还未被分配的内存块。空闲列表通常呈现为一个链表的形式,链表里的每个节点都代表着一个空闲的内存块,并且在节点中存储着该内存块的大小以及起始地址等相关信息。
jvm 怎样确保在对象分配过程中,多线程同时进行分配时不会出现内存泄露的问题呢?
CAS compare and swap
本地存在线程分配缓冲,其全称为 Thread Local Allocation Buffer,通常简称为 TLAB。
在 Java 中,因为多线程共享同一块内存区域,所以在对象分配时会有争抢线程的情况。本地线程分配缓冲意味着每个线程在创建时,会在堆中开辟出一块仅该线程独享的区域。当线程需要分配内存时,JVM 会尝试从该线程的 TLAB 里分配内存。倘若 TLAB 中没有足够的空间,就会退化成普通的堆内存分配,这时就需要加锁来确保线程安全。
对象创建完成后会对其中的变量进行初始化,会初始化各种类型的默认值,这一步操作保证了对象的实例字段在 Java 代码中可不赋初始值直接使用,程序能访问到这些字段对应的数据类型的零值。
设置对象头 — 设置对象头相关信息
执行 init 方法,也就是对象按照程序员的意愿进行初始化。从语言层面来讲,这包括为属性赋值(需注意,此赋值与赋零值不同,是由程序员赋予的值)以及执行构造方法。
四、对象内存分配流程
栈上分配
我们知晓 java 的对象是在栈上进行分配的。当对象未被引用时,会经由 gc 进行回收。然而,倘若创建诸多未被引用的对象,就会给 gc 带来一定压力。在创建对象的过程中,jvm 会借助一种逃逸分析技术,来判断所创建的对象是否会逃逸出我们的方法。倘若无法逃逸出去,且空间足够,就会实施栈上分配。这样一来,随着线程运行完毕,空间会直接被销毁,从而减少 gc 压力。
逃逸分析
public User save1(){
User user = new User();
user.setId(1);
user.setName(“admin”);
//TODO 保存数据库
return user;
public void save2(){
User user = new User();
user.setId(1);
user.setName(“admin”);
//TODO 保存数据库
复制代码
在 save1 方法中创建的对象,最终会被返回。这意味着 user 对象有可能会被方法外的变量所引用。然而,在 save2 方法中创建的对象,无法逃出 save2 方法。当 save2 方法运行结束后,user 对象就会变成垃圾对象。对于这样的对象,我们实际上可以将其分配到栈上,随着方法的结束而释放内存。
JVM 能够通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来对这种情况进行优化,以改变对象内存分配的位置,让对象能够通过标量替换而优先在栈上进行分配(即栈上分配)。在 JDK7 之后,逃逸分析是默认开启的。如果想要关闭逃逸分析,就需要使用参数(-XX:-DoEscapeAnalysis)。
当通过逃逸分析确定该对象不会被外部访问,且对象可以被进一步分解时,JVM 不会创建该对象,而是用若干个被这个方法使用的成员变量来代替该对象的成员变量,这些代替的成员变量在栈帧或寄存器上分配空间,从而不会因为没有一大块连续空间而导致对象内存不够分配。开启标量替换参数,即(-XX:+EliminateAllocations)。在 JDK7 之后,此参数是默认开启的。
在 JAVA 中,对象是可以被进一步分解的聚合量。
测试栈上分配
/**
minor gc 和full gc 的不同
minor gc 发生在新生代。它的特点是较为频繁,并且耗时较短。
Full gc 发生在早老年代 gc 时,通常会回收新生代、老年代和方法区。它的特点是发生不频繁,但耗时较长。
Eden与Survivor区默认8:1:1
大量对象被分配在 eden 区。当 eden 区满了时,会触发 minor gc。在 minor gc 中,可能有 99%以上的对象会成为垃圾被回收掉。剩余存活的对象会被挪到为空的那块 survivor 区。下一次 eden 区满了后,又会触发 minor gc,将 eden 区和 survivor 区的垃圾对象回收。然后,把剩余存活的对象一次性挪动到另外一块为空的 survivor 区。由于新生代的对象存活时间很短,朝生夕死,所以 JVM 默认的 8:1:1 的比例是很合适的,这样能让 eden 区尽量大,survivor 区够用即可。
JVM 默认带有参数 -XX:+UseAdaptiveSizePolicy(此参数默认开启),此参数会使 8:1:1 这个比例自动发生变化。倘若不想让这个比例发生变化,那么可以设置相关参数。
-XX:-UseAdaptiveSizePolicy
示例:
public class GCTest {
/**
* -XX:+PrintGCDetails
*
* @param args
*/
public static void main(String[] args) {
// 这里原本还有 allocation3、allocation4、allocation5、allocation6,但被注释掉了。
分配一个新的字节数组,其大小为 60 乘以 1024 乘以 1024;
分配内存空间,分配的内存大小为 8 乘以 1024 乘以 1024 字节,通过 new byte[]的方式进行分配,分配的结果存储在 allocation2 中。
}
Heap
PSYoungGen 的总量是 76288K,已使用 65536K。其内存范围是从 0x000000076ab00000 到 0x0000000770000000,而总范围是 0x00000007c0000000。
eden space 的大小为 65536K,且已被 100%使用,其使用范围为[0x000000076ab00000,0x000000076eb00000,0x000000076eb00000)
从空间 10752K 来看,目前使用了 0%,其内存范围为[0x000000076f580000,0x000000076f580000,0x0000000770000000)。
分配了 10752K 的空间,目前使用了 0%,其范围是从 0x000000076eb00000 到 0x000000076eb00000,再到 0x000000076f580000。
ParOldGen 的总容量为 175104K,已使用 0K。其内存范围为 0x00000006c0000000 到 0x00000006cab00000,而可用范围是 0x00000006cab00000 到 0x000000076ab00000。
object space 的大小为 175104K,其使用率为 0%,当前使用范围是从 0x00000006c0000000 到 0x00000006c0000000,可用范围是 0x00000006c0000000 到 0x00000006cab00000 。
Metaspace 占用了 3276K,其容量为 4496K,已提交 4864K,预留了 1056768K
类空间使用了 362K,其容量为 388K,已提交 512K,预留 1048576K
程序已完成并退出,代码为 0。
我们看到 eden 分配了 60M 的空间。此时 eden 区已经被占满。我们打开了 allocation2 的注释,然后查看 gc 日志。
[GC 发生分配失败] [PSYoungGen:从 65372K 变为 608K(76288K)] 65372K 变为 62048K(251392K),耗时 0.0220770 秒] [时间:用户时间=0.07 秒,系统时间=0.02 秒,实际时间=0.02 秒]
Heap
PSYoungGen 的总量为 76288K,已使用 9455K。其内存范围为 0x000000076ab00000 到 0x0000000774000000,总范围是 0x00000007c0000000。
eden space 的大小是 65536K,已使用 13%。其使用范围是从 0x000000076ab00000 到 0x000000076b3a3ef8,总范围是 0x000000076eb00000。
从空间 10752K 来看,已使用 5%。其使用范围为[0x000000076eb00000, 0x000000076eb98020, 0x000000076f580000)。
分配了 10752K 的空间,目前使用了 0%,其范围是从 0x0000000773580000 到 0x0000000773580000 再到 0x0000000774000000 。
ParOldGen 的总量是 175104K,已使用 61440K。其内存范围是从 0x00000006c0000000 到 0x00000006cab00000,而总范围是 0x000000076ab00000 。
object space 的大小为 175104K,其使用比例为 35%。使用范围是从 0x00000006c0000000 到 0x00000006c3c00010,且在 0x00000006cab00000 这个位置之前。
Metaspace used 3276K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 362K, capacity 388K, committed 512K, reserved 1048576K
我们看到,此时触发了一次 minorgc 操作。这次操作会把 eden 区的 60M 数据向 Survivor 区移动。然而,Survivor 区的空间不足。最终,这些数据被移动到了老年代。
复制代码
大对象直接进入老年代
大对象指的是需要大量连续内存空间的对象,例如字符串以及数组。同时,这与 JVM 参数相关。
PretenureSizeThreshold 能够设定大对象的尺寸。若对象超出所设定的尺寸,便会直接进入老年代,而不会进入年轻代。此参数仅在 Serial 和 ParNew 这两个收集器的情况下有效。
长期存活的对象进入老年代
jvm 每进行一次 minor gc 时,若有没被回收掉的对象,其年龄就会加 1 ,因为对象年龄在对象头中有一个字段来表示。当对象年龄大于 Jvm 设定的值时,比如默认情况下,jvm 中 minor gc 的默认年龄阈值是 15 ,cms 回收器的默认年龄阈值是 6 ,此时就会把对象从新生代挪到老年代。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来进行设置。
对象动态年龄判断机制
当前在 Survivor 区域里,有一块放对象的 s 区。当这块区域中一批对象的总大小大于该 Survivor 区域内存大小的 50%(可通过 -XX:TargetSurvivorRatio 指定)时,那么年龄等于或大于这批对象年龄最大值的对象,就能够直接进入老年代。
例如
-XX:MaxTenuringThreshold = 15
年龄为1 的对象 占 33%
年龄为2 的对象 占 33%
年龄为3 的对象占 34%
当 Survivor 区已满时,按照年龄从小到大对对象进行计算,即年龄 1、年龄 2、年龄 N 等对象。当指导对象所占空间超过 50%时,会将大于该年龄的对象移入老年代。
针对上述案例,当年龄 1 与年龄 2 的对象之和大于 50%时,就会将年龄大于等于 2 的对象提前转移到老年代。
老年代空间担保机制
年轻代每次进行 minor gc 之前,JVM 都会对老年代剩余可用空间进行计算。
这个可用空间比年轻代里现有的所有对象大小之和(包括垃圾对象)要小。
就会看一个“
jdk1.8 默认设置了“-XX:-HandlePromotionFailure”这个参数,需要确认该参数是否被设置了。
如果存在这个参数,就会去查看老年代的可用内存大小,并且会查看这个大小是否大于之前每一次 minor gc 之后进入老年代的对象的平均大小。
如果上一步结果小于或之前说的参数未设置,就会触发一次 Full gc,即对老年代和年轻代一起回收一次垃圾。若回收完毕后仍没有足够空间存放新对象,就会发生“OOM”。
如果在 minor gc 之后,剩余存活且需要挪动到老年代的对象大小比老年代可用空间大,那么就会触发 full gc。在 full gc 完成之后,如果仍然没有空间来放置 minor gc 之后的存活对象,那么就会发生“OOM”。
五、判断对象是否是垃圾对象的两种方法
引用计数法
一个对象未被一个变量引用时,其引用计数会加 1。当对象的引用计数为 0 时,可判断此对象为垃圾对象。然而,这里存在一个循环引用的问题。
public class CycleRefObject {
一个名为 public CycleRefObject 的实例 instance 为 null 。
public static void main(String[] args) {
创建了一个 CycleRefObject 对象 a ,此时 a 对象的引用计数为 1 。
创建了一个 CycleRefObject 对象 b ,此时 b 对象的引用计数为 1 。
b.instance 等于 a;此时 a 对象的引用计数加 1 变为 2。
a.instance 等于 b;b 对象的引用计数此时增加 1,变为 2。
a 为 null;此时 a 对象的引用计数从 -1 变为 1。
b 为 null;此时 b 对象的引用计数从 -1 变为 1。
当我们的程序运行结束后,a 对象的引用计数为 1,b 对象的引用计数也为 1,并且无法将它们的引用计数清零,这就是我们通常所说的循环引用。
}
复制代码
对象可达性分析算法
以“GC Roots”对象当作起点,从这些节点开始朝着下方去搜索被引用的对象,那些被找到的对象都被标记为非垃圾对象,而其他未被标记的对象则都是垃圾对象
GC Roots 根节点包括线程栈的本地变量,也包括静态变量,还包括本地方法栈的变量等等。
六、Java 中的引用类型
在 Java 中,存在着四种引用类型,分别是强引用、弱引用、软引用以及虚引用。
强引用 : 普通变量的引用
这个对象是通过 new User() 语句创建的。
软引用是用 SoftReference 软引用类型的对象来包裹对象。在正常情况下,这些对象不会被回收。然而,当 GC 完成后,如果发现没有空间存放新的对象,那么就会回收这些软引用的对象。软引用可以用来实现内存敏感的高速缓存。
创建了一个名为 user 的静态软引用,该软引用指向一个新创建的 User 对象。具体来说,通过 new SoftReference(new User()) 语句创建了一个软引用实例,并将其赋值给 public static SoftReference user 变量。
用 WeakReference 软引用类型的对象把对象包裹起来,这种弱引用与未引用的情况较为相似,垃圾回收器会直接将其回收掉,所以这种方式很少被使用。
创建了一个新的 User 对象,然后使用 WeakReference 类将其包装起来,生成了一个名为 user 的静态弱引用对象。
虚引用 : 几乎不用
七、总结
jvm 内部的模型一共分为堆、方法区、栈、本地方法栈以及程序计数器。
对象分配内存空间存在两种算法,分别是指针碰撞和空连列表。为了确保对象分配不出现并发问题,所采用的方法是 CAS 和 TLAB。
对象分配流程中 逃逸分析 标量替换以及栈上分配
对象被分配到 Eden 区,并且 Eden 区与 Survior 区的默认比例是 8 : 1 : 1
对象进入老年代存在几种机制。其一,大对象会直接进入老年代;其二,长期存活的对象会进入老年代;其三,有对象动态年龄判断机制;其四,还有老年代空间担保机制。
JVM 用于判断对象是否为垃圾的算法有两种,分别是引用计数算法和可达性分析算法。
在 Java 中,有四种常见的引用类型,分别是强引用、软引用、弱引用和虚引用。
- XX:+PrintGC 用于打印 Gc。