Java 中的物件池實現

2022-07-13 12:01:31

點贊再看,動力無限。Hello world : ) 微信搜「 程式猿阿朗 」。

本文 Github.com/niumoo/JavaNotes未讀程式碼部落格 已經收錄,有很多知識點和系列文章。

最近在分析一個應用中的某個介面的耗時情況時,發現一個看起來極其普通的物件建立操作,竟然每次需要消耗 8ms 左右時間,分析後發現這個物件可以通過物件池模式進行優化,優化後此步耗時僅有 0.01ms,這篇文章介紹物件池相關知識。

1. 什麼是物件池

池化並不是什麼新鮮的技術,它更像一種軟體設計模式,主要功能是快取一組已經初始化的物件,以供隨時可以使用。物件池大多數場景下都是快取著建立成本過高或者需要重複建立使用的物件,從池子中取物件的時間是可以預測的,但是新建一個物件的時間是不確定的。

當需要一個新物件時,就向池中借出一個,然後物件池標記當前物件正在使用,使用完畢後歸還到物件池,以便再次借出。

常見的使用物件池化場景:

  1. 物件建立成本過高。
  2. 需要頻繁的建立大量重複物件,會產生很多記憶體碎片。
  3. 同時使用的物件不會太多。
  4. 常見的具體場景如資料庫連線池、執行緒池等。

2. 為什麼需要物件池

如果一個物件的建立成本很高,比如建立資料庫的連線時耗時過長,在不使用池化技術的情況下,我們的查詢過程可能是這樣的。

查詢 1:建立資料庫連線 -> 發起查詢 -> 收到響應 -> 關閉連線
查詢 2:建立資料庫連線 -> 發起查詢 -> 收到響應 -> 關閉連線
查詢 3:建立資料庫連線 -> 發起查詢 -> 收到響應 -> 關閉連線

在這種模式下,每次查詢都要重新建立關閉連線,因為建立連線是一個耗時的操作,所以這種模式會影響程式的總體效能。

那麼使用池化思想是怎麼樣的呢?同樣的過程會轉變成下面的步驟。

初始化:建立 N 個資料庫連線 -> 快取起來
查詢 1:從快取借到資料庫連線 -> 發起查詢 -> 收到響應 -> 歸還資料庫連線物件到快取
查詢 2:從快取借到資料庫連線 -> 發起查詢 -> 收到響應 -> 歸還資料庫連線物件到快取
查詢 3:從快取借到資料庫連線 -> 發起查詢 -> 收到響應 -> 歸還資料庫連線物件到快取

使用池化思想後,資料庫連線並不會頻繁的建立關閉,而是啟動後就初始化了 N 個連線以供後續使用,使用完畢後歸還物件,這樣程式的總體效能得到提升。

3. 物件池的實現

通過上面的例子也可以發現池化思想的幾個關鍵步驟:初始化、借出、歸還。上面沒有展示銷燬步驟, 某些場景下還需要物件的銷燬這一過程,比如釋放連線。

下面我們手動實現一個簡陋的物件池,加深下對物件池的理解。主要是定一個物件池管理類,然後在裡面實現物件的初始化、借出、歸還、銷燬等操作。

package com.wdbyet.tool.objectpool.mypool;

import java.io.Closeable;
import java.io.IOException;
import java.util.HashSet;
import java.util.Stack;

/**
 * @author https://www.wdbyte.com
 */
public class MyObjectPool<T extends Closeable> {

    // 池子大小
    private Integer size = 5;
    // 物件池棧。後進先出
    private Stack<T> stackPool = new Stack<>();
    // 借出的物件的 hashCode 集合
    private HashSet<Integer> borrowHashCodeSet = new HashSet<>();

    /**
     * 增加一個物件
     *
     * @param t
     */
    public synchronized void addObj(T t) {
        if ((stackPool.size() + borrowHashCodeSet.size()) == size) {
            throw new RuntimeException("池中物件已經達到最大值");
        }
        stackPool.add(t);
        System.out.println("新增了物件:" + t.hashCode());
    }

    /**
     * 借出一個物件
     *
     * @return
     */
    public synchronized T borrowObj() {
        if (stackPool.isEmpty()) {
            System.out.println("沒有可以被借出的物件");
            return null;
        }
        T pop = stackPool.pop();
        borrowHashCodeSet.add(pop.hashCode());
        System.out.println("借出了物件:" + pop.hashCode());
        return pop;
    }

