Tuning Apache Commons DBCP2 for Production

This guide is part of HikariCP vs c3p0 vs DBCP2 Benchmark. Many services cannot migrate off Apache Commons DBCP2 on their own schedule: it arrives as Tomcat’s built-in JNDI pool, through an application server, or inside a vendor library. The pool itself is serviceable. Its defaults are not. Out of the box, maxTotal is 8, a borrow waits forever, no background evictor runs, and connections live until the database or a firewall kills them. The typical outage looks like request threads frozen with no error at all, followed hours later by a burst of stale-socket failures:

"http-nio-8080-exec-187" #212 daemon prio=5 WAITING (parking)
    at jdk.internal.misc.Unsafe.park(Native Method)
    at org.apache.commons.pool2.impl.LinkedBlockingDeque.takeFirst(LinkedBlockingDeque.java:1324)
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:311)
    at org.apache.commons.dbcp2.PoolingDataSource.getConnection(PoolingDataSource.java:134)

The objective is a DBCP2 configuration that fails fast when exhausted, evicts dead and idle connections on its own, and exposes enough telemetry to tell exhaustion from a leak.

Rapid incident diagnosis

Start from a thread dump, because DBCP2’s worst failure mode produces no log line. Count the threads parked in borrowObject:

jcmd $(pgrep -f catalina) Thread.print > /tmp/threads.txt
grep -c "GenericObjectPool.borrowObject" /tmp/threads.txt
grep -B2 -A12 "GenericObjectPool.borrowObject" /tmp/threads.txt | grep "at com.example" | sort | uniq -c | sort -rn | head

A large count means requests are waiting for a connection. The second command shows which application code paths are waiting — useful, but the paths holding connections matter more.

Next, read the pool state over JMX. When jmxName is set, the data source registers an MBean exposing NumActive, NumIdle and MaxTotal, and the underlying Commons Pool object exposes NumWaiters, MeanBorrowWaitTimeMillis and MaxBorrowWaitTimeMillis:

# jmxterm, non-interactive
echo "get -b org.apache.commons.dbcp2:name=ordersDS NumActive NumIdle MaxTotal" \
  | java -jar jmxterm.jar -l localhost:9010 -n

Then decide which of three causes applies:

  • Exhaustion. NumActive equals MaxTotal, waiters are non-zero, and the database shows those sessions actively running queries. The pool is too small or the queries too slow.
  • Leak. NumActive equals MaxTotal, but the database shows the corresponding sessions idle or idle in transaction for minutes. Code borrowed connections and never closed them.
  • Dead connections. NumActive is low, borrows succeed, but queries fail with Communications link failure or An I/O error occurred while sending to the backend. Idle connections were cut by a firewall or the server’s idle timeout and nothing validated them.

On PostgreSQL, the leak test is one query against the application’s user:

SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE usename = 'orders_app'
GROUP BY state;
Borrow behaviour with unbounded and bounded waits Two timelines. With maxWaitMillis of -1, request threads park indefinitely once the pool is full and the service appears hung. With a 3,000 ms wait, the borrow fails with a logged exception and the thread is released. maxWaitMillis = -1 (default) Borrow request pool full Thread parks in takeFirst() no timeout, no log line Service looks hung thread pool drained maxWaitMillis = 3000 Borrow request pool full Waits up to 3 s then SQLException Error logged thread released the same exhaustion event, one visible and one silent
With the default borrow wait of -1, exhaustion never produces an error; request threads simply stop. A bounded wait converts the same event into a fast, logged failure.

Mathematical sizing or parameter formula

Two formulas matter for DBCP2: the pool ceiling, and how often the evictor actually visits every idle connection.

The ceiling follows Little’s Law. Required connections equal arrival rate times hold time, divided by a target utilisation:

maxTotal = ceil( requests_per_second × db_hold_seconds ÷ 0.75 )

