JVM 基本知识

目录

前言

一、JVM 内存区域划分

1.1 程序计数器

1.2 栈

1.3 堆

1.4 方法区

二、 JVM 类加载机制

2.1 类加载需要经过的几个步骤

2.1.1 Loading - 加载

2.1.2 Linking - 连接 

2.1.3 initialization(初始化)

小结

经典面试题

三、JVM 垃圾回收 【重点】

3.1 垃圾回收 是干什么的

3.2 回收什么“垃圾”

3.3 具体怎么回收

3.3.1 找垃圾/判断垃圾

3.3.2 找垃圾

3.4 回收垃圾  

3.4.1 标记 - 清除

3.4.2 复制算法

3.4.3 标记 - 整理

前言

JVM 是 “八股文”,学习它的唯一目的:就是为了应付面试,实际工作中不会用到

本文主要讲解的重点:

  1. JVM 内存区域划分
  2. JVM 类加载机制
  3. JVM 的垃圾回收 【重点】

如果是开发 JVM 的,那么这本书非常重要,但我们只是小小程序员,这里没有应用场景,不用非常深入了解,看了也会睡着的程度 

一、JVM 内存区域划分

JVM 运行时数据区(内存区域划分),主要由以下 几个区域组成:

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

为什么要划分出这些区域?

举个例子:
假设我们买了个房子,到手肯定是需要装修的

这么大的一个场地,我们需要按照 功能需求,来划分出不同的小区域.
装修如此,其实我们的 内存,也是一样的!
JVM 的内存从哪来?

从操作系统这里申请来的。相当于是说:JVM 一启动,它会从系统那里,申请一块大内存,申请好之后,JVM 就会把这个“场地”,也划分成不同的区域【相当于买房,买来之后就对其进行装修】

因此,也就有了下面这几个部分。
JVM,主要由以下 几个区域组成:

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

 区域划分: 主要就是为了 让不同的区域来去表示(完成)不同的功能

1.1 程序计数器

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

其作用:就是保存了下一条执行的指令的地址在哪里~~ 

要明确:
指令,就是字节码(就编译产生的字节码文件【后缀 .class】)
程序 要想运行,JVM 就得把 字节码文件 加载起来,放到内存中。

这些指令被放在内存中之后, 程序就会把一条条指令,从内存中取出来,放到 CPU 上执行。


也就是说:有一个执行的过程。
既然有一个执行的过程,也


就需要随时记住,当前执行到哪一条指令了。

执行第一条——执行第二条——执行第三条——一直往下

因此,就需要有一个东西(程序计数器) 来记录当前执行到第几条指令了~


举个例子:看书
看书,不是一天两天的事
如果我们看完书,直接一合,放在书架上,很容易忘记自己看到哪里了~~

而用书签将其夹在最后看书的位置上,后面再看,可以根据书签的位置,锁定上回读到的位置,继续阅读~~
程序计数器 相当于代码 中的书签~~


计算机在执行程序时,需要把当前程序执行到那一行指令给记住。
为什么要记住当前执行到第几条指令呢?
1、因为 CPU 是 并发式执行的,不是只为 一个 进程 提供服务,CPU 要去服务多个进程的

也因如此,每个线程都会有一个程序计数器。


【PS: 操作系统上的每一个进程,都是需要到 CPU 上执行的因此,这就需要一个 并发式 的 调度过程。所以呢,我们就更需要“记住”当前程序执行到哪里了】


注意的是:程序计数器,在编写代码的过程中,是感知不到的,但是它切切实实存在,并且能够帮助我们的程序进行运行。
比如:
你想把 程序计数器 取出来,看看里面存的是什么?
对不起,做不到!
但是,并不会影响到 它的存在,也不会影响到它的工作流程。 


1.2 栈

栈里面存储的是:
1、局部变量
2、方法调用信息

        进行方法调用的时候,每次调用一个新的方法,都会涉及到“入栈”操作;
        每次执行完了一个方法,都会涉及到“出栈”操作

需要注意的是:
这里说的栈,虽然是 JVM 内存中的一个部分。
但是这里的工作过程是和数据结构中的栈,非常类似的。

每个像 方法A 和 B,这样的元素,叫做:栈帧

idea 在 测试 / 程序抛异常 的时候,它都能让我们看到 当前的调用栈信息。
调用栈信息:方法之间都是怎么调用过来的
这个过程就是靠读取上述的 栈 空间中的数据。
就相当于是 idea 读取了栈里面的信息,然后把信息打印出来,我们就能看到栈里的内容了。

注:其实每个栈帧里面,数据是如何排列的也有一些规则。入栈,出栈操作具体是如何实现的里面也有一些技巧和细节。但我们小小程序员不必卷下去了~


关于栈溢出问题:

如果一直递归调用方法,就会在栈上开辟一块又一块的空间,最终导致 栈溢出【Stack Overflow

这是因为 栈的空间,一般只有 几 M 到 几十 M,是随时可能会溢出的!
最可能出现 栈溢出 问题的,就是 递归。如果递归没有终止条件,它就会一直开辟栈空间,直到栈溢出~~

1.3 堆

