netty Recycler物件池

jtea發表於2024-03-15

前言

池化思想在實際開發中有很多應用,指的是針對一些建立成本高,建立頻繁的物件,用完不棄,將其快取在物件池子裡,下次使用時優先從池子裡獲取,如果獲取到則可以直接使用,以此降低建立物件的開銷。
我們最熟悉的資料庫連線池就是一種池化思想的應用,資料庫操作是非常頻繁的,資料庫連線的建立、銷燬開銷很大,每次都需要進行TCP三次握手和四次揮手,許可權檢查等,所以如果每次運算元據庫都重新建立連線,用完就丟棄,對於應用程式來說是不可接受的。在java世界裡,一切皆物件,所以需要有一個資料庫物件連線池,用於儲存連線池物件。例如使用hikari,可以配置spring.datasource.hikari.maximum-pool-size=20,表示最多可以池化20個資料庫連線物件。
此外,頻繁的建立銷燬物件還會影響GC,當一個物件使用完,再沒被GC root引用,就變成不可達,所引用的記憶體可以被垃圾回收,GC是需要STW的,頻繁的GC也會影響程式的吞吐量。

本篇我們要介紹的是netty的物件池Recycler,Recycler是物件池核心類,netty為了減少依賴,以及追求高效能,並沒有使用第三方的物件池,而是自己設計了一套。
netty在高併發處理IO讀寫,記憶體物件的使用是非常頻繁的,如果每次都重新申請,無疑效能會大打折扣,特別是對於堆外記憶體,申請和銷燬的成本更高,所以對記憶體物件使用池化是很有必要的。
例如:PooledHeapByteBuf,PooledDirectByteBuf,ChannelOutboundBuffer.Entry都使用了物件池,這些類內部都有一個Recycler靜態變數和一個Handle例項變數。

static final class Entry {
    private static final Recycler<Entry> RECYCLER = new Recycler<Entry>() {
        @Override
        protected Entry newObject(Handle<Entry> handle) {
            return new Entry(handle);
        }
    };

    private final Handle<Entry> handle;
}

原理

我們先透過一個例子感受一下Recycler的使用,然後再來分析它的原理。

public final class Connection {

	private Recycler.Handle handle;

	private Connection(Recycler.Handle handle) {
		this.handle = handle;
	}

	private static final Recycler<Connection> RECYCLER = new Recycler<Connection>() {
		@Override
		protected Connection newObject(Handle<Connection> handle) {
			return new Connection(handle);
		}
	};

	public static Connection newInstance() {
		return RECYCLER.get();
	}

	public void recycle() {
		handle.recycle(this);
	}

	public static void main(String[] args) {
		Connection c1 = Connection.newInstance();
		int hc1 = c1.hashCode();
		c1.recycle();
		Connection c2 = Connection.newInstance();
		int hc2 = c2.hashCode();
		c2.recycle();
		System.out.println(hc1 == hc2); //true
	}
}

程式碼非常簡單,我們用final修飾Connection,這樣就無法透過繼承建立物件。同時構造方法定義為私有,防止外部直接new建立物件,這樣就只能透過newInstance靜態方法建立物件。
Recycler是一個抽象類,newObject是它的抽象方法,這裡使用匿名類繼承Recycler並重寫newObject,用於建立一個新的物件。
Handle是一個介面,Recycler會建立並透過newObject方法傳進來,預設是DefaultHandle,它的作用是用來回收物件,放回物件池。
接著我們建立兩個Connection例項,可以看到它們的hashcode是一樣的,證明是同一個物件。
需要注意的是,使用物件池建立的物件,用完需要呼叫recycle回收。

原理分析
想象一下,如果由我們設計,怎麼設計一個高效能的物件池呢?物件池的操作很簡單,一取一放,但考慮到多執行緒,實際情況就變得複雜了。
如果只有一個全域性的物件池,多執行緒操作需要保證執行緒安全,那就需要透過加鎖或者CAS,這都會影響存取效率,由於執行緒競爭,鎖等待,可能透過物件池獲取物件的效率還不如直接new一個,這樣就得不償失了。
針對這種情況,已經有很多的經驗供我們借鑑,核心思想都是一樣的,降低鎖競爭。例如ConcurrentHashMap,透過每個節點上鎖,hash到不同節點的執行緒就不會相互競爭;例如ThreadLocal,透過線上程級別繫結一個ThreadLocalMap,每個執行緒操作的都是自己的私有變數,不會相互競爭;再比如jvm在分配記憶體的時候,記憶體區域是共享的,所以jvm為每個執行緒設計了一塊私有的TLAB,可以高效進行記憶體分配,關於TLAB可以參考:這篇文章

