1. JVM 内存区域划分
一个 Java 写的程序,跑起来就得到了一个 Java 进程(资源分配的基本单位)= JVM + 上面运行的字节码指令
1) 程序计数器(比较小的空间),保存了下一条要执行的指令的地址
这个不是 CPU 的寄存器,而是内存空间;这里的 “下一条要执行的指令” 是 Java 的字节码(不是 cpu 的 二进制 的机器语言)
2) 堆
JVM 上最大的空间,new 出来的对象都在 堆 上
3) 栈(Stack Overflow)
函数中的局部变量,函数的形参,函数之间的调用关系
Java 虚拟机栈(JVM 之上,运行的 Java 代码的方法调试关系)
本地方法栈(JVM 里,C++ 代码的函数调用关系)
4) 元数据区(方法区)
Java 程序中的指令(指令都是包含在类的方法中)
保存了代码中涉及到的 类的相关信息
类的 static 属性
分析:
在一个 Java 进程中,元数据区和堆都是只有一份的(同一个进程中的所有线程都是共用同一份数据的,共用同一份内存空间)
程序计数器和栈,则可能有多份(当一个 Java 进程中有多个线程的时候,每个线程都有自己的程序计数器和栈)
线程就代表一个 “执行流”,每个线程就需要保存自己的 “程序计数器”,每个线程也需要记录自己的调用关系
例:以下代码中创建的变量处于哪个内存区域
class test {
private int a;
private Test b = new Test();
private static int c;
private static Test d = new Test();
public static void main(String[] args) {
int e = 10;
Test f = new Test();
}
}
tip:一个变量处于哪个内存区域和变量是不是 “内置类型” 无关,而是和变量的形态有关
1) 局部变量 -> 栈
2) 成员变量 -> 堆
3) 静态成员变量 -> 元数据区(方法区)
2. JVM 类加载的过程
2.1 概述
加载一个 .class 文件就会创建一个对应的类对象
基于该类对象就可以创建该类的实例,类对象其实就是“对象”的说明书/蓝本
类对象例就包含了 .class 文件中的各种信息,如:
类的名字
类里有哪些属性,每个属性名字,每个属性的类型,public/private
类里有哪些方法,每个方法的名字、参数,public/private
继承的父类是啥
实现的接口有哪些
...
2.2 类加载的具体步骤(重要)
五个环节 / 三个环节(将中间3个环节合为1个)
1. 加载
把 .class 文件找到,代码中先找到类的名字,再找到对应的 .class 文件(涉及到一系列目录查找的过程),打开并读取文件内容
2. 验证
验证读到的 .class 文件中的数据是否正确合法(Java 标准文档中,明确定义了 .class 文件的格式)
Java 标准文档
3. 准备(分配内存空间)
最终需要得到类对象 => 需要内存
把刚才读取到的内容,确定出类对象需要的内存空间,申请这样的内存空间,并把内存空间中所有的内容都初始化为 0(Java 操作,C/C++ 申请到的内存不会进行置 0 操作)
置 0 操作是为了避免上次残留的数据被当前程序误使用
4. 解析(主要针对类中的字符串常量进行处理)
解析阶段是 Java 虚拟机将常量池内的 “符号引用(字符串常量,已经在 .class 文件中)” 替换为 “直接引用(里面保存了变量的地址)” 的过程,也就是初始化常量的过程
5. 初始化(针对类对象做最终的初始化操作)
执行静态成员的赋值语句
执行类中的静态代码块
针对 父类 也要加载
3. 面试题:双亲委派模型
3.1 概念
是类加载五个步骤中,第一个步骤里面的一个环节:给定 类全限定名,找到 对应的 class 文件位置
类加载器 JVM 中,已经内置了一些类加载器(JVM 中的功能模块),完成上述的 “类加载” 过程
JVM 默认有三个类加载器
(爷爷)BoorsrtapClassLoader 负责加载标准库中的类(标准库的类,也是有一个专门存放位置的)
(爸爸)ExtensionClassLoader 负责加载扩展类(JVM 厂商对 Java 功能做出的一些扩展)
(儿子)ApplicationClassLoader 负责加载第三方库中的类 / 自己写的代码中的类
tip:上面三者不是 Java 中父类子类的继承关系,而是在类加载器中有一个 parent 这样的引用指向父亲
3.2 双亲委派模型的工作流程
输入:类的全限定名(字符串),类似于 java.lang.String
输出:找到对应的 .class 文件
这样设定的最核心目的:防止用户自己写的类把标准库的类给覆盖掉
保证标准库的类被加载的优先级最高(扩展库其次,最后是第三方库)
4. 垃圾回收机制(GC)
4.1 引子
C/C++ 中,malloc/new 一个对象后,都需要手动释放内存 free/delete,如果不释放就可能产生内存泄漏(头号职业杀手,后果可能很严重,排查非常不好搞)
C 中,针对内存泄漏,直接摆烂了
C++ 中,针对内存泄漏,给出了一个方案:“智能指针”(然而它并不太智能)
在 Java 中对内存泄漏给出更系统更完整的解决方案(在 Java 之前也有一些语言使用了这样的方案)
垃圾回收,有了它,程序员可以放心大胆的 new,JVM 会自动识别哪些 new 完的对象再也不用了,就会把这样的对象自动释放掉
其他语言一看都觉得香,纷纷投入到 垃圾回收 的怀抱,如:Go、Python、PHP、JS...主流语言中大部分都是内置了 GC
但是,GC 也是有代价的,C++ 不是搞不了 GC,而是开发 C++ 的大佬们评估了之后,不愿承担这些代价
GC 需要 JVM 中引入额外的逻辑:
1) 消耗不少 CPU 开销,进行垃圾的扫描和释放
2) 进行 GC 的时候可能会触发 STW(Stop The World)问题,导致程序卡顿,对于性能要求高的场景就会影响很大
因此,GC 就会 提高开发效率,影响运行效率
4.2 Java 的垃圾回收
JVM 中有好几个内存区域,GC 主要负责的是 堆
其中 程序计数器和栈 是跟随线程的;元数据区(一个程序里面要加载的类都是有上限的,不会出现无限增长的情况)
垃圾回收,也就是回收内存,是以对象为维度进行回收的(回收对象)
4.3 GC 具体怎样回收
1) 先找出谁是垃圾
需要针对每个对象分别判定,是否为垃圾
在 Java 中使用一个对象,一般都是通过 “引用” 来使用的,如果一个对象没有引用指向了,就可以认为这个对象是垃圾了
方案一:引用计数
给每个对象分配一个计数器,衡量有多少个引用指向
每增加一个引用,计数器 + 1
每减少一个引用,计数器 - 1
当计数器减为 0,此时对象就是垃圾了
这样做是可以回收垃圾,但是假设 Test 类就只有一个 int 成员(4字节),此时为了引入引用计数,少说得搞个 short(2字节),内存多占用了 50%
上述引用计数方案在 Java 中(JVM)没有采纳,因为其存在两个问题:
1) 消耗额外的空间
2) 引用计数可能导致 “循环引用”,使得上述的判定出错(和死锁类似),如下示例:
以上的循环引用也是有解的,但是需要引入更多机制(环路检测),代价就更大了
方案二:可达性分析(用时间换空间)
JVM 中专门搞了一些线程,周期性的扫描代码中的所有对象,判定某个对象是否是 “可达”(可以被访问到),对应的,不可达的对象就是垃圾了
如上图,JVM 中进行可达性分析的线程就是在做这样的事,从 root 出发,尽可能的通过 root 访问到更多的对象,相当于遍历的过程(严格来说,不是 “树的遍历”,而是 “图的遍历”)
可达性分析的起点称为 GC root,一个程序中 GC root 不是只有一个,而是有很多个,例如:
1) 栈上的局部变量(引用类型)
2) 方法区中,静态的成员(引用类型)
3) 常量池引用指向的对象
把所有的 GC root 都遍历一遍,针对每一个都尽可能往下延申...
可达性分析是很消耗时间的,尤其是当程序中对象特别多的情况下
2) 释放垃圾的内存空间
a) 标记-清除(直接针对内存中的对应对象进行释放)
当标记到垃圾位置,并对其清除时,会引入 “内存碎片问题”,哪些对象要释放是随机的,很可能要释放的位置对于整个内存来说不是连续的,虽然将上述内存释放掉了,但是整体这些空闲内存并没有连在一起,后续申请内存的时候,就可能申请不了(申请的内存一定是连续的)
例如:上述回收完垃圾后,空闲内存一共 3M,现要申请 2M 的空间时,就会失败(不考虑未分配空间)
因此,尽量避免内存碎片,是释放内存的关键问题
b) 复制算法
将内存一分为二,同一时刻只使用其中的一半
当要回收垃圾时,将不是垃圾的对象拷贝到另一侧(确保这些被拷贝的对象是连续的)
然后将要回收一侧的空间全部释放掉
虽然能解决 “内存碎片化” 的问题,但是其缺点也很明显:
1) 内存空间利用率低
2) 如果存活下来的对象比较多,复制成本也比较大
c) 标记-整理
非常类似于顺序表中删除中间的元素
缺点:这样搬运的开销依旧不小
d) 分代回收
JVM 中真正的解决方案是把上面几个方案综合一下,取长补短
JVM 根据对象的年龄,把对象区分成:新生代(年轻的)、老年代(年老的)
tip:可达性分析是周期性的,每经过一轮扫描,对象仍然存活(不是垃圾),年龄 + 1
根据经验规律:
1. 绝大部分的新对象活不过第一轮 GC,留存下来的对象拷贝到幸存区
2. 幸存区是两个相等的空间,也是按照复制算法反复进行多次
新生代中,真正要拷贝的对象不多(经验规律),内存利用率低
3. 如果一个对象在幸存区已经反复拷贝多次,不是垃圾,年龄不断增长,达到一定程度之后,对象就要拷贝到老年代了
4. 根据经验规律,老年代中的对象生命周期都比较长,老年代的对象肯定还会进行可达性分析,但是进行 GC 的频率就会降低
另外,老年代也是通过标记整理的(需要整理的次数不多)
4.4 JVM 中的垃圾回收器
分代回收是 JVM 中 GC 的基本思想方法,具体落实到 JVM 的实现层面上,JVM 还提供了多种 “垃圾回收器” 对上述的分代回收做进一步的扩充和具体实现
1. CMS GC
CMS 设计理念:把整个 GC 过程拆分成多个阶段,能和业务线程并发运行的,就尽量并发,尽可能减少 STW 的时间
2. G1 GC
G1 是把内存分成很多块,不同的颜色(字母)表示这一小块是新生代(伊甸区/幸存区)、老年代
进行 GC 的时候,不要求一个周期就把所有的内存都回收一遍,而是一轮 GC 只回收其中的一部分就好(限制一轮 GC 花的时间/工作量),使 STW 的时间在可控范围之内
这些方案的主要目的:降低 STW 的影响(ZGC,目前比较新的垃圾回收器),目前可以把 Java STW 的时间降低到 1ms 以内