    /**
     * 歸還一個物件
     *
     * @param t
     */
    public synchronized void returnObj(T t) {
        if (borrowHashCodeSet.contains(t.hashCode())) {
            stackPool.add(t);
            borrowHashCodeSet.remove(t.hashCode());
            System.out.println("歸還了物件:" + t.hashCode());
            return;
        }
        throw new RuntimeException("只能歸還從池中借出的物件");
    }

    /**
     * 銷燬池中物件
     */
    public synchronized void destory() {
        if (!borrowHashCodeSet.isEmpty()) {
            throw new RuntimeException("尚有未歸還的物件,不能關閉所有物件");
        }
        while (!stackPool.isEmpty()) {
            T pop = stackPool.pop();
            try {
                pop.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        System.out.println("已經銷燬了所有物件");
    }
}

程式碼還是比較簡單的,只是簡單的範例,下面我們通過池化一個 Redis 連線物件 Jedis 來演示如何使用。

其實 Jedis 中已經有對應的 Jedis 池化管理物件了 JedisPool 了,不過我們這裡為了演示物件池的實現,就不使用官方提供的 JedisPool 了。

啟動一個 Redis 服務這裡不做介紹,假設你已經有了一個 Redis 服務,下面引入 Java 中連線 Redis 需要用到的 Maven 依賴。

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.2.0</version>
</dependency>

正常情況下 Jedis 物件的使用方式:

Jedis jedis = new Jedis("localhost", 6379);
String name = jedis.get("name");
System.out.println(name);
jedis.close();

如果使用上面的物件池,就可以像下面這樣使用。

package com.wdbyet.tool.objectpool.mypool;

import redis.clients.jedis.Jedis;

/**
 * @author niulang
 * @date 2022/07/02
 */
public class MyObjectPoolTest {

    public static void main(String[] args) {
        MyObjectPool<Jedis> objectPool = new MyObjectPool<>();
        // 增加一個 jedis 連線物件
        objectPool.addObj(new Jedis("127.0.0.1", 6379));
        objectPool.addObj(new Jedis("127.0.0.1", 6379));
        // 從物件池中借出一個 jedis 物件
        Jedis jedis = objectPool.borrowObj();
        // 一次 redis 查詢
        String name = jedis.get("name");
        System.out.println(String.format("redis get:" + name));
        // 歸還 redis 連線物件
        objectPool.returnObj(jedis);
        // 銷燬物件池中的所有物件
        objectPool.destory();
        // 再次借用物件
        objectPool.borrowObj();
    }
}

輸出紀錄檔:

新增了物件:1556956098
新增了物件:1252585652
借出了物件:1252585652
redis get:www.wdbyte.com
歸還了物件:1252585652
已經銷燬了所有物件
沒有可以被借出的物件

如果使用 JMH 對使用物件池化進行 Redis 查詢,和正常建立 Redis 連線然後查詢關閉連線的方式進行效能對比,會發現兩者的效能差異很大。下面是測試結果,可以發現使用物件池化後的效能是非池化方式的 5 倍左右。

Benchmark                   Mode  Cnt      Score       Error  Units
MyObjectPoolTest.test      thrpt   15   2612.689 ±   358.767  ops/s
MyObjectPoolTest.testPool  thrpt    9  12414.228 ± 11669.484  ops/s

4. 開源的物件池工具

上面自己實現的物件池總歸有些簡陋了,其實開源工具中已經有了非常好用的物件池的實現,如 Apache 的 commons-pool2 工具,很多開源工具中的物件池都是基於此工具實現,下面介紹這個工具的使用方式。

maven 依賴:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.11.1</version>
</dependency>

commons-pool2 物件池工具中有幾個關鍵的類。

  • PooledObjectFactory 類是一個工廠介面,用於實現想要池化物件的建立、驗證、銷燬等操作。
  • GenericObjectPool 類是一個通用的物件池管理類,可以進行物件的借出、歸還等操作。
  • GenericObjectPoolConfig 類是物件池的設定類,可以進行物件的最大、最小等容量資訊進行設定。

下面通過一個具體的範例演示 commons-pool2 工具類的使用,這裡依舊選擇 Redis 連線物件 Jedis 作為演示。

實現 PooledObjectFactory 工廠類,實現其中的物件建立和銷燬方法。

public class MyPooledObjectFactory implements PooledObjectFactory<Jedis> {

    @Override
    public void activateObject(PooledObject<Jedis> pooledObject) throws Exception {

    }

    @Override
    public void destroyObject(PooledObject<Jedis> pooledObject) throws Exception {
        Jedis jedis = pooledObject.getObject();
        jedis.close();
      	System.out.println("釋放連線");
    }

    @Override
    public PooledObject<Jedis> makeObject() throws Exception {
        return new DefaultPooledObject(new Jedis("localhost", 6379));
    }

    @Override
    public void passivateObject(PooledObject<Jedis> pooledObject) throws Exception {
    }

    @Override
    public boolean validateObject(PooledObject<Jedis> pooledObject) {
        return false;
    }
}

繼承 GenericObjectPool 類,實現對物件的借出、歸還等操作。

public class MyGenericObjectPool extends GenericObjectPool<Jedis> {

    public MyGenericObjectPool(PooledObjectFactory factory) {
        super(factory);
    }

    public MyGenericObjectPool(PooledObjectFactory factory, GenericObjectPoolConfig config) {
        super(factory, config);
    }

    public MyGenericObjectPool(PooledObjectFactory factory, GenericObjectPoolConfig config,
        AbandonedConfig abandonedConfig) {
        super(factory, config, abandonedConfig);
    }
}

可以看到 MyGenericObjectPool 類別建構函式中的入參有 GenericObjectPoolConfig 物件,這是個物件池的設定物件,可以設定物件池的容量大小等資訊,這裡就不設定了,使用預設設定。

通過 GenericObjectPoolConfig 的原始碼可以看到預設設定中,物件池的容量是 8 個

public class GenericObjectPoolConfig<T> extends BaseObjectPoolConfig<T> {

    /**
     * The default value for the {@code maxTotal} configuration attribute.
     * @see GenericObjectPool#getMaxTotal()
     */
    public static final int DEFAULT_MAX_TOTAL = 8;

    /**
     * The default value for the {@code maxIdle} configuration attribute.
     * @see GenericObjectPool#getMaxIdle()
     */
    public static final int DEFAULT_MAX_IDLE = 8;

下面編寫一個物件池使用測試類。

public class ApachePool {

    public static void main(String[] args) throws Exception {
        MyGenericObjectPool objectMyObjectPool = new MyGenericObjectPool(new MyPooledObjectFactory());
        Jedis jedis = objectMyObjectPool.borrowObject();
        String name = jedis.get("name");
        System.out.println(name);
        objectMyObjectPool.returnObject(jedis);
        objectMyObjectPool.close();
    }

}

輸出紀錄檔:

redis get:www.wdbyte.com
釋放連線

上面已經演示了 commons-pool2 工具中的物件池的使用方式,從上面的例子中可以發現這種物件池中只能存放同一種初始化條件的物件,如果這裡的 Redis 我們需要儲存一個本地連線和一個遠端連線的兩種 Jedis 物件,就不能滿足了。那麼怎麼辦呢?

其實 commons-pool2 工具已經考慮到了這種情況,通過增加一個 key 值可以在同一個物件池管理中進行區分,程式碼和上面類似,直接貼出完整的程式碼實現。

package com.wdbyet.tool.objectpool.apachekeyedpool;

import org.apache.commons.pool2.BaseKeyedPooledObjectFactory;
import org.apache.commons.pool2.KeyedPooledObjectFactory;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.AbandonedConfig;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.commons.pool2.impl.GenericKeyedObjectPool;
import org.apache.commons.pool2.impl.GenericKeyedObjectPoolConfig;
import redis.clients.jedis.Jedis;

/**
 * @author https://www.wdbyte.com
 * @date 2022/07/07
 */
public class ApacheKeyedPool {

    public static void main(String[] args) throws Exception {
        String key = "local";
        MyGenericKeyedObjectPool objectMyObjectPool = new MyGenericKeyedObjectPool(new MyKeyedPooledObjectFactory());
        Jedis jedis = objectMyObjectPool.borrowObject(key);
        String name = jedis.get("name");
        System.out.println("redis get :" + name);
        objectMyObjectPool.returnObject(key, jedis);
    }
}

class MyKeyedPooledObjectFactory extends BaseKeyedPooledObjectFactory<String, Jedis> {

    @Override
    public Jedis create(String key) throws Exception {
        if ("local".equals(key)) {
            return new Jedis("localhost", 6379);
        }
        if ("remote".equals(key)) {
            return new Jedis("192.168.0.105", 6379);
        }
        return null;
    }

    @Override
    public PooledObject<Jedis> wrap(Jedis value) {
        return new DefaultPooledObject<>(value);
    }
}

class MyGenericKeyedObjectPool extends GenericKeyedObjectPool<String, Jedis> {

    public MyGenericKeyedObjectPool(KeyedPooledObjectFactory<String, Jedis> factory) {
        super(factory);
    }

    public MyGenericKeyedObjectPool(KeyedPooledObjectFactory<String, Jedis> factory,
        GenericKeyedObjectPoolConfig<Jedis> config) {
        super(factory, config);
    }

    public MyGenericKeyedObjectPool(KeyedPooledObjectFactory<String, Jedis> factory,
        GenericKeyedObjectPoolConfig<Jedis> config, AbandonedConfig abandonedConfig) {
        super(factory, config, abandonedConfig);
    }
}

輸出紀錄檔:

redis get :www.wdbyte.com

5. JedisPool 物件池實現分析

這篇文章中的演示都使用了 Jedis 連線物件,其實在 Jedis SDK 中已經實現了相應的物件池,也就是我們常用的 JedisPool 類。那麼這裡的 JedisPool 是怎麼實現的呢?我們先看一下 JedisPool 的使用方式。

package com.wdbyet.tool.objectpool;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

/**
 * @author https://www.wdbyte.com
 */
public class JedisPoolTest {

    public static void main(String[] args) {
        JedisPool jedisPool = new JedisPool("localhost", 6379);
        // 從物件池中借一個物件
        Jedis jedis = jedisPool.getResource();
        String name = jedis.get("name");
        System.out.println("redis get :" + name);
        jedis.close();
        // 徹底退出前,關閉 Redis 連線池
        jedisPool.close();
    }
}

程式碼中新增了註釋,可以看到通過 jedisPool.getResource() 拿到了一個物件,這裡和上面 commons-pool2 工具中的 borrowObject 十分相似,繼續追蹤它的程式碼實現可以看到下面的程式碼。

// redis.clients.jedis.JedisPool
// public class JedisPool extends Pool<Jedis> {
public Jedis getResource() {
    Jedis jedis = (Jedis)super.getResource();
    jedis.setDataSource(this);
    return jedis;
}
// 繼續追蹤 super.getResource()
// redis.clients.jedis.util.Pool
public T getResource() {
    try {
        return super.borrowObject();
    } catch (JedisException var2) {
        throw var2;
    } catch (Exception var3) {
        throw new JedisException("Could not get a resource from the pool", var3);
    }
}

竟然看到了 super.borrowObject() ,多麼熟悉的方法,繼續分析程式碼可以發現 Jedis 物件池也是適用了 commons-pool2 工具作為實現。既然如此,那麼 jedis.close() 方法的邏輯我們應該也可以猜到了,應該有一個歸還的操作,檢視程式碼發現果然如此。

// redis.clients.jedis.JedisPool
// public class JedisPool extends Pool<Jedis> {
public void close() {
    if (this.dataSource != null) {
        Pool<Jedis> pool = this.dataSource;
        this.dataSource = null;
        if (this.isBroken()) {
            pool.returnBrokenResource(this);
        } else {
            pool.returnResource(this);
        }
    } else {
        this.connection.close();
    }
}
// 繼續追蹤 super.getResource()
// redis.clients.jedis.util.Pool
public void returnResource(T resource) {
    if (resource != null) {
        try {
            super.returnObject(resource);
        } catch (RuntimeException var3) {
            throw new JedisException("Could not return the resource to the pool", var3);
        }
    }
}

通過上面的分析,可見 Jedis 確實使用了 commons-pool2 工具進行物件池的管理,通過分析 JedisPool 類的繼承關係圖也可以發現。

6. 物件池總結

通過這篇文章的介紹,可以發現池化思想有幾個明顯的優勢。

  1. 可以顯著的提高應用程式的效能。
  2. 如果一個物件建立成本過高,那麼使用池化非常有效。
  3. 池化提供了一種物件的管理以及重複使用的方式,減少記憶體碎片。
  4. 可以為物件的建立數量提供限制,對某些物件不能建立過多的場景提供保護。

但是使用物件池化也有一些需要注意的地方,比如歸還物件時應確保物件已經被重置為可以重複使用的狀態。同時也要注意,使用池化時要根據具體的場景合理的設定池子的大小,過小達不到想要的效果,過大會造成記憶體浪費。

一如既往,文章中程式碼存放在 Github.com/niumoo/javaNotes.

<完>

文章持續更新,可以微信搜一搜「 程式猿阿朗 」或存取「程式猿阿朗部落格 」第一時間閱讀。本文 Github.com/niumoo/JavaNotes 已經收錄,有很多知識點和系列文章,歡迎Star。