這種無鎖化的設計在netty中非常常見,例如物件池,記憶體分配,netty還設計了FastThreadLocal來代替jdk的ThreadLocal,使得執行緒內的存取更加高效。
Recycler設計如下:

如上圖,Recycler內部維護了兩個重要的變數,StackWeakOrderQueue,實際物件就是包裝成DefaultHandle,儲存在這兩個結構中。
預設情況一個執行緒最多儲存4 * 1024個物件,可以根據實際情況,透過Recycler的建構函式指定。

private static final int DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD = 4 * 1024; // Use 4k instances as default.

Stack是一個棧結構,是執行緒私有的,Recycler內部透過FastThreadLocal進行定義,對Stack的操作不會有執行緒安全問題。

 private final FastThreadLocal<Stack<T>> threadLocal = new FastThreadLocal<Stack<T>>() {};        

FastThreadLocal是netty版的ThreadLocal,搭配FastThreadLocalThread,FastThreadLocalMap使用,主要最佳化jdk ThreadLocal擴容需要rehash,和hash衝突問題。

當獲取物件時,就是嘗試從Stack棧頂pop出一個物件,如果有,則直接使用。如果沒有就嘗試從WeakOrderQueue“借”一點過來,放到Stack,如果借不到,那就呼叫newObject()建立一個。

WeakOrderQueue主要是用來解決多執行緒問題的,考慮這種情況,執行緒A建立的物件,可能被執行緒B使用,那麼物件的釋放就應該由執行緒B決定。如果執行緒B也將物件歸還到執行緒A的Stack,那就出現了執行緒安全問題,執行緒A對Stack的讀取,寫入就需要加鎖,影響併發效率。
為了無鎖化操作,netty為其它每個執行緒都設計了一個WeakOrderQueue,各個執行緒只會操作自己的WeakOrderQueue,不會有併發問題了。其它執行緒的WeakOrderQueue會透過指標構成一個連結串列,Stack物件內部透過3個指標指向連結串列,這樣就可以遍歷整個連結串列物件。

站線上程A的角度,其它執行緒就是B,C,D...,站線上程B的角度,其它執行緒就是A,C,D...

從上圖可以看到,WeakOrderQueue實際不是一個佇列,內部是由一些Link物件構成的雙向連結串列,它也是一個連結串列。
Link物件是一個包含讀寫索引,和一個長度為16的陣列的物件,陣列儲存的就是DefaultHandler物件。

整個過程是這樣的,當本執行緒從Stack獲取不到可用物件時,就會透過cursor指標變數WeakOrderQueue連結串列,開始從其它執行緒獲取物件。如果找到一個可用的Link,就會將整個Link裡的物件遷移到Stack,然後刪除連結串列節點,為了保證效率,每次最多遷移一個Link。如果還獲取不到,就透過newObject()方法建立一個新的物件。

Recycler#get 方法如下:

 public final T get() {
    if (maxCapacityPerThread == 0) {
        return newObject((Handle<T>) NOOP_HANDLE);
    }
    Stack<T> stack = threadLocal.get();
    DefaultHandle<T> handle = stack.pop();
    if (handle == null) {
        handle = stack.newHandle();
        handle.value = newObject(handle);
    }
    return (T) handle.value;
}

pop方法判斷Stack沒有物件,就會呼叫scavenge方法,從WeakOrderQueue遷移物件。scavenge,翻譯過來是拾荒,撿的意思。

 DefaultHandle<T> pop() {
    int size = this.size;
    if (size == 0) {
        if (!scavenge()) {
            return null;
        }
        size = this.size;
    }
    //...
}

最終會呼叫到WeakOrderQueue的transfer方法,這個方法比較複雜,主要是對WeakOrderQueue連結串列和內部Link連結串列的遍歷。
這裡dst就是前面說的Stack物件,可以看到會把element元素遷移過去。

