JVM(Java虚拟机)

在这里,主要讨论3个方面:

1 JVM内存区域划分

2 类加载机制

3 垃圾回收机制

先来讨论第1个

1 JVM内存区域划分

JVM(Java虚拟机):仿照真实的机器,真实的操作系统,进行设计的

JVM仿照了操作系统的情况,对于进程的地址空间进行了分区域的设计

所以说,JVM内存空间划分,就相当于是JVM进程自身从操作系统申请到内存空间,再把内存进行不同的功能分配

那JVM具体是怎么划分的呢?

核心区域有四个

1)程序计数器(内存的一个空间)

很小的区域,只是用来记录当前指令执行到哪个地址了

线程销毁,自然就释放

2)元数据区

保存当前类被加载好的数据

类加载:把.class文件加载到内存里面

.class加载到内存就变成了类对象,保存在元数据区里

3)栈

保存方法的调用关系

每次调用方法就会进入方法内部执行,当方法执行完毕,返回到调用位置,继续往后走。为了达成顺利返回的效果,就引入了栈

可以认为,在JVM内部就维护了一个数据结构的栈,通过入栈和出栈来维护方法调用

栈这个空间不算很大,一般就是几MB或几十MB这样的情况(可以通过JVM的启动参数来配置)

4)堆

保存new的对象的

就像这样的代码:Test t = new Test();

new Test();是在堆上的,而t在哪,得分具体情况:

如果t是一个局部变量,t就是在栈上(方法栈帧销毁了,局部变量就销毁了)

如果t是一个成员变量,t就是在堆上(用到的时候,申请空间,不用的时候,释放掉,垃圾回收自动完成)

如果t是一个静态成员变量,t就是在元数据区(会一直在这里,一般只会谈类加载和类卸载)

堆是JVM中最大的空间区域了。往集合里面添加元素也是在堆上

如果堆上的对象,不再使用了的话,就需要被释放掉(这就是垃圾回收的话题,后面会讨论)

针对元数据区,有一个详细的图:

值得注意的是:元数据区和堆整个Java进程共用同一份。程序计数器和栈,一个进程中可能有多份

(每个线程有一份)

2 JVM 类加载(.class文件转换成内存中的类对象)

从面试角度来看,类加载主要关心两个方面:一个是类加载的步骤有哪些,另一个是类加载中的"双亲委派模型"是咋回事

先来谈谈类加载触发的时机:java代码用到哪个类,就会触发哪个类的加载(懒汉模式)

用到的几种情况:

构造这个类的实例

调用/使用 类静态属性/静态方法

使用某个类的时候,如果它的父类还没有加载,也是触发父类的加载

1)类加载的步骤

a)加载:

找到.class文件

根据类的 全限定名(包名 + 类名,形如 java.lang.String),找到文件之后,就可以打开文件,读取文件内容到内存里(不涉及到内容解析)

b)验证

解析,校验.class文件(二进制的文件,格式是有明确要求的)读到的内容,是否是合法的(是否符合下面的格式要求),并且把这里的内容转换成结构化的数据

这是.class文件的二进制格式。u4表示4个字节的无符号整数,u2表示2个字节的无符号整数,cp_info,field_info,......其他的结构体。magic是魔数,用来区分不同的二进制文件类型,是固定值,不同的二进制文有不同的取值。.java中写的代码,最终都会被转换到这个.class中,只不过换成二进制的表现形式

c)准备

给类对象申请内存空间(类加载最主要的目的就是类对象)

此处申请的内存空间,相当于是"全0"的内存空间

d)解析

初始化字符串常量

字符串常量本身就包含在.class文件中,就需要.class文件解析出来的字符串常量放到内存空间里(元数据区,常量池中)

e)初始化

针对刚才谈到的类对象进行最终的初始化

针对类对象的各种属性进行填充,包括类中的静态成员(static修饰的属性就是在类对象上,在元数据区,初始化时机就是在类加载中)

值得注意的是,如果这个类还有父类,并且父类还没有加载呢,此环节也会触发父类的类加载

其中,在Java的官方文档中,把 b c d这三个步骤合到一起了