A Tomcat service handling 300 requests per second, each holding a connection for 20 ms, needs 300 × 0.02 ÷ 0.75 = 8 connections. The default of 8 happens to fit; at 900 requests per second it needs 24, and the default pool becomes a hard throughput limit.

The evictor is less obvious. It wakes every timeBetweenEvictionRunsMillis and examines only numTestsPerEvictionRun idle connections per run (default 3). The time to visit every idle connection once is:

full_sweep_seconds = ceil( idle_connections ÷ numTestsPerEvictionRun ) × run_interval_seconds

With 24 idle connections, 3 tests per run and a 30-second interval, a full sweep takes 8 × 30 = 240 seconds. If a firewall between the service and the database drops idle flows after 350 seconds, some connections will be idle for up to minEvictableIdleTimeMillis plus a sweep before they are examined, and with a 30-minute idle timeout they will certainly be cut first. Two fixes: make minEvictableIdleTimeMillis plus one sweep shorter than the firewall timeout, or set numTestsPerEvictionRun to a negative value, which DBCP2 interprets as testing a fraction of the idle pool per run — -1 tests all of them.

Evictor sweep coverage over time Twenty-four idle connections are examined three at a time every thirty seconds, taking 240 seconds for a full sweep. Adding the idle threshold pushes the worst case past a 350-second firewall idle timeout, while testing all idle connections per run keeps it inside. Time to examine all 24 idle connections (scale: 0 to 400 s) 0 s firewall idle cut · 350 s one evictor run every 30 s, 3 connections each 240 s numTestsPerEvictionRun = 3: full sweep 240 s numTestsPerEvictionRun = -1: full sweep every run 30 s, so idle threshold plus one run stays far inside 350 s
The evictor visits only a few idle connections per run. The sweep time has to fit inside the shortest network idle timeout, not just the database's.

Exact remediation & configuration

The configuration below is for a directly constructed BasicDataSource; each property has the same name in Tomcat’s <Resource> element and in Spring’s spring.datasource.dbcp2.* namespace. Recent DBCP2 releases also accept Duration setters such as setMaxWait, which replace the ...Millis variants.

BasicDataSource ds = new BasicDataSource();
ds.setUrl("jdbc:postgresql://orders-db.internal:5432/orders");
ds.setUsername("orders_app");
ds.setPassword(secret);

// Sizing
ds.setInitialSize(4);
ds.setMaxTotal(24);                      // from Little's Law, not the default 8
ds.setMaxIdle(24);                       // == maxTotal: avoid close/reopen churn under bursts
ds.setMinIdle(4);

// Fail fast instead of parking forever
ds.setMaxWaitMillis(3000);

// Validation: cheap, bounded, and not on every borrow
ds.setValidationQuery("SELECT 1");       // omit on JDBC4 drivers to use isValid()
ds.setValidationQueryTimeout(2);         // seconds
ds.setTestOnBorrow(false);
ds.setTestWhileIdle(true);
ds.setFastFailValidation(true);          // don't validate a connection that already threw a fatal SQLState

// Evictor: run it, and make it cover the whole idle pool each run
ds.setTimeBetweenEvictionRunsMillis(30_000);
ds.setNumTestsPerEvictionRun(-1);
ds.setMinEvictableIdleTimeMillis(120_000);        // below the 350 s firewall cut
ds.setSoftMinEvictableIdleTimeMillis(300_000);    // may evict below minIdle only after this

// Lifetime: retire connections before the network or server does
ds.setMaxConnLifetimeMillis(1_800_000);
ds.setLogExpiredConnections(false);

// Leak diagnostics
ds.setRemoveAbandonedOnMaintenance(true);
ds.setRemoveAbandonedTimeout(120);        // seconds a connection may be held
ds.setLogAbandoned(true);                 // records the borrowing stack trace

ds.setJmxName("org.apache.commons.dbcp2:name=ordersDS");

The same settings as a Tomcat JNDI resource in context.xml:

