目录
🌲内存划分
🚩堆(线程共享)
🚩栈
🚩元数据区
🍃类加载过程
🚩双亲委派模型
🎄垃圾回收机制(GC)
🚩找到谁是垃圾(不被继续使用的对象)
🚩释放对应的内存
🏀标记-清除
🏀复制算法
🏀标记-整理
🏀分代回收
🌲内存划分
JVM也就是Java进程,这个进程一旦跑起来之后,就会从操作系统这里,申请一大块内存空间,JVM接下来就要进一步的对这个大的空间进行划分,划分成不同区域,每个区域都有不同的作用。
具体如何划分的呢?
JVM运行时数据区域也叫内存布局,但需要注意的是它和Java内存模型((JavaMemoryModel,简 称JMM)完全不同,属于完全不同的两个概念,它由以下5大部分组成:
🚩堆(线程共享)
堆的作用:程序中创建的所有对象都在保存在堆中。(也就是new出来的对象)
成员变量也是在堆中,new出来的对象包含了成员变量,这些东西是一起的
对于里面的新生代来年代后续讲述
🚩栈
分为Java虚拟机栈和本地方法栈。
保存了方法的调用关系,例如写代码时A调用B,B调用C......,这里的调用就是使用栈来维护,只不过虚拟机栈放的Java代码的调用关系,而本地方法栈是针对JVM内部的调用关系,也就是C++代码的调用关系
注意:上述的栈和堆与数据结构的栈和堆没有任何关系,只是名字相同
🚩元数据区
以前叫做方法区,从Java8开始,叫做元数据区。
里面放的是"类对象"。
还放了方法相关信息,类中有一些方法,每个方法都代表了一系列指令集合(JVM字节码指令),还有常量池,编译出来的字节码。
🚩程序计数器(PC)
他是内存区域中最小的区域,只需要保存当前要执行的下一条指令(JVM字节码)的地址。
具体代码实现:
基本原则:
一个对象在哪个区域,取决于对应变量的形态。
- 1)局部变量 =>栈上
- 2)成员变量 =>堆上
- 3)静态成员变量 =>元数据区/方法区
补充:上述四个区域中,堆和元数据区是整个进程只有一份,栈和程序计数器是每个线程都有一份,则堆和元数据区都是多个线程共享同一份数据,每个线程的局部变量,则不是共享的,每个线程都是有自己的一份。
🍃类加载过程
当前写的Java代码,是一个 .java文件,是在硬盘上的,一个Java进程要跑起来,需要先把 .java文件变成 .class文件,还是在硬盘上,在加载到内存中,得到"类对象"。
一个Java进程要跑起来,也就是要执行指令,要执行的cpu指令,都是通过字节码让JVM翻译出来,也就需要让字节码进入到内存中。
接下来我们来看下类加载的执行流程。
对于一个类来说,它的生命周期是这样的:
- 1)加载
在硬盘上,找到对应的 .class文件,读取文件内容
- 2)验证
检查 .class文件的内容是否符号要求。
.class文件是由javac编译器生成的,具体生成的 .class文件里面具体是什么样的格式,在Java官方文档中是有明确定义的。
- 3)准备
给类对象分配内存空间。
- 4)解析
针对字符串常量进行初始化,把刚才 .class文件中的常量的内容取出来,放到元数据区
- 5)初始化
针对"类对象"中的各个部分进行初始化(不是针对对象初始化,和构造方法无关),给执行静态成员,执行静态代码块进行初始化等。
面试:记住上述5个步骤,以及各个变量的内存区域
🚩双亲委派模型
双亲委派模型出现在上述"加载"这个环节,根据代码中写的"全限定类名"找到对应的 .class 文件。
全限定类名指 包名 + 类名。例如String => java.long.String ,List => java.util.List。
双亲委派模型描述了JVM加载 .class文件过程中,找文件的过程。这就涉及到"类加载器"
"类加载器"在JVM中包含了一个特定的模块/类,这个类负责完成后续类加载的过程。
JVM中内置了三个类加载器:负责加载不同的类
- 1)BootstrapClassLoader:负责加载标准库的类
- 2)ExtentionClassLoader:负责加载JVM扩展库的类(前面学习过程中没有涉及到任何扩展类,历史遗留,本身很少使用)
- 3)ApplicationClassLoader:负责加载第三方库的类和你自己写的代码的类
他们三个类存在一个父子关系:这个父子关系不是继承表示的,而是通过类加载器中存在一个"parent"这样的字段指向自己的"父亲"。
注意:"双亲委派模型"本身翻译是不标准的,更准确的翻译为"父亲委派模型"。
工作过程:
例如,给定了一个类的"全限定类名",自己写的类 => java111.Test
这就是双亲委派模型,拿到任务,先交给父亲处理,父亲处理不了,再自己处理。
上述过程主要为了应对场景:
比如你自己代码中写了一个类,这个类的名字和标准库/扩展库冲突了,JVM就会确保加载的类是标准库中的类(就不加载你自己写的类了)。相当于我自己写了一个java.long.String,那么这套模型就能够确保最终在JVM中加载原有的java.long.String了。
类加载过程中的双亲委派模型也是一个经典面试题。
🎄垃圾回收机制(GC)
垃圾回收机制,是Java提供的对于内存(变量或者对象)自动回收的机制。
GC回收的是"内存",更准确的说是对象,回收的是堆上是内存。
一定是一次回收一个完整的对象,不能回收"一部分对象"。
GC的具体流程,主要有两个步骤:
🚩找到谁是垃圾(不被继续使用的对象)
谁是垃圾这个事情,并不太好找,一个对象什么时候创建这个是明确的,但什么时候不在使用,这个时机往往很模糊。在编程中,一定要确保代码中使用的每个对象,都得是有效的,千万不要出现"体现释放"的情况。
因此判定一个对象是否是垃圾,判定方式就比较保守。比如,如果使用"上次使用时间"的方式来判定垃圾,就是不行的,这就容易错杀。
此处就引入了一个比较保守的做法,判定某个对象,是否存在引用指向它。在代码中都是通过对象的引用来使用的,那么如果没有引用指向这个对象,意味着这个对象注定无法在代码中被使用了。这就可以视为这个对象是垃圾了。
例如:Test t = new Test(); t = null; => 修改t的指向,new Test对象没有引用指向了,就视为垃圾。
具体怎么判定某个对象是否有引用的指向呢?方式有很多,此处介绍两种方式:
- 1)引用计数(不是JVM采取的方案,而是Python / PHP的方案)
引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
看似比较好用,但是存在两个缺陷:
a)消耗额外的存储空间
如果你的对象比较大浪费空间还好,如果对象比较小,并且对象数目还多,空间占用多了,空间的浪费的就多了。
b)存在"循环引用"的问题(面试官考引用计数,也就是靠你循环引用问题)
例如:
两行实例代码对应的图示
接下来:a.t = b,进行引用复制,把b里面的地址复制给Test类中的引用类型的成员,也就是把b地址复制给a对象中t成员,此时就有两个引用指向0x200了,那么0x200中的引用计数器就为2。b.t = a也是同理。
然后再执行 a = null; b = null;,此时a中就为null,意味着0x100中的引用计数器就为1,b为null,意味着0x200中的引用计数器就为1,这时候这两个对象相互指向对方,就导致了两个对象的引用计数都为1(不为0,不是垃圾),但是你外部代码也无法访问这两个对象!!!
- 2)可达性分析(是JVM采取的方案)
这个解决了空间的问题,也解决了循环引用问题,也付出了时间上的代价。
核心思想:"遍历",JVM把对象之间的引用关系理解成了一个"树形结构",JVM就会不停的遍历这样的结构,把所有能遍历访问到的对象标记成"可达",剩下的就是"不可达"。
在这里面,是有很多课这样的树(不一定是二叉树),这些树的根节点如何确定的?(GC roots)
🚩释放对应的内存
🏀标记-清除
直接把标记为垃圾的对象对应的内存,释放掉(简单粗暴)。
"标记-清除"算法的不足主要有两个 :
- 效率问题 : 标记和清除这两个过程的效率都不高
- 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中
需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收
🏀复制算法
"复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。
此算法实现简单,运行高效。算法的执行流程如下图 :
这里面最大的问题,空间浪费的太多了,另一方面要保留的对象比较多,时间花费也不少。
🏀标记-整理
能解决内存碎片,也能解决
标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动(类似于顺序表删除中间元素),然后直接清理掉端边界以外的内存。流程图如下:空间利用率的问题。
这样的搬运时间开销更大。在JVM中实际的方案,是综合上述的方案,更复杂的策略。
🏀分代回收
也就是分情况讨论,根据不同的场景/特点,选择合适的方案。根据对象的年龄来讨论的(我们说GC有一组线程会进行周期性的扫描,某个对象经历了一轮GC扫描之后,还是存在,没有成为垃圾,那么年龄 +1,依此内推)。