JVM中的引用有五种,分别是:强引用、软引用、弱引用、虚引用、终结器引用(引用强度逐渐减弱)。区分这几种引用的主要原因是可以根据不同强度的引用来使用不同的垃圾回收策略。

1. 强引用(Strongly Reference)

JVM中的默认的引用关系就是强引用,即对象被局部变量、静态变量等GC Root关联的对象引用,只要这层关系存在,普通对象就不能被回收。

2. 软引用(Soft Reference)

软引用的引用强度弱于强引用,当一个程序的内存不足时,就会将软引用中的数据进行回收。

软引用主要用于实现缓存。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyCache {  
private static Map<Integer, SoftReference<Object>> cache = new ConcurrentHashMap<>();

public static Map<Integer, SoftReference<Object>> getInstance() {
return cache;
}

public static void put(Integer key, Object value) {
cache.put(key, new SoftReference<>(value));
}

public static Object get(Integer key) {
SoftReference<Object> softReference = cache.get(key);
return softReference.get();
}
}

使用软引用实现缓存,以便在内存不足时进行垃圾回收。

3. 弱引用(Weak Reference)

弱引用的整体机制与软引用类似,主要区别在于,弱引用的对象在垃圾回收时,无论内存是否够用,都会被直接回收。它主要在 ThreadLocal中使用。如下图:

Pasted image 20240416150333

K 的强引用被断开后,此对象将会在下一次GC时被回收。尽可能地避免内存泄漏的问题。

注意:
此方案并不能完全避免内存泄漏。根据上图可知,即使K被回收了,V依然在内存中,依然可能造成内存泄漏的问题。只能说使用弱引用的方案只是尽可能地避免内存泄漏的问题,能比强引用多一层保障而已。因此要避免内存泄漏,还是需要在不用threadLocal后,即使地调用 remove()方法释放内存。

本文的重点不是ThreadLocal,只需要了解软引用的特点和应用场景即可。关于ThreadLocal,会在后续详细讨论。

4. 虚引用(Phantom Reference)

也被称为幽灵引用或幻影引用,不能通过虚引用来获取到它包含的对象。虚引用唯一的用途就是当对象被垃圾回收器回收的时候可以接到对应的通知。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Demo12 {  
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> pref = new PhantomReference<Object>(obj, queue);

obj = null;

new Thread(()->{
while (true){
Reference<?> poll = queue.poll();
if (poll != null) {
break;
}
}

System.out.println("虚引用对象被回收了");
}).start();

Thread.sleep(5000);
System.gc();

}
}

在JDK的NIO分配对外内存时就使用的虚引用的特性来管理堆外内存。

在Java程序里我们一般不需要手动回收资源,因为有JVM为我们做自动管理并回收内存。但是在NIO的堆外内存,也就是直接内存(Direct Memory),JVM是无法帮我们自动回收和管理的。那么我们就需要通过手动来对其进行释放操作。

可以手动管理内存吗?答案是可以的。

我们可以使用ByteBuffer.allocateDirect()方法,或者通过反射拿到Unsafe对象,使用unsafe.allocateMemory来开辟堆外内存。
使用使用unsafe.freeMemory(address)来手动释放内存。

那么请看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo13 {  
public static final int _1G = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1G);

// 暂停一下
System.in.read();

// 释放内存
byteBuffer = null;

// 暂停一下
System.in.read();
}
}

第一次暂停时,此Java应用所占内存如下图:

Pasted image 20240416154609

第二次暂停:
Pasted image 20240416154632

思考:
以上代码中明明没有调用unsafe.freeMemory(address),为什么堆外内存会被释放呢?

其实这里用的就是虚引用的特性。接下来看看ByteBuffer.allocateDirect()的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 创建堆外内存 
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

// 这就是一个虚引用对象
private final Cleaner cleaner;

// 具体创建堆外内存逻辑
DirectByteBuffer(int cap) {

super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
// 使用unsafe对象分配一块堆外内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 【重点】创建一个虚引用对象,这个虚引用对象指向this对象(也就是指向创建的byteBuffer对象)
// 这里的 Deallocator 可以重点探究。
// 其实这里就是将Deallocator保存到了虚引用对象cleaner上。当虚引用对象被放入队列后,就会执行Deallocator对象的clean() 方法来清除堆外内存。看下面源码体现
// 可以看到这里将分配的堆外内存地址传递给了Deallocator对象保存。到时候释放堆外内存的时候也就要依靠这个地址
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

以下是Deallocator释放内存的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static class Deallocator implements Runnable{

private static Unsafe unsafe = Unsafe.getUnsafe();

private long address;
private long size;
private int capacity;

private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}

// 当虚引用对象被放入队列后,JVM中会有一个线程去取这个队列中的元素。然后就会执行这个方法释放内存
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 通过分配堆外内存时保存的地址来释放堆外内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}

回收过程:

  • 当byteBuffer被回收后,在进行GC垃圾回收的时候,发现虚引用对象Cleaner是PhantomReference类型的对象,并且被该对象引用的对象(ByteBuffer对象)已经被回收了;
  • 那么他就将将这个对象放入到(ReferenceQueue)队列中;
  • JVM中会有一个优先级很低的线程会去将该队列中的虚引用对象取出来,然后回调clean()方法;
  • clean()方法里做的工作其实就是根据内存地址去释放这块内存(内部还是通过unsafe对象去释放的内存)。

5. 终结器引用

终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize()方法,在对象第二次被回收时,改对象才真正地被回收。

具体使用方法较为简单,直接重写finilize()方法即可。当垃圾回收器准备好释放对象占用的存储空间,将首先调用其 finalize() 方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

class T1 {
@Override
protected void finalize() throws Throwable {
System.err.println("finalize()......");
Demo14.test = this;
}
}


public class Demo14 {
public static T1 test = new T1();

public static void main(String[] args) throws IOException {
test = null;
System.gc();
System.in.read();

System.out.println(test);

System.in.read();
test = null;
System.gc();
System.out.println(test);
}
}

输出如下:

1
2
3
4
5
6
finalize()......

com.yang.T1@7f31245a

null

可见,finalize()会在回收前调用一次,且此方法只会调用一次,第二次GC时,将不会调用。

  finalize() 方法不是 C/C++ 的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。
  一个对象的 finalize() 方法最多只会被系统自动调用一次。
  finalize() 方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,虚拟机调用 finalize() 方法甚至不能保证finalize() 的逻辑执行完毕。
 finalize()方法内做普通的清除工作是不合适的。 如果一定要进行回收动作,最好自己写一个回收方法 dispose() 方法。应当注意的是如果子类重写了父类的 dispose() 方法,当进行清除动作时,应该先清除子类的,再清除父类的,原因在于:可能子类存在对父类的方法调用。