1. JVM内存区域划分
1.1 内存区域划分简介
内存区域划分:实际上JVM也是一个进程,进程运行时需要向操作系统申请一些系统资源(内存就是典型的资源),这些内存空间就支撑着后续Java程序的运行,而这些内存又会根据不同的应用场景被划分为不同的空间,这就叫做"内存区域划分"
,例如下图所示:
1.2 不同区域的应用场景
JVM内存区域大致分为四个部分:堆、栈、程序计数器、元数据区
- 堆:代码中使用
new
关键字创建的对象(不是引用)都会存放在堆上,而且对象中持有的非静态成员变量也存放在堆上 - 栈:栈又被分为"本地方法栈"和"虚拟机栈",主要包含代码中的局部变量以及方法的调用关系,需要注意本地方法栈是指JVM内部通过
C/C++
等语言编写的代码中的调用关系和局部变量(一般来说谈到栈,指代的是虚拟机栈) - 程序计数器:这是相对来说区域较小的空间,专用用来存放下一条要执行的Java代码的地址,与x86CPU中的eip等寄存器类似
- 元数据区:在1.8之前也被称为"方法区","元数据"是计算机中一个常用术语,通常是起辅助性质、描述性质的属性,主要包含类的信息、方法的信息(一个程序有哪些类、一个类有哪些方法、每个方法中包含哪些指令)
如果面试官提问:“了解Java中的堆和栈吗?”,这个时候一定要先反问面试官:“您说的是数据结构中的堆和栈还是JVM内存区域中的堆和栈”
特点小结:
- 其中栈和程序计数器可能在程序中持有多份(每个线程占有一份),而堆和元数据区所有线程共享一份
1.3 经典笔试题
给出如下代码:
class Test {private int n;private static int m;
}public class Main {public static void main(String[] args) {Test t = new Test();}
}
问:上述代码中,变量n、m、t各自处于JVM内存中的哪个区域?
答:其中n在堆上,m在元数据区中,t在栈上
- 变量n是对象中持有的非静态成员变量,因此在"堆"上
- 变量m是对象中持有的静态成员变量,又被称为"类属性",因此存放在"元数据区"
- 变量t是方法中的局部变量,是一个引用类型(不是对象本身),因此存放在"栈"上
区分一个变量究竟处于哪一块内存区域归根到底是看变量的类型,究竟是局部变量还是静态成员变量还是实例成员变量!
2. 类加载机制
2.1 类加载的过程
类加载:指的是Java进程运行的时候,需要把.class
文件从硬盘上,读取到内存中,并进行一系列校验解析的过程
类加载的过程大体上可以分为如下五个步骤(网上有些说法是三个步骤,这个情况及时把3、4、5步骤整合到一起了)
- 加载:把硬盘上的
.class
文件找到(涉及双亲委派模型,后续进行介绍),打开文件,读取文件内容(二进制文件) - 验证:当前需要确保读取到的二进制文件内容,是合法的
.class
文件(字节码文件)格式
具体的验证依据,在Java的虚拟机规范中有明确说明
如上图所示,我们可以简单分析一下其中部分字段含义:
magic
:也被称为"magic number"(魔幻数字),广泛应用于各种二进制格式文件中,用来标识当前的二进制文件是哪种类型u4、u2
:表示当前字段是一个由多少字节构成的无符号整数,例如u4就代表4字节的无符号整数,u2代表2字节的无符号整数major_version、minor_version
:主版本和次版本,平常所说的Java8、11、17都是日常中使用的版本,但是JVM内部还有另一套版本体系,此处用来验证版本是否符合要求
-
准备:给类对象申请内存空间,此时申请到的内存空间,里面的默认值为全0(这个阶段类对象中的静态成员变量的值也是0了)
-
解析:主要是针对类中的字符串常量进行处理,是Java将常量池内部符号引用替换为直接引用的过程,也就是初始化常量的过程,此处
.class
文件中填充给s的内容可以看做是"符号引用"比如说类中有一个字符串常量String s = “hello”,那么这个字符串是否需要存储在.class文件中呢?是一定需要进行存储的,但是文件没有"地址"这样的概念,于是可以存储一个类似于"地址偏移量"的概念,当.class文件加载到内存中时,就具有了"地址",于是s中的内容就可以替换为真实的地址了
-
初始化:针对类对象完成后续的初始化(例如执行静态代码块的逻辑、可能还会触发父类的加载)
2.2 双亲委派机制
双亲委派机制:首先需要明确,“双亲委派"机制是在上述五步骤中 加载 环节生效的!它描述了如何在硬盘上查找.class
文件的策略
类加载器:在JVM中用于执行类加载的过程中,有一个专门的模块就叫做"类加载器”(ClassLoader),通过给定全限定类名,如java.lang.String
,就可以找到对应的.class
文件,JVM的默认类加载器有三个(可以自定义),分别称为"BootstrapClassLoader"、“ExtensionClassLoader”、“ApplicationClassLoader”
- BootstrapClassLoader:负责查找标准库当中的目录
- ExtensionClassLoader:负责查找扩展库当中的目录
- ApplicationClassLoader:负责查找当前项目的目录以及第三方库的目录
上述三个类加载器,存在着"父子关系"(不是继承中的父子关系,而是类似于"二叉树",孩子节点中有一个parent引用指向自己的父亲节点),下面我们就来介绍查找项目目录org.example.MyClass
为例介绍"双亲委派机制"的工作流程
- 从
ApplicationClassLoader
作为入口出发,开始工作 ApplicationClassLoader
不会立刻搜索自己负责的目录,而是会将搜索任务交给父加载器ExtensionClassLoader
也不会立刻搜索自己负责的目录,而是会将搜索任务交给父加载器BoostrapClassLoader
发现自己没有父加载器,于是搜索自己的负责目录(标准库目录),没找到则返回给自己的子类加载器ExtensionClassLoader
搜索自己的负责目录(扩展库目录),没找到则继续返回给自己的子加载器ApplicationClassLoader
搜索自己的负责目录(项目目录以及第三方库),此时找到了,就打开文件进行读取,如果此时还未找到,就抛出ClassNotFoundException
异常信息,表明类加载失败
该种设定方式,可以有效避免因自己命名的类路径与标准库类冲突而导致标准库功能失效!
注意:上述JVM的"双亲委派机制"只是JVM提供的默认类加载器的默认遵守标准,如果实现一个自定义的类加载器,完全可以打破此规则,此时就不适用"双亲委派机制"了
3. GC垃圾回收机制
3.1 GC背景介绍
垃圾回收机制的引入:相信大家都学过C/C++
这样的语言,其中malloc/free
这样的函数就是用来进行动态内存管理的函数,此处申请到的内存的生命周期伴随整个进程,这对于服务器程序而言是相当不友好的!试想一下如果一个请求就要通过malloc申请一块内存,但是不使用free函数释放,那么终究会导致服务器内存不够用,这就是典型的 “内存泄漏” 问题
事实上很容易出现忘记手动调用free函数的情况,因为一段程序中一旦业务逻辑十分复杂,执行过程中很容易出现异常、return语句提前结束,就存在安全隐患!
因此大佬们就在探究,是否可能做到让程序来执行自动回收内存这样的机制呢?Java就属于早期便支持垃圾回收机制这样的语言,程序会自动判定某个内存是否还会被使用,如果不使用就自动释放
背景介绍:C/C++这样的语言为什么不补充垃圾回收机制呢?C语言比较"摆烂",自然没有引入新技术的动力,而C++标准委员会的大佬们认为GC这种机制会影响程序的执行效率以及引入额外的系统开销,这与C++追求性能到极致的初衷违背,例如说:引入GC会导致有些程序的正常业务逻辑暂停执行,业界称为"STW(Stop The World)"问题
3.2 GC工作流程
首先我们需要明确,内存区域中哪些部分需要进行GC:
- 程序计数器:不需要进行GC
- 栈:不需要GC,因为栈中的局部变量出了作用域就会被回收,这是栈的特性,与GC无关
- 元数据区:不需要GC,因为通常只有"类加载"的过程,没有"类卸载"的说法
- 堆:是GC的主战场
这里的GC,我们通常指的是回收 对象 来达到释放内存的目的,我们需要明确以下两个问题:1、如何识别出对象是"垃圾";2、被标记为垃圾的对象如何进行回收
3.2.1 如何识别"垃圾"
由于在Java中我们都是通过 引用 的方式来使用对象的,所以我们只需要判断当前对象是否存在引用指向,就可以判断出这个对象是否是"垃圾",我们常用的判断方法有以下两种:1、引用计数;2、可达性分析
3.2.1.1 引用计数
引用计数:通过给每个对象分配额外的内存空间,用来保存当前对象存在多少个引用指向
例如有如下程序:
class Test {}public class Main {public static void main(String[] args) {Test t1 = new Test();Test t2 = t1;}
}
此时内存空间可以简单表示为下图:
由于垃圾回收机制中专门的扫描线程,会获取到每个对象的引用计数情况,当发现引用计数为0时,就标记为"垃圾",有此时堆上的对象具有两个引用指向,即t1和t2,因此此时引用计数器的值为2,然后将上述main方法的代码修改一下:
public class Main {public static void main(String[] args) {Test t1 = new Test();Test t2 = t1;t1 = null; // 新增代码t2 = null; // 新增代码}
}
此时引用计数情况就会置为0!
此时发现该对象的引用计数为0,就可以将对象标记为垃圾,然后后续进行释放了!但是引用计数机制存在以下致命问题:
- 占用额外的存储空间:倘若每一个对象都是用2字节的内存空间来表示当前的引用计数值,那么如果堆空间中的对象很多时,总的消耗空间也会非常大
- 存在 循环引用 问题:我们下面专门分析循环引用的场景
循环引用示例:
倘若具有如下代码:
class Test {private Test t;
}public class Main {public static void main(String[] args) {Test a = new Test();Test b = new Test();a.t = b;b.t = a;a = null;b = null;}
}
此时我们执行a = null; b = null;
代码之前的内存区域示意图:
此时堆空间地址为0x11
的对象引用计数值为2,因为存在引用a以及堆空间地址为0x12
的成员变量t,此时我们执行代码a = null; b = null;
此时内存示意图如下图所示:
此时堆空间中地址为0x11
的对象内部引用计数仍为1,因为有地址为0x12
的对象中的成员变量t引用,但是此时我们已经无法通过引用来使用对象了,因为此时堆中的对象都需要使用对方来进行访问!形成了类似"死锁"的局面
- 同时,也因为上述的问题,JVM中并没有使用"引用计数"的方案,而是采用下面"可达性分析"的方式,但是诸如PHP、Python等就是用这样的方式来处理的(如何解决我就不得而知了~)
3.2.2.2 可达性分析
可达性分析:在程序中会存在各种各样的变量,如栈中的局部变量、对象中的非静态成员变量、static修饰的静态变量、常量池中的变量,以这些变量作为根节点出发,沿着这些变量找到其中持有的引用类型的成员,逐步遍历继续访问,所有能够被访问到的对象就不是"垃圾"了,此外的不能被访问到的变量就会被回收,算法类似于图的遍历
可达性分析代码示例:
class Node {public int val;public Node left;public Node right;
}public class Main {public static void main(String[] args) {Node root = buildTree();}public static Node buildTree() {Node a = new Node();Node b = new Node();Node c = new Node();Node d = new Node();Node e = new Node();Node f = new Node();Node g = new Node();a.left = b;a.right = c;b.left = d;b.right = e;e.left = g;c.right = f;return a;}
}
其中数据结构可以表达如下图所示:
其中我们虽然栈中只有一个引用root
,但是从root节点出发我们就可以遍历到a-g
中所有的节点,但是如果其中执行代码root.right = null;
那么此时节点c、e就无法被遍历到,此时对象c和e就会被标记为"垃圾"
3.2.2 "垃圾"如何回收
下面我们要探究的就是如何将已经被标记为"垃圾"的对象进行回收释放内存了:主要的释放策略有三种:1、标记-清除算法;2、标记-复制算法;3、标记整理算法
3.2.2.1 标记-清除算法
这是最朴素的方式,例如在堆空间中有如下需要释放的对象:
标记-释放算法就是将已经被标记为"垃圾"的空间直接释放,但是此时可能存在 内存碎片 问题:
上述的释放方式会导致有很多离散的、小的空闲内存空间,就很有可能导致后续申请连续内存失败!例如我们需要申请1M内存空间,剩余内存空间远远大于1M,但是并没有连续的1M内存空间可以提供,此时申请就会失败!
3.2.2.2 标记-复制算法
标记-复制 算法示意图如下:
"标记-复制"算法的核心就是采用"半区"复制的方式,不直接释放内存,而是将不是"垃圾"的对象,拷贝到另一半区,然后整体释放左半区内存空间,其虽然能够解决"内存碎片"问题,但是也有如下弊端:
- 总的可用内存变少了
- 当需要释放的对象远远少于剩余空闲内存时,此时需要复制的对象很多,复制开销就会很大!
3.2.2.3 标记-整理算法
"标记-整理"算法示意图如下:
类似于顺序表中的删除中间元素的搬运过程,然后也能解决"内存碎片"问题,而且不像"标记-复制"算法那样浪费大量内存空间,但是其中"搬运"的开销也是很大的!因此JVM并没有采取上述三种方法,而是采用"取长补短"这样的综合性方案
3.2.3 JVM分代回收算法
分代回收:JVM当中引入了一个概念:“对象年龄”,由于JVM中有专门的扫描线程周期性扫描/释放,如果一个对象被扫描了一次,仍然可达(不是垃圾),就将年龄+1(初始值可以看做0),JVM会根据对象的年龄的大小划分为不同的区域
新生代:对象年龄较小的区域,内部含有Eden区(伊甸区),Survivor区(幸存区)
老年代:对象年龄较大的区域
- 首先使用
new
关键字刚刚创建的对象都会被存放在Eden伊甸区,一个经验规律是伊甸区中的很多对象都是活不过第一轮GC扫描的 - 第一轮GC扫描完毕,少数伊甸区中存活的对象就会通过"标记-复制"算法拷贝到"幸存区",后续GC扫描线程不仅需要扫描伊甸区,也要扫描幸存区,而幸存区大部分对象也会被扫描而标记为垃圾,如果存活,就继续使用"标记-复制"算法拷贝到另一个幸存区当中(注意这里的幸存区是两块大小相同的内存空间),每次经历一轮GC存活就将年龄+1
- 如果有对象在幸存区当中存活了多轮GC,JVM就会认为这个对象生命周期很长,就会使用"标记-复制"算法将其拷贝到老年代
- 老年代的对象当然也会被GC扫描,但是扫描频次就大大降低了
- 对象在老年代如果标记为垃圾,JVM就会使用"标记-整理"算法释放内存