boolean transfer(Stack<?> dst) {
    //...
    if (srcStart != srcEnd) {
        final DefaultHandle[] srcElems = head.elements;
        final DefaultHandle[] dstElems = dst.elements;
        int newDstSize = dstSize;
        for (int i = srcStart; i < srcEnd; i++) {
            DefaultHandle element = srcElems[i];
            if (element.recycleId == 0) {
                    element.recycleId = element.lastRecycledId;
            } else if (element.recycleId != element.lastRecycledId) {
                throw new IllegalStateException("recycled already");
            }
            srcElems[i] = null;

            if (dst.dropHandle(element)) {
                // Drop the object.
                continue;
            }
            element.stack = dst;
            dstElems[newDstSize ++] = element;
        }            
    }
    //...
}

應用

我們專案使用了mybatis plus作為orm,其中用得最多的就是QueryWrapper了,每次查詢都需要new一個QueryWrapper。例如:

QueryWrapper<User> queryWrapper = new QueryWrapper();
queryWrapper.eq("uid", 123);
return userMapper.selectOne(queryWrapper);

資料庫查詢是非常頻繁的,QueryWrapper的建立雖然不會很耗時,但過多的物件也會給GC帶來壓力。
QueryWrapper是mp提供的類,它沒有池化的實現,不過我們可以參考上面netty DefaultHandle的思路,在它外面再包一層,然後池化包裝後的物件。
回收的時候還要注意清空物件的屬性,例如上面給uid賦值了123,下個物件就不能用這個條件,否則就亂套了,QueryWrapper提供了clear方法可以重置所有屬性。
同時,每次用完都需要手動recycle也是比較麻煩的,開發容易忘記,可以藉助AutoCloseable介面,使用try-with-resource的寫法,在結束後自動完成回收。
對於修改和刪除還有UpdateWrapper和DeleteWrapper,同樣思路也可以實現。

有了這些思路,程式碼就出來了:

public final class WrapperUtils {

	private WrapperUtils() {}

	private static final Recycler<PooledQueryWrapper> QUERY_WRAPPER_RECYCLER = new Recycler<PooledQueryWrapper>() {
		@Override
		protected PooledQueryWrapper newObject(Handle<PooledQueryWrapper> handle) {
			return new PooledQueryWrapper<>(handle);
		}
	};

	public static <T> PooledQueryWrapper<T> newInstance() {
		return QUERY_WRAPPER_RECYCLER.get();
	}

	static class PooledQueryWrapper<T> implements AutoCloseable {

		private QueryWrapper<T> queryWrapper;
		private Recycler.Handle<PooledQueryWrapper> handle;

		public PooledQueryWrapper(Recycler.Handle<PooledQueryWrapper> handle) {
			this.queryWrapper = new QueryWrapper<>();
			this.handle = handle;
		}

		public QueryWrapper<T> getWrapper() {
			return this.queryWrapper;
		}

		@Override
		public void close() {
			queryWrapper.clear();
			handle.recycle(this);
		}
	}
}

使用如下,可以看到列印出來的hashcode都是一樣的,每次執行後都會自動呼叫close方法,進行QueryWrapper屬性重置。

public static void main(String[] args) {
	try (PooledQueryWrapper<Case> objectPooledWrapper = WrapperUtils.newInstance()) {
		QueryWrapper<Case> wrapper = objectPooledWrapper.getWrapper();
		wrapper.eq("age", 1);
		wrapper.select("id,name");
		wrapper.last("limit 1");
		System.out.println(wrapper.hashCode());
	}

	try (PooledQueryWrapper<Case> objectPooledWrapper = WrapperUtils.newInstance()) {
		QueryWrapper<Case> wrapper = objectPooledWrapper.getWrapper();
		wrapper.eq("age", 2);
		wrapper.select("id,email");
		wrapper.last("limit 2");
		System.out.println(wrapper.hashCode());
	}

	try (PooledQueryWrapper<Case> objectPooledWrapper = WrapperUtils.newInstance()) {
		QueryWrapper<Case> wrapper = objectPooledWrapper.getWrapper();
		wrapper.eq("age", 3);
		wrapper.select("id,phone");
		wrapper.last("limit 3");
		System.out.println(wrapper.hashCode());
	}
}

總結

之前我們也分析過apache common pool,這也是一個池化實現,在redis客戶端也有應用,但它是透過加鎖解決併發問題的,設計沒有netty這麼精細。
上面的原始碼來自netty4.1.42,從整體上看整個Recycler的設計還是比較複雜的,主要為了解決多執行緒競爭和GC問題,導致整個程式碼複雜度比較高,所以netty在後來的版本中對其進行重構。
不過這不影響我們對它思想的學習,以後也可以借鑑到實際開發中。

更多分享,歡迎關注我的github:https://github.com/jmilktea/jtea

相關文章