<Resource name="jdbc/orders" auth="Container" type="javax.sql.DataSource"
          factory="org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory"
          driverClassName="org.postgresql.Driver"
          url="jdbc:postgresql://orders-db.internal:5432/orders"
          username="orders_app" password="${db.password}"
          initialSize="4" maxTotal="24" maxIdle="24" minIdle="4"
          maxWaitMillis="3000"
          validationQuery="SELECT 1" validationQueryTimeout="2"
          testOnBorrow="false" testWhileIdle="true"
          timeBetweenEvictionRunsMillis="30000" numTestsPerEvictionRun="-1"
          minEvictableIdleTimeMillis="120000" maxConnLifetimeMillis="1800000"
          removeAbandonedOnMaintenance="true" removeAbandonedTimeout="120" logAbandoned="true"
          jmxName="org.apache.commons.dbcp2:name=ordersDS"/>

Treat removeAbandonedOnMaintenance as a diagnostic, not a cure. It closes connections held longer than the timeout and logs the stack trace that borrowed them, which finds leaks quickly, but it will also close a connection legitimately held by a long batch job. Set the timeout above your longest legitimate transaction, and avoid removeAbandonedOnBorrow, which runs the abandoned-connection scan inline on the request thread whenever the pool is nearly full.

Roll the change out as you would any pool change: one instance first, compare MeanBorrowWaitTimeMillis and error rates against its neighbours for an hour, then the rest.

DBCP2 defaults versus production values A comparison of five DBCP2 settings: maxTotal 8 versus a sized value, maxWaitMillis -1 versus 3000, evictor disabled versus 30 seconds, three tests per run versus all, and unlimited connection lifetime versus 30 minutes. Setting Default Production maxTotal 8: a hidden throughput cap sized: 24 maxWaitMillis -1: threads park forever 3000 ms timeBetweenEvictionRuns -1: no evictor thread 30 s numTestsPerEvictionRun 3 per run: slow sweep -1: all idle each run maxConnLifetimeMillis -1: lives until cut 30 min
Five defaults account for nearly every DBCP2 incident. Each production value removes a failure mode rather than tuning performance.

Validation & verification

Verify each failure mode is gone, not just that the service starts.

Bounded wait. In staging, cap maxTotal at 2 and fire 50 concurrent requests that each hold a connection for a second. Every request beyond the first few must fail within about three seconds with the pool’s exception, not hang:

hey -n 50 -c 50 http://staging-orders:8080/slow-query | grep -E "Status code|Slowest"
grep -c "Timeout waiting for idle object" /var/log/tomcat/catalina.out

Evictor and lifetime. Watch the backend connection ages on the database. With a 30-minute maxConnLifetimeMillis, no connection from the service should be older than about 31 minutes, and idle ones should disappear after roughly two minutes when traffic stops:

SELECT pid, state, now() - backend_start AS age, now() - state_change AS in_state
FROM pg_stat_activity
WHERE usename = 'orders_app'
ORDER BY age DESC
LIMIT 10;

Leak detection. Deploy a test endpoint that borrows a connection and never closes it, call it once, and confirm that within removeAbandonedTimeout plus one evictor run the log contains the abandoned-connection report with the endpoint’s stack trace.

Telemetry. Scrape NumActive, NumIdle and NumWaiters into your metrics system and alert when NumWaiters stays above zero for more than a minute — the same saturation rule described in Alerting on Connection Pool Saturation.

DBCP2 inside Tomcat and Spring Boot

Most DBCP2 deployments are not constructed in code, and the same setting can be spelled three different ways depending on who builds the pool.

Tomcat’s bundled copy is repackaged. Tomcat ships DBCP2 under org.apache.tomcat.dbcp.dbcp2, and its JNDI factory reads attributes from the <Resource> element. It is easy to put a newer Commons DBCP2 JAR in WEB-INF/lib, configure it in code, and still have the container’s JNDI resource — with defaults — serving the application. Confirm which pool is live by its JMX name and class:

