类的加载机制
先使用一张图整个加载机制所包含的过程。
通过这张图我们可以了解到,关于类的加载其实就是可以分为五个大阶段,不过下面文中主要从加载、验证、准备、解析还有初始化这五个方面来做一个讲解:
加载
需要完成以下三项任务:
(1)通过一个类的全限定名来获取定义此类的二进制字节流。(并没有指定数据必须从Class文件中获取)
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
往简单了说,就是从文件中读取二进制数据,对获取到的二进制数据做一个转化,变成一个JVM能够认识的模样,也就是方法区运行时的数据结构,然后JVM中生成对应的内存空间作为入口,到时候各类数据的入口。
验证
确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
为什么需要这么一个环节呢? Class文件的产生,并不是一定来自Java源码。 他甚至可以由我们直接编写而成,验证能帮我过滤掉错误的Class文件,保障虚拟机的正确运行。
需要完成以下四项任务:
(1)文件格式验证: 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
(2)元数据验证: 对类的元数据信息中的数据类型等进行校验。
(3)字节码验证: 对类的方法体进行校验,确保程序语义的合法性。
(4)符号引用验证: 保证解析动作的正确执行。(动作在解析时发生)
准备
正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这个阶段存在一个思考。
Q1:为什么我们可以直接从main()
函数中调用到的i_static
,而调用不到i呢?
A1: 作为读者的你肯定会说,这不是废话吗,i_static
是用static
修饰的,当然可以调用。但是这是从使用的角度来思考了。
其实这就是准备阶段要干的事情了,在这个阶段,虚拟机已经为这些数据做好了存放的工作,所以我们能够调用。但是i
这个变量,在你没有实例化之前,他是没有被存放在内存空间的,自然也就不能够调用了。更直白的说,就是你找不到呗,找不到我怎么用。
Q2:那如果i_final_static
和i_static
呢,他们又会有什么样的区别?
A2: 这两个在准备阶段的值分别是0和123。这是因为对于没有final
修饰的类而言,数据只有在初始化时通过putstatic
的指令才会进行赋值,而final
的作用是将数据存放在了ConstantValue
的属性中,这样就在准备阶段时数据就已经准备完毕了。
解析
虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
解析是一个不定时的工作内容,因为像new
,数组引用这些都是一个视情况而定的事件。他所针对的动作有类、接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符。
初始化
初始化阶段就是执行类构造器<clinit>()
方法的过程。
Q1:那么<clinit>()
干的活到底是什么呢?
编译器自动收集类中的所有类变量的赋值动作和静态语句块 (static{}) 组合产生。
不过我感觉变量在静态语句块之后定义好像没什么用🤔🤔🤔🤔,毕竟结果告诉我们谁定义在后头,值就是谁的。
看完了静态语句块的一些内容,我们就要加入父子类继续判定它的加载顺序了。
可以发现加载的优先顺序如下:
- 静态 > 实例;
- 父类 > 子类
另外一个是我在牛客练习时知道的知识,叫做左编译右运行,其实是向上转型的概念,但是这种记法更生动形象。
直接用代码来验证这句话,现在将子类和父类修改成以下形式。
然后使用上面的Main类中的对象bean去调用这个函数,会出现什么情况?
找不到doSomething()
这个函数?这就是左编译的意思了,虽然是按照右边的子类运行,但是是不会将子类多出来的方法加入到方法区。
再调用上图中的commonHas()
方法后,你又会发现打印的结果是这样的。
它运行出了子类的结果,这也就是右运行的意思了。
类加载器
在Java虚拟机中,类加载器十分重要。每一个类的加载,都需要通过一个类的加载器。但是如果我们创建一个属于自己的类加载器,这个时候会出现一个什么样的情况呢? 接下来,我们用代码来进行验证测试。
从这里想来已经能够看出了,由一个类加载器统一创建的类,才存在可比性。因为类加载器是拥有独立的类名称空间的。更简单的说,就像上面的例子,如果不使用Java虚拟机提供的类加载器,你就会失去一大部分功能,比如equals()
、isAssignableFrom()
、isInstance()
、instanceof
。如果要相同,除非你直接在java源码上动手脚。
双亲委派模型
Q1:为什么需要这个模型?
其实这个模型的提出,就是为了解决类加载器可能不出现不同的问题。因为即便是相同的class
,由不同的类加载器加载时,结果就是不同的。
工作原理
双亲委派的工作流程非常简单,这就跟之前文章里的Android的事件分发机制一样,向上传递,由父类加载器先行尝试消费,如果父类无法完成这个任务,那么子加载器就要由自己动手完成。
- 启动类加载器:负责加载/lib下的类。
- 扩展类加载器:负责加载/lib/ext下的类。
- 系统类加载器/应用程序类加载器:
ClassLoader.getSystemClassLoader
返回的就是它。
通过上图我们可以知道,子加载器不断的给上一层加载器传递加载请求,那么这个时候启动类加载器势必是接受到过全部的加载请求的。如果不信,我们就用源码来证明。
讲完了他的工作原理,自然就要知道,他能够如何被破坏的了。
破坏双亲委派模型
Q2:为什么要破坏双亲委派?
拿最简单的例子,在上文中我们,提到过各个资源的加载范围,但是Driver
作为后来才加入的一个接口,他的很多api是由第三方服务商开发的。那么这个时候,破坏双亲委派就有了他的用武之地了,当然这只是他的用处之一。
下面来介绍,他是如何破坏双亲委派的。
如何开展破坏活动
先看看我们平时都是怎么用的。(当然这是很基础的写法了,因为现在池的概念加深,所以很多事情都已经被封装了。)
上面很明显就能看出这件事情就是关于DriverManager
展开的了。
这里根据前一章的内容先要对DriverManager
进行初始化,也就是调用了一个loadInitialDrivers()
函数。
从这一小段中,我们关注注释1
能够知道他专门去访问了一个ServiceLoader
的类,点进去之后我们能够发现这么三段代码。
由1 --> 2 --> 3的顺序循序渐进,你是否已经和我关注到一个问题了!!什么叫做线程上下文加载器 (Thread.currentThread().getContextClassLoader())?
线程上下文类加载器在Java 2
时引入。每个线程都有一个关联的上下文类加载器。如果你通过new Thread()
方式来创建新的线程,新线程将继承其父线程的上下文类加载器。如果程序对线程上下文类加载器没有任何改动的话,程序中所有的线程将都使用应用程序类加载器作为上下文类加载器。但是对于三方库而言,应用类加载器去完成子类加载器才能完成的任务显然是不可能的,这就需要Oracle
给三方提供的入口,也就是Thread.currentThread().setContextClassLoader();
的方法,让我有了自定义的空间,这也就有机会去完成我们想要的双亲委派破坏了。
溯源ClassLoader.getSystemClassLoader()
这张图里我们只用关注圈红的initSystemClassLoader()
函数。
然后在initSystemClassLoader()
函数中调用了一个Launcher
的类。
而Launcher
整个类的创建,想来读者也已经看到loader
这个变量了,通过getAppClassLoader()
这个函数所创建的loader
也就是我们口中所说的应用程序类加载器了,这也从源码上讲述了双亲委派机制的存在。
另外Thread.currentThread().setContextClassLoader(this.loader);
证明了一点,就是关于线程上下文加载器默认的就是应用程序类加载器。
Java内存模型
在此之前我们需要知道GC回收机制回收的是什么?他们的存储形式是什么样的?等等一系列问题。所以引入了内存模型的概念。
5大区域各自的作用:
- 程序计数器:指示当前线程所执行的字节码执行到了第几行。
- 虚拟机栈:为执行的方法创建栈帧,保存了局部变量表、操作站、动态链接、方法出口等信息,主要用来执行Java方法。
- 本地方法栈:运行方法与虚拟机栈相似,主要用来执行native方法。
- 堆区:用于存储对象的实例。
- 方法区:存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final常量、静态变量、编译器即时编译的代码等。
Object
作为所有类的父类以他创建一个对象,将涉及哪些区域的变动呢?
① Object obj
表示一个本地引用,存储在虚拟机栈的本地变量表中,表示一个reference
类型数据;
② new Object()
作为实例对象数据存储在堆中,另外还记录了Object
类的类型信息(接口、方法、field、对象类型等)的地址,这些地址所执行的数据存储在方法区中;
GC回收机制
既然要垃圾回收,那到底要回收的是哪些东西呢?
上文中Object
类的举例,已经有一定的苗头了。
在方法区中的数据,是从一开始就要求被加入的,那么回收掉他们难免会出现各种问题。而像Object
这样的类,只在一段时间内需要被使用,也就难免会成为多出来的碎片,也就成了典型的“占着茅坑不拉屎”的了。
所以,显而易见,我们要回收的就是这么一类垃圾数据了,而GC回收器回收的也就是这种new
出来以后没用了的数据了。
堆区的细节划分
- 新生代:
Eden
、From Space
、To Space
刚创建的对象一般都被放入Eden
中,Eden
满了以后,就会进行一次GC
操作,删去消亡的,把活跃的放到From
中。(To Space
和From Space
是一个轮换的,空的那份数据就是下一轮Eden满时要存放数据的From Space,另外一个就成了To Spcae) To Space
满了的时候就会将对象转移到老年代。 - 老年代: 经过了多次回收,但还是坚强存活下来的对象们所在的内存空间。
对象存活判断
引用计数
一个对象被引用时加一,被去除引用时减一。那么当数值为0时,这个引用就成为了一个垃圾。
但是,如果存在循环引用时,就不会结束引用。
可达性分析
和引用计数法比较,可达性分析法多了一个叫做GC Root
的概念,而这些GC Roots
就是我们可达性分析法的起点,在周志明前辈的《深入理解Java虚拟机》中就已经提到过了这个概念,它主要分为几类:
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池里的引用。
- 在本地方法栈中JNI引用的对象。
- 在Java虚拟机栈中引用的对象,譬如
Android
的主入口类ActivityThread
。 - 所有被同步锁持有的对象。
- 。。。。。
回收算法
复制收集
这是一个应用于新生代内存整理的方法。
因为新生代的Eden区是一个连续的内存空间 (占据新生代8成),通过遍历,把这个内存空间的消亡的对象删去,活跃的对象们重新放入一个新的空白内存空间中,也就是To Space
和From Space
的相互交换,各占据1成空间,另外需要注意的是To Space
和From Space
不是固定的,两个空间的名称可以互换,因为数据总是在这两个区间中越迁。
存在问题:内存折半。
标记清理
这是一个应用于老年代内存整理的方法。
如图所示,搜索出活跃的对象,清除消亡对象。
存在问题:会产生空间碎片。
标记整理
这是一个应用于老年代内存整理的方法。
比标记清理算法多一点,他需要排序。
存在问题:用性能换取空间碎片的整理。
关于垃圾回收器
- Serial收集器: 历史最悠久的垃圾回收器了,他对应这一个词
“Stop The World”
,作为单线程的进行处理垃圾回收器,在它干活时,程序小弟们必须老老实实给我爬。
- ParNew收集器: 本质上还是与
Serial收集器
相仿。对于老年代而言,同样采用的是Stop The World
的方案,但是对于新生代,多线程的加入无疑是能够提高效率的方案,多线程中垃圾收集的时间能够成倍数下降,也就更能让用户感知不到卡顿感觉。 - Parallel Scanvenge收集器: 作为一款使用于新生代的收集器,使用的垃圾回收算法也就是我们上面已经提及到的复制算法。它提供了
-XX:MaxGCPauseMillis(最大垃圾收集停顿时间)
和-XX:GCTimeRatio(吞吐量大小)
两个可控选项,但是要注意这两个选项,同时都小时,说明GC
的次数会愈加频繁;而同时都大时,会导致卡顿感明显。
- Serial Old收集器: 是一款作用于老年代的垃圾收集器,使用的垃圾回收算法就是 标记-整理 。
- Parallel Old收集器: 同样是一款作用于老年代的垃圾收集器,使用的垃圾回收算法就是 标记-整理 。
- CMS收集器: 它的重点时非常关注应用的相应速度,使用的回收算法是标记-整理 。它的工作分为四个步骤:
(1)初始标记。(2)并发标记。(3)重新标记。(4)并发清理
。其中,初始标记和重新标记是会发生Stop The World
的,不过时间比较短。
- 三大缺憾: (1)对处理器资源的敏感,并且自己会占用去一部分线程,导致吞吐率下降。(2)无法处理标记完成以后出现的垃圾。(3)标记-清理算法的空间碎片问题
- G1回收器: 同样的遵循年代划分的设计,但是面向的对象的回收策略以性价比作为了基准。图中的每一个空格也就是
Region
的大小是可以设定的,而作为大小的判断是通过当前对象占有的Region
的大小占比来进行判定的,当然我们知道会出现这样的数据一次性占去了很多个Region
,因为我们说过他终究是存在年代划分的,这样的数据绝大多数把他认为是老年代的数据。而对于数据回收一般会分为四个步骤:(1)初始标记(2)并发标记(3)最终标记(4)筛选回收
。
具体的操作步骤请看why技术的文章
面试官问我G1回收器怎么知道你是什么时候的垃圾?
四大引用
上面我们说到了回收器的存在,但是能否回收才决定了我们的回收算法是否生效,一般来说分为以下四类:
- 强饮用:一般为使用关键词
new
实例化的对象,这类引用GC回收器宁可溢出,也不会回收。 - 软引用:有用但是不必要的对象们,只有在内存不足时,才会被GC回收器回收。一般使用于缓存各种资源。
- 弱引用:GC发生时就会被回收的对象们,是一种防治oom的方法。弱引用的应用场景在我的 Android工具包MVP框架 中使用到,另外在上次的 锦囊篇|一文摸懂LeakCanary 中也有提及。
- 虚引用:形同虚设的引用。
上面三种讲的很清楚了,但是这个虚引用到底有什么用呢?
它的作用在于跟踪垃圾回收过程,在对象被收集器回收时收到一个系统通知。 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,将这个虚引用加入引用队列,在其关联的虚引用出队前,不会彻底销毁该对象。 所以可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了。我个人认为可以和弱引用一样做一个内存泄漏检查的解决方案,但是还不清楚他的缺憾在哪儿。
参考资料
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 -- 周志明