堆,也是我们最常使用的一块空间。

堆 是 一个进程 只有一份,多个线程共用一个堆。
因此,堆是 JVM内存中 占空间最大的区域。

那么,堆里面主要存储一些什么数据呢?

  1. new 出来的对象
  2. 对象的成员变量

堆的空间比较大,但是操作速度比栈更慢一点:

        栈上开辟一块空间是非常快的,只需要简单改一个计算器的值,进行一个加减法就行了。
        而在 堆上 开辟一块内存,就需要去操作 系统内核中的一些重要的数据结构。
因此,在堆上开辟空间的开销要更比栈更大一些。
在这里,我们不作过多的讨论。 

另外,再补充一点。

网上有一种说法:
内置类型的变量,在栈上
引用类型的变量,栈堆上
思考一个问题:这种说法是否正确?
这显然是一个非常错误的说法!!!
正确的说法:
局部变量,在栈上
成员变量 和 new 的对象,在堆上。
大家要明确:
我们的变量,到底是在栈上,还是堆上;和你是不是 内置类型 和 引用类型 无关!
也就是 变量的类型,并不会影响 变量 存储的位置。

真正影响变量存储位置的原因: 主要在于 变量 是以 局部变量 形态 出现,还是以 成员变量出现的。
在读的朋友们,可能还有一些朋友不知道为什么 网上的说法是错误的!
这是因为 我们在 看到 引用类型变量的时候,就会联想到对象。
从而会认为 引用类型的变量 是存储在 堆上的。
其实不是的!下面我们就来分析一下

1.4 方法区

在方法区中,存储的是 “类对象

类对象,这个我讲过很多次了。
 . java 文件,经过编译器编译之后,会生成一个 .class 文件(二进制字节码)
在程序被执行的时候,,class文件 会被 加载到 内存中,也就被 JVM 构造成了类对象。
这个加载的过程,我们称为 “类加载”。

这里的类对象,就是放到方法区中。

类对象里面都有哪些东西呢?

类对象就描述了 这个类 “长什么样”:
类的名字是什么,里面都有哪些成员,方法;
以及每个成员的名字叫什么,类型是什么,其修饰词是哪个(public/private/protect);
每个方法叫什么名字,是什么类型(public/private/protect),方法里面又有哪些指令。
东西还有多,这里就不列举了。
总之,与类相关的信息,都是在这个类对象里 。

另外,再补充一点: 类对象里面还有一个很重要的东西:静态成员。
换句话来说:static 修饰的成员(变量/方法),成为了 “类属性”。
而普通的成员,叫做 “实例属性”。
另外,static单词本身的意思 和 “类属性” 无关!!!


 总结: JVM 内存分配总图

 

 另外,上述学习的这个内容区域的划分,不一定是符合实际情况的。
JVM 在实现的时候,具体怎么划分这个区域,不一定完全相同,不同厂商,不同版本的 JVM 实现上可以会存在一些差异。
比如:
上图中的 元数据区,有的 JVM 就没有这一块。

像这里面的常量池,有些 JVM 就可能会把它放在 堆上。
这里就不做深究 

二、 JVM 类加载机制

类加载,其实是设计一个 运行时环境 的 重要的核心功能。

我们这里还是以应付面试为目的!
重点去学习其中的 常见面试题即可。


类加载是干什么的?
把 .class文件,加载到 内存中,然后,构建成类对象
下面,我们来看看 类加载的流程图 / 类的生命周期 

 故:类加载可以分为三大步骤: Loading(加载),Linking(连接), nitialization (初始化) 

 参考java官方文档:

爪哇SE规范 (oracle.com)https://docs.oracle.com/javase/specs/index.html

2.1 类加载需要经过的几个步骤

2.1.1 Loading - 加载

它主要做的事情:先找到对应的.class 文件,打开并读取 .class字节码文件。然后,同时初步生成一个 类对象。

我们最终的目标:是得到一个完整的类对象。但是!在当前的初始阶段,只能搞出粗略的对象 ~~

如果要细分的话:
在加载 Loading 阶段,Java虚拟机需要完成以下三件事情:

1)通过一个类的全限定名(包名)来获取定义此类的二进制字节流。【其实就是 打开文件 和 读取文件 的过程】

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。【解析文件】

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。【通过访问入口,将解析到的属性 给设置到 类对象里面去】

Loading 中的一个关键环节,.class文件到底里面是什么样式的?

虽然.class文件 是一个二进制格式的文件,但是里面也是有相关的规则的,回到官方文档,找到 第 4 个部分 The class File Format (类的文件格式)

这里就是.class文件的格式内容。

一个换句说:我们去实现一个 Java 编译器,就得按照这个格式去构造实现一个 JVM,就得安装这个格式进行加载。

下面这个格式对于 编译器 和 JVM 的实现,都是非常重要的!
u4 就是 4个字节 的 unsigned int;u2 就是 2个字节的 unsigned int
cp_info/field info 都是结构体 (C语言里面的结构体) 

2.1.2 Linking - 连接 

 Linking - 连接,一般就是建立好多个实体之间的联系。

 通过上图,我们可以发现: Linking 又可以分三个步骤
