關(guān)于Http持久連接和HttpClient連接池的深入理解
一、背景
HTTP協(xié)議是無狀態(tài)的協(xié)議,即每一次請求都是互相獨(dú)立的。因此它的最初實(shí)現(xiàn)是,每一個(gè)http請求都會打開一個(gè)tcp socket連接,當(dāng)交互完畢后會關(guān)閉這個(gè)連接。
HTTP協(xié)議是全雙工的協(xié)議,所以建立連接與斷開連接是要經(jīng)過三次握手與四次揮手的。顯然在這種設(shè)計(jì)中,每次發(fā)送Http請求都會消耗很多的額外資源,即連接的建立與銷毀。
于是,HTTP協(xié)議的也進(jìn)行了發(fā)展,通過持久連接的方法來進(jìn)行socket連接復(fù)用。

從圖中可以看到:
- 在串行連接中,每次交互都要打開關(guān)閉連接
- 在持久連接中,第一次交互會打開連接,交互結(jié)束后連接并不關(guān)閉,下次交互就省去了建立連接的過程。
持久連接的實(shí)現(xiàn)有兩種:HTTP/1.0+的keep-alive與HTTP/1.1的持久連接。
二、HTTP/1.0+的Keep-Alive
從1996年開始,很多HTTP/1.0瀏覽器與服務(wù)器都對協(xié)議進(jìn)行了擴(kuò)展,那就是“keep-alive”擴(kuò)展協(xié)議。
注意,這個(gè)擴(kuò)展協(xié)議是作為1.0的補(bǔ)充的“實(shí)驗(yàn)型持久連接”出現(xiàn)的。keep-alive已經(jīng)不再使用了,最新的HTTP/1.1規(guī)范中也沒有對它進(jìn)行說明,只是很多應(yīng)用延續(xù)了下來。
使用HTTP/1.0的客戶端在首部中加上"Connection:Keep-Alive",請求服務(wù)端將一條連接保持在打開狀態(tài)。服務(wù)端如果愿意將這條連接保持在打開狀態(tài),就會在響應(yīng)中包含同樣的首部。如果響應(yīng)中沒有包含"Connection:Keep-Alive"首部,則客戶端會認(rèn)為服務(wù)端不支持keep-alive,會在發(fā)送完響應(yīng)報(bào)文之后關(guān)閉掉當(dāng)前連接。

