池化技術在後端開發中應用非常廣泛,有數據庫連接池,線程池,對象池,常量池等。池化技術的出現是爲了提高性能。實際就是對一些使用率較高,且創建銷燬比較耗時的資源進行緩存,避免重複地創建和銷燬,做到資源回收利用。
傳統的數據庫連接池有DBCP,C3P0,目前新一代的連接池Druid、HikariaCP等較傳統的數據庫連接池在性能上有很大的提升。本文將手寫一個簡單功能的數據庫連接池,以理解數據庫連接池的基本原理和使用。
一、原生數據庫訪問方式所帶來的問題
一般來說,java應用訪問數據庫有以下幾步操作:
-
裝載數據庫驅動,
Class.forName(JDBC_DRIVER);
-
通過jdbc建立數據庫連接,
DriverManager.getConnection(JDBC_URL, JDBC_USERNAME, JDBC_PASSWORD)
-
訪問數據庫,執行sql語句
String sql = "select email from user where id = 5"; PreparedStatement statement = conn.prepareStatement(sql); ResultSet result = statement.executeQuery(); while (result.next()){ String email = result.getString(1); System.out.println("【email】:"+email); }
-
斷開數據庫連接
result.close(); state.close(); conn.close();
每一個請求過來都要建立數據庫連接,連接數據庫需要建立網絡連接、系統要分配內存資源、使用完必須斷開連接,這些過程可能比實際進行的數據庫操作耗時好多倍。並且在高併發量的請求情況下,應用響應速度就會非常慢,甚至造成服務器崩潰。
由此可見,數據庫連接是一種非常昂貴的資源。
二、數據庫連接池技術
爲解決上述問題,可以採用數據庫連接池技術。數據庫連接池的基本思想就是爲數據庫連接建立一個“緩衝池”。初始化時在緩衝池中放入指定數量的連接,當需要建立數據庫連接時,只需從“緩衝池”中取出一個,使用完畢之後再放回去。我們可以通過設定各種參數來控制初始連接數量、最大連接數量、最大空閒連接數量、等待時間等,還可以通過連接池的管理機制來監視數據庫連接的數量和使用情況,方便開發測試和性能調優。
有了這些需求下面來實現一個簡單的數據庫連接池,實現連接池初始化方法、獲取連接方法、回收連接方法、銷燬池方法,以及幾個參數的可配置。
1、工具類:MysqlConnectionUtils
首先將jdbc層面的代碼封裝成工具類,提供獲取連接,關閉連接的方法,以Mysql爲例,這部分代碼不是重點,所以配置信息也直接寫到類裏面了:
public class MysqlConnectionUtils {
public static final String JDBC_DRIVER = "com.mysql.jdbc.Driver";
public static final String JDBC_URL = "jdbc:mysql://localhost:3306/usercase?useUnicode=true&characterEncoding=utf-8";
public static final String JDBC_USERNAME = "root";
public static final String JDBC_PASSWORD = "123456";
public static Connection getConnection() {
Connection connection=null;
try {
Class.forName(JDBC_DRIVER);
connection = DriverManager.getConnection(JDBC_URL, JDBC_USERNAME, JDBC_PASSWORD);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
return connection;
}
public static void closeConnection(Connection conn){
if (conn == null) {return;}
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
2、定義接口:ConnectionPool
定義接口:
public interface ConnectionPool {
/*初始化*/
void init(int initSize,int maxSize,int idleCount,long waitTime);
/*獲取連接*/
Connection getConnection() throws TimeoutException;
/*回收*/
void recycle(Connection conn);
/*銷燬*/
void destory();
}
3、實現類:MyConnectionPool
屬性
這個就是我們要實現的線程池了,首先看該類的一些屬性:
public class MyConnectionPool implements ConnectionPool {
//初始化連接數量
int initSize;
//最大連接數量
int maxSize;
//最大空閒連接數量
int idleCount;
//最大等待時間
long waitTime;
//活躍連接數
AtomicInteger activeSize;
//空閒隊列
BlockingQueue<Connection> idle;
//已使用隊列
BlockingQueue<Connection> busy;
這裏定義了兩個隊列,一個存放空閒連接,一個存放正在使用的連接。活躍連接數就是連接池中未關閉的連接的總數,也就是兩個隊列的連接總數。
取出連接和回收連接方法模型圖:
構造器
通過構造方法完成初始化:
public MyConnectionPool(int initSize, int maxSize, int idleCount, long waitTime) {
init(initSize ,maxSize,idleCount,waitTime);
}
public MyConnectionPool() {}
@Override
public void init(int initSize, int maxSize, int idleCount, long waitTime) {
this.initSize = initSize;
this.maxSize = maxSize;
this.idleCount = idleCount;
this.waitTime = waitTime;
this.activeSize = new AtomicInteger();
this.idle = new LinkedBlockingQueue<Connection>();
this.busy = new LinkedBlockingQueue<Connection>();
//初始化連接
initConnection(initSize);
}
private void initConnection(int initSize) {
for (int i = 0; i <initSize ; i++) {
if (activeSize.get()<maxSize){//兩重判斷是爲了保證線程安全
if (activeSize.incrementAndGet()<=maxSize){
//創建連接
Connection conn = MysqlConnectionUtils.getConnection();
//加入空閒隊列
idle.offer(conn);
}else{
activeSize.decrementAndGet();
}
}
}
}
構造線程池時,輸入四個初始化參數,分別是初始化連接數、最大連接數、最大空閒連接數和超時時間。在構造方法中初始化兩個核心隊列,使用LinkedBlockingQueue有序阻塞隊列。並在構造方法中創建初始化連接數數量的連接,放入空閒隊列,注意每成功創建一個連接,活躍連接數activeSize加1。
這裏activeSize使用原子類AtomicInteger,是考慮多線程的情況下線程安全問題,防止創建的線程數超出。
初始化方法兩重if判斷中進行原子操作,可形成鎖,保證線程安全。
接下來實現從連接池中獲取連接的方法:
獲取連接方法:getConnection()
@Override
public Connection getConnection() throws TimeoutException {
long startTime = System.currentTimeMillis();
//1.從空閒隊列裏獲取,return
Connection conn = idle.poll();
if (conn != null) {
busy.offer(conn);
System.out.println("["+Thread.currentThread().getName()+"]"+" ************從空閒隊列獲取連接************");
return conn;
}
//2.空閒隊列沒有連接了,並且活躍連接數量不超過最大連接數量,創建一個新的連接return
if (activeSize.get()<maxSize){//兩重判斷是爲了保證線程安全
if (activeSize.incrementAndGet()<=maxSize){
conn = MysqlConnectionUtils.getConnection();
System.out.println("["+Thread.currentThread().getName()+"]"+" ************創建一個新的連接************");
busy.offer(conn);
return conn;
}else {
activeSize.decrementAndGet();
}
}
//3.連接全部正在使用,並且活躍連接數超過最大連接數量,則等待正在使用的連接被回收到空閒隊列。
//waitTime代表getConnection()方法的等待時間,poll方法阻塞時間其中一部分
long timeout = waitTime - (System.currentTimeMillis()-startTime);
try {
//阻塞方法,等待從idle隊列獲取連接
conn = idle.poll(timeout, TimeUnit.MILLISECONDS);
if (conn == null) {
throw new TimeoutException("["+Thread.currentThread().getName()+"]"+" ************獲取連接超時,等待時間爲"+timeout+"ms************");
}
busy.offer(conn);
} catch (InterruptedException e) {
e.printStackTrace();
}
return conn;
}
以上代碼核心就是兩個隊列彈出元素和加入元素的過程。然後插入了一些打印信息,方便測試的時候查看。
獲取連接實際上有三種可能的方式以及一種超時拋異常的情況:
- 空閒隊列中有可用連接,直接從空閒隊列彈出
- 空閒隊列沒有連接了,並且活躍連接數沒有超過配置的最大連接數,此時自己創建連接給到調用者,注意每成功創建一個連接,活躍連接數activeSize加1。(從這裏可以看出,如果最大空閒連接數設置得過小,最大連接數遠大於最大空閒連接數的話,線程池會大量創建連接,性能就會比較差。)
- 空閒隊列沒有連接,並且活躍連接數達到配置的最大連接數,此時不能在創建連接,只能阻塞等待,當超時時間內有連接被回收進空閒隊列,阻塞結束,獲得連接。
- 當達到超時時間沒有連接被回收,拋出超時異常。(這裏超時時間的配置應當根據多數線程佔用連接的時間決定)
然後看另一個關鍵的方法,回收方法:
回收方法:recycle()
@Override
public void recycle(Connection conn) {
if (conn == null) { return;}
//回收連接,就是將連接從使用隊列移動到空閒隊列
if (busy.remove(conn)){//第一步,移除
//如果實際的空閒連接數量大於用戶指定的最大空閒連接數量,則連接多餘了,關閉連接
if (idle.size()>=idleCount){
MysqlConnectionUtils.closeConnection(conn);
//關閉連接,則活躍連接減一
activeSize.decrementAndGet();
System.out.println("["+Thread.currentThread().getName()+"]"+" ++++空閒連接數量太多,關閉連接++++");
return;
}
if (!idle.offer(conn)){//第二步,放入,如果放入空閒隊列失敗,關閉連接
MysqlConnectionUtils.closeConnection(conn);
activeSize.decrementAndGet();
}
System.out.println("["+Thread.currentThread().getName()+"]"+" ++++回收連接成功++++");
}else{//從busy隊列移除失敗,可能因爲該連接不在busy隊列中
MysqlConnectionUtils.closeConnection(conn);
activeSize.decrementAndGet();
System.out.println("["+Thread.currentThread().getName()+"]"+" ++++未知連接,直接關閉,不予回收++++");
}
}
回收方法實際就是兩步,
- 將指定的連接從busy隊列中彈出,如果該連接不是busy隊列中的連接,也就是彈出失敗,就直接關閉它。
- 檢查空閒隊列內的連接數,如果達到配置的最大空閒連接數,則直接關閉連接。如果沒有達到,就將該連接放入空閒隊列。
注意每關閉一個連接,活躍連接數activeSize減1。
連接池銷燬方法:destroy()
@Override
public void destory() {
while (busy.size()!=0){
recycle(busy.poll());
}
while(idle.size()!=0){
MysqlConnectionUtils.closeConnection(idle.poll());
activeSize.decrementAndGet();
}
}
將連接全部回收,然後關閉所有控線連接。
至此,一個簡單的數據庫連接池實現完畢。核心方法就是獲取連接方法和回收方法。下面通過設置不同的參數來測試一下功能好不好使,以及分析這些參數應當根據那些因素來配置。
三、測試連接池
測試類代碼如下:
public class TestMain {
private static int threadNum = 50;
public static void main(String[] args) {
String sql = "select email from user where id = 5";
CountDownLatch cdl = new CountDownLatch(1);
ConnectionPool pool = new MyConnectionPool(10,20,15,20000L);
for (int i = 0; i <threadNum ; i++) {
new Thread(() ->{
Connection conn = null;
try {
cdl.await(); //阻塞在這,等待所有線程準備好,發令槍響,一起執行
conn = pool.getConnection();
PreparedStatement statement = conn.prepareStatement(sql);
ResultSet result = statement.executeQuery();
while (result.next()){
String email = result.getString(1);
System.out.println("【email】:"+email);
}
//Thread.currentThread().sleep(1000L*new Random().nextInt(5));//模擬線程佔用連接的時間
} catch (TimeoutException | InterruptedException | SQLException e) {
e.printStackTrace();
} finally {
pool.recycle(conn);
}
}).start();
}
cdl.countDown(); //發令槍,喚醒所有線程
sleep(3000);//等待所有線程執行完畢
System.out.println("已建立連接數:"+((MyConnectionPool) pool).activeSize.get()+
",空閒連接數:"+((MyConnectionPool) pool).idle.size()+
",正在使用連接數:"+((MyConnectionPool) pool).busy.size());
pool.destory();
System.out.println("detroy MyConnectionPool!!");
System.out.println("已建立連接數:"+((MyConnectionPool) pool).activeSize.get()+
",空閒連接數:"+((MyConnectionPool) pool).idle.size()+
",正在使用連接數:"+((MyConnectionPool) pool).busy.size());
}
public static void sleep(long time){
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
首先設置50個線程併發訪問連接池,初始化連接數10、最大連接數20、最大空閒連接數15、超時時間20秒,
ConnectionPool pool = new MyConnectionPool(10,20,15,20000L);
測試結果:
[Thread-1] 從空閒隊列獲取連接
…
[Thread-17] 創建一個新的連接
…【email】:[email protected]
[Thread-20] ++++回收連接成功++++
…
[Thread-23] ++++空閒連接數量太多,關閉連接++++
…
已建立連接數:15,空閒連接數:15,正在使用連接數:0detroy MyConnectionPool!!
已建立連接數:0,空閒連接數:0,正在使用連接數:0
由於篇幅原因,打印信息省略一部分。
本次測試得到以下信息:
- 由於設置的超時時間是20秒,遠大於各線程佔用時間,所以沒有出現超時異常的情況。
- 初始化了10個連接,“創建一個連接”輸出了10行,“空閒連接數量太多,關閉連接”輸出了5行,所有線程執行完畢後,已建立連接數:15,空閒連接數:15,正在使用連接數:0。說明沒有連接泄露。
- 最大空閒連接數實際就是併發量夠大時的連接的緩存容量,
更改參數再次測試,設置超時時間爲3秒,模擬線程隨機1到5秒的佔用連接時間,主線程等待10秒:
ConnectionPool pool = new MyConnectionPool(10,20,15,3000L);
Thread.currentThread().sleep(1000L*new Random().nextInt(5));//模擬線程佔用連接的時間
sleep(10000);
[Thread-1] 從空閒隊列獲取連接
…
[Thread-17] 創建一個新的連接
…【email】:[email protected]
java.util.concurrent.TimeoutException: [Thread-38] 獲取連接超時,等待時間爲3000ms
at com.youzi.test.concurrent.connPool.MyConnectionPool.getConnection(MyConnectionPool.java:100)
at com.youzi.test.concurrent.connPool.TestMain.lambda$main$0(TestMain.java:23)
at java.lang.Thread.run(Thread.java:745)[Thread-20] ++++回收連接成功++++
…
[Thread-23] ++++空閒連接數量太多,關閉連接++++
…
已建立連接數:15,空閒連接數:15,正在使用連接數:0detroy MyConnectionPool!!
已建立連接數:0,空閒連接數:0,正在使用連接數:0
可以看到由於線程佔用連接時間爲隨機1到5秒,而設置超時時間是3秒,在被佔用連接數量達到最大連接數20時,某線程3秒內沒有獲取到連接,就會拋出超時異常。不影響其他線程。
線程池連接數量始終符合根據配置該有的數量。沒有超出配置,也沒有連接泄露。
手寫連接池測試完畢。