轻松了解JVM

JVM

  • 内存区域划分
    • 程序计数器
    • 方法区
  • 类加载
    • Loading
    • Linking
    • Initializing
  • 双亲委派模型
    • 经典面试题
    • 双亲委派模型
  • 垃圾回收
    • 垃圾回收的是什么
    • 找垃圾/判定垃圾
      • 基于引用计数
      • 基于可达性分析
    • 回收垃圾
      • 标记-清除
      • 复制算法
      • 标记-整理
      • 分代回收
  • 垃圾回收器的实现

内存区域划分

JVM 的内存区域主要有四个区:

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

JVM 运行时数据区域也叫内存布局,它和 Java 内存模型(Java Memory Model,简称JMM)完全不同,属于完全不同的两个概念。但是不同的厂商,JVM 的具体实现是不一样的。

程序计数器

程序计数器占的区域在内存中是最小的一块。

  1. 作用是保存下一条指令的地址在哪里。
  2. 因为操作系统是以线程为单位调度执行的,每个线程都得记录自己的执行位置,所以每个线程都会有一个程序计数器。
  3. 指令就是字节码(就编译产生的字节码文件【后缀 .class】),程序要想运行,JVM 就得把 字节码文件 加载起来,放到内存中。然后程序就会把一条条指令,从内存中取出来,放到 CPU 上执行。

栈里面放的是 局部变量 和 方法调用信息。

  1. 方法调用的时候,每次调用一个新的方法,就都涉及到 “入栈” 操作。
  2. 每次执行完一个方法,都涉及到 “出栈” 操作。
  3. 如果不停的调用方法,就会栈溢出。
  4. 栈里面的每一个元数,叫做栈帧。每个线程都有一份栈。
  5. 栈的空间是很小的,一般也就 几M 或 几十M,在递归的时候,没写好结束条件的话,很容易溢出的。

有关栈的内容,和栈帧的内容,在 C语言 里面讲过:传送门:深入了解函数栈帧

每个进程只有一份堆,多个线程共用一个堆,new 出来的对象,就在堆上。

  1. 对象的成员变量也在堆中。
  2. 局部变量在栈上,成员变量和 new 的对象在堆上,引用也在堆上。引用如果在局部变量里面的话,就是在栈上

方法区

方法区里面放的是 “类对象” ,Java->class(二进制字节码)

  1. .class 会被加载到内存中。也就被 JVM 构成了类对象,这样的类对象就放到了方法区。
  2. 类对象就描述了 这个类 “长什么样”,描述类名字是啥,里面有哪些成员,有哪些方法,每个成员的名字和类型等等。
  3. 方法区里面还放着 static 修饰的成员(类属性),普通的成员就是“实例属性”

类加载

类加载主要就是把 .class 文件加载到内存中,构建成类对象。主要有三个部分:loading、linking、initializing。从 Java SE 官方文档可以看到

找到对应的 JDK:
轻松了解JVM_第1张图片
点击下面的 HTML,进入:
在这里插入图片描述
然后向下方就能找到讲解类加载的部分:
轻松了解JVM_第2张图片
这里的 Loading 就是讲解 类加载的 :
轻松了解JVM_第3张图片

Loading

  1. 就是先找到对应的 .class 文件,然后打开并读取 .class 文件,同时初步生成一个 类对象。
  2. loading 中的一个关键环节 .class 文件会把读取并解析到的信息,初步填到类对象里面。
  3. .class 文件是一个二进制文件,但是里面也有相关的规则,找到第四部分 The class File Format(类的文件格式) 轻松了解JVM_第4张图片
  4. 打开之后如下:
    轻松了解JVM_第5张图片
    u4 就是 4个字节 的 unsigned int。u2 就是 2个字节 的 unsigned int。cp_info/field_info 都是结构体。
  5. 最上面的:
    在这里插入图片描述表示文件开头的四个字节是一个数字,主要表示文件的格式,因为二进制文件的种类有很多,Word,Excel,图片,视频,音频。。。。。而这些不同种类的二进制格式也不一样,所以二进制文件开头都有一个 magic number 来表示当前是一种什么样子的二进制文件。

