目录
- 类加载机制
- 类加载机制的步骤
- 加载
- 验证
- 准备
- 解析
- 初始化
- 双亲委派模型
- 工作原理
- 双亲委派模型的优点
- 垃圾回收机制
- 死亡对象的判断
- 可达性分析算法
- 可达性分析算法的缺点
- 引用计数算法
- 循环引用问题
- 垃圾回收算法
- 标记-清除算法
- 复制算法
- 标记-整理算法
- 分代算法
类加载机制
对于任意一个类,都需要经历这样一个过程:
这个过程就是类的生命周期,从加载到初始化,属于类加载过程,其中验证、准备和解析都属于连接操作。
类加载机制的步骤
加载
加载是整个类加载中的一个阶段,该阶段通过类加载器将类的字节码文件加载到内存中,并创建对应的Class对象。说白了就是找到.class文件。
验证
验证加载阶段得到的.class文件是否合法(.class文件的格式是固定的)
准备
为.class文件中的类变量(static修饰)分配内存空间并初始化(不会赋值)。
对于public static int num = 10;
准备阶段num初始化为0而不是10
解析
将字符串常量池中的符号引用变为直接引用,即初始化常量。
符号引用就相当于字符串在文件内部的位置,不能直接通过符号引用访问到字符串;直接引用就是绝对位置,即可以通过这个引用直接访问到字符串。
初始化
执行类的初始化(包括静态变量的赋值和静态代码块的执行,如果有父类先加载父类)
准备阶段和解析阶段是可以顺序互换的,但是都要在初始化阶段之前。
双亲委派模型
双亲委派模型是一种类加载机制,在加载阶段执行。
JVM内置了三种类加载器,分别用于加载不同的类:
- BootStrapClassLoader(引导类加载器):JVM内置的类加载器,负责加载核心类库
- ExtensionClassLoader(扩展类加载器):加载Java扩展类库
- ApplicationClassLoader(应用程序类加载器):加载应用程序的类
其中ExtClassLoader是ApplicationClassLoader的父类,BootStrapClassLoader是ExtensionClassLoader的父类。
程序员也可以自定义类加载器(需要继承自ApplicationClassLoader)
工作原理
当一个类收到类加载请求,他不会直接去加载,而是把这个请求委托给父类加载器,如果父类加载器还有父类,就继续向上委托,直到顶层的启动类加载器。顶层的启动类加载器没有父类,就去查找对应范围内是否存在该类,存在就加载并返回,否则交给子类加载器。如果最底层的类加载器也没有找到,则抛出ClassNotFoundException(受查异常)
双亲委派模型的优点
- 避免了重复加载类的问题,保证类的唯一性
假如有A和B两个类需要加载,它们的父类是C,在加载A类之前就会先加载C类,此后再去加载B类就不用加载C了;如果直接从顶层类加载器开始加载,就可能出现同一个类被不同的类加载器重复加载的情况,从而得到不同的对象。
- 保护核心库的安全性,防止恶意篡改代码或替换核心库
没有双亲委派模型(先访问顶层类加载器),就意味着可以自己实现一些核心类,这显然是不允许的,并且也不能保证安全。
- 促进了Java程序的模块化和可扩展,可以自定义类加载器处理需求
双亲委派模型并不是强制的,有些情况下需要打破,例如热部署等
垃圾回收机制
在传统的编程语言(如C/C++)中,需要手动分配和释放内存。这往往会出现内存泄漏和内存溢出的问题,为了尽量避免这样的问题,JVM引入了垃圾回收机制,自动的释放无用内存,从而减少开发人员的负担。
死亡对象的判断
可达性分析算法
JVM确认是否为垃圾的算法是可达性分析,它的实现思路是:
遍历所有的GC Roots,找到它们的直接引用,并把这些引用标记为可达;然后遍历这些可达对象,继续找到它们的直接引用,并标记为可达;重复以上步骤直到遍历到没有引用。最后,没有标记为可达的对象就是垃圾。(整个走过的路径就称为引用链)
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象(虚拟机栈用于存储方法调用和局部变量);
- 方法区中类静态属性引用的对象(方法区用于存储类信息、常量、静态变量等);
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象(本地方法栈用于支持Java程序与本地代码(如C、C++等)交互的数据结构)。
可达性分析算法的缺点
- 判断是否为垃圾的过程需要遍历所有的引用链,这会耗费大量资源和时间;
- 在进行可达性分析时,必须暂停用户所有线程,即STW(Stop The World),这会影响程序性能以及用户体验等。
为什么要触发STW?
假如在检查某条引用链时,某个其他线程删除了这条引用链上的某个对象,那么从删除位置开始往后的对象都是不可达的,这时可达性分析就可能出现误判。因此必须在进行可达性分析前暂停所有的用户线程。
引用计数算法
不仅仅是Java实现了垃圾回收机制,也有很多其他的语言也实现了垃圾回收机制,如python、php等则是使用引用计数算法来判断是否为垃圾的。
引用计数算法的实现思路为:
给对象增加一个计数器,每当有一个对象引用它时,就让计数器加一,每一个引用失效时,就让计数器减一,当计数器为0时,认为对象不再使用。
引用计数算法的思路简单,效率也比可达性分析算法要高,但是Java却不使用,原因就在于引用计数无法解决循环引用的问题。
循环引用问题
例如:
public class Test {
public Test n;
public static void main(String[] args) {
Test test1 = new Test();// test1引用次数为1
Test test2 = new Test();// test2引用次数为1
test1.n = test2;// test2引用次数为2
test2.n = test1;// test1引用次数为2
test1 = null;// test1引用次数为1
test2 = null;// test2引用次数为2
}
}
当把test1和test2都置为null后,显然是没有任何对象会使用到这两个引用的,也就是说这两个对象已经是垃圾了,但由于这两个对象互相引用,计数器不为0,就不进行回收。这就是引用计数法的弊端,无法解决循环引用问题。
垃圾回收算法
确定哪些对象是垃圾后,就要执行垃圾回收算法来清除这些垃圾。主要的垃圾回收算法有:
标记-清除算法
顾名思义,就是先标记再清除。通过前文所述操作对所有用不到的对象标记后,统一将其清除。
标记-清除算法虽然实现思路简单,但是却有着致命缺点:
标记清除后会产生大量不连续的内存碎片,在后续申请内存时,如果剩余内存空间还有1G,但是最大连续的剩余内存空间还不到500MB,那这时去申请500MB的内存空间都申请不下来。
从上图可以看到,虽然剩余空间还有5格,但是要申请一个大小为三格的对象都申请不下来。
复制算法
为了解决标记-清除算法造成的内存碎片化的问题,引入了复制算法。
复制算法的实现思路为:
首先把内存块分成均匀的两块,每次只使用其中的一块内存,在进行垃圾回收时,把存活的对象复制到另一块内存中,然后再清除这一块内存。这样就解决了内存碎片化的问题。
复制算法的实现也比较简单,但是缺点也很明显:
内存空间利用率很低,每次只能用一半丢一半。另外,假如内存中存活的对象很多,而死亡的很少,这时复制算法效率也是很低的。
标记-整理算法
标记-整理算法也是对标记-清除算法的一种改进,在标记完不会立即进行清除,而是把所有的存活对象往一端移动,把所有的存活对象和死亡对象隔开以后,在对死亡对象进行清除。
标记-整理算法和复制算法一样,当存活对象很多时,效率会很低。
分代算法
实际开发中的内存情况是多种多样的,因此不能简单的使用上述的某一种算法来实现垃圾回收。分代算法通过区域的划分,在不同的区域执行不同的垃圾回收算法,从而更好地实现垃圾回收。合适的才是最好的。
分代算法将内存区划分为新生代和老年代两大内存区块,根据经验而谈:如果一种事物已经存在了了很久,那么它很可能会继续存在很久。就像音乐一样,如果某一首歌已经火了好多年了,那么它很可能继续火很多年;当然更多的歌都只是昙花一现。依据这条经验,内存区划分为新生代和老年代,在新生代中,每次垃圾回收都有大批对象死去,只有少量存活(就像经典歌曲往往是少数的),因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用标记-清除或者标记-整理算法。
- 新生代分为伊甸区和幸存区,新创建的对象都放在伊甸区,每轮存活的对象放到幸存区
- 由于大多数的对象生命周期都很短,因此伊甸区很大,幸存区较小
- 幸存区分为大小相等的两部分,每次只使用一部分
- 每次垃圾回收,伊甸区和幸存区的幸村对象一样,都复制到另一半幸存区。
- 在幸存区幸存的次数达到一定程度后,就会从新生代转移到老年代。
- 如果创建的对象本身很大,则直接放到老年代。
垃圾回收算法只是垃圾回收的思想,实际的垃圾回收是JVM基于这些思想并加以扩展实现的垃圾回收器来完成的。