一.判断对象是否存活
若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。
1.1 引用计数法
给对象加一个引用计数器,当对象增加一个引用时计数器+1,引用失效时计数器-1,当计数为0的对象可被回收。
这个方法实现简单,效率也很高,符合大部分情况,但是在java中存在循环引用的问题,引用计数法无法解决这个问题。
举个例子:
java
public class ReferenceCountingGC {
public Object instance = null;
public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;
}
}
objectA 和 objectB互相引用,所以他们的计数器都不为0,GC无法回收他们,当这种对象越来越多,就有可能引发内存溢出。
1.2 可达性分析算法
通过定义GC Roots的对象作为起点进行搜索,将能够到达的对象视为可用,不可达的对象是为不可用
可作为GC Roots的对象包括一下几种:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象(Native方法)
- 方法区中,类静态属性引用的对象
- 方法区中,常量引用的对象
那么这种方式是如何解决循环引用的问题呢?
可达性分析算法中使用了辅助数据结构,例如 记忆集 或者 存活对象集合, 这些数据结构用于记录可能存在循环依赖的对象,当可达性分析算法在标记过程中访问到一个对象时,它会检测该对象是否处于记忆集或者存活对象集合中,如果存在就不会继续遍历该对象的引用关系,避免无限循环。
二. 引用的种类划分
在《JVM内存模型》这篇文章中已经简单的描述了一下四种引用类型,这里详细说下。
无论是引用计数法还是可达性分析算法都用到了 引用 这个概念,JDK1.2以前,Java中引用的定义很传统,一个对象只有引用和被引用两种状态,我们希望能描述这一类对象:当内存空间足够时,则保留,当GC后内存很紧张,就可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景,所以在JDK1.2之后,将引用分为四类,不同的引用类型,主要体现的是对象不同的可达性状态和GC的影响。
2.1 强引用(Strong Reference)
使用new一个新对象的方式来创建引用。
java
Object obj = new Object();
只要强引用存在,垃圾收集器永远不会回收被引用的对象。但是,如果我们错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。
2.2 软引用(Soft Reference)
软引用是一种较强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。JVM会确保抛出OOM之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留,当内存不足时才丢弃。
可以使用SoftReference类来创建软引用:
java
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
2.3 弱引用(Weak Reference)
强度比软引用还要弱一些,当JVM进行垃圾回收时,无论内存是否充足,都会回收,也就是说它只能存活到下一次GC前。
可以使用WeakReference类来实现弱引用:
java
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
在Tomcat中,ConcurrentCache就使用了WeakHashMap来实现缓存功能。ConcurrentCache采取的是分代缓存,经常使用的对象放入Eden区,而不常用的对象放入longterm。eden使用ConcurrentHashMap实现,longterm使用WeakHashMap,保证不常使用的对象容易被回收。
java
public final class ConcurrentCache<K, V> {
private final int size;
private final Map<K, V> eden;
private final Map<K, V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}
public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}
2.4 虚引用(Phantom Reference)
虚引用是最弱的一种引用关系,一个对象是否有虚引用的存在完全不会对其生存时间构成影响,它仅仅是提供了一种确保对象被finalize以后,做某些事情的机制。
为对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
可以使用PhantomReference来实现:
java
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;
2.5 finalize() 方法
finalize方法用是来做关闭外部资源等工作,但是try-finally等方式可以做的更好,并且运行该方法的代价高昂,不确定性大,无法保证各个对象的调用顺序,一般情况不会使用。
当一个对象可被回收时,如果需要执行该对象的finalize方法,那么对象会被放入一个队列中,虚拟机会以较低的优先级执行这些方法,但不会保证所有的finalize方法都会被执行,如果finalize方法出现耗时操作,虚拟机就会直接停止指向该方法,并将对象清除。如果执行finalize方法时,将this赋给了某一个引用,那么该对象就重生了,如果没有则会被清除。
任何一个对象的finalize方法只会被系统自动调用一次,如果对象面临下一次回收,它的finalize方法不会被再次执行,想继续在finalize中自救就失效了。
2.6 回收方法区内存
方法区中存放生命周期较长的类信息,常量,静态变量,每次GC只有少量的垃圾被清除。方法区中主要清除两种垃圾:
- 废弃常量
- 无用的类
2.6.1 如何判定废弃常量?
只要常量池中的常量不被任何变量或者对象引用,那么这些常量就会被清除掉。比如,一个字符串"string"进入了常量池,但是当前系统没有任何的一个String对象引用常量池中的"string"常量,也没有其他地方引用这个常量,必要的话,"string"常量会被清理出常量池。
2.6.2 如何判定无用的类?
- 该类的所有对象都已经被清除
- 加载该类的ClassLoader已被回收
- 该类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法(一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区时创建,在方法区该类被删除时清除。)
三. 垃圾收集算法
当虚拟机判定无效对象,无用类,废弃常量之后,就应该回收这些垃圾了。
3.1 标记-清除算法
标记的过程是:遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象
清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉,与此同时,清除那些被标记过的对象的标记,以便于下次回收。
这个方法有两个不足:
- 效率问题:标记和清除两个过程的效率不高
- 空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提交触发另一次GC。
3.2 标记-整理算法
标记的过程和上一个相同,整理的过程是移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低
3.3 复制算法
为了解决效率问题,复制收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完,需要进行GC时,就将存活的对象复制到另一块上面,然后将第一块内存全部清除。
- 优点:不会有内存碎片的问题
- 缺点:内存缩小为原来的一半,浪费空间
为了解决空间利用率问题,可以将内存分为三块:Eden,From Survivor,To Survivor,比例默认是8:1:1,每次使用Eden和其中一块survivor,回收时,将Eden和survivor中还存活的对象一次性复制到另一块survivor上,最后清理掉Eden和刚才使用的Survivor空间,这样只有10%空间被浪费。
但是我们无法保证每次回收都只有不多于10%的对象存活,当survivor空间不够时,需要依赖其他内存(泛指老年代)进行分配担保。
分配担保:为对象分配内存空间时,如果Eden+survivor中空闲区域无法装下该对象,会触发Minor GC进行垃圾回收,但如果Minor GC过后仍然有超过10%的对象存活,这些对象直接通过分配担保机制进入老年代,然后再将新对象存入Eden区。
3.4 分代收集算法
根据对象存活周期的不同,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。
- 年轻代使用复制算法
- 老年代使用标记-整理或者标记-清除算法
3.4.1 新生代
新生代是大部分对象创建和销毁的区域,在通常的Java应用中,绝大部分的对象生命周期都是很短暂的,其内部又分为Eden区域,作为对象初始分配的区域;两个survivor区域,有时候也叫from,to区域,被用来放置从Minor GC中保留下来的对象
JVM会记录survivor区中的对象一共被来回复制了几次,如果一个对象呗复制的次数为15(可在-XX:+MaxTenuringThreshold配置),那么该对象将被晋升到老年代。另外如果耽搁survivor区已经被占用了50%,那么较高复制次数的对象也会晋升到老年代。
3.4.2 老年代
放置长生命周期的对象,通常都是从survivor区拷贝过来的对象,也有一些特殊情况,如果对象较大,JVM会试图直接分配在Eden其他位置上,如果对象太大,完全无法在新生代找到足够长且连续的空闲空间,JVM就会直接分配到老年代。
3.4.3 永久代
这部分是早期Hotspot JVM的方法区实现方式,在jdk1.8之后就不再使用了。
3.4.4 一些常用JVM参数
|---------------------|-----------------------------------------------------------------------------------|
| 配置 | 描述 |
| -Xss
| 虚拟机栈大小。 |
| -Xms
| 堆空间初始值。 |
| -Xmx
| 堆空间最大值。 |
| -Xmn
| 新生代空间大小。 |
| -XX:NewSize
| 新生代空间初始值。 |
| -XX:MaxNewSize
| 新生代空间最大值。 |
| -XX:NewRatio
| 新生代与年老代的比例。默认为 2,意味着老年代是新生代的 2 倍。 |
| -XX:SurvivorRatio
| 新生代中调整 eden 区与 survivor 区的比例,默认为 8。即 eden
区为 80% 的大小,两个 survivor
分别为 10% 的大小。 |
| -XX:PermSize
| 永久代空间的初始值。 |
| -XX:MaxPermSize
| 永久代空间的最大值。 |