JVM的简单介绍
在刚学习Java时,老师就会向我们介绍三个东西:JDK(Java开发工具包)、JRE(Java运行时环境)、JVM(Java虚拟机)。这里介绍一下,不同的CPU,上面支持的指令是不同的,市场CPU也有很多不同的架构(ARM、x86.....),而且不同的系统,生成的可执行程序也不同(Windows的是 PE格式,Linux 是ELF格式);像C++这样的语言是直接编译成二进制的机器指令,如果要换个
目录
一、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)如果对象在老年代寿终正寝(被标记为垃圾了),就会按照 标记-整理 的方式,去释放内存。
都看到这了,点个赞再走吧,谢谢谢谢谢
更多推荐
所有评论(0)