1、verification (验证)
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证选项:

  • 文件格式验证
  • 字节码验证
  • 符号引用验证

简单来说 verification (验证)环节,主要就是验证 从class文件中读取到的内容 是不是和规范中 规定的格式,完全匹配!

2、Preparation (准备)

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
3、Resolution(解析)
        这块不太好理解,涉及到实现细节了,Resolution阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。 

        我们可以发现: 常量池中的元素 (结构体) 中,只是一些简单的编号要想真正把常量替换过来,还得需要 根据编号来找到 集中放置常量的位置。

  1. 在 .class文件中,常量是集中放置的,每一个常量都有一个编号
  2. 在.class文件中,"常量池中 元素(结构体) 的初始情况下,只是记录编号
  3. 需要根据编号找到对应的内容,填充到类对象中。

这里的实体,你可以暂且理解为 是 类对象 中 常量池中元素的内容。
根据其元素内容(编号),将对应值联系起来。
不仅仅是和 元素结构体,建立关系,更是为了 建立 与 类对象 的关系。
毕竟这些联系起来的数据,最终都是要填入 类对象中的。 

2.1.3 initialization(初始化)

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

在前面 Linking 的 preparation(准备)阶段,只是给静态成员分配内存,并给予初始值(默认值),有没有初始化?没有!
此时,就需要根据我们所写的代码,把静态变量后面的表达式进行求值;该去new的,就去new,该执行什么方法,就执行什么方法。
最终完成这样的一个初始化过程

小结

类加载是把 .class 文件 加载到内存中,生成一个 类对象。
其中需要经过 3 个步骤:Loading,Linking,initialization

  • Loading(下载)环节 对class文件进行读取并解析,生成一个“初步简陋”的类对象。(不完成形态)
  • Linking(连接)环节可以分为三个阶段.....
    • Verification(验证):验证 初步生成的类对象 中的数据格式,是否符合 《Java虚拟机规范》中的要求。如果不符合,类加载就会失败,并抛出异常。反之,通过了验证环节,就会进入下一步。
    • Preparation(准备):此时,就会针对静态变量进行分配空间,基于 初始值 / 默认值。
    • resolution(解析):将 常量池的中元素,根据其内容中的编号,找到对应的数据,填入类对象中。需要注意的是,这里只是分配的空间,找到了对应的数据。但是还没有进行初始化赋值,只是把数据和内存给准备好了。
  • initialization(初始化):这里才是真正对 静态变量进行初始化赋值

经过上面,这一系列操作,我们就可以得到一个完整的类对象了。
而这个过程,就被称为 类加载。

学习这块的目的,是为了 面试 / 笔试 的时候,能够应付一些相关的问题。
大家要能够知道 class文件 最终是如何真正执行起来的。

经典面试题

类加载时间例题

双亲委派模型

        面试爱考,只因为这个东西 有个高大上的名字,本身不难理解~~
        双亲委派模型,它描述的就是:JVM中的类加载器,如何根据类的全限定名(java.lang.String)来找到 .class文件 的过程。
简单来说:双亲委派模型,就是一个找文件的过程。
        核心环节:解析class文件,构造一个类对象,验证,初始化....(找文件不是核心环节)

        双亲委派模型,是类加载中的次要环节,处于 Loading 阶段 的 比较靠前的部分。

        Loading环节,主要是先找到对应 class文件,然后打开并读取 class文件,同时初步生成一个类对象。

那么,问题来了:具体是怎么去找文件的呢?
        在 JVM 里 提供了专门的对象,叫做 类加载器,找文件的过程就是类加载器负责的。

.class 文件可能放置的位置有很多,有的要放到 jdk 目录里,有的放到项目目录里,还有的在其它特定的位置…所以,为了方便找到 .class 文件。于是JVM 提供了 多个类加载器。每个类加载器负责一个片区。这个类加载负责一片区域,另一个类加载负责另一片区域。。。。
简单来说:每个类加载器负责区域是不一样的。

默认的类加载,主要与三个:

1、BootStrapClassLoader[模拟线路类加载器]
负责加载标准库中的类 (Stirng,ArrayList,Scanner...)
标准库中常用的类,都是 BootStrapClassLoader负责的。

2、ExtensionClassLoader[扩展类加载器]
负责加载一些 JDK 扩展的类,现在很少会用到。因为这些扩展类,都是一些老古董了,几乎是用不到的

3、ApplicationClassLoader[应用类加载器]
负责加载当前项目 目录中的类[我们自己写的Java代码,不是会创建一个类吗? 就是这个]

除了上面的三个类加载器,程序员是可以自己自定义 类加载器的,用它来加载其他目录中的类。其中 Tomcat就自定义了 类加载器,用来专门 加载 webapps 里面的 .class 文件。因为 Tomcat 是要把 打好的war包 (包含了class文件),放到 webapps里面的。

双亲委派模型,就是描述了这个找目录的过程,也就是上述 类加载器是如何配合的:
例子1: 考虑 加载 java.lang.String 的过程是什么样子的。

1、程序启动,先进入 ApplicationClassLoader类加载器[以 ApplicationClassLoader 作为入口]

