JVM的内存区域划分,类加载器和GC
1.JVM中的内存区域划分
当Java程序运行的时候,jvm会从系统内存中申请一块内存空间,程序会根据实际用途在内存中划分不同的区域出来,不同的区域有不同的作用。 (这就是所谓的区域划分)
(1)堆(线程共享):
代码中new出来的对象,就都是在堆里。对象中持有的非静态成员变量,也就在堆里。
(2)栈(线程私有):(本地方法栈/虚拟机栈)
包含了方法的掉用关系和局部变量。
(3)程序计数器(线程私有):
这个区域是专门用来存储下一条要执行的java指令的地址。
(4)元数据区(线程共享):(以前的版本中,叫做“方法区”,从1.8开始,改名字)
存储的是被虚拟机加载的类信息、方法的信息、常量、静态变量、即时编译器编译后的代码等数据。
例如:一个程序有那些类,每个类有哪些方法,每个方法里面都包含哪些指令,都会记录在元数据区。
分析下面代码,判断n,m,t是在哪个区
class Test {private int n;private static int m;
}
main() {Test t = new Test();
}
t在main方法中的局部变量(引用类型),因此在main方法的栈区
n是在类中的成员变量,因此n在堆区
t是类中的静态成员变量,因此在元数据区
区分一个变量在哪个内存区域中,最主要就是看变量的“形态”(局部变量,成员变量,静态成员变量...)
2.JVM的类加载器
类加载,指的是java程序运行的时候,把硬盘上的.class文件,读取到内存中,进行一系列解析校验的过程。(.class => 类对象)
类加载过程
类加载的过程大概分成5个步骤:
1.加载:
找到.class文件,并且读出文件内容
2.校验:
校验.class文件的格式是否符合JVM规范要求
3.准备:
给类的对象分配内存(此时内存空间是全0的 => 类的静态成员也就是全0的值)
4.解析:
针对类中的字符串常量进行处理
5.处理:
把类对象的各个部分的属性进行赋值填充 => 触发对父类的加载 初始化静态成员,执行静态代码块
双亲委派模型
在加载环阶涉及到一个双亲委派模型,描述了如何让查找.class文件。
JVM中进行类加载的操作,是有一个专门的模块,称为“类加载器”(ClassLoader)。类加载器的作用,给他一个“全限定类名”(带有包名的类名,例如java.lang.String),找到对应的.class文件。
JVM中的类加载器默认是有三个
BootstrapCLassloader:负责查找标准库的目录
EXtensionClassLoader:负责查找扩展库的目录
ApplicationClassLoader:负责查找当前项目的代码目录以及第三方库的目录
三个类加载器存在“父子关系”,有一个指针(引用)parent,指向自己的“父”类加载器。
双亲委派模型工作流程图:
双亲委派模型详细工作过程为: 1.从 ApplicationClassLoader 作为入口,先开始工作。
2.ApplicationClassLoader 不会立即搜索自己负责的目录会把搜索的任务交给自己的父亲。
3.代码就进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader 也不会立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲。
4.代码就进入到 BootstrapClassLoader 范畴了,BootstrapClassLoader 也不想立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲。
5.BootstrapClassLoader 发现自己没有父亲才会真正搜索负责的目录(标准库目录)通过全限定类名,尝试在标准库目录中找到符合要求的.class文件。如果找到了,接下来就直接进入到打开文件/读文件等流程中。如果没找到,回到孩子这一辈的类加载器中,继续尝试加载。
6.ExtensionClassLoader 收到父亲交回给他的任务之后自己进行搜索负责目录(扩展库的目录),如果找到了,接下来进入到后续流程。如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载。
7.ApplicationClassLoader 收到父亲交回给他的任务之后自己进行搜索负责的目录(当前项目目录/第三方库目录)。如果找到了,接下来进入到后续流程。如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载。由于默认情况下ApplicationClassLoader没有孩子了,此时说明类加载过程失败了!就会抛出CLassNotFoundException异常。
上述这一些列规则,只是JVM自带的类加载器遵守的默认规则,如果自己写类加载器,也可以打破上述规则。
3.JVM的垃圾回收算法(GC)
垃圾回收中的一个很重要的问题:STW(stop the world)问题。触发垃圾回收的时候,很可能使当前程序的其他业务逻辑被暂停。但经过长期发展,Java的GC技术能把STW时间控制在1ms之内。
不需要回收的内存:
程序计数器:不需要GC,计数器有固定大小,不会越长越大。
栈:不需要GC,栈有自己的生周期,局部变量是在代码块执行结束之后自动销毁。
元数据区:一般不需要GC,其中类的信息也是有限的,一般都是涉及到“类加载”,很少涉及到"类卸载"
需要回收的内存:
堆:需要GC,其中创建出来的对象需要进行回收。
垃圾回收的具体过程:
3.1.识别垃圾
在Java当中,使用对象,一定需要通过引用的方式来使用(除了匿名对象外)。如果一个对象没有任何引用指向他,就视为无法在代码中被使用,就可以作为垃圾了。
3.1.1引用技术
这种思想方式,并没有在JVM中使用,但是广泛应用于其他主流语言的垃圾回收机制当中(Python,PHP)
给每个对象安排一个额外的空间,空间里要保存当前这个对象有机构引用。
当每次增加一个引用指向内存里的对象,该对象的计数器就会+1,当减少一个引用指向该对象,该对象的计数器就会-1,当计数器到达0时,此时被专门的扫描线程给发现,就会被认为是垃圾说明这个对象可以被释放了。
存在的问题:
问题一:消耗额外的内存空间。
要给每一个对象都安排一个空间保存计数器,如果整个程序中的对象数目很多,总的额外消耗空间也会很多。尤其是当每个对象空间比较小时 (假设对象占4个字节),而计数器消耗的空间(假设占2个字节)占比就会很大。
问题二:出现“循环引用的问题”。
出现循环引用的经典代码:
class Test {Test t;public static void main(String[] args) {/**1、初始情况:两个new对象 会分别申请一块空间 每个对象中包含自己的引用计数器为1 */Test test1 = new Test(); Test test2 = new Test();//test2引用指向的对象,又多了一个指向它的引用,计数器+1变成2//test1引用指向的对象,又多了一个指向它的引用,计数器+1变成2test1.t = test2;test2.t = test1;//将test1置为null,引用减少,test1指向的对象计数器-1变成1//将test2置为null,引用减少,test1指向的对象计数器-1变成1test1 = null;test2 = null;//此时方法中已经找不到指向刚开始new出来的对象了,但它们的计数器还未变成0//因此无法释放那部分房间,造成内存泄漏...其他代码}
}
3.1.2可达性分析
本质上是”时间“换“空间”,相比于引用计数器,需要 消耗更多的额外的空间,但总的来说还是可控的。不会产生“循环引用”的问题。JVM就使用的是这技术。
在写代码的过程中,会定义很多变量,比如栈上的局部变量,方法区的静态类型的变量等,因此就可以从这些变量作为起点,出发,尝试去进行“遍历”,所谓的遍历就是会沿着这些变量中持有的引用类型的成员,再进一步的往下进行访问。所有能被访问到的对象,自然就不是垃圾了,剩下的遍历一圈也访问不到的对象,自然也就是垃圾了。
JVM自身知道一共有哪些对象,通过可达性分析的遍历,把可达的对象都标记出来了,剩下的自然就是不可达。
3.2.清理垃圾
通过上述两种机制的扫描,把标记为垃圾的对象的内存空间进行释放。
具体的垃圾释放方式,有下面三种
3.2.1 标记-清除
把标记为垃圾的对象,直接释放掉。
此时蓝色区域是正在被使用的内存区域,灰色区域是已经被标记为垃圾的区域,标记-清除模式就会直接释放对应的空间。
但是!这也会出现一个问题,会出现很多内存碎片,时间一久导致产生出很多,很小,离散的空间,当程序如果需要申请一块空间时,可能会申请失败,出现内存剩余空间总和大于要申请的空间,但找不到连续的一段符合要申请空间的大小。
3.2.2 复制算法
复制算法的核心:将内存划分为两部分,一部分用来释放扫描后标记成垃圾的对象,再把不是垃圾的对象复制到另外一部分。
复制算法相较于标记-清除算法,规避了存在大量内存碎片的问题,但同时也有缺点。
1.将内存划分一半,总的可使用内存减少。
2.如果每轮要复制的对象比较多,那复制开销也比较大。
3.2.3标记-整理
大概过程如下图所示:
通过这个过程,也能有效解决内存碎片问题,并且这个过程也不像复制算法一样,需要浪费过多的内存空间。但是复制的对象比较多的时候,搬运内存开销还是比较大。
3.3.3 分代回收(JVM采用的方式)
在JVM中,堆内存划分大侄如下图
垃圾回收过程:
-
当代码中 new 出一个新的对象,这个对象就是被创建在伊甸区的,伊甸区中就会有很多的对象,一个经验规律: 伊甸区中的对象,大部分是活不过第一轮 GC,这些对象都是"朝生夕死",生命周期非常短!
-
第一轮 GC 扫描完成之后, 少数伊甸区中幸存的对象,,就会通过复制算法, 拷贝到生存区,后续 GC 的扫描线程还会持续进行扫描,不仅要扫描伊甸区,也要扫描生存区的对象。生存区中的大部分对象也会在扫描中被标记为垃圾,少数存活的,就会继续使用复制算法,拷贝到另外一个生存区中!只要这个对象能够在生存区中继续存活,就会被复制算法继续拷贝到另一半的生存区中,每次经历一轮 GC 的扫描对象的年龄都会 +1。
-
如果这个对象在生存区中,经过了若干轮 GC 仍然健在,JVM 就会认为这个对象生命周期大概率很长,,就把,这个对象从生存区,拷贝到老年代。
-
老年代的对象也要被 GC 扫描 但是扫描的频次就会大大降低了。
-
对象如果在老年代经过扫描发现被标为垃圾,那将会按照标记-整理的方式,释放内存。