2)双亲委派模型

描述了类加载中,根据全限定类名,找到.class文件的过程

类加载的过程有个专门的模块去负责,那就是类加载器

JVM默认提供了三种类加载器:

这三个类加载器首先要进行"找.class文件"环节,会根据全限定名(类似于 java.lang.String 或者 com.lsy.xxxx.Test)进行查找

这三个类加载器主要的区别就是负责找的目录范围是不同的

接下来,就来谈谈双亲委派模型的过程:

进行类加载,通过全限定类名,找.class的时候,就会从 ApplicationClassLoader 作为入口开始。然后把加载类这样的任务,委托给 ExtensionClassLoader ,ExtensionClassLoader 也不会立即查找,而是委托给 BootstrapClassLoader,因为它没有父亲,只能自己进行类加载,根据类名,找标准库看是否存在匹配的.class文件。BootstrapClassLoader 没有找到,再把任务还给 ExtensionClassLoader,接下来 ExtensionClassLoader 来负责进行找.class文件,找到就加载。没找到,就把任务还给 ApplicationClassLoader,接下来ApplicationClassLoader负责找 .class,找到就加载,没找到就抛出异常。

上面三个类加载器是属于JVM自带的。程序员可以自定义类加载器,当自定义的时候,就可以把自定义的类加载器放到双亲委派模型中,当然也可以不放。

3 垃圾回收(GC)

是Java中释放内存的手段。JVM会自动识别出,某个内存是不是后续不再使用了,如果是,就自动释放

值得注意的是,GC会对程序的运行效率有一定的影响。并且会有STW(stop the world)的问题(触发了大规模的GC,就可能会因为GC使得其他业务代码不得不暂停下来,等待GC结束之后再跑=>卡了)。针对STW的问题,在Java17及以上版本,可以做到让STW大部分情况下 < 1ms的时间。

GC是回收JVM中 堆 内存区域的,因为会创建很多旧对象,同时也会有旧对象消亡,这些旧的对象就需要GC来释放。所以说是"回收内存",本质上是"回收对象",不会出现把一个对象释放一半的情况

接下来聊聊 GC 工作过程

3.1 GC的工作过程

简单类说,分为2个大步骤

1 找到垃圾(不再使用的对象)

2 释放垃圾(对应的内存释放)

接下来详细的谈一下

1 找到垃圾(不再使用的对象)

有两种方案

1)引用计数

Python,PHP采用了这个方案

每个对象在new的时候,都搭配一个小的内存空间来保存一个整数

比如:

所以说每次进行引用赋值的时候,都会自动触发引用计数的修改,就可以通过引用计数记录有多少个引用

那有什么用呢?

在Java中,要想使用某个对象,一定是通过引用来完成的。如果引用计数为0了,就说明没有引用指向这个对象了,这个对象就是 垃圾

它的缺陷:

a)内存消耗的更多

尤其是对象本身比较小,引用计数消耗的空间比例就更大

b)可能出现"循环引用"这样的问题

通过代码来解释一下什么是循环引用

接下来 a.t = b;

接下里 b.t = a;

接下来 a = null;

接下来 b = null;

此时,这俩对象的引用不为0。虽然不为0,但是这俩对象都无法使用

对于Python,PHP来说,虽然使用引用计数,但是需要搭配其他的方案来辅助解决上述循环引用的问题

2)可达性分析Java采取

可达性分析是用时间换空间

具体步骤:

a)以代码中的一些特定对象,作为遍历的起点=>GCRoots

特定对象:栈上的局部变量(引用类型)

常量池引用指向的对象

静态成员(引用类型)

b)尽可能的遍历,判定某个对象是否能访问到

c)每次访问到一个对象,都会把这个对象标记成"可达"

当完成所有对象的遍历之后,未被标记成可达的对象就是"不可达"

值得注意的是 JVM中一共有多少个对象,JVM自身是知道的。通过可达性分析,知道了哪些是可达的,剩下的就是'"不可达"(接下里要回收的垃圾)

这个方案很好的解决了内存占用和循环引用的问题