2、ApplicationClassLoader 就会检查下,它的 父 加载器是否已经加载过了。如果没有,就调用 父 (父 和 类是分开读的) 类加载器 ExtensionClassLoader

3、ExtensionClassLoader 也会检查下,它的 父 加载器是否已经加载过了,如果没有,就调用  加载器 BootStrapClassLoader

4、BootStrapClassLoader 也会检查下,它的 父 加载器是否已经加载过了,然后它发现自己没有"父亲",于是它自己扫描自己负责的目录

5、由于 String 这个类在标准库能找到! 因此,直接由 BootStrapClassLoader 负责后续的加载过程。至此,查找环节就结束了。

例子2: 如果我们所要加载的类,是我们自定义的!那么查找的过程又是如何?

假设 这个类的类名是 Test

1、程序启动,先进入 ApplicationClassLoader类加载器[以 ApplicationClassLoader作为入口]

2、ApplicationClassLoader就会检查下,它的 父 加载器是否已经加载过了。如果没有,就调用 父 (父 和 类是分开读的) 类加载器 ExtensinglassLoader。

3、ExtensionClassLoader 也会检查下,它的 父 加载器是否已经加载过了,如果没有,就调用 父 类加载器 BootStrapClassLoader

4、BootStrapClassLoader 也会检查下,它的 父 加载器是否已经加载过了,然后它发现自己没有"父亲",于是它自己扫描自己负责的目录。

5、由于 这个类是我们自定义的,因此,在标准库中是找不到的。所以,RootStrapClassLoader 是扫描不到的! 因此,就会回到 子 类加载器ExtensionClassLoader继续扫描

6、ExtensionClassLoader 就会扫描自身所负责的目录,如果没有扫描到,就会回到 子 类加载器 AplicationClassLoader,继续扫描。

7、ApplicationClassLoader 也会扫描自身所负责的目录,由于 我们自己写的类,肯定是项目中的目录下,因此肯定是能找到 Test 类的!于是,就会进行后续的加载。至此,查找目录的环节,就结束了
8、另一个结果:如果最终 ApplicationClassLoader也没有找到,就会抛出 ClassNotFoundException 异常! !至此,查找目录的环节,就结束了。

这一套查找规则,就称为 “双亲委派模型”!!!
举个例子:小学生写作业
小孩(Application)回家拿作业,自己不会,让家长教,家里就两个大人,肯定找father (Extension)教,father也不会,于是 father 去找 grandfather (BootStap)结果,grandfather 也不会,那就只能 小孩自己去想了。 

其实,“双亲委派模型” 就跟我们以后在公司工作一样。

可以认为: 就是一个往上汇报,再往下进行反馈的过程。 那么,问题来了:JVM 为什么要这样去设计?

理由就是:如果程序员自己写的类,和标准库中某个类的全限定类名重复了,也能够顺利的加载到标准库中的类!!!
其目的,就是为了保证 里面 加载类 的 唯一性。

问题一:如果在程序种创建一个 java.lang.String 的 类,我们创建的这个类,就和 标准库中的 全限定类名是一样的,你们认为这能创建成功吗 ?   —— 答案是可以的!只是不能运行而已

        此时,如果我们程序尝试加载这个类,最终加载的就是 标准库中的 类。就如同我们在讲“双亲委派模型”的情况是一样的,想要加载我们写的类,是需要经过层层“汇报”。从 ApplicationClassLoader 进入,然后开始向上“汇报”,到了 BootStrapClassLoader 之后,由于 java.lang,String 是标准库中的类。因此,后续的加载操作,就由 BootStrapClassLoader 来执行。查找文件的操作,到这里就结束了!
轮不到 ApplicationClassLoader 了,自然也就不会加载我们自定义的类了!


问题二:如果是自定义的类加载器,是否也需要遵守“双亲委派模型”?

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

比如:像 Tomcat 加载 webapps 中的类,就没有遵守。因为遵守了,也没有意义!
毕竟,这里的这些类,都是我们程序员写好了的,自己往上面部署的。
如果 自己 当前这个 专属的类加载器 都找不到,你还能指望 标准库中的类加载器 来找到吗?不现实!
因为,这些类一定是程序员自己定义的,是绝对不可能出现在 标准库中的!!!
所以,Tomcat 就没有做这个多余的动作!

三、JVM 垃圾回收 【重点】

垃圾回收,又称 GC(Garbage collector - 垃圾回收器)

3.1 垃圾回收 是干什么的

我们写代码的时候,经常会申请内存!!!
那什么时候会申请内存?

  1. 创建变量
  2. new 对象
  3. 加载库的时候
  4. ..........

这些操作,都是要去申请内存的。
毕竟,我们程序要想运行,离不开硬件上的支持。
而 内存,又是我们整个计算机中最最关键的硬件设备之一。

俗话说得好:有借有还,再借不难。
内存从哪里申请呢?从操作系统申请!
但是 你不能一直占这不放,在你不使用这块内存的时候,是要还给操作系统的!

