目录
一、JVM的简单介绍
JVM的执行流程
二、JVM中的内存区域划分
1、堆(只有一份)
2、栈(可能有N份)
3、程序计数器(可能有N份)
4、元数据区(只有一份)
经典笔试题
三、JVM的类加载机制
类加载的过程
1、加载
2、验证
3、准备
4、解析
5、初始化
双亲委派模型(JVM查找 .class文件 的策略)
双亲委派模型的工作过程
四、JVM中的垃圾回收算法
1、垃圾回收机制(GC)
2、JVM的内存区域哪些需要被GC回收
3、哪些内存需要被回收?
4、垃圾回收的过程
(1)识别出垃圾
a、引用计数
1、消耗额外的内存空间
2、可能会产生 “循环引用的问题”
b、可达性分析(JVM用的是这个)
(2)把标记为垃圾的对象的内存进行释放掉
a、标记-清除
b、复制算法
c、标记-整理
分代回收(JVM使用的)
一、JVM的简单介绍
在刚学习Java时,老师就会向我们介绍三个东西:JDK(Java开发工具包)、JRE(Java运行时环境)、JVM(Java虚拟机)。
这里介绍一下,不同的CPU,上面支持的指令是不同的,市场CPU也有很多不同的架构(ARM、x86.....),而且不同的系统,生成的可执行程序也不同(Windows的是 PE格式,Linux 是ELF格式);像C++这样的语言是直接编译成二进制的机器指令,如果要换个平台执行,就要重新编译,适配另一个机器CPU架构的二进制的机器指令。
这也是为啥手机的系统只有安卓和ios,如果搞一个新系统,现有的软件就不能适配,没有生态,也就没有市场。
JVM就是Java 虚拟机,我们知道java程序是可以跨平台运行的,其实就是因为JVM;Java先通过javac,把 .java文件 转换成 .class文件,要运行Java程序的时候,JVM就会把 .class文件转换成适配当前CPU的二进制机器指令,这样就不需要重新编译,来适配当前CPU了。
.class文件就是字节码文件,是Java自己搞的一套 “CPU指令”,在某个具体的系统平台上执行,通过JVM把 .class文件转换为对应CPU能是的指令。JVM相当于一个翻译官,把Java的字节码文件转换成当前CPU能识别的指令。
所以,我们编写和发布Java程序,只需要发布 .class文件 即可,如果在Windows系统上,就会把 .class文件 转换成Windows能支持的可执行程序,在Linux,就能转换成Linux上能支持的可执行程序。
不同平台上的 JVM 是存在差异的,不是同一个,但针对Java层面上,是同一一致的,当前的 .HotSpot VM是当前主流的 JVM。
JVM的执行流程
Java在执行前,要把Java代码转换成字节码(.class),而字节码是JVM的一套指令集规范,不能直接交给底层操作系统去执行,还要将字节码转换成底层系统指令,再交给CPU去执行。
二、JVM中的内存区域划分
JVM其实也是一个进程,那就能在任务管理器看到,启动之前写的UDP回显服务器,如图:
在任务管理器也会就有相关的进程,如图:
进程在运行过程中,就要向操作系统申请一些资源(内存就是其中的典型资源),这些内存空间,就支撑了后续Java程序的执行,其中,在Java中定义变量(就会申请内存),而这些内存,其实就是JVM申请的从系统这边申请到的内存。这里的JVM就类似 “二房东”(租客出租自己租的房间)。
JVM从系统申请了一块大块内存,这一大块内存给Java程序使用的时候,又会根据实际的使用用途,来划分出不同的空间,不同的区域有不同的作用,这就是所谓的 “区域划分”。它由以下5大部分组成,如图:
注意:这里的堆和栈、和数据结构中的堆和栈,完全不同。
1、堆(只有一份)
代码中 new 出来的对象,就都是在堆里,对象持有的非静态成员变量,也在堆里。
2、栈(可能有N份)
不同线程都会有自己的栈,所以是有N份。
这里的栈还能细分:本地方法栈和虚拟机栈,包含了方法调用关系和局部变量。其中本地方法栈(一般不会关注本地方法栈,谈到栈,默认指的是虚拟机栈),是Java内部的(通过C++写的代码),这里的方法调用关系和局部变量;而虚拟机栈记录了java代码的方法调用关系和局部变量。
3、程序计数器(可能有N份)
不同线程都会有自己的栈,所以是有N份。
这个区域空间比较小,专门用来存储下一条要执行的 Java 指令的地址。x86也有一个类似的寄存器:eip。
4、元数据区(只有一份)
以前的java版本中,叫做 “方法区”,从Java1.8开始就改名了。
“元数据” 是计算机中一个常见的术语,往往指定是一些辅助性、描述性的属性,比如硬盘上不仅仅要存储文本的数据,还有存储一些辅助信息:文件创建时间、文件格式、文件大小、文件位置等等。
在这里,元数据指的也是辅助性的属性:类的信息、方法的信息。一个程序,有哪些类;每个类包含哪些方法;每个方法包含哪些指令;这些都是放在元数据区的。
经典笔试题
如下是伪代码:
class Test {
private int n;
private static int m;
}
......
main() {
Test t = new Test();
}
问:上面的n、m、t分别在JVM中的哪个内存区域?(区分一个变量在哪个内存区域中,主要看的就是:变量的形态,局部变量、成员变量、静态成员变量.....)
n是Test的成员变量,所以在堆上。
t在main方法中,这个变量是局部变量,所以是在栈上。
m被static修饰,说明是类属性(非static修饰的称为“实例属性 / 方法”),就是在类对象中,也就是在元数据区中(方法区)。(类对象:Test.m,JVM把 .class文件 加载到内存之后,就会把这里的信息使用对象来表示,此时这样的对象就是类对象;类对象包含了一系列信息(包括但不限于):类的名称,类继承自哪个类、实现了哪些接口;都有哪些属性,都叫啥名字,是啥类型,都是啥权限;都有哪些方法,都叫啥名字,都是啥参数,都是啥权限;.java文件 中涉及到的信息都会在 .class 中有所体现(注释不会包含))。
三、JVM的类加载机制
类加载,指的是java在运行程序的时候,需要把 .class文件 从硬盘,读取到内存,并进行一系列校验解析的过程。
类加载的过程,在java官方文档中给出了说明,如图:
正常来说,程序猿不需要关注这些具体的加载过程,工作上用不着,但面试要考。
类加载的过程
类加载大体的过程可以分为5个步骤(也有资料说是3个,就是把2、3、4给合并了)
1、加载
把硬盘上的 .class文件 找到,并且打开文件,读取里面的内容(认为读取到的是二进制的内容)。
2、验证
需要确保当前读取到的内容,是合法的(是 .class文件 格式的),具体的验证依据,在java的虚拟机规范文档中,有明确的格式说明。
平常我们说java有8,9,17的版本,而JVM也有版本,这里的JVM执行 .class文件, 也会验证版本是否符合要求,一般来说,高版本的JVM可以运行低版本的 .class文件,反之则不一定能行。
3、准备
给类对象申请内存空间,此时的申请到的内存空间,里面的默认值都是全 0 的。(这个阶段,类对象的静态成员变量值,也相当于是0)
4、解析
主要针对类中的字符串常量进行处理;解析阶段是 java虚拟机 将常量池内的 符号引用 替换为 直接引用 的过程,也就是初始化常量的过程。
现有一字符串变量,如下:
private String s = "hello";
上述代码中,很明确 s 变量保存了 hello ,是字符串常量的地址,但是在 .class 文件中,是没有地址这样的概念的,地址是内存的地址,这里是文件(硬盘),虽然没有地址,但这里可以用 “偏移量” 这样的概念(类似地址),进行标记,接下来要把 .class文件加载到内存中,hello这个字符串就加载到内存中了,此时 hello这个字符串就有地址了,原本s变量是用偏移量保存 hello 的,因为hello有地址了,就可以把s的值替换成 hello 的地址。如图:
而偏移量这里可以认为是 符号引用,把s的地址替换成hello的地址,就可以认为是 直接引用。
5、初始化
针对类对象,完成后续的初始化,把类对象的各个部分的属性进行赋值填充,这个阶段Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权交给应用程序。
双亲委派模型(JVM查找 .class文件 的策略)
JVM中进行类加载的操作,有一个专门的模块,称为 “类加载器(ClassLoader)”,JVM中的类加载器默认是有三个的(也可以自定义):
1、BootstrapClassLoader:负责查找标准库的目录
2、ExtensionClassLoader:负责查找扩展库的目录
3、ApplicationClassLoader:负责查找当前项目代码目录,已经第三方库的目录
上述的三个类加载器,存在 “父子关系”,不是面向对象的父类、子类的继承关系,而是类似“二叉树”,有一个指针(引用)parent指向自己的 “父”类加载器。
标准库指的是,Java 语法规范里面描述了标准库中应该有哪些功能,而扩展库,是实现 JVM 的厂商 / 组织,会在标准库的基础上,扩充一些功能(JVM内置的,不同厂商的扩展可能不太一样)。这块内容在上古时期,用处比较多,随着时代发展,这块内容就很少会使用了。
双亲委派模型的工作过程
如图:
1、从 ApplicationClassLoader 作为入口,先开始工作。
2、ApplicationClassLoader 不会立即搜索自己负责的目录,而是把搜索的任务交给自己的父亲。
3、此时进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader也不会立即搜索自己负责的目录,也要把搜索任务交给自己的父亲。
4、此时进入到 BootstrapClassLoader 范畴了,BootstrapClassLoader 也不想立即搜索自己负责的目录,也要把搜索任务交给自己的父亲。
5、此时,BootstrapClassLoader发现自己没有父亲,才会真正搜索自己负责的目录,通过全限定类名,尝试在标准库目录中找到符合要求的 .class文件;
这里如果找到了,接下来就直接进入到打开文件 / 读文件等流程中,如果没找到,要回到下面孩子那里的加载器中,继续尝试加载。
6、此时回到孩子这里(父亲没有找到),ExtensionClassLoader,收到父亲交给它的任务后,尝试在扩展库目录中查找符合要求的 .class文件。
这里如果找到了,接下来就进入打开文件 / 读文件 等流程;如果没找到,就要回到孩子节点那里,继续尝试加载。
7、此时回到孩子这里(父亲没有找到),ApplicationClassLoader 收到父亲交回给它的任务后,自己进行搜索负责的目录(当前项目目录 / 第三方库目录)。
如果找到了,接下来就进入打开文件 / 读文件 等流程;如果没找到也是回到孩子这一辈的类加载器中,继续尝试加载,但由于默认情况下,ApplicationClassLoader 没有孩子了,此时说明,类加载的过程失败了,就会抛出 ClassNotFoundException 异常。
上述的一系列规则,只是JVM自带的类加载器,遵守的默认规则,如果是我们自己写的类加载器,也可以打破上述规则;比如,自己写的类加载器,指定这个加载器就在某个目录中尝试加载,此时如果类加载器的parent不去和已有的这些类加载器连到一起,此时就是独立的,就不涉及双亲委派了。
四、JVM中的垃圾回收算法
1、垃圾回收机制(GC)
在C语言阶段,我们学习了malloc申请内存,和free释放内存,这里涉及到一个很重要的问题:内存泄漏,如果服务器每个请求的申请一块内存,但忘记free掉,就会导致内存泄漏。实际开发中也很容易出现 free 忘记调用,或者因为一些情况,没有执行到,例如中间存在 if -> return走了。
后面就有人提出,释放内存的任务能不能让程序自动负责,而不是程序员手动释放掉呢?Java就搞出来了,它属于早期就支持垃圾回收这样的语言。
引入了垃圾回收机制,就不需要程序员手动释放掉内存了,程序会自动判断,某个内存还是否会被使用,如果内存后续不用了,就会自动释放掉。
后世的各种编程语言,大部分都带有垃圾回收机制(例如:PHP、Python),但C / C++并没有引入,C不引入的原因是C在摆烂,C++不引入的原因是:C++委员会的大佬认为,C++追求的是极致的性能,而引入垃圾回收会影响程序执行的性能,对于追求极致性能这样的观点是相悖的。
这也是垃圾回收不可避免的一重要问题:STW(stop the world)问题,触发垃圾回收的时候,很可能会导致当前程序的其他业务被暂停。
但Java发展了这么多年,GC功能也越来越强大,有办法将STW的时间控制在 1ms 之内。
2、JVM的内存区域哪些需要被GC回收
1、堆:垃圾回收的主战场,因为里面放的是new 出来的对象和非静态成员变量,生命周期是和调不调用类有关的,一般不会伴随程序的整个生命周期,生命周期不明确。
2、栈:不会被GC,因为栈里放的是局部变量,局部变量都是在代码块执行完后自动销毁的,这是栈自己的 特点,和GC没关系,它的生命周期非常明确(也和线程有关)。
3、程序计数器:不需要被GC,这也是因为它的生命周期和线程有关,随线程而生,随线程而亡。
4、元数据区:不需要被GC,原因也和程序计数器一样。
3、哪些内存需要被回收?
这里的垃圾回收与其说是回收垃圾,更准确的说是回收对象,每次垃圾回收的时候,释放的对象(实际单位都是对象)。
如图:
4、垃圾回收的过程
(1)识别出垃圾
怎么识别出垃圾?就是判定这个对象后续是否要继续使用,在Java中,使用对象,一定需要通过引用的方式来使用,如果没有人任何一个引用指向这个对象,就视为是无法被代码使用,就视为垃圾了。
有一个例外不需要引用才能使用对象:匿名对象,不需要引用开使用它,但匿名对象的代码执行完后,就会被当做垃圾释放掉,如图:
如下伪代码:
void func() {
Test t = new Test();
t.adshixz();
}
上面通过 new Test()这个操作,就会在堆上申请一段空间,如图:
当这个走出func这个方法后,也就是执行到最下面的大括号 " } ",局部变量 t 就会被释放了,栈上也就没有 Test t 这个引用了,那也就没有引用指向new Test() 这个对象了,如图:
因为没有引用指向new Test() 这个对象了,所以对上的这块内存也要被释放掉。
但如果代码更复杂一点,这里的判定过程就会更麻烦,如下是伪代码:
Test t1 = new Test();
Test t2 = t1;
t3 = t2;
t4 = t4;
此时就会有多个引用,指向同一个对象,就需要确保这些引用都销毁了,此时 Test() 对象才能被视为垃圾,才要被释放,但如果上面这些引用的生命周期都各不相同,此时情况就不好判断了。
所以,要给出识别垃圾的方案,有2种思路:a、引用计数,b、可达性分析;在Java中,JVM用的是b方案,在PHP和Python中,用的是a方案。下面介绍这两种方案:
a、引用计数
这种思想方法,并没有在JVM中使用,但广泛应用于其他主流语言的垃圾回收机制中(Python、PHP),这里也是要给每个对象安排一个额外的空间(在栈上),空间里记录的是当前这个对象有几个引用(个数)。如下是伪代码:
Test a = new Test();
Test b = a;
a = null;
b = null;
在代码执行到 Test b = a;这里,new Test() 对象这时候的引用计数就是2,因为有a 和 b 都执行这个对象,如图:
但代码执行到 b = null;时,a 和 b都为null了,此时就没有引用指向new Test() 这个对象了,此时引用计数就会变成 0 ,因为引用计数变为0了,就会把栈上的 new Test() 这个对象当成垃圾,给释放掉,如图:
引用计数就是记录对象被多少个引用指向了,会额外使用一些内存空间来记录这个的个数,当它为0了,就会被视为是垃圾,给释放掉了。
但引用计数有两个很头疼的问题:
1、消耗额外的内存空间
上面也说了,为了记录当前对象被多少个引用指向了,就要开辟额外的内存空间,来记录这个个数,我们假设计数器按2个字节算,如果整个程序的对象很多,那要消耗的内存资源就会很多,还有,假设一个对象有4个字节,但计数器的内存却有它的一半了,这就很难受了。
就像买房,买房花钱买的是100平,但房子的实际面积才70平(有公摊面积的原因,楼层越高,公摊面积就越大,实际得房率就越低),这就和难受了。
2、可能会产生 “循环引用的问题”
如果产生这个问题,此时引用计数就无法正确工作了;如下是伪代码:
Class Test {
Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
a = null;
b = null;
首先new 出来两个对象后,就会在堆上开辟两段内存空间,此时这两个对象各自的计数器为1,当代码执行完a.t = b,b.t = a后,这两个对象的计数器就变为2,如图:
但是,如果代码执行完a = null、b = null,此时就会把a的引用和b的引用给释放掉,此时对象的各自计数器也就会变成1,如图:
因为a、b都为null了,计数器也就会变成1,但此时就出问题了,这两个对象能被使用吗?显然是不能的,现在是既不能使用这两个对象,那能释放掉这两个对象吗?也不能释放掉这两个对象;因为 a.t 和 b.t 这两个引用拿不到了(a和b都是null),要把0x100地址的对象的计数器-1,就要找到0x200的地址(b.t的地址),但不能找到,要把0x200地址的对象的计数器-1,就要找到a.t的地址(0x100),也不能找到。这就是 “循环引用的问题”。也是因为有这个问题,Java就没使用引用计数了。
b、可达性分析(JVM用的是这个)
本质是在用 “时间” 换 “空间”,相比于引用计数,消耗的时间是更多的,但总体来说,是可控的,不会产生类似 “循环引用” 的问题。
在写代码中,会定义很多变量,比如:栈上的局部变量 / 方法区的静态类型的变量 / 常量池中的引用对象......,就可以从这些变量作为起点,出发,尝试去进行 “遍历”,所谓的遍历,就是会沿着这些变量 持有的引用类型成员,进一步往下访问,所有能被访问到的,就不是垃圾,剩下的因为遍历了一遍还是不能访问到的对象,就是垃圾了。
比如下面的伪代码(二叉树):
class TreeNode {
char val;
TreeNode left;
TreeNode right;
}
//创建一个二叉树的方法
TreeNode buildTree() {
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;
a.right = c;
b.left = d;
b.right = e;
e.left = g;
c.right = f;
return a;
}
二叉树是长这样子的,如图:
虽然这个代码中,虽然只有一个root这样的引用,但是实际上上述 7个节点都是 “可达的”;JVM中存在扫描线程,会不停的对代码中已有的变量进行遍历,经可能多的去访问到对象。
像上面这图,如果加上代码:f = null; ,就会遍历不到 f,这时候就成为f不可达,也就会被视为垃圾了。
不可达 还有 传递性,如果我们加上代码:c = null; ,这时候 c 和 f 都会访问不到,c 和 f 就都会被视为垃圾,因为要访问 f,就必须通过 c 来访问,但 c 已经置为空了,也就访问不到;如果把根节点 a 置为空,那就这整棵二叉树都是不可达的,那整颗二叉树都会被视为垃圾。(就像谍战片的间谍,如果他的线人 gg 了,那他就会和他的组织失去联系)
JVM 自身知道一共有哪些对象,通过可达性分析的遍历,就可以把可达的对象都标记出来,剩下的也就是不可达的了,会被视为垃圾,回收掉。
(2)把标记为垃圾的对象的内存进行释放掉
具体有要咋释放?还有说法,主要的释放方式,有三种:a、标记-清除,b、复制算法,c、标记-整理
a、标记-清除
把标记为垃圾的对象,直接释放掉(最朴素的做法),如图:
但一般不会使用这个,因为会有内存碎片问题,比较致命。下面是内存碎片的介绍
按照上述被标记的内存,给释放掉后,就会出现很多小的,但是又是离散的空闲空间,可能会导致后续申请内存的失败;比如现在要申请1M的内存(要申请的内存是连续的),这些小的空闲内存总和有2M,但是因为这些小的内存是离散的,不是连续着的,没有1M这么大的连续空间,此时的申请1M内存的操作就会失败。(类似去买房,付首付,你有30w的首付了,但分散在很多张银行卡中,这些银行卡加起来的总和有30w,但此时你也不能付首付,大概就是这个意思)
b、复制算法
核心思想:就是把内存一分为二,当找到了是垃圾的内存,先不把它释放掉,而是把不是垃圾的内存,复制到另一半那里,然后再将原来那一半的内存空间全部给释放掉,如图:
这种算法不会有 内存碎片 的问题,但也会有其他的问题:
1、可用的内存空间变少了,只有原来内存的一半。
2、如果每次要复制的对象比较多(不是垃圾内存),此时复制的开销就会比较大了;而适合用这种算法是垃圾内存比较多,要复制的对象比较少,释放的垃圾内存比较多,适合用这种算法。
c、标记-整理
这个也能解决内存碎片问题,类似顺序表删除中间元素(搬运),把标记了是垃圾的内存,将后面的内存往前覆盖,直到后面的内存都往前搬运完了,再看看最后一次搬运后,这里的内存之后,还有没有内存,有就释放掉(后面的要么就是重复的、要么就是垃圾内存)。如图:
虽然能解决碎片问题,也不会像复制算法一样,需要浪费过多的内存空间,但是也有缺陷:这里搬运的内存开销很大。
上处三个释放内存的操作,各自都有各自的缺陷,因此,JVM没有直接使用上述的方案,而是结合上述思想,搞出了一个 “综合性” 方案,取长补短,这个方案名叫:分代回收
分代回收(JVM使用的)
分代回收,引入了 对象的年龄 的概念,在JVM中有专门的线程负责周期性扫描 / 释放,一个对象如果被线程扫描了一次,可达了(不是垃圾),年龄就 +1(初始年龄相当于是 0);在JVM中,会把整个堆分为两大部分:新生代(年龄小的对象)、老年代(年龄大的对象),如图:
这里每经过一轮GC,对象的年龄都会++。
(1)当代码中 new 出一个新的对象,这个对象就是在伊甸区的,伊甸区会有很多对象,但有一个经验规律:伊甸区的对象,大部分都是活不过第一轮GC的,这些对象的生命周期都非常短,都是“朝生夕死”的。
(2)第一轮GC扫描后,伊甸区中存活下来的对象,就会通过复制算法,转移到生存区中,后面GC扫描的过程中,不仅要扫描伊甸区的对象,也要扫描生存区的对象,生存区的对象大部分也都会被标记为垃圾,给回收掉的,所以这里也会用复制算法,把被标记为垃圾的保留在原生存区中,把能在生存区继续存活的对象,复制到另一半生存区中,然后再把原生存区中的垃圾给回收掉。
(3)如果生存区的对象,经过了若干次GC扫描后,还存在,JVM就会认为,这个生命周期大概率是很长的,就会把这些对象从生存区拷贝的老年代。
(4)老年代这里,也要被GC扫描,但是扫描的频率就会大大的降低了。因为到了老年代这里,对象如果要g,早就g了,既然没g,那么就说明老年代这里的生命周期是比较长的,就不用频繁的去扫描,意义也不大,会白白浪费时间,所以要降低扫描的频率。
(5)如果对象在老年代寿终正寝(被标记为垃圾了),就会按照 标记-整理 的方式,去释放内存。