Linking

  1. 这里一般是建立好多个实体之间的联系。
  2. 里面的 Verification 是校验的过程,主要是验证读到的内容是不是和 规范中《Java虚拟机规范》 规定的格式完全匹配,如果不规范就会类加载失败,并且抛出异常。验证选项有:文件格式验证,字节码验证,符号引用验证…
  3. Preparation:给静态变量分配内存,并且设置类变量的初始值。
  4. Resolution:根据编号找到对应的内容,并且填充到类对象中。.class 文件中,常量是集中放置的,每个常量有一个编号。.class 文件的 结构体 里初始情况下只是记录了编号。需要根据编号找到对应的内容,然后填充到类对象当中。

Initializing

真正对类对象进行初始化,尤其是针对静态成员。

双亲委派模型

经典面试题

先看代码:

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();
    }
}

求代码的运行结果。运行结果如下:
轻松了解JVM_第6张图片

  1. 因为程序是从 main 开始执行,main 这里是 Test 方法,所以要执行 main,就需要先加载 Test。
  2. Test 继承自 B,要加载 Test,就要先加载 B
  3. B 继承自 A,要加载 B,就要先加载 A
  4. 只要这个类被用到了,就要先加载这个类(实例化,调用方法,调用静态方法,被继承…都算被用到)

这些执行的大的原则是:

  1. 类加载阶段会进行 静态代码块 的执行,想要创建实例,势必要先进行类加载。
  2. 静态代码块只是在类加载阶段执行一次。
  3. 构造方法和构造代码块,每次实例化都会执行,构造代码块在构造方法前面。

双亲委派模型

双亲委派模型是 类加载 中的一个环节,描述的是 JVM 中的 类加载器,如何根据类的全限定名(java.lang.String)找到 .class 文件的过程。默认的类加载器如下:

  1. BootStrapClassLoader:负责加载标准库中的类(String,ArrayList,Random,Scanner)。
  2. ExtensionClassLoader:负责加载 JDK 扩展的类(现在很少用)。
  3. ApplicationClassLoader:负责加载当前项目目录中的类。

程序员也可以自定义类加载器,来加载其他目录中的类,像 Tomcat 就自定义了类加载器,加载 webapps。

双亲委派模型,就描述了找目录过程中,也就是上面那三个类加载器是如何配合的

  1. 加载标准库,假设是加载 java.lang.String :
    a)程序启动,先进入 ApplicationClassLoader 类加载器。
    b)ApplicationClassLoader 会检查它的父加载器是否已经加载过了,如果没有,就调用父加载器 ExtensionClassLoader。
    c)ExtensionClassLoader 也会检查下,他的父加载器是否加载过了,如果没有,就调用父加载器 BootStrapClassLoader。
    d)BootStrapClassLoader 也会检查它的父加载器是否加载,自己没有父亲,于是自己扫描自己负责的目录。
    e)java.lang.String 这个类在标准库中能找到,直接由 BootStrapClassLoader 负责后续的加载过程,查找环节就结束了。
    f)大致流程如下:
    轻松了解JVM_第7张图片

  2. 加载自己的类:
    a)程序启动,先进入 ApplicationClassLoader 类加载器。
    b)ApplicationClassLoader 会检查它的父加载器是否已经加载过了,如果没有,就调用父加载器 ExtensionClassLoader。
    c)ExtensionClassLoader 也会检查下,他的父加载器是否加载过了,如果没有,就调用父加载器 BootStrapClassLoader。
    d)BootStrapClassLoader 也会检查它的父加载器是否加载,自己没有父亲,于是自己扫描自己负责的目录,没扫描到,于是回到子加载器继续扫描。
    e)ExtensionClassLoader 也扫描自己负责的目录,也没扫描到,回到子加载器继续扫描。
    f)ApplicationClassLoader 也扫描自己负责的目录,能找到 Test 类,于是进行后续加载,查找目录的环节结束。
    g)最终 ApplicationClassLoader 也找不到,就会抛出 ClassNotFoundException 异常。
    f)流程如下:
    轻松了解JVM_第8张图片

上面这一套的查找规则,就称为 “双亲委派模型” 。更直观的就是我们有问题问班长,班长再问辅导员,然后辅导员告诉班长,班长再告诉我们。

JVM 这样设计的原因是:一旦程序员自己写的类,和标准库中的类,全限定类目重复了,也能够顺利加载到标准库当中的类。如果是自定义类加载器,不一定遵守双亲委派模型。

package java.lang;

public class String {
    public static void main(String[] args) {
        System.out.println("和限定类重复");
    }
}

运行结果如下:
轻松了解JVM_第9张图片