我们一般说:
申请内存的时机,一般都是明确的。
而 释放内存的时期,则不是那么清楚的!!
意思就是说:
思考一下,我们一般什么时候申请内存?
在我们需要保存 某个 / 某些 数据的时候,就需要 申请内存。
那什么时候,释放内存?
当这块内存,我们不用了的时候,才会去释放【回收】。
垃圾回收,就是把我们不需要内存空间给回收。

但是!问题来了:什么叫做不用了?如何才能判断这块内存已经不需要了,需要进行 “垃圾回收” ?这个其实不好判断!
我们来举个例子,来了解这其中的意思。

在代码里,我们去创建一个变量(申请一个内存),关于这个变量什么时候不再使用了?也不是那么容易就能确定的!!!

如果我们释放内存的时机有问题!

1、释放的时机,早了!变量还需要被使用,结果 内存早就被回收了!变量就无法进行使用了。举个例子:我们去吃饭,吃到一半,服务员就把碗筷收走了~~

2、既然前面释放早了,有问题!那我们就晚点释放变量的内存,行不行?

迟释放,也有问题!还是上面那个例子:我们吃好饭走了,服务员还迟迟不收碗筷,等下个顾客来的时候,发现没位子吃饭了~~

在内存中,就是这样的情况:如果你用完之后,没有及时释放这块内存,其它程序需要使用这块内存的时候,是不可以的。


由此,我们得出结论:内存释放的时机,早了不行,晚了也不行!

要能够恰到好处,才是最好的!【这是最理想的情况】

那么,这个问题是如何解决的呢?

大家要明确:不同的语言,处理的方式是不一样的!

在C语言中,就会经常遇到一个臭名昭著的问题:内存泄露

内存泄露,简单来说:就是申请了内存之后,忘记释放内存,或者释放晚了!导致可用内存越来越少,最后,无内存可用!内存泄露 这个问题,很难发现!

因为它不一定是程序一执行,就会出现问题。【有的泄露慢,有的快,暴露问题的时机,是不确定的!】如果泄露快,那还好,一会就能发现。

但如果泄露慢,程序上线之后,发现没有什么问题,结果等到晚上的时候,程序崩了。 

因此,内存泄露问题,非常难以排查!需要花费很多很多的时间去排查! 


轮到Java,其实不光是Java,还有Go,Python,PHP。。。现在市面上的大部分主流编程语言都采取了一个方案:垃圾回收机制

这个垃圾回收机制,大概就是由运行时环境(JVM 虚拟机/Python解释器/Go 运行时...)来通过 更复杂的策略判定 内存是否可以被回收,并进行回收的操作。

 在java中写代码,你只管写,至于内存什么时候释放?Java 来负责处理。

因为你选择一门好语言,它会在背后默默为你做很多事情!


垃圾回收:本质上是靠运行时环境,额外做了很多的工作,来完成自动释放内存的操作。

这样做的好处,就是让程序员的心智负担大大降低了!!!

如果你是C++的程序员,那么势必天天脑子里都要惦记 内存 什么时候释放,才是最合适的!【纠结的要死】可能很多的时间,都会花在考虑 内存释放 的问题上。

像 Java 这种语言,内存释放的问题不用管,你就咔咔直写,背后都帮你处理好了。


此时,就有一个问题:既然垃圾回收机制,这么香!为什么C++自己不搞一个 垃圾回收机制?

这是因为 垃圾回收机制 也有着它自己的劣势:

1、要消耗额外的开销(消耗资源更多了,但是注意!并不意味程序执行的速度会很慢!)

2、可能会影响程序的流程运行,垃圾回收经常会引入 STW 问题:Stop The Word(世界停住了),就类似一个时间停止的技能,就是代码在运行的时候,会产生停顿。就这个问题,C++表示忍不了!虽然 C++也有大佬提案:引入GC,也不是实现不了, 但是,并没有被标准采纳!原因就是 上面的 STW 问题 和 资源开销 触犯了 C++的 底线。


C++语言有两条“高压线/底线”,是它的核心原则,也是他安身立命之本。【资源开销】 1、和C语言兼容,也能够和各种硬件,各种操作系统 做到最大化的兼容

2、追求 性能的极致【STW,就是触犯了这一条】

正是因为这两点,让C++有了核心的竞争力,有了 屹立不倒的资本。 因为需要加载的资源有很多,如果硬件设备跟不上,可能就不行了。

而另一方面,在追求极致性能的时候,是不允许有STW这种情况的,最后垃圾回收,这个提案,没有被采纳~~


虽然现在的计算机语言有很多。但是,在很多领域里,C/C++仍然处在“舍我其谁”的位置。比如:人工智能,游戏引擎,高性能服务器,操作系统.

这些对于兼容和性能有着极高的场景,C++处于核心地位。这里有一个特殊情况:

Rust,这是最近几年新兴起的一个语言。这个语言的目标:撼动,甚至取代C++的地位。

Rust 语言,对于自己的定位也是“极致性能”+“兼容性强”。Rust 语言,对于内存回收,是通过 强语法级别的约束 来实现的。强语法级别的约束:

在一个代码编译的时候,编译器就会做非常强力的检查,就能提前识别出 可能存在内存泄露的代码,并直接报错。也就是说:如果你的 内存释放时机不对/内存泄露,直接一编译,就会直接报错。