echo "beans -d Catalina" | java -jar jmxterm.jar -l localhost:9010 -n | grep -i datasource

Spring Boot binds spring.datasource.dbcp2.*. When HikariCP is excluded and DBCP2 is on the classpath, Spring Boot creates a BasicDataSource and binds properties by relaxed name:

spring:
  datasource:
    type: org.apache.commons.dbcp2.BasicDataSource
    url: jdbc:postgresql://orders-db.internal:5432/orders
    dbcp2:
      initial-size: 4
      max-total: 24
      max-idle: 24
      min-idle: 4
      max-wait-millis: 3000
      test-on-borrow: false
      test-while-idle: true
      validation-query-timeout: 2
      time-between-eviction-runs-millis: 30000
      num-tests-per-eviction-run: -1
      min-evictable-idle-time-millis: 120000
      max-conn-lifetime-millis: 1800000
      remove-abandoned-on-maintenance: true
      remove-abandoned-timeout: 120
      log-abandoned: true
      jmx-name: org.apache.commons.dbcp2:name=ordersDS

Parameter names changed between DBCP 1 and DBCP2. Configuration copied from older documentation silently does nothing, because unknown attributes are ignored:

DBCP 1.x name DBCP2 name Notes
maxActive maxTotal The most common silent failure: the pool stays at 8
maxWait maxWaitMillis (or maxWait as a Duration in recent releases) Default still waits forever
removeAbandoned removeAbandonedOnBorrow / removeAbandonedOnMaintenance Split into two triggers
— maxConnLifetimeMillis No DBCP 1 equivalent
— fastFailValidation No DBCP 1 equivalent

After any configuration change, read the effective values back over JMX — MaxTotal, MaxWaitMillis, TimeBetweenEvictionRunsMillis — rather than trusting the file. A MaxTotal of 8 after you configured 24 means a misspelled or legacy attribute.

Metrics without JMX scraping. Micrometer’s DataSourcePoolMetrics exposes active, idle, minimum and maximum connections for DBCP2 pools under jdbc.connections.* when Spring Boot Actuator is present, which is enough for the saturation alert even without the Commons Pool wait statistics.

Frequently Asked Questions

Should maxIdle be lower than maxTotal?
Usually not. When maxIdle is lower, every connection returned while the idle count is at maxIdle is closed immediately, and the next burst reopens it. That close-and-reopen churn is expensive, especially with TLS. Let the evictor shrink the pool after quiet periods instead.
Is testOnBorrow=true safer?
It catches dead connections on the request path at the cost of a round trip on every borrow. With the evictor testing idle connections and maxConnLifetimeMillis retiring old ones, the residual risk is small. Keep testOnBorrow only when a network device cuts idle flows faster than any sweep interval you can afford.
Does lifo matter?
DBCP2 returns the most recently used idle connection by default (lifo=true), which keeps a warm core of connections and lets the rest age out through the evictor. Switch to FIFO only when you need traffic spread evenly across connections, for example behind a load balancer distributing sessions across replicas.
When is it worth migrating to HikariCP instead?
When contention, not configuration, is the problem: many hundreds of threads borrowing concurrently, and MeanBorrowWaitTimeMillis high even though connections are available. The measured gap and the translation table are in Migrating from c3p0 to HikariCP, and the same mapping applies from DBCP2.
Does DBCP2 support a connection initialisation statement?
Yes: connectionInitSqls runs a list of statements once when each physical connection is created, useful for session settings such as SET application_name. It runs once per connection, not per borrow, so it is not a place for per-request state.
Is poolPreparedStatements worth enabling?
It caches prepared statements per connection inside the pool, which helps drivers without their own cache. Most modern drivers — PostgreSQL’s and MySQL Connector/J — cache prepared statements themselves, so enabling it doubles the caching and the memory. Leave it off unless profiling shows statement preparation cost.