垃圾回收

在写代码的时候,经常回申请内存,当不需要的时候,就需要回收内存。于是就有了内存的释放时机,早了不行,迟了也不行。

  1. 如果释放内存早了,变量还需要被使用,结果内存以及被回收了,变量就无法使用了。就导致出 bug 了。
  2. 如果释放迟了,那就是浪费内存了。就像图书馆占座。人不在,但是占座浪费时间。

垃圾回收,本质上事考运行时环境,额外做了很多的工作,来自动释放内存。

垃圾回收的缺点

  1. 额外的资源消耗。
  2. 可能回影响程序的流畅运行。(垃圾回收经常会引入 STW 问题,就是 Stop The World,时间停止)

垃圾回收的是什么

内存是由很多种的:

  1. 程序计数器:固定大小,不涉及到释放,也就不需要 GC。
  2. 栈:函数执行完毕,对应的栈帧就自动释放了,也不需要 GC。
  3. 堆:这里是最需要 GC 的,代码中大量的 “内存” 都是在堆上。
  4. 方法区:类对象,类加载来的,进行 “类卸载” 的时候,就需要释放内存,卸载操作时一个非常非常低效的操作。

堆中的内存布局是这样的:
轻松了解JVM_第10张图片
像上面这种,一部分在使用,一部分不再使用,是不释放的。等对象完全不使用的时候,才真正释放。GC 会提高软件的开发效率。

垃圾回收的两个大阶段:

  1. 找垃圾,判定垃圾。
  2. 释放垃圾。

找垃圾/判定垃圾

基于引用计数

就是针对每个对象,都会额外引入一小块内存,保存这个对象有多少个引用指向它。假如说,这样的代码 Test t = new Test(); ,他的引用计数就是这样的:
轻松了解JVM_第11张图片
如果再加一个 Test t2 = t; 就是 t 和 t2 都是指向这个对象的引用,那么结果如下:
轻松了解JVM_第12张图片
内存不在使用的时候,就该释放了。也就是引用计数为 0 的时候,就不再使用了。

基于引用计数的致命缺陷:

  1. 空间利用率低,每个 new 的对象都需要计数器,如果对象本身很小(比如说只有四个字节),那么多出的计数器,相当于空间浪费了一倍。

  2. 会有循环引用的问题。如下面的代码

    class Test {
        Test t = null;
    }
    
    Test t1 = new Test();
    Test t2 = new Test();
    

    内存布局如下:
    轻松了解JVM_第13张图片
    如果加入这样的代码:

    t1.t = t2;
    t2.t = t1;
    

    意思就是 t2 赋给了 t1 里面的 t 属性。t1 赋给了 t2 里面的 t 对象。内存模型如下:
    轻松了解JVM_第14张图片
    然后接下来,把指向置为 null :

    t1 = null;
    t2 = null;
    

    然后两个对象的引用技术,不为 0,所以无法释放,但是由于引用长在了彼此的身上,外界的代码又无法访问到这个两个对象。初始窗口,这俩对象就被孤立了,既不能使用,又不能释放,就出现了 “内存泄漏” ,如下图:
    轻松了解JVM_第15张图片

基于可达性分析

就是通过额外的线程,定期的针对整个内存空间的对象进行扫描。有一些起始位置(GCRoots),会类似于 深度优先遍历一样,把可以访问到的对象进行标记,能标记的就是可达对象,如果没标记就是不可达,就是垃圾。

  1. 非垃圾:
    轻松了解JVM_第16张图片
    这些节点都能达到,所以就不是垃圾。

  2. 垃圾,也就是如果 a.right = null
    轻松了解JVM_第17张图片
    右边的访问不到,所以就是垃圾。

  3. GCRoots:
    a)栈上的局部变量
    b)常量池中的引用指向的对象
    c)方法区中的家庭成员指向的对象

  4. 可达性分析的优点,克服了 引用计数的两个缺点,自身的缺点就是系统开销大,遍历一次可能比较慢。

  5. 找垃圾的核心就是:没有引用指向,就不使用了,就是垃圾。

回收垃圾

主要有三种策略:

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

标记-清除