但是!这个语言,也存在着一个巨大的问题:它的语法 比C++更复杂!!!

通过对于 内存释放的问题,我们引出了 Java 的 垃圾回收机制。
并且,该机制在大部分主流的语言中,都应用了。


3.2 回收什么“垃圾”

通过上述的内容,我们知道了 垃圾回收 是干什么的。
下面,我们就来 看看 Java 的垃圾回收,具体是回收什么“垃圾”。

回收肯定是回收内存,但是内存有很多种。就如同前面所说的:

1、程序计数器   2、栈   3、堆  4、方法区

那么,这里的那些内存是需要回收的呢?

1、程序计数器

这个不需要回收,它就是固定大小的,里面只是存储一个地址。因此,也就不会涉及到 释放,也就不需要 GC。

2、栈

这个也不需要手动回收。只要函数调用完毕,对应的栈帧就自动释放了。

3、堆

这个是我们最需要GC的。代码中大量的内存 都是在 堆上的!

毕竟 对象是存储在 堆上的,而 Java 又是 万物皆对象。自然 堆 也就是 吃内存的“大头”。

4、方法区

这里面存储的都是 类对象,通过类加载得来的。什么时候 类对象 不使用了呢?

进行“类卸载”的时候,就需要释放内存。

但实际上“类卸载”操作,其实是一个非常非常低频的操作。因此,几乎就不太会涉及到 卸载。

因为 程序再大,但是 类都是有限的,固定的就这么多。所以,整体的开销也不会很大。

而且,这些类指不定什么时候又会用到,因此,就不怎么会涉及到“类卸载”的操作。因此,方法区也很少还会涉及到垃圾回收。

经过这么一分析:

堆,是我们主要进行 垃圾回收的区域。

也就是说:在Java中,主要回收的是new对象所开辟的内存空间。

 再来看看堆上的情况

在堆的整个空间上,其实就会有一些对象。

  • 有的对象,整个内存,都必须要存储在“正在使用区”的!
  • 有的对象,整个内存,都是在“不再使用区”
  • 还有的,介于“正在使用区”和“不再使用区”之间的。

这就好比:

任何组织里,人都有三个派别。1、积极派 2、消极派 3、中立派 对象也是一样的,

  • 有的对象,整个内存都需要使用;【积极派】
  • 有的对象,整个内存都不用【消极派】
  • 有的对象,只有部分内存 是需要使用的。【中立派】

那么,问题来了:

上述三个派别,哪些是需要进行回收释放 内存的??很显然 积极派 肯定是不能释放的!

消极派,肯定是要释放的!

关键就是中立派,一部分在使用,另一部分不再会被使用。那到底释不释放?

默认 中立派的整个内存,都不释放。

除非,中立派的整个内存,都不会再使用了,才会进行回收。因为,在GC 中就不会出现“半个对象”的情况!!!!!为什么不能出现 回收“半个对象”的情况?

主要还是为了 让 垃圾回收 实现起来更方便!更简单!


由上述内容,我们可以得出一下结论:

        垃圾回收机制,主要是回收堆上的对象。
        回收的单位是:对象(一个完整的对象)

3.3 具体怎么回收

下面我们来看一下,垃圾回收具体是怎么回收的、

垃圾回收的过程,分为两个大的阶段:1、找垃圾/判断垃圾  2、释放垃圾/释放资源 

3.3.1 找垃圾/判断垃圾

现在主要的任务是:找到垃圾。 
如何去找垃圾?如何去判定这是一个垃圾?
当下主流的思路,有两种方案:

  1.         1、基于引用计数【不是Java采取的方案,而是Python...:采取的方案】
    1.         2、基于可达性分析【这个是Java采取的方案】

之所以,我在这里都拿出来讲,完全是因为那本引起面试考JVM的书,它里面把这两个思路都写了。【深入理解Java虚拟机】

因此有时候,面试官也会问。
这里需要注意的是,一定分清楚下面面试官问的两个问题!!!
面试官的问题:
        1、谈谈垃圾回收机制如何判定是不是垃圾
        2、谈谈Java的垃圾回收机制中如何判定是不是垃圾
这两个问题是不是一样的!!!!!
        第一个问题:回答上面的两种方案基于引用计数和基于可达性分析
        第二个问题:就是详细介绍基于可达性分析的方案。

3.3.2 找垃圾

1、  引用计数

基于引用计数——针对每个对象,我们都会额外引入一小块内存,保存这个对象有多少个引用指向它。

那什么情况下,引用计数为0呢?

再来看一段伪代码:

