1. JVM 内存区域划分
总体来说,JVM 按照功能可以分为 5 个部分:
- 程序计数器
- 栈
- 堆
- 方法区
1.程序计数器
程序计数器 是内存中最小的一块区域。
它主要就是用来保存 下一条需要执行的指令的地址。
为什么?
- 因为我们知道 CPU 是并发执行的。它需要给许多进程提供服务。所以 CPU 在调度来调度去的过程中,就需要知道 每个进程 下一次需要执行指令位置。
- 正因为操作系统是以线程为单位进行调度的,所以每个线程就有一个 程序计数器。
2.栈
这个我们比较熟悉,里面存放的是 局部变量 和 方法调用信息。
每调用一个方法,就会执行 入栈 操作;当一个方法执行完毕之后,就会执行 出栈 操作。
注意:每个线程都有一个栈空间,并且栈空间默认情况下会比较小,几兆 或 几十兆。
3.堆
堆 和 栈 不同,它的空间大小最大。并且一个进程有一个堆,多个线程共用同一块堆空间。
堆中存放的内容就是 对象。
局部变量在栈上,成员变量和new出的实例在堆上。
4.方法区
在方法区中存放的是 "类对象"。
我们平常写的代码都是 .java 文件,通过转换之后,就会成为 .class 文件(字节码文件)。
然后 .class 文件就会被加载到内存中,然后被 JVM 构造成 "类对象"。(这个加载的过程也被称为:类加载)
类对象里面有啥?
- 有哪些成员变量?
- 变量的类型是什么?
- 有哪些方法?
- 这些变量和方法是public还是private的?
- static修饰的变量和方法也在里面。(static修饰的成员表示是 "类" 的,不是 "实例" 的)。
2. 类加载
类加载就是把 .class 文件 加载 到 内存中,并构建成 类对象。
类加载 分为三个步骤:
- loading
- linking
- initializing
1. loading
- 先找到 .class 文件
- 然后打开并读取 .class 文件
- 最后初步构建一个类对象 (用来当作在方法区中访问这个类的访问入口)
1. .class文件的具体格式
- u4/u2 :4个字节/2个字节的 unsigned int
- cp_info/field_info/... : 这都是结构体
具体的含义:
- magic :标识文件的格式(图片、视屏、文档...)。
- minor_version/major_version : 描述生成这个 .class 文件的 java 版本。
- constant_pool_count / constant_pool[constant_pool_count-1] : 常量池的个数,以及具体信息。
- access_flags : 是public还是private...。
- this_class : 这个类的信息。
- super_class : 父类的信息。
- interfaces_count / interfaces[interfaces_count] : 接口个数,以及接口的具体信息。
- fields_count / fields[fields_count] : 成员变量个数,以及具体信息。
- methods_count / methods[methods_count] : 方法的个数,以及具体的信息。
- attributes_count / attributes[attributes_count] : 其他属性的个数,以及具体的信息。
通过上面的 数据 ,就能将一个 .class 文件的所有信息都给描述出来了。在 loading 步骤中会将这些信息填到 类对象 里。
2. linking
建立多个实体之间的联系。
- Verification (校验)
- Preparation (准备)
- Resolution (解析)
1. Verification (校验)
这个主要是验证 读取的数据 是否和 规范中 规定的格式 完全一样。
2. Preparation(准备)
这个阶段主要是给 static 修饰的静态成员来 分配内存空间 + 进行初始化(设置0) 。
3. Resolution (解析)
这里是对常量进行初始化。
在 .class 文件中 ,常量是统一放置的,每个常量都有一个编号。
.class 文件的结构体里,只记录了这个编号。所以就需要根据这个 编号 来具体的找对应的信息。然后填充到 类对象 里。
3. initializing
到这个环节才会 真正对类对象进行初始化 。尤其是针对 静态成员。
3.构造/静态/实例代码块
class A {
public A(){
System.out.println("A 的构造方法");
}
{
System.out.println("A 的实例代码块");
}
static{
System.out.println("A 的静态代码块");
}
}
class B extends A{
public B(){
System.out.println("B 的构造方法");
}
{
System.out.println("B 的实例代码块");
}
static{
System.out.println("B 的静态代码块");
}
}
public class Test extends B{
public static void main(String[] args) {
new Test();
new Test();
}
}
结果: A 的静态代码块 B 的静态代码块 A 的实例代码块 A 的构造方法 B 的实例代码块 B 的构造方法 A 的实例代码块 A 的构造方法 B 的实例代码块 B 的构造方法
总结:
- 调用 main 方法之前先要进行 类加载,将所有用到的类加载到内存中,构造 类对象。
- 类加载的过程中,会处理静态成员,并且只会执行一次。
- 想要调用类的普通方法或成员变量,需要先创建实例。
- 子类创建实例时,需要先帮父类创建实例。
- 每一次new实例的时候都会调用构造方法。
4.双亲委派模型
双亲委派模型 是 类加载 中 loading 的一个环节。
它描述了 类加载器 如何通过 全限定名(java.lang.String)找到.class文件的过程。
JVM 里面专门提供了对象来负责类加载,叫 类加载器。
其中查找 .class 文件也是 类加载 负责。
由于 .class 文件存放的位置比较多,所以 JVM 里面提供了多个类加载来负责。每个类加载负责一块区域。
JVM 中默认的类加载器有三个:
BootStrapClassLoader : 负责加载标准库中的类(String/ArrayList/Scanner)。
ExtensionClassLoader : 负责加载 JDK 扩展的类。(现在很少用到)
ApplicationClassLoader : 负责加载当前项目中的类。
当然程序员可以自定义类加载器。
双亲委派模型 就是描述上述的 类加载 器是如何相互配和的。
举个例子:当需要加载一个自定义类 Test 时。
- 启动程序,首先会进入 ApplicationClassLoader 类加载器。
- ApplicationClassLoader 类加载器并不会立即扫描自己负责的范围,而是先检查父 类加载器ExtensionClassLoader 扫描了没有。没有就进入 ExtensionClassLoader 类加载器。
- 在 ExtensionClassLoader 类加载中也会先检查父 类加载器 BootStrapClassLoader 扫描了没有。没有就进入BootStrapClassLoader 类加载器。
- 在 BootStrapClassLoader 类加载器中,由于它没有父 类加载器了,所以它就会扫描自己负责的范围。如果没找到,就回到 ExtensionClassLoader 子 加载器中进行扫描。还找不到就进入 ApplicationClassLoader 子加载器进行扫描。
- 当在ApplicationClassLoader 类加载器中找到了,就由该类加载负责完成后续步骤。查找.class文件环节结束。
- 如果在 ApplicationClassLoader 类加载中也找不到,那么就会抛出一个 ClassNotFountException 异常。
以上这一套流程就称为 双亲委派模型 。
1. 为什么 JVM 要这么设计?
一旦程序员自定义的类和标准库中的类 全限定类名 相同时,也能顺利加载到 标准库中的类。
2. 如果自定义类加载器,那么是否需要遵守 双亲委派模型?
可以遵守,也可以不遵守。主要看需求。
5. 垃圾回收机制(GC)
我们在编写代码的时候,会经常申请内存。
所以这就出现了一个问题:
- 申请内存的时机通常是很明确的(程序员想用就可以申请),而释放内存的时机则是不确定的(释放时机不合适,就会出现问题)。
不同的语言有对这个问题有不同的解决办法:
在 C 语言中,这个问题全由 程序员 自己来处理。所以在 C 语言中也就会有产生一个问题 "内存泄露"。
在 C++ 中,则不是和 C 一样完全不管。而是加了一个 "智能指针" ,从而大大缓解了 "内存泄露" 问题。但是它没有从根本上解决 "内存泄露" 问题。
java、python、Go、PHP ... 编程语言则是使用的 "垃圾回收机制(GC)" 来解决内存释放问题的。这样就使得程序员在编写代码时,不太需要考虑内存释放。想用时就申请内存,然后由 垃圾回收机制 来给程序员来擦屁股。
垃圾回收机制的具体内容:
垃圾回收机制 就是靠运行时环境来通过更加复杂的策略判定内容是否可以回收,并进行回收的动作。
1. 垃圾回收机制的劣势
从上面的介绍,我们就会发现 C++ 没有引入 "垃圾回收机制" 。
为什么?就是 垃圾回收机制 有一些劣势。
- 它会有格外的开销(消耗的资源更多了)。
- 可能会影响程序的运行流畅 (引入STW问题)。
这两点让C++偏离了安身立命之本。
2. 垃圾回收要回收的是啥?
首先我们在上面的 JVM 中介绍了几块区域:
- 程序计数器 :这个东西是固定大小,并且每个线程都有一块。不涉及到释放,所以也就不需要GC。
- 栈 :一旦函数调用完毕,所对应的栈帧就自动释放了,所以也不需要GC。
- 堆 :这里是GC使用的主要地方。
- 方法区 :这里存放的是类对象。类对象则是由类加载来的。想要释放内存,就需要进行 "类卸载" 操作。但是这个操作又是一个非常低频的操作。
接下来我们就针对 堆 上的垃圾回收进行详细的分析:
如图:
在这个图中,我们可以明确看到需要释放的内存就是 蓝色 区域的内存块。
但在这里有一个不确定的问题:那就是中间的内存块它是有一部分在使用的,这个对象到底回不回收掉?
答:这个对象是不回收的,只有当整个对象都不在使用后,才能被当做垃圾处理(所以垃圾回收的基本单位就是 对象)。
3. 垃圾回收的步骤(重要)
- 找垃圾/判定垃圾
- 回收垃圾
1. 找垃圾
这在当下有两种主流的思想:
- 基于引用计数(python采取)
- 基于可达性分析(java采取)
1. 基于引用计数
原理:
针对每个对象都会引入一块格外的内存,这块内存中就统计了有多少个引用指向这个对象。当这个值为0时,就表示没有引用指向这个对象,也就说明程序员已经拿不到这个对象了。所以这个对象就是个垃圾。
两个缺点
-
空间利用率比较低
如果创建的对象比较大,那么额外用来存放引用计数的这块空间就不算什么。但是,如果这个对象本身就比较小,例如 4 个字节,这时用来存放引用计数的空间恰巧也是 4 个字节。那么就会产生较大的浪费。
-
循环引用问题
举例说明:
//1.第一步 class Test{ public Test t = null; } Test t1 = new Test(); Test t2 = new Test(); //2.第二步 t1.t = t2; t2.t = t1; //3.第三步 t1.t = null; t2.t = null;
通过上面的三步,最终导致外界没有引用指向这个两个 Test 对象,但是它们的 引用计数 却不为0。
这就出现了 "内存泄露" 问题。不能被使用的对象,也不能被释放掉。
2. 基于可达性分析
原理:
通过一些额外的线程,定期对整个内存中的对象进行扫描。
扫描的过程 :从起点( GCRoots)使用类似于 深度优先遍历 的方式,将所有可达的对象都标记一遍。不能被标记的对象就是垃圾。
GCRoots 的取值:
- 栈上的局部变量。
- 常量池中引用指向的对象。
- 方法区中静态成员所指向的对象。
缺点
虽然 可达性分析 解决了 空间利用率 和 循环引用 的问题。
但它也有缺点:系统开销大,遍历一遍的速度慢。
2. 回收垃圾
回收垃圾也有几种不同的策略:
- 标记-清除
- 复制算法
- 标记-整理
1. 标记-清除
标记:就是通过 可达性分析 来进行对象标记。
清除:就是将没有标记的对象直接释放内存。
产生的问题:内存碎片
解释:
假如当前内存中总共可使用的内存大小是 1GB。
这时我想申请 500M 的内存空间。但由于在申请空间时是申请连续的空间。
而在这 1GB 的空闲内存中,有可能没有连续的500M空闲内存。
所以最后就可能导致申请失败!
2. 复制算法
为了解决 内存碎片 ,所以就有了 复制算法。
它的核心是 : 用一半留一半。
但是这个 复制算法 也有了两个缺点:
- 空间利用率低。
- 如果需要保留的对象很多,释放的对象很少。这时复制的开销就大。
3. 标记-整理
这个是针对 复制算法 来做出改进。
类似于顺序表中,将元素一个一个搬运的过程。
分析:虽然解决了 空间利用率 问题,但是 复制开销 依然大。
4. 分代回收(重要)
通过上面的介绍,我们可以明显的发现如果只是用其中的一种方式,是不能过完成内存释放的。
所以在 JVM 中,是使用多种方式来一起使用的。称为:分代回收
原理:针对 对象 进行分类,按照年龄来区分。每度过一轮 GC 扫描 ,就涨一岁。同时针对不同的年龄就采取不同的方案。
分析:
- 刚开始new出来的对象就放到 伊甸区。
- 当 伊甸区 的对象 熬过 一轮 GC 扫描之后,就会被拷贝到 幸存区 (使用复制算法)。
- 幸存区 的对象来回进行多轮的 GC 扫描。(也是使用的 复制算法)
- 当 幸存区 的对象经过 多轮 GC 之后,就进入了 老年代。
老年代的特点:
- 老年代 里的 扫描 频率 大大低于 年轻代。
- 老年代 里使用的是 标记-整理 来进行回收内存的。
注意:
- 新生代 里的对象 存活 概率小,所以就使用 复制算法。
- 老年代 里的对象 存活 时间长,所以就使用 标记-整理 来回收内存。
特殊情况:
-
如果是一个大对象(占用内存多的),那么它就会直接进入 老年代。
这是因为 大对象 的内容多,不适合使用 复制算法。
6. 垃圾回收器
1. 比较老的
-
Serial / Serial Old
这是串行收集垃圾。
在垃圾扫描和回收的时候,业务线程要停止工作(也就是说会出现严重的 STW 问题)。
Serial 是 年轻代 使用的。
Serial Old 是 老年代 使用的。
-
ParNew / Parallel Scavenge / Parallel Old
这是并发收集垃圾。也就是引入了多线程。
ParNew 和 Parallel Scavenge 是给 年轻代 使用的。
Parallel Old 是给 老年代 使用的。
Parallel Scavenge 比 ParNew 多了一些参数,可以控制 STW 的时间。
2. 比较新的
-
CMS(java8)
这个垃圾回收器设计的比较巧妙,它的初衷就是尽可能的让 STW 时间短。
回收的具体过程:
- 初始标记,速度很快,会引起短暂的 STW(只是找到GCRoots)。
- 并发标记,虽然速度很慢,但可以与业务代码并发执行,不会产生 STW 问题。
- 重新标记,这个是针对 (2)的微调,防止执行完业务逻辑代码后,原先正在使用的对象变成了垃圾。由于是微调,所以即使会引起STW,但速度也很快。
- 回收内存,这个也是与业务代码并发执行的。
注意:
- 初始化标记 和 重新标记 是基于可达性分析。
- 回收内存 是使用的 标记-整理。
-
G1(java11 开始 JVM 使用的)
原理:
将整个内存分为多个小区域(称为 Region),针对不同的 Region 进行不同的标记。
有的 Region 放年轻代对象,有的 Region 放老年代对象。
在扫描的时候一次性扫描若干 Region(不要求一次 GC 就扫描完,可以进行多次)。这样就对业务逻辑代码的影响到了很小。