JVM知识整理

17 阅读14分钟

1. JVM 内存区域划分

总体来说,JVM 按照功能可以分为 5 个部分:

  1. 程序计数器
  2. 方法区

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文件的具体格式

.class文件格式.png

  • 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

建立多个实体之间的联系。

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 的构造方法

总结:

  1. 调用 main 方法之前先要进行 类加载,将所有用到的类加载到内存中,构造 类对象。
  2. 类加载的过程中,会处理静态成员,并且只会执行一次。
  3. 想要调用类的普通方法或成员变量,需要先创建实例。
  4. 子类创建实例时,需要先帮父类创建实例。
  5. 每一次new实例的时候都会调用构造方法。

4.双亲委派模型

双亲委派模型 是 类加载 中 loading 的一个环节。

它描述了 类加载器 如何通过 全限定名(java.lang.String)找到.class文件的过程。

JVM 里面专门提供了对象来负责类加载,叫 类加载器。

其中查找 .class 文件也是 类加载 负责。

由于 .class 文件存放的位置比较多,所以 JVM 里面提供了多个类加载来负责。每个类加载负责一块区域。

JVM 中默认的类加载器有三个:

BootStrapClassLoader : 负责加载标准库中的类(String/ArrayList/Scanner)。

ExtensionClassLoader : 负责加载 JDK 扩展的类。(现在很少用到)

ApplicationClassLoader : 负责加载当前项目中的类。

当然程序员可以自定义类加载器。

双亲委派模型 就是描述上述的 类加载 器是如何相互配和的。

举个例子:当需要加载一个自定义类 Test 时。

  1. 启动程序,首先会进入 ApplicationClassLoader 类加载器。
  2. ApplicationClassLoader 类加载器并不会立即扫描自己负责的范围,而是先检查父 类加载器ExtensionClassLoader 扫描了没有。没有就进入 ExtensionClassLoader 类加载器。
  3. 在 ExtensionClassLoader 类加载中也会先检查父 类加载器 BootStrapClassLoader 扫描了没有。没有就进入BootStrapClassLoader 类加载器。
  4. 在 BootStrapClassLoader 类加载器中,由于它没有父 类加载器了,所以它就会扫描自己负责的范围。如果没找到,就回到 ExtensionClassLoader 子 加载器中进行扫描。还找不到就进入 ApplicationClassLoader 子加载器进行扫描。
  5. 当在ApplicationClassLoader 类加载器中找到了,就由该类加载负责完成后续步骤。查找.class文件环节结束。
  6. 如果在 ApplicationClassLoader 类加载中也找不到,那么就会抛出一个 ClassNotFountException 异常。

以上这一套流程就称为 双亲委派模型 。

1. 为什么 JVM 要这么设计?

一旦程序员自定义的类和标准库中的类 全限定类名 相同时,也能顺利加载到 标准库中的类。

2. 如果自定义类加载器,那么是否需要遵守 双亲委派模型?

可以遵守,也可以不遵守。主要看需求。


5. 垃圾回收机制(GC)

我们在编写代码的时候,会经常申请内存。

所以这就出现了一个问题:

  • 申请内存的时机通常是很明确的(程序员想用就可以申请),而释放内存的时机则是不确定的(释放时机不合适,就会出现问题)。

不同的语言有对这个问题有不同的解决办法:

在 C 语言中,这个问题全由 程序员 自己来处理。所以在 C 语言中也就会有产生一个问题 "内存泄露"。

在 C++ 中,则不是和 C 一样完全不管。而是加了一个 "智能指针" ,从而大大缓解了 "内存泄露" 问题。但是它没有从根本上解决 "内存泄露" 问题。

java、python、Go、PHP ... 编程语言则是使用的 "垃圾回收机制(GC)" 来解决内存释放问题的。这样就使得程序员在编写代码时,不太需要考虑内存释放。想用时就申请内存,然后由 垃圾回收机制 来给程序员来擦屁股。

垃圾回收机制的具体内容:

垃圾回收机制 就是靠运行时环境来通过更加复杂的策略判定内容是否可以回收,并进行回收的动作。

1. 垃圾回收机制的劣势

从上面的介绍,我们就会发现 C++ 没有引入 "垃圾回收机制" 。

为什么?就是 垃圾回收机制 有一些劣势。

  1. 它会有格外的开销(消耗的资源更多了)。
  2. 可能会影响程序的运行流畅 (引入STW问题)。

这两点让C++偏离了安身立命之本。

2. 垃圾回收要回收的是啥?

首先我们在上面的 JVM 中介绍了几块区域:

  1. 程序计数器 :这个东西是固定大小,并且每个线程都有一块。不涉及到释放,所以也就不需要GC。
  2. 栈 :一旦函数调用完毕,所对应的栈帧就自动释放了,所以也不需要GC。
  3. 堆 :这里是GC使用的主要地方
  4. 方法区 :这里存放的是类对象。类对象则是由类加载来的。想要释放内存,就需要进行 "类卸载" 操作。但是这个操作又是一个非常低频的操作。

