今天是Javaee初阶篇章的完结篇,下次的Javaee进阶就是将这些理论变成实际操作,比如做一个前端的页面等等。
上次我们讲到了类加载与双亲委派模型,接下来我们继续。
类加载的五个步骤,我们聊过,那么类加载触发的时机是什么时候?
Java程序一启动,就会加载用到的所有的类吗?不是的,类加载遵循之前提到的"懒汉模式",在Java代码中,用到哪个类,就会触发哪个类的加载。而用到这些类的具体表现形式为:
(1)构造这个类的实例
(2)调用/使用类静态属性或静态方法
(2)使用某个类的时候,如果他的父类还没有加载,也会触发父类的加载
双亲委派模型
这里是类加载常考的高频问题。
双亲委派模型,不如称作"单亲委派"或"父亲委派",就是parent翻译过来的。
类加载器
JVM中有专门的模块负责类加载。
JVM默认提供了三种类加载器:BootstrapClassLoader、ExtensionClassLoader与ApplicationClassLoader。

这里的"父子关系"不是"父类子类",而是通过parent这样的引用指向的。
这三个类加载器,首先要进行"找.class文件"环节,就像给定一个类名,类似于java.lang.String、com.bit.xxxx.Test这种,这三个类加载器负责找的目录范围是不同的,比如"Java扩展库"指的是JVM的厂商对于Java的库做的扩充;而在当前学习的内容中,但凡是某个东西通过maven来下载过来的,都是第三方库。
双亲委派模型的过程
前面是对这个过程的铺垫,现在才是重头戏。
(1)进行类加载,通过全限定类名找.class的时候,就会从ApplicationClassLoader作为入口开始
(2)然后ApplicationClassLoader不会立即进行查找,把加载类这样的任务,委托给父亲ExtensionClassLoader来进行
(3)ExtensionClassLoader也不会立即进行查找,而是委托给父亲BootstrapClassLoader来进行
(4)BootstrapClassLoader也想委托给父亲,由于没有父亲,只能自己进行类加载,根据类名找标准库范围是否存在匹配的.class文件,在标准库目录中找到,就进行加载
(5)BootstrapClassLoader没有找到,再把任务还给孩子ExtensionClassLoader,接下来ExtensionClassLoader来负责进行找.class文件的过程,找到就加载,没找到,就把任务还给孩子ApplicationClassLoader
(6)接下来ApplicationClassLoader负责找.class文件,找到就加载,没找到就抛出异常

这一套流程,目的是为了约定优先级,收到一个类名之后,一定是先在标准库中找,再到扩展库中找,最后才是在第三方库中找。
垃圾回收(GC)
这是Java中释放内存的手段。
在C语言中,要先申请内存malloc,申请之后,一定要手动调用free方法进行释放,否则就会出现内存泄露。可是手动时free很可能不小心就忘了,或者因为一些原因导致free没有执行到,就像下图的伪代码,一旦符合if条件,就return了,自然不会执行free。

手动释放内存太麻烦了,也太容易出错了,Java就引入了垃圾回收,进行自动释放,JVM就会自动识别出某个内存是不是后续不再使用了,然后就自动释放了。
GC回收JVM中堆的内存区域。

说是"回收内存",本质上是"回收对象",当然,不会出现把一个对象释放一半的情况。
GC的工作过程分为两步:
1.找到垃圾(不再使用的对象)
2.释放垃圾(对应的内存释放掉)
1.找到垃圾(不再使用的对象)
找垃圾,有两种方法,一种是引用计数,另一种是可达性分析。
(1)引用计数(Python、PHP采用此方案)
每个对象在new的时候,都搭配一个小的内存空间,用来保存一个叫"引用计数"的整数。

这个整数就表示当前对象有多少个引用指向他。
每次进行引用赋值的时候,都会自动触发引用计数的修改,通过引用计数记录有多少个引用。

比如上图,t引用指向new Test对象,那么引用计数改为1;同理,t2指向了new Test,改为2;t3也指向了,改为3。但当一个引用为null时,这个引用不指向任何一个对象,于是引用计数由3变成2。在Java中,要想使用某个对象,一定是通过引用来完成的,如果引用计数为0了,就说明没有引用指向这个对象了,这个对象就是垃圾。
可是引用计数还会出现以下问题:
1)内存消耗的更多
尤其是对象本身比较小,引用计数消耗的空间比例就更大。
假设引用计数是4个字节,对象本身是8个字节,引用计数就相当于提高了50%的空间占用率。
2)可能出现"循环引用"这样的问题


