一.双亲委派模型(在加载环节)
简单描述了如何查找 .class 文件的策略.
概念:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
启动类加载器:加载 JDK 中 lib 目录中 Java 的核心类库,即$JAVA_HOME/lib目录。扩展类加载
器。加载 lib/ext 目录下的类。
应用程序类加载器:加载我们写的应用程序。
自定义类加载器:根据自己的需求定制类加载器。
JVM 中进行类加载的操作,是有一个专门的模块,称为“类加载器"(ClassLoader)
JVM 中的类加载器默认是有 三个 的.(也可以自定义)
类加载器的作用:(问百度文心)
上述的三个类加载器,存在"父子关系”(不是 面向对象中的父类,子类继承关系)
而是类似于“二叉树”,有一个指针(引用) parent, 指向自己的“父”类加载器。
双亲委派模型的工作过程
1.从 ApplicationClassLoader 作为入口,先开始工作.
2.ApplicationClassLoader 不会立即搜索自己负责的目录,会把搜索的任务交给自己的父亲
3.代码就进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader 也不会立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲
4.代码就进入到 BootstrapClassLoader 范畴了,BootstrapClassLoader 也不想立即搜索自己负责的目录也要把搜索的任务交给自己的父亲
5.BootstrapClassLoader 发现自己没有父亲,才会真正搜索负责的目录(标准库目录)
通过全限定类名,尝试在标准库目录中找到符合要求的 .class 文件
6.ExtensionClassLoader 收到父亲交回给他的任务之后。自己进行搜索负责目录(扩展库的目录)
如果找到了,接下来进入到后续流程。
如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载
7.ApplicationClassLoader 收到父亲交回给他的任务之后,自己进行搜索负责的目录 (当前项目目录/第三方库目录),如果找到了,接下来进入后续流程。
如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载
由于默认情况下 ApplicationClassLoader 没有孩子了,
此时说明类加载过程失败了! 就会抛出 ClassNotFoundException 异常
优点:
1.避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模
型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户
自己提供的因此安全性就不能得到保证了。
补充:
上述这一系列规则,只是JVM 自带的类加载器,遵守的默认规则如果咱们自己写类加载器,也可以打破上述规则。
比如,自己写类加载器,指定这个加载器就在某个目录中尝试加载.此时如果类加载器的 parent 不去和已有的这些类加载器连到一起,此时就是独立的,不涉及到双亲委派了。
二.垃圾回收机制(GC)
举个栗子~
能否让释放内存的操作,由程序自动负责完成?而不是依赖程序猿手工释放呢?
Java 就属于早期就支持垃圾回收这样的语言。
引入这样的机制之后,就不需要靠手动来进行释放了
程序会自动判定,某个内存是否会继续使用。
如果内存后续不用了,就会自动释放掉
垃圾回收中的一个很重要的问题: STW (stop the world) 问题:触发垃圾回收的时候,很可能会使当前程序的其他的业务逻辑被暂停。
Java 发展这么多年,GC 这块的技术积累也越来越强大,有办法把 STW 的时间控制到 1ms 之内
一个服务器请求/响应 处理时间,典型的时间 几毫秒 - 几十毫秒。
垃圾回收,具体步骤:
2.1识别垃圾
(1)识别垃圾,哪些对象是垃圾(不再使用),哪些对象不是垃圾
识别出垃圾:判定你这个对象后是否继续要使用。
即在 Java 中使用对象,一定需要通过引用的方式来使用.(当然,有一个例外,匿名对象)
匿名对象类似如下:
如果一个对象没有任何引用指向他,就视为是无法被代码中使用,就可以作为垃圾了。
举个栗子~
void fun(){
{
Test t = new Test();
t.xxx();//调用xxx方法
}
如上,通过new Test()在堆上创建了对象。
执行到这个之后,此时局部变量 t 就直接被释放了
此时再进一步,上述 new Test 对象,也就没有引用指向他了
此时,这个代码就无法访问使用这个对象,这个对象就是垃圾了
如果代码更复杂些,情况又不一样了
Test t1 = new Test();
Test t2 = t1;
Test t3 = t2;
Test t4 = t3;
......
此时就会有很多引用指向 new Test 同一个对象(此时有很多引用,都保存了 Test 对象的地址)
此时通过任意的引用都能访问 Test 对象需要确保所有的指 Test 对象的引用都销毁了,才能把 Test 对象视为垃圾.
如果代码比较复杂,上述这些引用的生命周期各不相同的,此时情况就不好办.
我们用两种方法去处理
1.引用计数
概念:给每个对象安排一个额外的空间,空间里要保存当前这个对象有几个引用。
引用计数这种思想方法,并没有在JVM 中使用,但是广泛应用于其他主流语言的垃圾回收机制中.(Python,PHP)
举一个栗子~
根据以上过程:
此时垃圾回收机制(有专门的扫描线程,去获取到当前每个对象的引用计数的情况)
发现对象的引用计数为 0,说明这个对象就可以释放了(就是垃圾了)。
引用计数机制,是一个简单有效的机制,存在两个关键的问题
问题一:消耗额外的内存空间。
要给每个对象都安排一个计数器.(如果计数器按照 2 个字节算
如果整个程序中对象数目很多,总的消耗的空间也会非常多
尤其是如果每个对象体积比较小(假设每个对象 4个字节)
计数器消耗的空间,已经达到对象的空间的一半
问题二: 引用计数可能会产生“循环引用的问题”此时,引用计数就无法正确工作了。
例如:
2.可达性分析
举个栗子~
class Node {
char val;
Node left;
Node right;
}
Node buildTree() {
Node a = new Node0;
Node b new Node0:
Node c = new Node();
Node d = new Node0;
Node e = new Node()
Node f = new Node();
Node g = new Node();
a.left = b;
a.right = c;
b.left = d;
bright = e;
e.left = g;
c.right = f;
return a;//返回根节点
}
Node root = buildTree ();
根据代码画出二叉树的图。
虽然这个代码中,只有一个 root 这样的引用了,但是实际上上述 7 个节点对象都是“可达的“。
JVM 中存在扫描线程,会不停的尝试对代码中已有的这些变量进行遍历,尽可能多的去访问到对象。
上述的代码,如果执行这个代码;
root.right.right = null;
出现断开之后,此时 f 这个对象就被"孤立"了.按照上述从 root 出发进行遍历的操作
就也无法访问到 f了,f 这个节点对象就称为"不可达"。此时的f就作为垃圾。
上述的代码,如果执行这个代码;
root.right = null;
此时 c 就不可达了,由于 f的访问必须通过 c,c 不可达也就会导致 f不可达,那 c 和 f 都是垃圾了 。
2.2处理垃圾
(2)把标记为垃圾的对象的内存空间进行释放
主要释放的方式有三种。
1.标记-清除
把标记为垃圾的对象,直接释放掉.(最朴素的做法)。
黑色部分当作垃圾回收了。
此时就是把标记为垃圾的对象对应的内存空间直接释放。
但是会有比较致命的问题。上述释放方式,就可能会产生很多的小的内存碎片!!
内存碎片:离散的空闲内存空间
因为内存申请,都是一次申请一个连续的内存空间,就可能会导致后续申请内存失败。
比如申请 1M 内存空间,此时,1M 字节都是连续的。
如果存在很多内存碎片,就可能导致总的空闲空间,远远超过 1MB,但是并不存在比 1MB 大的连续的空间。
此时,去申请内存空间就会失败!!
2.复制算法
复制算法,核心就是不直接释放内存,而是把不是垃圾的对象复制到内存的另一半里
接下来就把另外一侧空间整体释放掉。
确实能够规避内存碎片问题,但是也有缺点.
1.总的可用内存变少了。比如买两个饼果子,吃一个丢一个
2.如果每次要复制的对象比较多,此时复制开销也就很大了。
需要是当前这一轮 GC 的过程中,大部分对象都释放,少数对象存活,这个时候适合使用复制。
3.标记-整理
用法类似与顺序表删除中间元素。
下面是一个例子~,打红色x部分是要回收的垃圾
通过这个过程,也能有效解决内存碎片问题
并且这个过程也不像复制算法一样,需要浪费过多的内存空间
但是,这里的搬运内存开销很大。
因此,JVM 中没有直接使用上述的方案,而是结合上述思想,搞出了一个“综合性”方案,取长补短。
4.分代回收
分代回收:(依据不同种类的对象,采取不同的方式)
先引入一个概念:对象的年龄。
JVM 中有专门的线程负责周期性扫描/释放。
一个对象,如果被线程扫描了一次,可达了(不是垃圾),年龄就 + 1 (初始年龄相当于是 0)
JVM 中就会根据对象年龄的差异, 把整个堆内存分成两个大的部分
新生代(年龄小的对象)/ 老年代(年龄大的对象)。
如下图
1.当代码中 new 出个新的对象,这个对象就是被创建在伊甸区的,伊甸区中就会有很多的对象
个经验规律,伊甸区中的对象,大部分是活不过第一轮 GC,这些对象都是“朝生夕死”,生命周期非常短!!
2.第一轮 GC 扫描完成之后,少数伊甸区中幸存的对象,就会通过复制算法,拷贝到 生存区
后续 GC 的扫描线程还会持续进行扫描.不仅要扫描伊区,也要扫描生存区的对象
生存区中的大部分对象也会在扫描中被标记为垃圾,少数存活的,就会继续使用复制算法,拷贝到另外一个生存区中!!只要这个对象能够在生存区中继续存活,就会被复制算法继续拷贝到另一半的生存区中.每次经历一轮 GC的扫描,对象的年龄都会 + 1
3.如果这个对象在生存区中,经过了若干轮 GC 仍然健在
JVM 就会认为,这个对象生命周期大概率很长,就把这个对象从生存区,拷贝到老年代
4.老年代的对象,当然也要被 GC 扫描但是扫描的频次就会大大降低了
老年代的对象,要 G 早 G 了~~ 既然没 G 说明生命周期应该是很长的
频繁 GC 扫描意义也不大,白白浪费时间.不如放到老年代,降低扫描频率
5.对象在老年代寿终正寝,此时JVM 就会按照标记整理的方式释放内存。
上述分代回收是JVM GC 中的核心思想
但是JVM 实际的 垃圾回收 的实现细节上还会存在一些变数和优化。
三.垃圾收集器
如果说上面我们讲的收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
垃圾收集器的作用:垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。
以下这些收集器是 HotSpot 虚拟机随着不同版本推出的重要的垃圾收集器:
上图展示了7种作用于不同分代的收集
之间可以搭配使用。所处的区域,表示它是属于新生代收集器还是老年代收集器。在讲具体的收集器之前我们先来明确三个概念:
并行(Parallel) : 指多条垃圾收集线程并行工作,用户线程仍处于等待状态
并发(Concurrent) : 指用户线程与垃圾收集线程同时执行(不一定并行,可能会交替执行),用户程
序继续运行,而垃圾收集程序在另外一个CPU上。
吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。
为什么会有这么多垃圾收集器?
自从有了 Java 语言就有了垃圾收集器,这么多垃圾收集器其实是历史发展的产物。最早的垃圾收集器 为 Serial,也就是串行执行的垃圾收集器,Serial Old 为串行的老年代收集器,而随着时间的发展,为 了提升更高的性能,于是有了 Serial 多线程版的垃圾收集器 ParNew。后来人们想要更高吞吐量 的垃圾收集器,吞吐量是指单位时间内成功回收垃圾的数量,于是就有了吞吐量优先的垃圾收集器 Parallel Scavenge(吞吐量优先的新生代垃圾收集器)和 Parallel Old(吞吐量优先的老年代垃圾收集器)。随着技术的发展后来又有了 CMS(Concurrent Mark Sweep)垃圾收集器,CMS 可以兼顾吞吐量和以获取最短回收停顿时间为目标的收集器,在 JDK 1.8(包含)之前 BS 系统的主流垃圾收集器,而在 JDK 1.8 之后,出现了第一个既不完全属于新生代也不完全属于老年代的垃圾收集器 G1(Garbage First),G1 提供了基本不需要停止程序就可以收集垃圾的技术。