通過keep-alive補(bǔ)充協(xié)議,客戶端與服務(wù)器之間完成了持久連接,然而仍然存在著一些問題:
- 在HTTP/1.0中keep-alive不是標(biāo)準(zhǔn)協(xié)議,客戶端必須發(fā)送Connection:Keep-Alive來激活keep-alive連接。
- 代理服務(wù)器可能無法支持keep-alive,因?yàn)橐恍┐硎?盲中繼",無法理解首部的含義,只是將首部逐跳轉(zhuǎn)發(fā)。所以可能造成客戶端與服務(wù)端都保持了連接,但是代理不接受該連接上的數(shù)據(jù)。
三、HTTP/1.1的持久連接
HTTP/1.1采取持久連接的方式替代了Keep-Alive。
HTTP/1.1的連接默認(rèn)情況下都是持久連接。如果要顯式關(guān)閉,需要在報(bào)文中加上Connection:Close首部。即在HTTP/1.1中,所有的連接都進(jìn)行了復(fù)用。
然而如同Keep-Alive一樣,空閑的持久連接也可以隨時(shí)被客戶端與服務(wù)端關(guān)閉。不發(fā)送Connection:Close不意味著服務(wù)器承諾連接永遠(yuǎn)保持打開。
四、HttpClient如何生成持久連接
HttpClien中使用了連接池來管理持有連接,同一條TCP鏈路上,連接是可以復(fù)用的。HttpClient通過連接池的方式進(jìn)行連接持久化。
其實(shí)“池”技術(shù)是一種通用的設(shè)計(jì),其設(shè)計(jì)思想并不復(fù)雜:
- 當(dāng)有連接第一次使用的時(shí)候建立連接
- 結(jié)束時(shí)對應(yīng)連接不關(guān)閉,歸還到池中
- 下次同個(gè)目的的連接可從池中獲取一個(gè)可用連接
- 定期清理過期連接
所有的連接池都是這個(gè)思路,不過我們看HttpClient源碼主要關(guān)注兩點(diǎn):
- 連接池的具體設(shè)計(jì)方案,以供以后自定義連接池參考
- 如何與HTTP協(xié)議對應(yīng)上,即理論抽象轉(zhuǎn)為代碼的實(shí)現(xiàn)
4.1 HttpClient連接池的實(shí)現(xiàn)
HttpClient關(guān)于持久連接的處理在下面的代碼中可以集中體現(xiàn),下面從MainClientExec摘取了和連接池相關(guān)的部分,去掉了其他部分:
public class MainClientExec implements ClientExecChain {
@Override
public CloseableHttpResponse execute(
final HttpRoute route,
final HttpRequestWrapper request,
final HttpClientContext context,
final HttpExecutionAware execAware) throws IOException, HttpException {
//從連接管理器HttpClientConnectionManager中獲取一個(gè)連接請求ConnectionRequest
final ConnectionRequest connRequest = connManager.requestConnection(route, userToken);final HttpClientConnection managedConn;
final int timeout = config.getConnectionRequestTimeout(); //從連接請求ConnectionRequest中獲取一個(gè)被管理的連接HttpClientConnection
managedConn = connRequest.get(timeout > 0 ? timeout : 0, TimeUnit.MILLISECONDS);
//將連接管理器HttpClientConnectionManager與被管理的連接HttpClientConnection交給一個(gè)ConnectionHolder持有
final ConnectionHolder connHolder = new ConnectionHolder(this.log, this.connManager, managedConn);
try {
HttpResponse response;
if (!managedConn.isOpen()) { //如果當(dāng)前被管理的連接不是出于打開狀態(tài),需要重新建立連接
establishRoute(proxyAuthState, managedConn, route, request, context);
}
//通過連接HttpClientConnection發(fā)送請求
response = requestExecutor.execute(request, managedConn, context);
//通過連接重用策略判斷是否連接可重用
if (reuseStrategy.keepAlive(response, context)) {
//獲得連接有效期
final long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
//設(shè)置連接有效期
connHolder.setValidFor(duration, TimeUnit.MILLISECONDS); //將當(dāng)前連接標(biāo)記為可重用狀態(tài)
connHolder.markReusable();
} else {
connHolder.markNonReusable();
}
}
final HttpEntity entity = response.getEntity();
if (entity == null || !entity.isStreaming()) {
//將當(dāng)前連接釋放到池中,供下次調(diào)用
connHolder.releaseConnection();
return new HttpResponseProxy(response, null);
} else {
return new HttpResponseProxy(response, connHolder);
}
}
這里看到了在Http請求過程中對連接的處理是和協(xié)議規(guī)范是一致的,這里要展開講一下具體實(shí)現(xiàn)。
PoolingHttpClientConnectionManager是HttpClient默認(rèn)的連接管理器,首先通過requestConnection()獲得一個(gè)連接的請求,注意這里不是連接。
public ConnectionRequest requestConnection(
final HttpRoute route,
final Object state) {final Future<CPoolEntry> future = this.pool.lease(route, state, null);
return new ConnectionRequest() {
@Override
public boolean cancel() {
return future.cancel(true);
}
@Override
public HttpClientConnection get(
final long timeout,
final TimeUnit tunit) throws InterruptedException, ExecutionException, ConnectionPoolTimeoutException {
final HttpClientConnection conn = leaseConnection(future, timeout, tunit);
if (conn.isOpen()) {
final HttpHost host;
if (route.getProxyHost() != null) {
host = route.getProxyHost();
} else {
host = route.getTargetHost();
}
final SocketConfig socketConfig = resolveSocketConfig(host);
conn.setSocketTimeout(socketConfig.getSoTimeout());
}
return conn;
}
};
}
可以看到返回的ConnectionRequest對象實(shí)際上是一個(gè)持有了Future<CPoolEntry>,CPoolEntry是被連接池管理的真正連接實(shí)例。
從上面的代碼我們應(yīng)該關(guān)注的是:
Future<CPoolEntry> future = this.pool.lease(route, state, null)
如何從連接池CPool中獲得一個(gè)異步的連接,F(xiàn)uture<CPoolEntry>
HttpClientConnection conn = leaseConnection(future, timeout, tunit)
如何通過異步連接Future<CPoolEntry>獲得一個(gè)真正的連接HttpClientConnection
4.2 Future<CPoolEntry>
看一下CPool是如何釋放一個(gè)Future<CPoolEntry>的,AbstractConnPool核心代碼如下:
private E getPoolEntryBlocking(
final T route, final Object state,
final long timeout, final TimeUnit tunit,
final Future<E> future) throws IOException, InterruptedException, TimeoutException {
//首先對當(dāng)前連接池加鎖,當(dāng)前鎖是可重入鎖ReentrantLockthis.lock.lock();
try { //獲得一個(gè)當(dāng)前HttpRoute對應(yīng)的連接池,對于HttpClient的連接池而言,總池有個(gè)大小,每個(gè)route對應(yīng)的連接也是個(gè)池,所以是“池中池”
final RouteSpecificPool<T, C, E> pool = getPool(route);
E entry;
for (;;) {
Asserts.check(!this.isShutDown, "Connection pool shut down"); //死循環(huán)獲得連接
for (;;) { //從route對應(yīng)的池中拿連接,可能是null,也可能是有效連接
entry = pool.getFree(state); //如果拿到null,就退出循環(huán)
if (entry == null) {
break;
} //如果拿到過期連接或者已關(guān)閉連接,就釋放資源,繼續(xù)循環(huán)獲取
if (entry.isExpired(System.currentTimeMillis())) {
entry.close();
}
if (entry.isClosed()) {
this.available.remove(entry);
pool.free(entry, false);
} else { //如果拿到有效連接就退出循環(huán)
break;
}
} //拿到有效連接就退出
if (entry != null) {
this.available.remove(entry);
this.leased.add(entry);
onReuse(entry);
return entry;
}
//到這里證明沒有拿到有效連接,需要自己生成一個(gè)
final int maxPerRoute = getMax(route);
//每個(gè)route對應(yīng)的連接最大數(shù)量是可配置的,如果超過了,就需要通過LRU清理掉一些連接
final int excess = Math.max(0, pool.getAllocatedCount() + 1 - maxPerRoute);
if (excess > 0) {
for (int i = 0; i < excess; i++) {
final E lastUsed = pool.getLastUsed();
if (lastUsed == null) {
break;
}
lastUsed.close();
this.available.remove(lastUsed);
pool.remove(lastUsed);
}
}
//當(dāng)前route池中的連接數(shù),沒有達(dá)到上線
if (pool.getAllocatedCount() < maxPerRoute) {
final int totalUsed = this.leased.size();
final int freeCapacity = Math.max(this.maxTotal - totalUsed, 0); //判斷連接池是否超過上線,如果超過了,需要通過LRU清理掉一些連接
if (freeCapacity > 0) {
final int totalAvailable = this.available.size(); //如果空閑連接數(shù)已經(jīng)大于剩余可用空間,則需要清理下空閑連接
if (totalAvailable > freeCapacity - 1) {
if (!this.available.isEmpty()) {
final E lastUsed = this.available.removeLast();
lastUsed.close();
final RouteSpecificPool<T, C, E> otherpool = getPool(lastUsed.getRoute());
otherpool.remove(lastUsed);
}
} //根據(jù)route建立一個(gè)連接
final C conn = this.connFactory.create(route); //將這個(gè)連接放入route對應(yīng)的“小池”中
entry = pool.add(conn); //將這個(gè)連接放入“大池”中
this.leased.add(entry);
return entry;
}
}
//到這里證明沒有從獲得route池中獲得有效連接,并且想要自己建立連接時(shí)當(dāng)前route連接池已經(jīng)到達(dá)最大值,即已經(jīng)有連接在使用,但是對當(dāng)前線程不可用
boolean success = false;
try {
if (future.isCancelled()) {
throw new InterruptedException("Operation interrupted");
} //將future放入route池中等待
pool.queue(future); //將future放入大連接池中等待
this.pending.add(future); //如果等待到了信號量的通知,success為true
if (deadline != null) {
success = this.condition.awaitUntil(deadline);
} else {
this.condition.await();
success = true;
}
if (future.isCancelled()) {
throw new InterruptedException("Operation interrupted");
}
} finally {
//從等待隊(duì)列中移除
pool.unqueue(future);
this.pending.remove(future);
}
//如果沒有等到信號量通知并且當(dāng)前時(shí)間已經(jīng)超時(shí),則退出循環(huán)
if (!success && (deadline != null && deadline.getTime() <= System.currentTimeMillis())) {
break;
}
} //最終也沒有等到信號量通知,沒有拿到可用連接,則拋異常
throw new TimeoutException("Timeout waiting for connection");
} finally { //釋放對大連接池的鎖
this.lock.unlock();
}
}
上面的代碼邏輯有幾個(gè)重要點(diǎn):
- 連接池有個(gè)最大連接數(shù),每個(gè)route對應(yīng)一個(gè)小連接池,也有個(gè)最大連接數(shù)
- 不論是大連接池還是小連接池,當(dāng)超過數(shù)量的時(shí)候,都要通過LRU釋放一些連接
- 如果拿到了可用連接,則返回給上層使用
- 如果沒有拿到可用連接,HttpClient會判斷當(dāng)前route連接池是否已經(jīng)超過了最大數(shù)量,沒有到上限就會新建一個(gè)連接,并放入池中
- 如果到達(dá)了上限,就排隊(duì)等待,等到了信號量,就重新獲得一次,等待不到就拋超時(shí)異常
- 通過線程池獲取連接要通過ReetrantLock加鎖,保證線程安全
到這里為止,程序已經(jīng)拿到了一個(gè)可用的CPoolEntry實(shí)例,或者拋異常終止了程序。
4.3 HttpClientConnection
protected HttpClientConnection leaseConnection(
final Future<CPoolEntry> future,
final long timeout,
final TimeUnit tunit) throws InterruptedException, ExecutionException, ConnectionPoolTimeoutException {
final CPoolEntry entry;
try { //從異步操作Future<CPoolEntry>中獲得CPoolEntry
entry = future.get(timeout, tunit);
if (entry == null || future.isCancelled()) {
throw new InterruptedException();
}
Asserts.check(entry.getConnection() != null, "Pool entry with no connection");
if (this.log.isDebugEnabled()) {
this.log.debug("Connection leased: " + format(entry) + formatStats(entry.getRoute()));
} //獲得一個(gè)CPoolEntry的代理對象,對其操作都是使用同一個(gè)底層的HttpClientConnection
return CPoolProxy.newProxy(entry);
} catch (final TimeoutException ex) {
throw new ConnectionPoolTimeoutException("Timeout waiting for connection from pool");
}
}
五、HttpClient如何復(fù)用持久連接?
在上一章中,我們看到了HttpClient通過連接池來獲得連接,當(dāng)需要使用連接的時(shí)候從池中獲得。
對應(yīng)著第三章的問題:
- 當(dāng)有連接第一次使用的時(shí)候建立連接
- 結(jié)束時(shí)對應(yīng)連接不關(guān)閉,歸還到池中
- 下次同個(gè)目的的連接可從池中獲取一個(gè)可用連接
- 定期清理過期連接
我們在第四章中看到了HttpClient是如何處理1、3的問題的,那么第2個(gè)問題是怎么處理的呢?
即HttpClient如何判斷一個(gè)連接在使用完畢后是要關(guān)閉,還是要放入池中供他人復(fù)用?再看一下MainClientExec的代碼
//發(fā)送Http連接 response = requestExecutor.execute(request, managedConn, context);
//根據(jù)重用策略判斷當(dāng)前連接是否要復(fù)用
if (reuseStrategy.keepAlive(response, context)) {
//需要復(fù)用的連接,獲取連接超時(shí)時(shí)間,以response中的timeout為準(zhǔn)
final long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
if (this.log.isDebugEnabled()) {
final String s; //timeout的是毫秒數(shù),如果沒有設(shè)置則為-1,即沒有超時(shí)時(shí)間
if (duration > 0) {
s = "for " + duration + " " + TimeUnit.MILLISECONDS;
} else {
s = "indefinitely";
}
this.log.debug("Connection can be kept alive " + s);
} //設(shè)置超時(shí)時(shí)間,當(dāng)請求結(jié)束時(shí)連接管理器會根據(jù)超時(shí)時(shí)間決定是關(guān)閉還是放回到池中
connHolder.setValidFor(duration, TimeUnit.MILLISECONDS);
//將連接標(biāo)記為可重用 connHolder.markReusable();
} else { //將連接標(biāo)記為不可重用
connHolder.markNonReusable();
}
可以看到,當(dāng)使用連接發(fā)生過請求之后,有連接重試策略來決定該連接是否要重用,如果要重用就會在結(jié)束后交給HttpClientConnectionManager放入池中。
那么連接復(fù)用策略的邏輯是怎么樣的呢?
public class DefaultClientConnectionReuseStrategy extends DefaultConnectionReuseStrategy {
public static final DefaultClientConnectionReuseStrategy INSTANCE = new DefaultClientConnectionReuseStrategy();
@Override
public boolean keepAlive(final HttpResponse response, final HttpContext context) {
//從上下文中拿到request
final HttpRequest request = (HttpRequest) context.getAttribute(HttpCoreContext.HTTP_REQUEST);
if (request != null) { //獲得Connection的Header
final Header[] connHeaders = request.getHeaders(HttpHeaders.CONNECTION);
if (connHeaders.length != 0) {
final TokenIterator ti = new BasicTokenIterator(new BasicHeaderIterator(connHeaders, null));
while (ti.hasNext()) {
final String token = ti.nextToken(); //如果包含Connection:Close首部,則代表請求不打算保持連接,會忽略response的意愿,該頭部這是HTTP/1.1的規(guī)范
if (HTTP.CONN_CLOSE.equalsIgnoreCase(token)) {
return false;
}
}
}
} //使用父類的的復(fù)用策略
return super.keepAlive(response, context);
}
}
看一下父類的復(fù)用策略
if (canResponseHaveBody(request, response)) {
final Header[] clhs = response.getHeaders(HTTP.CONTENT_LEN);
//如果reponse的Content-Length沒有正確設(shè)置,則不復(fù)用連接 //因?yàn)閷τ诔志没B接,兩次傳輸之間不需要重新建立連接,則需要根據(jù)Content-Length確認(rèn)內(nèi)容屬于哪次請求,以正確處理“粘包”現(xiàn)象 //所以,沒有正確設(shè)置Content-Length的response連接不能復(fù)用
if (clhs.length == 1) {
final Header clh = clhs[0];
try {
final int contentLen = Integer.parseInt(clh.getValue());
if (contentLen < 0) {
return false;
}
} catch (final NumberFormatException ex) {
return false;
}
} else {
return false;
}
}
if (headerIterator.hasNext()) {
try {
final TokenIterator ti = new BasicTokenIterator(headerIterator);
boolean keepalive = false;
while (ti.hasNext()) {
final String token = ti.nextToken(); //如果response有Connection:Close首部,則明確表示要關(guān)閉,則不復(fù)用
if (HTTP.CONN_CLOSE.equalsIgnoreCase(token)) {
return false; //如果response有Connection:Keep-Alive首部,則明確表示要持久化,則復(fù)用
} else if (HTTP.CONN_KEEP_ALIVE.equalsIgnoreCase(token)) {
keepalive = true;
}
}
if (keepalive) {
return true;
}
} catch (final ParseException px) {
return false;
}
}
//如果response中沒有相關(guān)的Connection首部說明,則高于HTTP/1.0版本的都復(fù)用連接
return !ver.lessEquals(HttpVersion.HTTP_1_0);
總結(jié)一下:
- 如果request首部中包含Connection:Close,不復(fù)用
- 如果response中Content-Length長度設(shè)置不正確,不復(fù)用
- 如果response首部包含Connection:Close,不復(fù)用
- 如果reponse首部包含Connection:Keep-Alive,復(fù)用
- 都沒命中的情況下,如果HTTP版本高于1.0則復(fù)用
從代碼中可以看到,其實(shí)現(xiàn)策略與我們第二、三章協(xié)議層的約束是一致的。
六、HttpClient如何清理過期連接
在HttpClient4.4版本之前,在從連接池中獲取重用連接的時(shí)候會檢查下是否過期,過期則清理。
之后的版本則不同,會有一個(gè)單獨(dú)的線程來掃描連接池中的連接,發(fā)現(xiàn)有離最近一次使用超過設(shè)置的時(shí)間后,就會清理。默認(rèn)的超時(shí)時(shí)間是2秒鐘。
public CloseableHttpClient build() { //如果指定了要清理過期連接與空閑連接,才會啟動清理線程,默認(rèn)是不啟動的
if (evictExpiredConnections || evictIdleConnections) { //創(chuàng)造一個(gè)連接池的清理線程
final IdleConnectionEvictor connectionEvictor = new IdleConnectionEvictor(cm,
maxIdleTime > 0 ? maxIdleTime : 10, maxIdleTimeUnit != null ? maxIdleTimeUnit : TimeUnit.SECONDS,
maxIdleTime, maxIdleTimeUnit);
closeablesCopy.add(new Closeable() {
@Override
public void close() throws IOException {
connectionEvictor.shutdown();
try {
connectionEvictor.awaitTermination(1L, TimeUnit.SECONDS);
} catch (final InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
}); //執(zhí)行該清理線程
connectionEvictor.start();
}
可以看到在HttpClientBuilder進(jìn)行build的時(shí)候,如果指定了開啟清理功能,會創(chuàng)建一個(gè)連接池清理線程并運(yùn)行它。
public IdleConnectionEvictor(
final HttpClientConnectionManager connectionManager,
final ThreadFactory threadFactory,
final long sleepTime, final TimeUnit sleepTimeUnit,
final long maxIdleTime, final TimeUnit maxIdleTimeUnit) {
this.connectionManager = Args.notNull(connectionManager, "Connection manager");
this.threadFactory = threadFactory != null ? threadFactory : new DefaultThreadFactory();
this.sleepTimeMs = sleepTimeUnit != null ? sleepTimeUnit.toMillis(sleepTime) : sleepTime;
this.maxIdleTimeMs = maxIdleTimeUnit != null ? maxIdleTimeUnit.toMillis(maxIdleTime) : maxIdleTime;
this.thread = this.threadFactory.newThread(new Runnable() {
@Override
public void run() {
try { //死循環(huán),線程一直執(zhí)行
while (!Thread.currentThread().isInterrupted()) { //休息若干秒后執(zhí)行,默認(rèn)10秒
Thread.sleep(sleepTimeMs); //清理過期連接
connectionManager.closeExpiredConnections(); //如果指定了最大空閑時(shí)間,則清理空閑連接
if (maxIdleTimeMs > 0) {
connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS);
}
}
} catch (final Exception ex) {
exception = ex;
}
}
});
}
總結(jié)一下:
- 只有在HttpClientBuilder手動設(shè)置后,才會開啟清理過期與空閑連接
- 手動設(shè)置后,會啟動一個(gè)線程死循環(huán)執(zhí)行,每次執(zhí)行sleep一定時(shí)間,調(diào)用HttpClientConnectionManager的清理方法清理過期與空閑連接。
七、本文總結(jié)
- HTTP協(xié)議通過持久連接的方式,減輕了早期設(shè)計(jì)中的過多連接問題
- 持久連接有兩種方式:HTTP/1.0+的Keep-Avlive與HTTP/1.1的默認(rèn)持久連接
- HttpClient通過連接池來管理持久連接,連接池分為兩個(gè),一個(gè)是總連接池,一個(gè)是每個(gè)route對應(yīng)的連接池
- HttpClient通過異步的Future<CPoolEntry>來獲取一個(gè)池化的連接
- 默認(rèn)連接重用策略與HTTP協(xié)議約束一致,根據(jù)response先判斷Connection:Close則關(guān)閉,在判斷Connection:Keep-Alive則開啟,最后版本大于1.0則開啟
- 只有在HttpClientBuilder中手動開啟了清理過期與空閑連接的開關(guān)后,才會清理連接池中的連接
- HttpClient4.4之后的版本通過一個(gè)死循環(huán)線程清理過期與空閑連接,該線程每次執(zhí)行都sleep一會,以達(dá)到定期執(zhí)行的效果
上面的研究是基于HttpClient源碼的個(gè)人理解,如果有誤,希望大家積極留言討論。
好了,以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
相關(guān)文章
Java實(shí)現(xiàn)在Word指定位置插入分頁符
在Word插入分頁符可以在指定段落后插入,也可以在特定文本位置處插入。本文將以Java代碼來操作以上兩種文檔分頁需求,需要的可以參考一下2022-04-04
spring boot 下對JSON返回值去除null和空字段操作
這篇文章主要介紹了spring boot 下對JSON返回值去除null和空字段操作,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10
JAVA設(shè)置手動提交事務(wù),回滾事務(wù),提交事務(wù)的操作
這篇文章主要介紹了JAVA設(shè)置手動提交事務(wù),回滾事務(wù),提交事務(wù)的操作,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-04-04
Java設(shè)計(jì)模式之原型模式詳細(xì)解析
這篇文章主要介紹了Java設(shè)計(jì)模式之原型模式詳細(xì)解析,原型模式就是用一個(gè)已經(jīng)創(chuàng)建的實(shí)例作為原型,通過復(fù)制該原型對象來創(chuàng)建一個(gè)和原型對象相同的新對象,需要的朋友可以參考下2023-11-11
在Spring Boot項(xiàng)目中引入本地JAR包的步驟和配置
本文探討了在Spring Boot項(xiàng)目中引入本地JAR包的步驟和必要的配置,通過使用Maven的system作用域,開發(fā)者可以將自定義的本地庫或功能集成到Spring Boot應(yīng)用程序中,,需要的朋友可以參考下2023-10-10