在这个代码中,t在Test方法中,因为t=null,引用计数为0,然后有了a引用,申请了一块地址为0x100的内存空间,a也保存了0x100地址,有了b引用同理,而当执行到a.t=b时,b的值赋值给a指向的new Test中的t,引用计数加一,b.t=a时同理,两个new Test引用计数加一,变成2。
可是当a和b都为null时,两个new Test的引用计数分别减一,都变成了1,此时两个对象的引用不为0,虽然不为0,但是这两个对象都无法使用,要想使用第一个t,就要用第二个对象的t来访问;要想使用第二个对象的t,就要用第一个对象的t来访问,这样就形成了"循环引用"。
(2)可达性分析(Java采用了这个方案)
引用计数,是有空间开销的,可达性分析,是用时间来换空间的。
他的具体流程如下:
1.以代码中的一些特定对象,作为遍历的起点------"GCroots"
遍历访问的对象共有以下三类:
1)栈上的局部变量(引用类型)
2)常量池引用指向的对象
3)静态成员(引用类型)

就像上图,遍历时访问所有对应的对象。进而引出第二步。
2.尽可能的进行遍历,判定某个对象是否能访问到
3.每次访问到一个对象,都会把这个对象标记成"可达";当完成所有的对象的遍历之后,未被标记成"可达"的对象是"不可达"
此时JVM中一共有多少个对象,JVM自身就知道了,通过可达性分析,知道了哪些是可达的,哪些是不可达的(接下来要回收的垃圾)。
这样,就很好的解决了内存占用与循环引用的问题。
我们写一段二叉树的伪代码:

下图是遍历过程:

如果root.right.right=null,那么这样的操作会使f不可达,下一轮GC过程中,此处的f就会被当做垃圾。
为什么说是"下一轮"?其实,可达性分析,这个过程是"周期性"的,每隔一定的时间,触发一次这样的可达性分析的遍历,这个过程就非常消耗时间和资源。
但当root.right=null时,此时c就不可达了,同时要想访问f,也需要通过c,c不可达也会使f不可达,因此c、f都会被当成垃圾。
当我们已经知道了哪些对象是垃圾,如何进行释放呢?
2.释放垃圾(对应的内存释放掉)
这里有三种释放垃圾的方式:
1.标记-清除
把垃圾对象的内存直接进行释放,但这样做会产生内存碎片问题。

如图,我们将1到8当中的垃圾标记清除掉之后,发现这些被清除后空闲的空间不是连续的,而申请内存都是申请连续内存,比如申请1MB的内存空间,必须是连续的,不能是多个部分拼到一起的。因此这些空闲的内存空间就无法申请,只能从7往后申请。
内存碎片如果非常多,总的空闲空间虽然很大,但是由于不是连续的,想申请一个稍微大一点的内存都会失败。
2.复制算法

比如这个内存空间里,同样是那些犬牙交错的垃圾,我们不妨将其分成两部分,一次只使用其中的一半,把不是垃圾的对象拷贝到另外一侧,然后再把这一侧整体释放掉,下次可以重复这样的操作。此时就可以确保空闲的内存是连续的了。
但这样做仍然有缺点:
1.内存的空间利用率很低
2.一旦不是垃圾的对象很多,复制的成本会很高(尤其是这样的对象包含大的对象的时候)
3.标记-整理

同样是这垃圾与可达相互交错的内存空间,我们标记好可达的对象,将他们搬运到左侧,再将他们后面的垃圾都释放掉。
这样做的优点是解决内存碎片的问题,保证了内存的利用率,类似于顺序表中的搬运。
缺点是内存搬运数据的操作,开销是挺大的(复制成本的问题仍然还在)。
Java是怎么做的?
Java选择了"分代回收",把上面的123(主要是2和3)结合起来,扬长避短。
Java的分代回收
代,指对象的年龄,某个对象,经历一轮GC可达性分析之后,不是垃圾,此时对象的年龄+1(初始情况是0)。

我们把一个内存空间分成两半,一半叫"新生代",另一半叫"老年代",针对不同的年龄的对象采取不同的策略,如果一个对象年龄很大(老年代),那么大概率还会存在很久,反之,如果一个对象在新生代,这个对象挂掉的可能性很大。
新生代分为伊甸区与两个幸存区。
于是老年代,GC的频次就可以降低了;新生代,GC的频次就会比较高。
(1)将新创建的对象放到"伊甸区",由于绝大部分伊甸区的对象,活不过第一轮GC,于是幸存区比伊甸区小,伊甸区的对象,如果经过了GC的扫描后没有被释放,就进入到幸存区,这里用复制算法,因为活下来的少,复制的对象规模是很少的,复制的开销是可控的。
(2)在幸存区中的对象,也要经历GC的扫描,每一轮GC都会消灭一大部分对象,剩余的对象再次通过复制算法,复制到另外一个幸存区。之后的GC扫描也是如此,如果活下来了,就复制放入到另外一个幸存区中。
(3)如果这个对象在幸存区中经历了多次复制,都存活下来了,对象的年龄也就大了,就会晋升到老年代中,此时以及进入老年区以后的GC扫描方法,都是通过标记整理来进行的。

JVM释放对象,采取的是"分代回收"这样的综合性策略。
JVM的内容到这里就全部结束了,希望大家认真掌握。下次我们再会。