void func(){
Test t=new Test();
Test t2 = t;
func()://调用方法的过程中,创建了对象(分配了内存),在方法执行的过程中,引用计数为2

        当方法执行结束的时候,由于t和t2都是局部变量,随着栈帧一起释放了。这一释放,此时,就没有引用类型变量指向Test对象了,也就是说没有代码能够访问到这个对象了。故:导致引用计数为0了。此时,就认为这个对象是一个“垃圾”。
        简单来说:引用计数的方案,是通过指向对象的引用个数来决定对象的生死。
引用计数确实能解决当前如何判定垃圾的问题!而且简单,高效,可靠性高,但是!有两个致命缺陷!
1、空间利用率比较低!
每个new的对象都得搭配一个计数器,假设一个计数器得4个字节。
如果对象本身很大,几百个字节,那么,这4个字节就是毛毛雨。但是!如果对象本身很小,就四个字节,那么,计数器的这四个字节,分量就很重!相当于空间利用率浪费了一半。
一个计数器比自己对象的内存还要大,这显然这就很浪费空间。

2、会有循环引用的问题

我们来看一个伪代码
class Test{
    Test t = null;
}
Test t1 = new Test();
Test t2 = new Test();

 加入关键代码:

t1.t = t2
t2.t = t1

 将t1和t2引用类型变量置空,代码:

t1 = null;
t2 = null;

也就意味着t1和t2将不再指向各自的Test对象了,引用减1

此时,我们就能发现:因为两个引用计数不为零,所以无法释放。
但是!由于引用在彼此的身上,所以外界的代码又无法访问到这里的两个对象。
此时,就很尴尬了。这两个对象既不能被释放,又不能被其它数据访问到。
即:这两个对象就被孤立了。
这就出现了内存泄露的问题。“占着茅坑不拉屎,又不让别人拉。。。” 
我们循环问题浪费的是内存。
而软件没删除干净,浪费的是硬盘空间。
 

整体来说:
引用计数的方案,确实能够解决问题。
但是 光使用 引用计数,是存在限制的。

所以像 Python ,PHP 进行 GC 也不是只考虑 引用计数,还依赖了其它的机制进行配合。
而在 Java中,直接就不使用 引用计数 了!
直接采用的 可达性分析 分析 方案。
这也是下面我们要讲的内容


2、可达性分析(Java 所采用的方案

可达性分析是通过 额外的线程,定期的针对整个内存空间的对象进行扫描。
怎么扫描呢?
它有一些 起始位置,统称为 GCRoots。
从这个 GCRoots 出发,它会 类似于 深度优先遍历一样,把可以访问到的对象都标记一遍。
也就是说:带有标记的对象就是 可达的对象。
可达的对象:有引用变量指向它,可通过 引用变量 访问到的对象。

反之,没有被标记的对象,就是 不可达的对象,
不可达的对象:没有 引用变量 指向它,无妨被访问到的对象。
此时,这个不可达对象,就是 垃圾。


我们下面来看例子: 

TreeNode a= new TreeNode();
TreeNode b= new TreeNode();
TreeNode c= new TreeNode();
TreeNode d= new TreeNode();
TreeNode e= new TreeNode();
TreeNode f= new TreeNode();
TreeNode g= new TreeNode();


a.left = b;
b.left = d;
b.right = e;
c.left = g;
a.right = c;
c.right = f;
TreeNode root = a;

树上的任意节点,都可以通过a直接/间接的获取到。
回到可达性分析,结合上面的二叉树进行分析了解。
GC在进行可达性分析的时候,当GC扫描到a的时候,就会把a能访问到的所有元素都访问一遍,并且进行标记。
这些被标记的节点,就都不是垃圾。
但是如果代码中,加一句代码:c.right=null; 就意味着c与f之间的“线”就没了。即c不再指向f,f被抛弃了。
此时,就意味着:下一次,从a开始进行遍历的时候,就访问不到f了!!
此时,f就是垃圾。
即:f就应该被回收掉!!!
如果是这样的代码:a,right=nulI;

这个就是可达性分析判断垃圾的方式。
核心要点,就是遍历思维。
由此,不难想到可达性分析的缺陷:如果内存中的对象很多,那这个遍历就会很慢!!!
因此,在Java中进行GC,还是比较消耗时间和系统资源的!!!
还有一个问题:既然是遍历,那从哪里开始进行遍历呢???
它有一些起始位置,统称为GCRoots。.
从这个GCRoots出发,它会类似于深度优先遍历一样,把可以访问到的对象都标记一
遍。
虽然我们知道是GCRoots开始遍历,但是GCRoots具体又是什么东西呢??
        1、栈上局部变量
        2、常量池中的引用指向的对象
        3、方法区中的静态成员指向的对象
因此,遍历的时候,就从这三个位置进行遍历。
同时,也很明显,这不是一个两个,而是一堆! 
 

由此,我们可以得出结论:

可达性分析的优点,就是克服了 引用计数的两个缺陷【空间利用率低 和 循环引用】。
同时缺点也很明显!系统开销大,遍历一次可能比较慢。
主要就是因为 标记垃圾,这件事,很拖慢效率。

所以,我们后面遇到的大部分 垃圾回收器,很多就是 针对 扫描,这一块进行优化。


总结
找垃圾,核心就是确认这个对象,在未来是否还会被使用?

那么,什么算是不会再使用了?
        没有引用指向的对象,就是不会再使用了。此时,这些对象就是垃圾。
        无论是 引用计数,还是 可达性分析,都是基于 引用来判断 对象,是不是垃圾。

释放垃圾
        确定了垃圾之后,接下来就是回收垃圾了。


3.4 回收垃圾  

回收垃圾 ( 释放内存 ) 有三种基本策略:

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

3.4.1 标记 - 清除

 

此时,我们发现:如果直接进行释放,虽然内存是还给系统了。但是!被释放的内存是离散的。【被释放的内存空间,不是连续的】
离散,会带来的问题就是:“内存碎片”(计算机中一个典型的问题)。
假设:现在空闲的内存有1个G。
如果我们接下来申请500M内存,也是可能会申请失败。
因为要申请的内存,都是连续的内存空间。
而这里的1G内存,可能是多个内存碎片加载一起的结果
【PS:内存碎片,就是一些未被分配的内存空间(空白区域)和已经释放了的空间】 

3.4.2 复制算法

为了解决 标记-清除 方法 带来的 内存碎片 的问题。
我们引入了 复制算法!

 复制算法,是怎么释放垃圾的呢?
直接把不是垃圾的对象,拷贝到另一半内存中,随后,将原来的那一半空间,进行整体释放。
这样不就始终能保持有一半的空间是连续的嘛。 

 

此时,内存碎片的问题,就迎刃而解了。
有的明友可能很细,说这样做,不就改变了对象的地址吗?那还能访问到拷贝的对象吗?
注意!地址变了,没有关系!!!
JVM内部肯定是会保证你用户端持有的引用能够找到对应的对象的!
因为,搬运也是JVM搬运的;地址也是JVM维护的;然后,那个引用也是JVM反馈给你的。
因此,不必担心地址改变了的问题!JVM在后台都给你处理好了!
 
直白来说:复制算法,在数据申请内存的时候,只使用一半的内存。
另一半内存等着复制算法进行搬运(复制)。
因此,复制算法有2个很大的问题!!!!
        1、内存空间利用率低了!!【就只能使用一半的空间】
        2、如果要保留的对象多,要释放的对象少,此时复制带来系统开销就非常大。
即:复制算法,虽然解决了内存碎片的问题,但同时又引入了其它的问题。

3.4.3 标记 - 整理

这里是 针对赋值算法,再做出改进。

总结

关于垃圾回收,我们有三种思路。
但是我们发现,每种思路自身都有着一些缺陷。
总的来说:在进行垃圾回收的时候,无论单独使用哪种方法,效果都不是很好。

实际 JVM 中的实现,会把多种方案结合起来使用。
这个思路,我们称为 “分代回收”。

分代回收,是根据 对象的 “年龄”,把 对象分成了不同的类。

有的朋友,可能会很好奇:对象还会有年龄?这又不是人。
这里的年龄,是指一个对象熬过一轮GC的扫描,就称为“长了一岁”。
这肯定是会在内存中申请一块空间来存储年龄的,就是在Object对象头里。【不细说】
针对不同“年龄”的对象,我们采取的不同的方案!!!
假设:我们有一个堆,并且把空间“一分为二”【注意!不是只用一半了!】

 

那么,我们的对象是怎么在这几个区域来回轮转的?
1、刚new出来的对象,就存放在伊甸区【不要怀疑名字,真的就叫这个名字】
2、如果伊甸区的对象熬过一场GC的扫描,就会拷贝到幸存区。(应用了复制算法)
有的人可能有疑问:伊甸区这么大,对象肯定不少,你确定幸存区能存的下?
根据一位资深程序员的经验,幸存区是够用的!
大部分的对象都是“朝生夕死”的情况下,就是屁股还没坐稳,就去世了。。。
真正能熬过一轮GC的对象,并不多!!!!
只能这样说:大部分新new的对象,都挺不过第一轮GC。
3、在后续的几轮GC中,我们幸存区中的对象在两个幸存区之间来回拷贝【复制算法】
每一轮都会淘汰一部分幸存者
4、在持续若干轮之后,对象终于“多年媳妇熬成婆”,进入老年代。
老年代区域有个特点:里面的对象都是“年龄大的”。
这里就涉及到一个基本经验规律:一个对象越老,它继续存活的可能性就越大。【直白点说:要死早死了。。】
因此,老年代的GC扫描频率大大低于新生代,毕竞,老年代的命都很硬!
需要注意的是:老年代采用的标记-整理的方式进行回收的!!!!
 

上述过程都是面试中的经典问题。
再来给你们举个例子,加深你们对 分代回收 的理解。 

这个分代回收的过程,就我们找工作的过程是一样的。
1、先投简历【伊甸区】
2、进行筛选简历【GC扫描:伊甸区->幸存区的过程:大部分都会被淘汰掉】
3、两个幸存区之间的来回拷贝【就好比:通过简历筛选之后,面试和笔试,有好几轮。每一轮都会淘汰一批人】
4、通过重重筛选,章到offer,就可以入职了。【进入了老年代】
5、进入公司了,就真的进入“保险箱”了吗?肯定不是啊!你表现不好,照样辞了你。。【老年代,使用标记-整理来进行回收垃圾】
公司也是有末位淘汰(绩效考核)机制,如果你干的不好,照样辞。
大概6个月,就可以看到结果了。长的一年,也有可能。
 

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

这就好比:大佬,都是名校保送的!!! 是不需要进行高考的! 

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值