垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。
虚拟机中对象创建的过程:
1、遇到new
指令,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。 如果没有,那必须先执行相应的类加载过程。
2、在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定。
为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java
堆中划分出来。有两种方式:
指针碰撞: 假设
Java
堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。空闲列表: 如果
Java
堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式由Java
堆是否规整决定,而Java
堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact
)的能力决定。
除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案也有两种:
一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用
CAS
配上失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在
Java
堆中预先分配一小块内存,称为 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB
),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB
,可以通过-XX:+/-UseTLAB
参数来设定。
3、**内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为 零值。**如果使用了TLAB
的话,这一项工作也可以提前至TLAB
分配时顺便进行。这步操作保证了对象的实例字段在Java
代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
4、**接下来,Java虚拟机还要对对象进行必要的设置,**例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()
方法时才计算)、对象的GC
分代年龄等信息。这些信息存放在对象的对象头(Object Header
)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5、在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始—— **构造函数,即Class
文件中的
方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。**一般来说(由字节码流中new指令后面是否跟随invokespecial
指令所决定,Java
编译器会在遇到new
关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new
指令之后会接着执行
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
一个Java对象在内存中包括对象头、实例数据和补齐填充3个部分:
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。
对于reference类型来说,在32位系统上占用4bytes, 在64位系统上占用8bytes。
Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。
程序通过栈上的 reference 数据来操作堆上的具体对象。
主流的访问方式有句柄和直接指针两种,HotSpot使用句柄
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.instance = b;
b.instance = a;
a = null;
b = null;
doSomething();
}
}
在上述代码中,a 与 b 引用的对象实例互相持有了对象的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。
通过一系列 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到 GC Root 间没有任何引用链相连,则证明此对象不可能再被使用。
Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:
某个区域的对象可能被位于堆中其它区域的对象所引用,需要将这些其它对象叶加入到 GC Root 集合。
当一个对象被判断为不可达之后,需要判断该对象是否有必要执行 finalize() 方法,如果对象没有覆盖该方法,或者该方法已经被虚拟机调用过,那么此对象将不会被回收。
如果虚拟机认为该对象需要执行 finalize() 方法,则会将该对象放到一个队列当中,虚拟机启动一个线程区执行该队列的 finalize() 方法。
如果在该方法中让对象重新被引用,那么该对象就会被移出队列,从而不会被回收。
任何一个对象的 finalize() 方法只会被执行一次。
类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要回收常量池中废弃的常量和不再使用的类。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
在常量池中的 “java” 没有被任何字符串对象引用,虚拟机也没有其它地方引用这个字面量,则当发生内存回收时,这个 “java” 常量就会被清理出常量池。常量池中其它类(接口)、方法、字段的符号引用也与此类似。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 提供了四种强度不同的引用类型。
传统所说的“引用”。
被强引用关联的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();
该类对象是描述一些还有用,但非必须的对象。
被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference 类来创建弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
使用 PhantomReference 来创建虚引用。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
将回收对象依据经过垃圾收集的次数分配到不同的区域中存储。
新生代:将朝生夕灭的对象保存在一块,从而不需要再标记哪些对象需要回收。只需要关注如何保留少数需要存活的对象。
老年代:将难以消亡的对象集中在一块,那么虚拟机可以使用较低的频率来回收这个区域。
分配区域的难点在于对象不是孤立的,对象会存在跨代引用。第三条假说证明互相引用的两个对象,倾向于同生共灭。
针对跨代引用,只需在新生代上建立一个全局的数据结构,这个结构将老年代划分成若干小块,标记哪一块内存存在跨代引用。这块内存的对象在 Minor GC 时会被加入 GC Root 中。
根据划分的区域可以有不同的回收类型
根据划分的区域也可以有与区域立马存储对象存亡特征相匹配的垃圾收集算法
此算法是其它算法的基础。
如果标记的对象是可回收对象,那么回收时就回收被标记的对象。如果标记的对象是活动对象,那么回收时就回收未被标记的对象。
以下按“被标记对象为活动对象叙述”。
在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
标记和清除过程效率都不高
如果堆中包含大量对象,而且其中大部分是需要被回收的。这时必须要进行大量标记和清除动作,导致两个过程的效率都会随对象数量增长而降低。
会产生大量不连续的内存碎片,导致无法给大对象分配内存,此时不得不启动另一次垃圾收集动作。。
需要停顿用户线程来标记、清理可回收对象,只是停顿时间相对较短。
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后**清理 Eden 和使用过的那一块 Survivor。**Serial、ParNew 等新生代收集器均采用这种内存布局。
默认内存比例
当尚未使用的 Survivor 不足以容纳一次 Minor GC 存活后的对象时,就需要依赖老年代进行分配担保,使得放不下的那些对象直接进入老年代。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
因为可能需要进行空间分配担保,所以在老年代中一般不能直接选用。
针对老年代对象的存亡特征设计的算法。
让所有存活的对象都向一端移动,再更新所有引用这些对象的地方,然后直接清理掉端边界以外的内存。
这个过程中必须全程暂停用户应用程序,也叫 “Stop The World”。
过程:
两者的标记过程相同,而整理算法需要移动对象。
内存:
移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
停顿时间:
不移动对象停顿时间会更短,甚至可以不停顿。
移动则整个过程都会停顿。
吞吐量:
吞吐量 = (赋值器 + 收集器)的效率。
不移动对象会使得收集器的效率提升,但内存分配和访问相比垃圾收集频率高得多,所以总吞吐量下降。
如:关注延迟的CMS
收集器。
移动对象则相反。
如:关注吞吐量的Parallel Scavenge
收集器
两者融合
让虚拟机平时使用“标记-清除”算法,但内存碎片化程度已经大到影响内存分配时,使用一次“标记-整理”算法,如 CMS
收集器。
ZGC、Shenandoah:读屏障(Read Barrier)实现整理过程与用户线程的并发执行,即整理过程不必再 Stop the World。
Java 捡垃圾黑科技
Java 捡垃圾利器
⭐️ 如果对你有帮助,请点个赞
《深入理解Java虚拟机-第三版》
cyc 2018
JavaGuide