- Class类加载过程与类加载器
当我们new了一个对象,会发生什么呢?来段代码:
public class Tested {
public static int T = 10;
public int c = 1;
}
在编译期,编译器会将 Tested.java类转换成 Tested.class 字节码文件。当虚拟机接收到new 字节码指令时,如果此时类还未被初始化,则虚拟机会先进行类的初始化过程。
1. 虚拟机会通过 Tested.class 文件内的全限定名来获取该类的字节码二进制流。全限定名位于class文件数据存储结构内的常量池中,常量池可以理解为Class文件中的“资源仓库”。
2. 虚拟机会将class字节码流内的静态存储结构转换为方法区内的运行时数据结构。即将class文件存储结构转换成方法区中保存类变量等的数据结构。
3. 虚拟机会在方法区中生成Class 对象,这个很特殊,并将该对象当作访问方法区中这些数据类型的对外接口。
4. 虚拟机会对于该二进制流进行校验,确保该class 文件二进制流包含的信息都符合要求。
5. 检验通过后,虚拟机会为这些类变量在方法区中分配内存,并进行初始化。但注意此时 T = 0,因为此时虚拟机还未执行任何 java 方法。
6. 虚拟机会将方法区内常量池内的符号引用,转换成能被方法区之外直接访问的直接引用。
7. 此时,虚拟机才会执行< clinit> 类构造方法,进行真正的类变量初始化操作,T = 10。
在类加载完成后。虚拟机会为new Tested() 的Tested对象,在java堆中分配内存。而对象所需要的内存大小在类加载完成后就被确定了。
指针碰撞
如果 java 中的内存是规整的,即使用过的放在一边,空闲的在另一边,中间放着指针作为分界点的指示器。那所分配的内存就仅仅是将指针像空闲空间挪动一段与对象大小相等的距离。这种方式内称为指针碰撞。
空闲列表
如果 java 中的内存是不工整的,使用过的和空闲的内存相互交错,那么虚拟机就必须维护一个列表记录哪些内存是可用的。在分配的时候从列表中找到一块足够大的空间给对象示例,并更新表的记录。这种分配方式称为空闲列表。
当我们的对象内存被分配完毕后,虚拟机就会对对象进行初始化操作。
1. 将分配给对象的内存空间初始化为零值(不包括对象头),如:c = 0。注意在栈内分配内存的叫局部变量表。
2. 虚拟机设置对象头的数据,如:对象属于哪个类的实例,对象的哈希码,对象GC分代年来等。
3. 虚拟机会执行< init >对象构造方法,进行对象的初始化工作,如 c = 1。
此时Tested 对象在我们眼里就算出生了,在虚拟机眼里就是真正可用的了。可对象的生命并不是无穷的,它也会经历自己的死亡。
可达性分析
在主流实现中,我们通过可达性分析来判断一个对象是否存活。实现思路是:通过一系列被称为 “GC Roots” 的对象作为起始点,从这些节点开始像下搜索,搜索所走的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的。见图:
即使 Obj5 与 Obj4 由于与 GC Roots 没有引用链相连,所以我们称 GC Roots 到对象 Obj4 和 Obj5 不可达。所以 Obj4 和 Obj5 就是可回收的。
既然Obj4 和 Obj5 是可回收的,那么是否一定会被回收呢?不一定。此时虚拟机会进行第一次的标记过程。因为 java 内能够重写 finalize() 方法(在这里只是分析特例,不推荐在任何情况下使用此方法),当对象重写了此方法,并且 finalize() 方法还未被虚拟机调用,那么虚拟机就会将此对象放入一个专门的F-Queue队列,由一个单独的 Finalizer 线程去执行它,如果队列中对象的 finalize() 方法在虚拟机第二次标记之前执行,并在此次执行过程中又将自己与GC Roots 引用链相连,那么虚拟机在进行第二次标记时,就会将该对象从 F-Queue队列移除,否则就宣告该对象死亡。注意:finalize() 方法只会被执行一次,所以一个对象一生只有一次机会进入F-Queue队列,有机会逃脱本此死亡。
如果对象已经宣告死亡了,那么虚拟机怎么来回收它吗?
标记-清除算法
这是最基础的收集算法,主要分为标记和清除两个阶段。首先标记出所以需要回收的对象,在标记完成后统一回收所有被标记的对象。可以参考上面的空闲列表。其有两点不足:
- 效率问题,标记和清除两个过程效率都不高。
- 空间问题,因为堆中的内存不是规整的,已使用的和空闲的内存相互交错,这也就导致了每次GC回收后,产生大量的内存碎片,而当再次分配一个大对象时,如果无法找到足够的连续内存,又会再此触发GC回收。
复制算法
复制算法是将堆内存分成大小相等的两块,每次只使用其中一块,这样内存就是规整的了,参考指针碰撞。每当一块内存使用完了,就将该块内存中存活的对象复制到另一边,随后将该块内存一次清理掉。
现在的虚拟机都采用这种方式来回收新生代,只是并不是按照1:1的比例来划分内存,而是将内存分为一块较大的 Eden 空间,和两块较小的 Survivor 空间(HotSpot虚拟机默认Eden:Survivor = 8 :1)。每次只使用 Eden 和 其中一块 Survivor 空间,当回收时,将 Eden 空间和当前正使用的 Survivor 空间内存活的对象复制到另一块空闲的 Survivor空间,随后清空 Eden 和 刚才用过的 Survivor 内存。
注意:由于我们无法保证每次 存活的对象所占内存一直都不大于 Survivor 内存值,所以就会有溢出风险。所以在分代收集算法中,虚拟机会将内存先划分为一块新生代内存和一块为老年代内存。而在新生代内存中,会采用这种8:1:1的内存分配方式,如果溢出了,就将该情况下的存活对象全部放在老年代内存里,说白了就是一种兜底策略。这里要注意的是,不是溢出的那部分,而是全部的存活对象。
标记-整理算法
标记-整理算法中的标记过程,与标记-清除算法中的标记过程一样,不同的是,当标记完成并清理回收完对象后,会将当前不连续的碎片内存就行整理,即存活的对象都移到一端,来保证接下来要分配的内存的规整性。我们的分代收集算法中的老年代内存块,就是采用的该算法(当然也可以是标记-清除算法,不同虚拟机的策略不同)。所以就不再对分代收集算法就行赘述了。
参考
1、周志明,深入理解JAVA虚拟机:机械工业出版社