以局部变量(引用类型)为例:

可达性分析很像"树"结构的遍历 或者 "图"结构的遍历

可达性分析,这个过程是周期性的。每隔一定的时间,触发一次这样的可达性分析的遍历

2 释放垃圾(对应的内存释放)

有三种方式

1) 标记-清除

把垃圾对象的内存,直接释放。这样做会有一个明显的缺陷:产生内存碎片问题

这样空闲空间,不是连续的。会给下一次申请内存,带来麻烦。因为申请内存,都是申请连续的内存,而不是多个部分拼在一起的

2) 复制算法

可以有效的解决内存碎片问题

通过可达性分析,1 3 5 是垃圾

之后把不是垃圾的对象,拷贝到另外一侧,然后再把这一侧给整体释放掉

最后再添加新的对象,就在右侧添加

此时可以确保,空闲的内存都是连续的

缺点:

内存的空间利用率是很低的

一旦不是垃圾的对象较多,复制的成本就会很高(尤其是这样的对象中包含大的对象的时候)

3)标记-整理

优点:解决内存碎片问题和保证内存利用率

接下来的释放就类似于顺序表的"搬运"

搬运之后,后面直接释放

缺点:内存搬运数据的操作,开销是很大的

复制成本的问题依然存在

那怎么办呢?

Java给出的办法是分代回收

分代回收

主要是把上面的 1)2)3)结合起来,扬长避短

代指的是 对象的年龄,这个年龄的单位是轮次,用GC轮次来描述对象的年龄

比如:初始情况是0,某个对象经历一轮GC可达性分析之后,不是垃圾,此时对象的年龄就+1

根据对象不同的年龄大小,就把整个内存空间分成两部分,一个是新生代,另一个是老年代

针对不同的年龄对象采取不同的策略。有个经验:如果某个对象已经是一个年龄大的对象,此时大概率还会存在很久,反之。所以,老年代的GC频次就可以降低,新生代GC频次会比较高

在新生代里又做了进一步的区域划分

**新创建的对象就放到"伊甸区",绝大部分的伊甸区的对象,活不过第一轮GC,**所以幸存区比伊甸区小

如果熬过第一轮GC,就通过复制算法,复制到幸存区,同时伊甸区再释放(伊甸区=>幸存区,复制算法,复制的对象少,开销可控)

幸存区中的对象,也要经历GC扫描。每一轮GC都会消灭一大部分对象。剩余的对象再通过复制算法,复制到另外一个幸存区。

如果这个对象在幸存区中经历了多次复制,都存活下来了,对象的年龄就大了,就会晋升到老年代了,一旦进入老年代,就会使用标记整理算法。

新生代中的对象大部分会快速消亡,使得每次复制的开销都可控

老年代的对象大部分生命周期会很长,使整理的开销也都可控

值得注意的是,有个特殊情况,如果某个对象特别大,就会直接进入老年代。因为复制的成本高

以上说的内容只是策略。JVM中的 垃圾收集器 模块来实现上述分代回收的策略。换句话说,分代回收只是最基本的策略,落到具体的垃圾收集器上都会有一些特定的、更进阶的策略。

OK啦,到此结束!!!!!

相关推荐
xieliyu.7 小时前
JVM 垃圾回收机制详解:从标记过程、回收算法到垃圾收集器
java·jvm·笔记·java-ee
此时不提桶,更待何时16 小时前
01-04-B-垃圾回收面试与生产事故实战
java·jvm·面试
kian.wqn1 天前
【JVM核心体系02】JVM 运行时内存区域:堆、栈、元空间、直接内存一次讲清
jvm
用户094248568032 天前
第12章:JDK 诊断工具箱——jps / jstat / jmap / jcmd / jhsdb
java·jvm
程序员阿鹏2 天前
双亲委派机制
java·jvm·数据结构·后端
用户094248568032 天前
第11章:OpenJDK反射、动态代理与 MethodHandle 初探
java·jvm
wuminyu2 天前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
binqian2 天前
【java】Java 两种锁机制与线程阻塞唤醒原理
jvm·spring boot·spring