深入理解 Java 引用类
网络上很多分析 ThreadLocal 的文章漏洞百出,本文将陆续分析其错误认识。希望通过本文让你深入理解引用类型的使用和最佳实践,同时详细地分析了相关源码,耐心看完,相信你一定会有所收获。
废弃的 finalize 方法
Object:: finalize 方法官方已不推荐使用,其安全性、性能和可靠性均不理想。最初使用 finalize 的目的是:当对象回收时清理其他非虚拟机管理的资源。官方推荐使用
- java. lang. ref. Cleaner 和虚引用清理资源;
- 实现 AutoClosable 接口,使用 try-with-resource 关闭资源。
对象可达性的细分
根据 java.lang.ref 包中定义,可达性从强到若可以分为:
- 强可达(strongly reachable) 无需经过任何引用对象(指的是 Reference 对象)即可达。
- 软可达(softly reachable) 非强可达,可以通过软引用对象(Soft Reference) 到达。
- 弱可达(wealy reachable) 非软可达,可以通过弱引用对象(WeakReference)到达。
- 虚可达(phantom reachable) 不属于上述几种,对象已经 finalize, 存在虚引用指向该对象,实际上对象已进行清理,无法再次获取该对象。
- 不可达,字面意义,可被垃圾回收。
引用类
public abstract sealed class Reference<T>
permits PhantomReference, SoftReference, WeakReference, FinalReference {
private T referent;
volatile ReferenceQueue<? super T> queue;
volatile Reference next;
private transient Reference<?> discovered;
public T get() {
return this.referent; // 返回强引用 referent
}
public final boolean refersTo(T obj) {
return refersToImpl(obj); // 判断方法
}
}
有些文章没有对引用进行详细分析,导致描述不清。这里规定一下名词含义,后续不再特殊说明。
引用对象(reference-object) -- 指的是 Reference 的实例。
对象,被引用对象(referent)。
我们对于弱引用等的认识应该从以上两个名词出发,reference-object 大多数情况下是强引用,referent 才是我们理解的引用指向的对象。垃圾回收时 referent 通过相关机制回收,而 reference-object 的回收应用的是强引用的逻辑,有时需要代码保证自己被回收,比如 WeakHashMap 中 Entry 的回收。
不考虑清理相关操作,reference-object 是不可变的,没有 set 方法。虽然我们可以把引用对象看做是值类型的包装类,但是这样不利用我们理解回收过程。
非强引用的用途
C++代码需要考虑内存回收,需要编写析构函数。Java 程序虽然没有这种烦恼,但是提供了一些半可控的回收机制。
JDK1.2 加入了这三种引用,软引用会在对象为软可达和内存不足时进行回收,严格来说是不满足以上两个条件时不会回收,满足条件不一定立即回收;弱引用条件为弱可达和垃圾回收时,虚引用可以进行清理操作,可以替换 finalize 方法。
引用队列和引用类配合使用。referent 对象达到相应的可达性时,reference-object 会自己加入引用队列。reference-object 不可达时,不会加入引用的队列。使用引用队列后需要编写代码处理队列中的 reference-object。官方说法如下:
应用场景:
-
缓存: 如 guava Cache, WeakHashMap, TheadLocal。考虑缓存场景,我们需要对某些信息进行缓存,比如读取到的文件信息、数据库信息等,但是缓存内容不能无限大,缓存常常会超过虚拟机容许范围,此时为避免 OOM,我们可以使用非强引用缓存,内存不足时交由虚拟机进行自动回收。
-
资源清理:如清理堆外内存
-
日志
使用虚引用清理堆外内存
public abstract sealed class ByteBuffer
extends Buffer implements Comparable<ByteBuffer>
permits HeapByteBuffer, MappedByteBuffer {
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
}
NIO 操作时常常会分配堆外内存,但是我们无需手动回收 byteBuffer,必然存在回调清理的机制,DirectByteBuffer 使用了虚引用清理堆外内存。提示:看源码时应该看其基本思想。
sealed class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer permits DirectByteBufferR
{
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap, null);
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 {
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;
}
try {
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); // 1
} catch (Throwable t) {
// Prevent leak if the Deallocator or Cleaner fail for any reason
UNSAFE.freeMemory(base);
Bits.unreserveMemory(size, cap);
throw t;
}
att = null;
}
private record Deallocator(long address, long size, int capacity) implements Runnable {
private Deallocator {
assert address != 0;
}
public void run() {
UNSAFE.freeMemory(address); // 2
Bits.unreserveMemory(size, capacity);
}
}
}
// 3
public class Cleaner extends PhantomReference<Object> {
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk; // thunk 意思是延迟执行的代码
}
}
代码 1 处创建了 cleaner 对象和 Deallocator 对象, 代码 2 处 Deallocator 对象用来释放堆外内存。Cleaner 是轻量级的 finalize 实现,如代码 3 处,Cleaner 继承自虚引用,当 referent(这里是 byteBuffer)为虚可达时,reference-handler 线程会回调 cleaner 中的 thunk(这里是 Deallocator:: run)方法。
WeakHashMap 是如何回收对象的?
和普通的 hashMap 类似,其特点是 weakHashMap 支持 null key 或 null value, 支持负载因子和初始容量等参数。使用的拉链法+头插法。视图方法(keySet, values, entrySet) 返回的迭代器支持 fail-fast。hash(key) 采用多次扰乱方法。桶的个数为 2 的幂次。
使用时主要区别在于其状态是不稳定的,随着垃圾回收的进行,WeakHashMap 返回的值可以是变化的,需要特别注意。比如 put 后的值无法获取。官方推荐使用使用了 == 语义的 equals 实现的 key,可以避免意想不到的问题,因为当前线程持有了 key 的强引用,所以和普通的 HashMap 使用起来没有区别,同时还可以自动回收(普通的 HashMap 需要手动 remove)。
我们来看几个重要的方法实现:
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
V value;
final int hash;
Entry<K,V> next;
Entry(Object key, V value,
ReferenceQueue<Object> queue,
int hash, Entry<K,V> next) {
super(key, queue); // 1
this.value = value;
this.hash = hash;
this.next = next;
}
}
从代码 1 处可以看出 Entry 为软引用,指向 key。
WeakHashMap 内部维护了引用队列,用于清理过时的 Entry(key 垃圾回收后)。Entry 是链表的节点。
public V put(K key, V value) {
Object k = maskNull(key);
int h = hash(k);
Entry<K,V>[] tab = getTable();
int i = indexFor(h, tab.length);
for (Entry<K,V> e = tab[i]; e != null; e = e.next) {
if (h == e.hash && matchesKey(e, k)) {
V oldValue = e.value;
if (value != oldValue)
e.value = value;
return oldValue;
}
}
modCount++;
Entry<K,V> e = tab[i];
tab[i] = new Entry<>(k, value, queue, h, e);
if (++size > threshold)
resize(tab.length * 2);
return null;
}
private boolean matchesKey(Entry<K,V> e, Object key) {
// check if the given entry refers to the given key without
// keeping a strong reference to the entry's referent
if (e.refersTo(key)) return true; // 1
// then check for equality if the referent is not cleared
Object k = e.get(); // 2
return k != null && key.equals(k);
}
和 hashMap 一致:计算桶位,开始遍历,找到对应 entry,返回旧址;找不到,创建新 entry(头插法), 按条件扩容。
代码 1 处防止使用强引用,优化对 key 可能的垃圾回收。2 处处理 equals 语义支持,支持用户自定义的 equals 实现。
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Objects.requireNonNull(action);
int expectedModCount = modCount;
Entry<K, V>[] tab = getTable();
for (Entry<K, V> entry : tab) {
while (entry != null) {
Object key = entry.get(); // 1
if (key != null) {
action.accept((K)WeakHashMap.unmaskNull(key), entry.value);
}
entry = entry.next;
if (expectedModCount != modCount) {
throw new ConcurrentModificationException();
}
}
}
}
代码 1 处得到 key 的强引用防止迭代时回收。
private Entry<K,V>[] getTable() {
expungeStaleEntries();
return table;
}
public int size() {
if (size == 0)
return 0;
expungeStaleEntries();
return size;
}
private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
private void expungeStaleEntries() {
for (Object x; (x = queue.poll()) != null; ) {
synchronized (queue) { // 1
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) x;
int i = indexFor(e.hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> p = prev;
while (p != null) {
Entry<K,V> next = p.next;
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
// stale entries may be in use by a HashIterator
e.value = null; // Help GC
size--;
break;
}
prev = p;
p = next;
}
}
}
}
getTable(), size()等方法会进行清理过时 entry 操作,清理时需要同步访问引用队列(代码 1 处),每次获取弱引用(也就是 entry), 计算桶位置,遍历链表找到对应 entry 清理 value。
这里同步操作而且是在循环内部同步是因为在清理过程过也有可能生成新的过时 entry。
总结以上分析,WeakHashMap 不能保证过时的 entry 被及时清理,因为清理操作是由代码触发的,可能会造成一定的内存泄露。
ThreadLocal 的实现
ThreadLocal 类最初使用的是 WeakHashMap, 之后优化为了内部实现哈希表(ThreadLocalMap)。Entry 中 key 为 threadLocal 对象,value 为本线程绑定值。每一个线程对象都会维护一个 ThreadLocalMap。
内部实现哈希表,当发生 hash 冲突时按顺序放入桶内(R 算法),由于 key 是 threalLocal 对象,源码实现了均匀的 hashcode。没有使用引用队列,为避免清理过时 entry 时扫描所有桶,采用启发式算法,时间复杂度为 o(log(n)),是一种平衡策略。
以下为一些重要的源码分析,按注释形式给出。
public class ThreadLocal<T> {
// 1. 自定义哈希值生成,不变量
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode =
new AtomicInteger();
/**
* The difference between successively generated hash codes - turns
* implicit sequential thread-local IDs into near-optimally spread
* multiplicative hash values for power-of-two-sized tables.
*/
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
// 2. 设置值,找到桶位,设置值;同时清理遇到的过时 entry;部分清理 entry
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) { // 3. 避免产生强引用
e.value = value;
return;
}
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
// 4. 内部实现哈希表,哈希冲突少,开放地址法
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
// 5. 相比 WeakHashMap 实现内存占用小很多
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 6. 清理过时 entry,考虑了清理过程中产生过时 entry 的情况
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.refersTo(null))
slotToExpunge = i;
// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (e.refersTo(key)) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
if (e.refersTo(null) && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// If key not found, put new entry in stale slot
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
}
}
同一语义下 threadLocal 对象创建了一个对象,不同的线程会创建多个 map 对象。
需要指出的是内存泄露虽然和 WeakHashMap 类似,但是由于我们经常使用的静态 threadLocal 字段,这种情况下弱引用不是造成内存泄露的原因,因为我们还持有 key 的强引用。这时发生内存泄露和我们使用 HashMap 而不清理是一个道理,threadLocal 可不背锅。
由于线程池对于线程的复用,最佳实践是使用完 threadLocal 时 remove,避免读到之前设置的值,避免内存泄露。
关于 threadLocal 为非静态字段的例子,可以看我之前写的《深入理解双重检查锁》。
FastThreadLocal 实现
netty 中进一步优化了 ThreadLocal 实现,使用了 FastThreadLocal。从上一节可以看出,ThreadLocal 还是会有哈希冲突的情况,FastThreadLocal 则直接保存索引,加快访问。FastThreadLocal 相关代码中没有使用弱引用。
private final int index;
public FastThreadLocal() {
index = InternalThreadLocalMap.nextVariableIndex();
}
/**
* Returns the current value for the current thread
*/
@SuppressWarnings("unchecked")
public final V get() {
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
Object v = threadLocalMap.indexedVariable(index);
if (v != InternalThreadLocalMap.UNSET) {
return (V) v;
}
return initialize(threadLocalMap);
}
总结
虽然非强引用有着一定功能,但是其回收机制对我们来说很大程度上不可控,出现问题也不好排查。我们在实际生产中应该尽量减少使用。
ThreadLocal 类也应该减少使用,方法使用的依赖应该在参数中体现,方便测试。
使用缓存时应该对使用的缓存数据进行预估,设置好缓存淘汰机制。不同类型的数据使用不同的缓存对象。