接下来我们就针对 堆 上的垃圾回收进行详细的分析:

如图:

堆上的内存垃圾.png

在这个图中,我们可以明确看到需要释放的内存就是 蓝色 区域的内存块。

但在这里有一个不确定的问题:那就是中间的内存块它是有一部分在使用的,这个对象到底回不回收掉?

答:这个对象是不回收的,只有当整个对象都不在使用后,才能被当做垃圾处理(所以垃圾回收的基本单位就是 对象)。

3. 垃圾回收的步骤(重要)

  1. 找垃圾/判定垃圾
  2. 回收垃圾

1. 找垃圾

这在当下有两种主流的思想:

  • 基于引用计数(python采取)
  • 基于可达性分析(java采取)

1. 基于引用计数

原理:

针对每个对象都会引入一块格外的内存,这块内存中就统计了有多少个引用指向这个对象。当这个值为0时,就表示没有引用指向这个对象,也就说明程序员已经拿不到这个对象了。所以这个对象就是个垃圾。

两个缺点
  1. 空间利用率比较低

    如果创建的对象比较大,那么额外用来存放引用计数的这块空间就不算什么。但是,如果这个对象本身就比较小,例如 4 个字节,这时用来存放引用计数的空间恰巧也是 4 个字节。那么就会产生较大的浪费。

  2. 循环引用问题

    举例说明:

    //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;
    

循环引用.png

通过上面的三步,最终导致外界没有引用指向这个两个 Test 对象,但是它们的 引用计数 却不为0。

这就出现了 "内存泄露" 问题。不能被使用的对象,也不能被释放掉。

2. 基于可达性分析

原理:

通过一些额外的线程,定期对整个内存中的对象进行扫描。

扫描的过程 :从起点( GCRoots)使用类似于 深度优先遍历 的方式,将所有可达的对象都标记一遍。不能被标记的对象就是垃圾。

GCRoots 的取值:

  1. 栈上的局部变量。
  2. 常量池中引用指向的对象。
  3. 方法区中静态成员所指向的对象。
缺点

虽然 可达性分析 解决了 空间利用率 和 循环引用 的问题。

但它也有缺点:系统开销大,遍历一遍的速度慢。


2. 回收垃圾

回收垃圾也有几种不同的策略:

  1. 标记-清除
  2. 复制算法
  3. 标记-整理

1. 标记-清除

标记:就是通过 可达性分析 来进行对象标记。

清除:就是将没有标记的对象直接释放内存。

产生的问题:内存碎片

解释:

假如当前内存中总共可使用的内存大小是 1GB。

这时我想申请 500M 的内存空间。但由于在申请空间时是申请连续的空间。

而在这 1GB 的空闲内存中,有可能没有连续的500M空闲内存。

所以最后就可能导致申请失败!

2. 复制算法

为了解决 内存碎片 ,所以就有了 复制算法。

它的核心是 : 用一半留一半。

复制算法.png

但是这个 复制算法 也有了两个缺点:

  1. 空间利用率低。
  2. 如果需要保留的对象很多,释放的对象很少。这时复制的开销就大。

3. 标记-整理

这个是针对 复制算法 来做出改进。

类似于顺序表中,将元素一个一个搬运的过程。

标记-整理.png

分析:虽然解决了 空间利用率 问题,但是 复制开销 依然大。


4. 分代回收(重要)

通过上面的介绍,我们可以明显的发现如果只是用其中的一种方式,是不能过完成内存释放的。

所以在 JVM 中,是使用多种方式来一起使用的。称为:分代回收

分代回收.png

原理:针对 对象 进行分类,按照年龄来区分。每度过一轮 GC 扫描 ,就涨一岁。同时针对不同的年龄就采取不同的方案。

分析:

  1. 刚开始new出来的对象就放到 伊甸区。
  2. 当 伊甸区 的对象 熬过 一轮 GC 扫描之后,就会被拷贝到 幸存区 (使用复制算法)。
  3. 幸存区 的对象来回进行多轮的 GC 扫描。(也是使用的 复制算法)
  4. 当 幸存区 的对象经过 多轮 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 时间短。

    回收的具体过程:

    1. 初始标记,速度很快,会引起短暂的 STW(只是找到GCRoots)。
    2. 并发标记,虽然速度很慢,但可以与业务代码并发执行,不会产生 STW 问题。
    3. 重新标记,这个是针对 (2)的微调,防止执行完业务逻辑代码后,原先正在使用的对象变成了垃圾。由于是微调,所以即使会引起STW,但速度也很快。
    4. 回收内存,这个也是与业务代码并发执行的。

    注意:

    • 初始化标记 和 重新标记 是基于可达性分析。
    • 回收内存 是使用的 标记-整理。
  • G1(java11 开始 JVM 使用的)

    原理:

    将整个内存分为多个小区域(称为 Region),针对不同的 Region 进行不同的标记。

    有的 Region 放年轻代对象,有的 Region 放老年代对象。

    在扫描的时候一次性扫描若干 Region(不要求一次 GC 就扫描完,可以进行多次)。这样就对业务逻辑代码的影响到了很小。