目錄
- 環境:jdk1.8,MySQL 5.5.41,mysql-connector-java-5.1.26.jar,lombok-1.18.6.jar
- 需求:我們常用各種數據庫連接池,如druid、c3p0、dbcp、tomcat-jdbc或是SpringBoot默認使用的hikari等等,但是數據庫連接池的實現原理是怎樣的,我們可以通過自己實現一個簡單的數據庫連接池,來理解它的底層機制。
- 準備工作:(1)建表ticket;(2)封裝一Jdbc工具類MyJdbcConnect,用於獲取和關閉Jdbc連接。
一、準備工作
(1)建表ticket,插入一條測試記錄,如下:
(2)封裝一Jdbc工具類MyJdbcConnect,用於獲取和關閉Jdbc連接。代碼如下:
package com.szh.jdbcpool;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Data
public class MyJdbcConnect {
private static String driverClass = "com.mysql.jdbc.Driver";
private static String url = "jdbc:mysql://127.0.0.1:3306/cjia2?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
private static String username = "root";
private static String password = "root";
static {
try {
Class.forName(driverClass);
} catch (ClassNotFoundException e) {
log.error(e.getMessage());
}
}
private Connection connection;
public MyJdbcConnect() {
try {
connection = DriverManager.getConnection(url, username, password);
} catch (SQLException e) {
log.error(e.getMessage());
}
}
public void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
} catch (SQLException e) {
log.error(e.getMessage());
}
}
}
二、非連接池方式
我們先使用最傳統的方式, 使200個線程同時去獲取Jdbc連接並查詢唯一的一張車票ticket,代碼如下:
package com.szh;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.concurrent.CountDownLatch;
import com.szh.jdbcpool.MyJdbcConnect;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class NonPoolTests {
final static int threadNum = 200;
private final static CountDownLatch COUNT_DOWN_LATCH = new CountDownLatch(threadNum);
public static void main(String[] args) {
for (int i = 0; i < threadNum; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
COUNT_DOWN_LATCH.await();
String sql = "select * from ticket limit 1";
Connection connection = new MyJdbcConnect().getConnection();
ResultSet resultSet = connection.createStatement().executeQuery(sql);
resultSet.next();
log.info("{} 查詢結果:{}", Thread.currentThread().getName(), resultSet.getString("ticket_no"));
} catch (InterruptedException | SQLException e) {
log.error(e.getMessage());
}
}
}).start();
COUNT_DOWN_LATCH.countDown();
}
}
}
運行一下,看看200個線程是否都能成功獲取到Jdbc連接並查詢成功,結果如下:
23:17:10.388 [Thread-64] INFO com.szh.NonPoolTests - Thread-64 查詢結果:G7001
23:17:10.391 [Thread-28] ERROR com.szh.jdbcpool.MyJdbcConnect - Data source rejected establishment of connection, message from server: "Too many connections"
Exception in thread "Thread-154" java.lang.NullPointerException
at com.szh.NonPoolTests$1.run(NonPoolTests.java:27)
at java.lang.Thread.run(Thread.java:745)
...
結果表明,數據源拒絕建立連接,來自服務器的消息:“連接太多”。顯而易見,需要同時建立的Jdbc連接太多,而Jdbc連接的建立又較費資源和時間,所以必須使用數據庫連接池達到Jdbc連接複用,以解決高併發問題。
三、自定義連接池方式
我們常用各種數據庫連接池,所以對它的常用配置屬性比較瞭解,如最大連接數量maxActive、超時等待時間maxWait和最大空閒連接數量maxIdle等等,但是各個配置是如何生效的,底層的運行機制是怎樣?
3.1 自定義連接池
接下來,我們針對這3個配置來實現一個自己的簡單的數據庫連接池MyPool,代碼如下:
package com.szh.jdbcpool;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class MyPool {
int maxActive;
long maxWait;
int maxIdle;
// 數據庫連接池裏的Jdbc連接有2種狀態,一是正在使用中,二是用過以後又被返還的空閒中
LinkedBlockingQueue<MyJdbcConnect> busy;
LinkedBlockingQueue<MyJdbcConnect> idle;
// 目前在池子中已創建的連接數(不能大於最大連接數maxActive)
AtomicInteger createdCount = new AtomicInteger(0);
/**
* 連接池初始化
*
* @param maxActive
* 最大連接數量,連接數連不能超過該值
* @param maxWait
* 超時等待時間以毫秒爲單位 6000毫秒/1000等於60秒,當連接超過該時間便認爲其實空閒連接
* @param maxIdle
* 最大空閒連接,當空閒連接超過該值時就挨個關閉多餘的連接,但不能小於minldle
*/
public void init(int maxActive, long maxWait, int maxIdle) {
this.maxActive = maxActive;
this.maxWait = maxWait;
this.maxIdle = maxIdle;
this.busy = new LinkedBlockingQueue<>();
this.idle = new LinkedBlockingQueue<>();
}
/**
* 從連接池中獲取數據庫連接。忽略poll、offer的結果判斷。
*/
public MyJdbcConnect getResource() throws Exception {
MyJdbcConnect myJdbcConnect = idle.poll();
// 有空閒的可以用
if (myJdbcConnect != null) {
boolean offerResult = busy.offer(myJdbcConnect);
return myJdbcConnect;
}
// 沒有空閒的,看當前已建立的連接數是否已達最大連接數maxActive
if (createdCount.get() < maxActive) {
// 已建立9個,maxActive=10。3個線程同時進來..
if (createdCount.incrementAndGet() <= maxActive) {
myJdbcConnect = new MyJdbcConnect();
boolean offerResult = busy.offer(myJdbcConnect);
return myJdbcConnect;
} else {
createdCount.decrementAndGet();
}
}
// 達到了最大連接數,需等待釋放連接
myJdbcConnect = idle.poll(maxWait, TimeUnit.MILLISECONDS);
if (myJdbcConnect != null) {
boolean offerResult = busy.offer(myJdbcConnect);
return myJdbcConnect;
} else {
throw new Exception("等待超時!");
}
}
/**
* 將數據庫連接返還給連接池。忽略poll、offer的結果判斷。
*/
public void returnResource(MyJdbcConnect jdbcConnect) {
if (jdbcConnect == null) {
return;
}
// 忽略連接狀態的檢查
// jdbcConnect.getConnection().isClosed()
boolean removeResult = busy.remove(jdbcConnect);
if (removeResult) {
// 控制空閒連接的數量
if (maxIdle <= idle.size()) {
jdbcConnect.close();
createdCount.decrementAndGet();
return;
}
boolean offerResult = idle.offer(jdbcConnect);
if (!offerResult) {
jdbcConnect.close();
createdCount.decrementAndGet();
}
} else {
// 無法複用
jdbcConnect.close();
createdCount.decrementAndGet();
}
}
}
3.2 運行測試自定義連接池
對非連接池方式的NonPoolTests稍加改造,初始化連接池,以連接池的方式獲取和返還連接,如下:
package com.szh;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.concurrent.CountDownLatch;
import com.szh.jdbcpool.MyJdbcConnect;
import com.szh.jdbcpool.MyPool;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class PoolTests {
final static int threadNum = 200;
private final static CountDownLatch COUNT_DOWN_LATCH = new CountDownLatch(threadNum);
public static void main(String[] args) {
MyPool pool = new MyPool();
pool.init(20, 2000, 10);
for (int i = 0; i < threadNum; i++) {
new Thread(new Runnable() {
@Override
public void run() {
MyJdbcConnect connect = null;
try {
COUNT_DOWN_LATCH.await();
String sql = "select * from ticket limit 1";
connect = pool.getResource();
Connection connection = connect.getConnection();
ResultSet resultSet = connection.createStatement().executeQuery(sql);
resultSet.next();
log.info("{} 查詢結果:{}", Thread.currentThread().getName(), resultSet.getString("ticket_no"));
} catch (Exception e) {
log.error(e.getMessage());
} finally {
pool.returnResource(connect);
}
}
}).start();
COUNT_DOWN_LATCH.countDown();
}
}
}
運行一下,看看200個線程是否都能成功獲取到Jdbc連接並查詢成功,結果如下:
23:52:24.044 [Thread-6] INFO com.szh.PoolTests - Thread-6 查詢結果:G7001
...
23:52:24.249 [Thread-198] INFO com.szh.PoolTests - Thread-198 查詢結果:G7001
結果表明,200個線程均成功從連接池中獲取到連接併成功查詢,可以有效複用Jdbc連接,減輕服務器和數據庫的資源壓力。當然,不同的連接池使用的思路都有不同,本文只是一種實現方案。
3.3 技術總結答疑
(1)在手寫Jdbc連接池的過程中,使用到了哪些關鍵技術和設計模式?
答:關鍵技術(Jdbc、多線程Runnable、阻塞隊列BlockingQueue、原子操作類AtomicInteger、計數器CountDownLatch);設計模式(享元模式)。
(2)爲何使用隊列Queue而不用別的數據結構或集合類?
答:首先說明爲什麼使用隊列Queue,作爲實現各種消息中間件的底層數據結構,它具有這兩個特性,①線程安全,②先進先出剛好滿足Jdbc連接的時效性。以下是隊列的API:
(3)Queue的實現那麼多,爲何使用阻塞隊列BlockingQueue而不用非阻塞隊列ConcurrentLinkedQueue等?
答:這便牽扯到阻塞非阻塞隊列的區別了,阻塞隊列在offer的時候,若隊列已經滿了,則阻塞住(加鎖)一段時間等待有空閒位置;同理,阻塞隊列在poll的時候,若隊列爲空,也阻塞住(加鎖)一段時間等待隊列有元素;阻塞隊列的這個特性剛好可以滿足連接池的maxWait屬性的需求,因爲,從數據庫連接池的設計來看,當發生上面兩種情景時,我們應該先等待連接池一段時間以更大的概率來獲得和複用寶貴的Jdbc連接(更多的複用正是各種池或享元模式的核心),而不能沒有一點耐心地直接要求連接池返回給我們一個成功與否的結果,而這正是非阻塞隊列的特性。從源碼分析,也不難看出,阻塞隊列的offer/poll函數可接收timeout的阻塞等待時長,內部使用ReentrantLock和Condition的加鎖機制達到隊列阻塞效果;反觀非阻塞隊列的offer/poll函數實現,則沒有這樣的阻塞處理。
(4)爲何用LinkedBlockingQueue而不用ArrayBlockingQueue?
答:這便牽扯到鏈表和數組的區別了,鏈表便於節點增刪,數組便於查找。而對數據庫連接池的設計來說,不存在要根據索引查找(RandomAccess隨機查找)隊列裏的某個中間位置的元素,更多的是隊列頭部元素(即最早進來的Jdbc連接對象)的offer/poll操作。
(5)爲何用Queue而不用Deque?二者有何區別和關聯?
答:Queue很常見,而Deque則相對少一些,Deque即Double ended Queue,用作雙端隊列的場景,它是Queue的子接口。顯然,從Jdbc連接對象的時效性來看,只能先進先出,最後進的沒有理由也要求先出,所以Deque不適用。
(6)最大等待時間maxWait的實現核心poll(long timeout, TimeUnit unit)內部如何實現自動通知?
答:依賴於ReentrantLock和Condition的加鎖機制。可翻閱jdk源碼LinkedBlockingQueue<E>.poll(long timeout, TimeUnit unit)。
(7)AtomicInteger爲何能實現線程安全?
答:歸功於CAS機制(compareAndSet)。如果有多個線程要對內存中的數值10進行自增操作,那麼,每個線程都會去內存中獲取並記錄下原本的數值10,然後再記錄下自增以後的期望值11,等到真正要做自增操作時,會先比較內存中變量的最新值和自己記錄過的原本的數值10,若相等,則這個線程可以對該變量進行原子自增;若不相等,代表該數值10已被別的線程自增過,則需要再次記錄最新的修改後的數值(假設)11,同樣再記錄下自增以後的期望值12。