本篇内容为了解 JVM 的内存区域划分,类加载机制,垃圾回收机制。实际开发中几乎用不到,但为了某些情况我们又不得不了解。
目录
一、JVM中的内存区域划分
1.1 内存区域划分考点
二、JVM的类加载机制
2.1 类加载流程
2.2 类加载什么时候会触发?
2.3 双亲委派模型
三、JVM垃圾回收机制
3.1 找垃圾
3.1.1 引用计数
3.1.2 可达性分析
3.2 释放垃圾
3.2.1 标记清除
3.2.2 复制算法
3.2.3 标记整理
3.2.4 分代回收
一、JVM中的内存区域划分
JVM 本质上是一个 Java 进程,Java 进程会从操作系统申请一大块区域给 Java 代码使用。这一大块区域会进行划分,主要划分为堆、栈、方法区/元数据区。具体分为:
- 堆:存放对象实例(所有被 new 出来的对象)
- 栈:存放方法调用的栈(局部变量、方法的参数、返回地址等)
- 方法区/元数据区:存储类元数据(类结构、方法代码、静态变量、常量池等)
- 程序计数器:记录当前线程执行的字节码指令地址(即JVM指令的行号)
- 本地方法栈:为执行本地方法(Native Method,如JNI调用)提供栈帧
- 虚拟机栈:执行 Java 代码
如上图所示,对基本的内存区域划分图,下面来详细解释每个区域对应的工作。
- 堆,主要是 new 出来的对象即成员变量
- 栈,主要是用来维护方法之间的调用即局部变量
- 方法区/元数据区,方法在内存中是按照一些二进制指令的形式来存储的(字节码),特殊情况热加载机制需要在字节码维度做出一些特殊的处理。因此它是类和加载之后的类对象(.class文件、Test.class文件)(静态变量等)
- 虚拟机栈, 给 Java 代码使用的
- 本地方法栈 ,给JVM 内部的本地方法使用(JVM内部通过C++方法实现)
- 程序计数器,记录当前程序指定到哪个指令,使用简单的 long 类型存储了一个内存地址,内存地址就是下一个要执行的字节码所在的地址
我们在编写 Java 代码时,会产生一份线程,这个线程会同时在操作系统和 Java 虚拟机存在,在 Java 层面会在虚拟机栈存一份线程,而操作系统层面会在本地方法栈存一份。
此外,堆区和元数据区,在一个 JVM 进程中只有一份。而栈(本地方法栈和虚拟机栈)和程序计数器是有多份的。
1.1 内存区域划分考点
考点,给出一串代码,回答某个变量处于哪个区域。
void func() {Test test = new Test();
}
test 是一个引用类型且是一个局部变量因此它是在栈上的,而 new Test() 是对象的本体它是在堆上的。 在栈中的数据存放的是在堆中对象的地址,如下图所示:
二、JVM的类加载机制
2.1 类加载流程
把 .class 文件加载到内存,得到类对象这样的过程叫做类加载。程序需要想运行,就需要依赖某些指令和数据放入内存中。流程:加载、验证、准备、解析、初始化。
- 加载,找到 .class 文件,并且读文件内容。
- 验证,.class 文件是否为明确的数据格式(二进制),在官方文档中 class 文件中是以一个 C++ 结构体形式存放的,每一步都有明确的数据格式。
- 准备,给类对象分配内存空间(未初始化的空间,内存空间中的数据全是0,类对象中的静态成员也全是0),类加载最终就是为了得到类对象。
- 解析,针对字符串常量进行初始化,会将符号引用替换为直接引用。
- 初始化,针对类对象进行初始化(初始化静态成员、执行静态代码块、类如果有父类还要加载父类)以上的五个步骤都是 JVM 中的一块代码,都是人为划分出来的,
解析步骤问题,常量池内的符号引用替换为直接引用的过程?举例说明?
举例说明,在高中我暗恋一个女生,有一次全校举行看电影,我为了和我的女神坐在一起,但排列方式是按照座位来决定的。于是在去操场的过程中我一直帖着她走,最后到达地点后我成功的与她坐在了一起。而这个过程就是符号引用替换到直接引用了,也就是说在到达地点之前我是不知道能够与她坐一起而在去操场的过程中她和我的相对位置是确定的,直到最后位置确定了,她和我之间的变为绝对位置了。
在 Java 层面,类名、方法名、字段名都是以字符串形式存放的,在 .class 文件中字符串常量之间会建立一个“偏移量”,即字符串常量不知道自己在内存中实际地址,在真正类加载到内中后,就会把该字符串常量放入特定的内存地址,此时字符串常量之间的相对地址还是存在的只不过类加载后给定了特殊的内存来使它们连接,也就变成了直接引用。
2.2 类加载什么时候会触发?
类加载机制并不是 JVM 一启动就把 .class 文件都加载了,而是通过一个“懒加载”的机制(懒汉模式)即非必要不加载,必要:
- 创建了这个类的实例
- 使用了这个类的静态方法/静态属性
- 使用子类,会触发父类的加载
2.3 双亲委派模型
双亲委派模型为在第一个步骤加载中出现的问题,在加载中找到 .class 文件的过程。
JVM中,加载类 需要用到一组特殊的模块,类加载器。JVM中 内置了三个类加载器:
- BootStrap ClassLoader 负责加载 Java 标准库中的类
- Extension ClassLoader 负责加载一些非标准的但是 Sun/ Oracle 扩展库的类
- Application ClassLoader 加载项目中自己写的类,以及第三方库中的类
加载一个类的过程为:
先给定一个类的全限定类名 “java.long.String" 的字符串,并把问题抛给 Application ClassLoader ,Application ClassLoader 作为“儿子”再把问题抛给 Extension ClassLoader ,Extension ClassLoader 作为“父亲“再把问题抛给 BootStrap ClassLoader ,BootStrap ClassLoader 作为”爷爷“ 而它上方为 null 此时就会自行处理问题,处理不了则把问题抛给”父亲“Exten..,依次类推,直到某一步能解决问题则执行后续的2345步骤,否则则抛出一个异常 “ClassNotFoundException”。以上的流程类似于,员工在处理问题时依次向上通报。
三、JVM垃圾回收机制
什么是 JVM 垃圾回收机制,c 语言中 malloc 必须手动进行 free 进行释放,如果程序光申请内存不释放,程序后续会崩溃。
在 Java 以及后面发明的语言,引入了 GC(Garbage Collection,垃圾回收) 来解决上述问题,它能够有效的减少内存泄露出现的问题。
内存申请的时间是明确的(使用了必须要释放),释放的时间是模糊的(程序彻底不使用了才能释放),C/C++ 靠程序设计者本人来实现的,而 Java 通过 JVM 自动判定,通过一系列策略来进行判定。
3.1 找垃圾
JVM 有多个内存区域,主要释放堆里面的内容即被 new 出来的对象。程序计数器,主要存放地址的不需要释放而是随着线程的结束一并销毁。栈,也是随着线程的结束而销毁,它里面主要存放局部变量,当方法调用完毕,方法中的局部变量就随着出栈操作就销毁了。方法区,存储的是类对象很少被销毁。
Java 中使用一个对象需要通过引用,如果一个对象没有被引用,那么它就是一个垃圾。如果一个对象被引用但并没有实质性的使用它也不能很草率的判定它是一个垃圾,因此 Java 对垃圾对象的识别是很保守的。
Java 怎样判断一个对象是否有引用指向呢?采用可达性分析,但判断的方式却有两种:引用计数和可达性分析。
3.1.1 引用计数
引用计数,给对象分配一块额外的空间,保存一个整数,表示这个对象有几个引用指向。随着引用的增加,计数器就增加。引用的销毁,计数器就减少。当计数器为0时,则认为该对象没有引用即该对象为一个垃圾。缺陷:浪费内存空间,存在循环引用问题
3.1.2 可达性分析
把对象之间的引用关系,理解成了一个树形结构。即从一些特殊的起点出现进行遍历。只要能遍历到该对象就是可达的,遍历不到(不可达)则认为是一个垃圾。 有以下伪代码:
class Node {int value;Node left;Node right;
}Node a = new Node();a.left = b
a.right = c
b.left = d
b.right = e
e.left = g
c.right = fNode root = createTree()
以上代码中的 abcdefg 结点都是能够被访问的即都是可达的,当 root.right = null 时,c 和 f 就变为不可达了,此时就认为 c 和 f 为两个垃圾。
可达性分析关键要点在于对对象进行遍历,进行遍历的起点称之为 gcroots ,可以是:
- 栈上面的局部变量,栈可能有多个(每个栈的每个局部变量都是起点)
- 常量池中引用的对象
- 方法区中的静态成员引用的对象
综上所述,可达性分析就是从所有的 gcroots 出发的,看看该 gcroots 通过引用能访问到哪些对象,把能范围的对象标记为“可达”,剩下未标记的就是不可达的。可达性分析,弥补了引用计数的两个缺陷,从而产生了自己的问题:
消耗更多时间,当某个对象变为垃圾时不能第一时间发现,因为扫描的过程是需要消耗时间的。此外,当遍历的过程中当前对象的引用发生变化了就会采用 STW(Stop The World)来解决该问题,直到问题解决。
3.2 释放垃圾
3.2.1 标记清除
将垃圾对象直接释放,释放的空间都是离散的垃圾碎片。
3.2.2 复制算法
为了避免内存碎片,采用了复制算法。将内存划分为两块区域,左侧区域用来使用,当需要释放垃圾对象时,将不释放的对象移至右侧,然后把左侧整块区域释放即可。
缺点:
- 内存利用率较低
- 当大部分是有用的对象,采用复制算法成本较高
3.2.3 标记整理
将不是垃圾的对象依次移动到前面,剩余后面的空间全部释放掉。
3.2.4 分代回收
针对不同“年龄”的对象采用不同的回收策略。
回收策略大概如下:
新创建的对象存放在伊甸区,经过各方大佬确认,当 GC 扫描到伊甸区后绝大部分对象都会被释放掉。
当对象通过 GC 在伊甸区的第一轮扫描,则会把该对象复制到生存区。生存区中有两块相同的区域,其实就是一个复制算法,用来判断该对象是否为垃圾,不是则把该对象复制到生存区的另一半。
当该对象经过多轮的 GC 后依然存在生存区,此时该对象就会通过复制算法复制到老年代区。
进入老年代区的对象,消亡的概率相对新生代要少很多,毕竟要死早死了也熬不到老年,因此在后续的 GC 扫描中就会对老年代的扫描频率降低。如果老年代中发现某个对象是垃圾了,则会使用标记整理策略来进行释放该对象。
特殊情况,如果对象非常大,则直接进入老年区,因为大对象进行复制算法成本较高,而且比较大的对象又不是很多。