标记就是可达性标记,清除就是直接释放内存,释放之后的内存可能是不连续的,就是内存碎片:

  1. 释放之前:
    轻松了解JVM_第18张图片

  2. 释放之后:
    轻松了解JVM_第19张图片
    于是就有了分散开的内存碎片。

  3. 假设空闲内存是 1G,可能是多个空闲内存加起来一共有 1G,如果要申请 500M 的内存,也可能申请失败。如下图:
    轻松了解JVM_第20张图片
    这样的问题,会影响程序的执行。

复制算法

为了解决内存碎片,引入的复制算法。就是把申请的内存一分为二,然后不是垃圾的,拷贝到内存的另一边,然后把原来的一半内存空间整体都释放。

  1. 内存使用情况:
    轻松了解JVM_第21张图片

  2. 然后就是把不是垃圾的拷贝到另外一半,然后释放原来的另一半:
    轻松了解JVM_第22张图片

  3. 释放结果:
    轻松了解JVM_第23张图片
    这样的话,内存碎片问题就解决了。

  4. 复制算法的问题:
    a)内存空间利用率低。
    b)如果保留的对象多,要释放的对象少,此时复制开销就很大。

标记-整理

就是针对复制算法,再做出改进。类似于顺序表删除中间元素,有一个搬运操作。

  1. 内存情况:
    轻松了解JVM_第24张图片

  2. 将未标记的元素,都拷贝(移动)到前面:
    轻松了解JVM_第25张图片

  3. 释放内存:轻松了解JVM_第26张图片

  4. 但这种方法还是有问题:不能解决复制/搬运的开销问题。

实际在使用的时候,是多种方法相结合起来的,也就是 “分代回收”

分代回收

就是针对进行分类(根据对象的 “年龄” 分类),一个对象熬过一轮 GC 的扫描,就 “长了一岁”。针对不同年龄的对象,采取不同的方案。 一块内存,分为两半,一半放 “新生代” ,一半放 “老年代” ,然后新生代里面又有伊甸区和两个 “幸存区” 。幸存区大小一样:
轻松了解JVM_第27张图片

  1. 对象刚创建出来的时候,就放在伊甸区。
  2. 如果伊甸区的对象熬过一轮GC扫描,就会被拷贝到 幸存区(应用了复制算法)。
  3. 在后续的几轮 GC 中,幸存区的对象在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰掉一波幸存者。
  4. 在持续若干次之后,对象就进入老年代,一个对象越老,继续存活的可能性就越大,所以老年代的扫描频率大大低于新生代,老年代中使用标记整理的方式进行回收。

分代回收中,还有一个特殊情况: 有一类对象可以直接进入老年代(大对象,占有内存多的对象),大对象拷贝开销比较大,不适合使用复制算法,所以直接进入老年代。

垃圾回收器的实现

常用的垃圾回收器如下:

  1. Serial 收集器(新生代)/ Serial Old 收集器(老年代):这俩都是串行收集,在进行垃圾扫描和释放的时候,业务线程要停止工作。就是这边停止工作,他先扫描完,再去进行释放,然后业务线再继续工作。这种方式扫描的慢,释放的也慢,也会产生严重的 STW 问题。
  2. ParNew 收集器(新生代)/Parallel Scavenge 收集器(新生代,是并行清除)/ Parallel Old 收集器(老年代):这些回收器都是并发收集的,引入了多线程,Parallel Scavenge 比 ParNew 多出了一些参数,可以用来控制 STW 的时间。

下面是新的垃圾回收器,核心思想就是:化整为零,:

  1. GMS 收集器:设计的比较巧妙,设计初衷是为了尽可能的让 STW 时间短。
    a)初始标记,速度很快,会引起短暂的 STW(只是找到 GCRoots)
    b)并发标记,虽然速度很慢,但是可以和业务线程并发执行,不会产生 STW
    c)重新标记,在 b 的业务代码可能会影响并发标记的结果,针对 b 的结果进行微调,虽然会引起 STW,但是只是微调,速度快。
    d)回收内存,也是和业务线程并发的,所以就没有 STW。
  2. G1 收集器:是唯一一款全区域的垃圾回收器。
    a)把整个内存,分成了很多小的区域。
    b)给这些区域进行了不同的标记。
    c)有的区域放新生代对象,有的放老年代对象。
    d)然后再扫描的时候,一次扫描若干个区域(不追求一轮 GC 就扫描完,分舵从来扫)对于业务代码影响是更小的。
    e)在当下可以优化带让 STW 停顿时间小于 1ms。

你可能感兴趣的:(JavaEE,jvm,java,开发语言,类加载,内存区域划分)