How to Use Virtual Threads in Java (Project Loom) with Spring Boot

Virtual threads are one of the most exciting additions to modern Java. They make it much easier to write highly concurrent applications without the complexity of managing large thread pools, callbacks, or reactive pipelines.

If you build applications with Spring Boot, virtual threads can help your app handle many more concurrent tasks with simpler code.

In this post, we’ll cover:

  • What virtual threads are
  • Why they matter
  • When to use them
  • How to enable them in Spring Boot
  • A few important best practices and warnings

What are virtual threads?

Traditional Java threads are often called platform threads. They are mapped closely to operating system threads. That means they are relatively expensive in terms of memory and scheduling.

Virtual threads are lightweight threads managed by the JVM, not directly by the operating system. They are designed to make blocking code cheap again.

In simple terms:

  • Platform threads = heavier, fewer, more expensive
  • Virtual threads = lightweight, many more, cheaper to create

This means you can write code in the usual imperative style, but still support high concurrency.


Why virtual threads matter

For years, Java developers had two main choices for handling concurrency:

  1. Use thread pools and blocking code
  2. Use asynchronous/reactive programming

Virtual threads give you a third option:

  • Keep the simple blocking style
  • Avoid the complexity of reactive code
  • Scale better under lots of concurrent I/O operations

This is especially useful for applications that spend a lot of time waiting on:

  • Database calls
  • HTTP requests
  • File I/O
  • Remote service calls

If your application is mostly I/O-bound, virtual threads can be a great fit.


Virtual threads vs platform threads

Here’s a simple comparison:

Feature Platform Threads Virtual Threads
Cost to create Higher Very low
Memory usage Higher Lower
Number you can run Limited Much larger
Good for blocking code Yes, but expensive Yes, and efficient
Managed by OS Yes Mostly by JVM

The biggest win is that virtual threads let you run many blocking tasks concurrently without exhausting thread resources as quickly.


When should you use virtual threads?

Virtual threads are a strong choice when your app does lots of blocking I/O and you want simple code.

Good use cases:

  • REST APIs with many simultaneous requests
  • Service-to-service communication
  • Database-heavy applications
  • Background jobs that wait on I/O
  • File processing or batch operations

Less ideal use cases:

  • CPU-intensive tasks
  • Work that depends heavily on thread-local assumptions
  • Libraries that block while holding locks in a problematic way

Virtual threads do not magically make CPU-bound code faster. They help most when threads spend time waiting.


Project Loom in Java

Virtual threads are part of Project Loom, a long-running effort to modernize concurrency in Java.

Loom also introduced other improvements around structured concurrency and scoped values, but the headline feature most developers use today is virtual threads.

Virtual threads became a standard Java feature in recent releases, so you no longer need special preview settings in modern Java versions.


How Spring Boot supports virtual threads

Spring Boot has first-class support for virtual threads.

If you are using Spring Boot 3.2+, you can usually enable them with a simple configuration property:

spring.threads.virtual.enabled=true

That tells Spring Boot to use virtual threads in places where it manages request handling and task execution.

This is one of the nicest parts: in many cases, you can get the benefits of virtual threads with very little code change.


Basic setup in Spring Boot

1. Use a recent Java version

Virtual threads require a modern JDK, for example Java 25.

2. Use a recent Spring Boot version

Make sure your Spring Boot version supports virtual threads well. Spring Boot 3.2 or later is recommended.

3. Enable virtual threads

Add this to your application configuration:

spring.threads.virtual.enabled=true

Or in YAML:

spring:
  threads:
    virtual:
      enabled: true

That’s often enough for web applications.


What happens when you enable them?

When virtual threads are enabled, Spring can use them for tasks such as:

  • Handling incoming HTTP requests
  • Running @Async methods
  • Executing some scheduled or background tasks

The exact behavior depends on the Spring component and configuration, but the overall idea is that work can run on virtual threads instead of a small pool of platform threads.


Example: a Spring Boot REST controller

Here is a simple example of how your code can stay clean and blocking while still benefiting from virtual threads.

package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {

    private final DemoService demoService;

    public DemoController(DemoService demoService) {
        this.demoService = demoService;
    }

    @GetMapping("/hello")
    public String hello() {
        return demoService.fetchMessage();
    }
}
package com.example.demo;

import org.springframework.stereotype.Service;

@Service
public class DemoService {

    public String fetchMessage() {
        // Simulate a blocking operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Hello from a virtual thread!";
    }
}

With virtual threads enabled, each request can be handled with a lightweight thread, even though the service method blocks.


Example: using virtual threads for background tasks

You can also create virtual threads manually when needed.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExample {

    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> System.out.println("Task 1 on virtual thread"));
            executor.submit(() -> System.out.println("Task 2 on another virtual thread"));
        }
    }
}

This is useful when you want a simple executor that creates a new virtual thread for each task.


Using @Async with virtual threads

Spring’s @Async support can also benefit from virtual threads.

For example:

package com.example.demo;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class EmailService {

    @Async
    public CompletableFuture<String> sendEmail() {
        // Simulate work
        return CompletableFuture.completedFuture("Email sent");
    }
}

If Spring is configured to use virtual threads for task execution, these async methods can run on virtual threads, making blocking work much cheaper.


Virtual threads do not replace everything

It’s important not to oversell virtual threads. They solve one problem very well: cheap blocking concurrency.

They do not replace:

  • Proper database indexing
  • Efficient network design
  • Good application architecture
  • Caching
  • Load balancing
  • Performance tuning

Virtual threads are a concurrency tool, not a performance silver bullet.


Best practices when using virtual threads

1. Keep code simple

One of the main advantages of virtual threads is that you can keep using normal blocking code. Don’t add unnecessary complexity.

2. Avoid long synchronized blocks

Virtual threads can behave poorly if they spend too much time blocked inside synchronized sections. Prefer shorter critical sections and consider alternatives where appropriate.

3. Watch out for thread-local usage

Some older code relies heavily on ThreadLocal. Virtual threads support it, but large-scale usage can still create complexity and memory overhead.

4. Test your dependencies

Most modern libraries work well, but some older libraries may assume platform-thread behavior or may not play nicely with high concurrency.

5. Don’t use virtual threads for CPU-heavy work expecting miracles

If your bottleneck is CPU, virtual threads will not fix it. For CPU-bound tasks, focus on algorithms, parallelism, and profiling.


Common questions

Are virtual threads faster?

Not always in a direct “single task runs faster” sense. Their big advantage is that they allow more concurrency with less overhead, especially for blocking I/O.

Do I need reactive programming anymore?

Not necessarily. Virtual threads reduce the need for reactive programming in many applications, especially if you prefer imperative code.

Can I use virtual threads with Spring MVC?

Yes. Spring MVC is a great fit because it already uses a blocking request model, which maps naturally to virtual threads.

Can I use them with Spring WebFlux?

You can, but WebFlux is built around a reactive model. Virtual threads are often more valuable in traditional blocking stacks like Spring MVC.


When virtual threads are a great fit in Spring Boot

Virtual threads are especially attractive if:

  • You already have a Spring MVC application
  • You use JDBC and blocking database access
  • Your codebase is imperative and you want to keep it that way
  • You want to handle more concurrent requests with less thread-pool tuning

In many cases, virtual threads let you modernize your app without rewriting it.


A practical migration strategy

If you want to adopt virtual threads in an existing Spring Boot app, here’s a simple approach:

  1. Upgrade to a compatible Java and Spring Boot version
  2. Enable virtual threads in configuration
  3. Test your main request paths
  4. Watch application metrics under load
  5. Check library compatibility
  6. Tune only where needed

You usually do not need to rewrite your whole application.


Final thoughts

Virtual threads are a major step forward for Java concurrency. They make it possible to write straightforward, blocking-style code while still supporting high throughput and scalability.

For Spring Boot developers, this is especially valuable because it means:

  • Less concurrency boilerplate
  • Easier-to-read code
  • Better scaling for I/O-heavy workloads
  • A smoother path than fully reactive programming for many apps

If your application spends a lot of time waiting on I/O, virtual threads are absolutely worth trying.


Summary

Use virtual threads in Spring Boot when you want:

  • Simple blocking code
  • High concurrency
  • Less thread-pool management
  • Better scalability for I/O-bound workloads

Enable them with:

spring.threads.virtual.enabled=true

Then test, measure, and enjoy a much simpler concurrency model in Java.

How do I handle database timeouts in JDBC?

In JDBC, “database timeouts” can mean a few different things, and you handle each at a different layer. The most practical approach is to set timeouts deliberately and then catch the right exception types so you can decide whether to retry, fail fast, or surface a user-friendly error.

1) Connection timeout (can’t connect / handshake takes too long)

a) DriverManager login timeout (global)

This limits how long DriverManager will wait when establishing a connection.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectTimeoutExample {
    public static void main(String[] args) throws SQLException {
        DriverManager.setLoginTimeout(10); // seconds

        try (Connection c = DriverManager.getConnection(
                "jdbc:mysql://localhost/kodejava",
                "kodejava",
                "s3cr*t"
        )) {
            // connected
        }
    }
}

b) Driver-specific connect/socket timeouts (recommended)

Most drivers expose properties like connectTimeout and socketTimeout (names vary by vendor). These are often more reliable than setLoginTimeout.

Example pattern using connection properties:

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Properties;

public class DriverPropertiesTimeoutExample {
    public static void main(String[] args) throws SQLException {
        Properties props = new Properties();
        props.setProperty("user", "kodejava");
        props.setProperty("password", "s3cr*t");

        // Vendor-specific keys; check your driver docs:
        props.setProperty("connectTimeout", "10000"); // ms (example)
        props.setProperty("socketTimeout", "30000");  // ms (example)

        try (Connection c = DriverManager.getConnection(
                "jdbc:mysql://localhost/kodejava",
                props
        )) {
            // ...
        }
    }
}

Rule of thumb: set both a connect timeout and a read/socket timeout, otherwise a query can hang at the network layer even if you set a query timeout.


2) Query execution timeout (a statement runs too long)

Use Statement.setQueryTimeout(int seconds) (works for Statement, PreparedStatement, CallableStatement). On timeout, drivers typically throw a SQLTimeoutException (a subclass of SQLException).

package org.kodejava.jdbc;

import java.sql.*;

public class QueryTimeoutExample {
    public static void main(String[] args) throws SQLException {
        try (Connection c = DriverManager.getConnection("jdbc:mysql://localhost/kodejava", "kodejava", "s3cr*t");
             PreparedStatement ps = c.prepareStatement("SELECT * FROM product WHERE price > ?")) {

            ps.setBigDecimal(1, new java.math.BigDecimal("100.00"));
            ps.setQueryTimeout(5); // seconds

            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    // consume results
                }
            }
        } catch (SQLTimeoutException e) {
            // This is your “query took too long” bucket.
            throw new RuntimeException("Query timed out; consider optimizing SQL or raising timeout.", e);
        }
    }
}

Important notes:

  • setQueryTimeout is enforced by the driver, and behavior can differ:
    • Some drivers send a cancel to the server.
    • Some only time out client-side.
  • If the thread is interrupted, or you want a manual escape hatch, you can also call Statement.cancel() from another thread.

3) Lock wait / deadlock timeouts (transaction waits too long)

These are not “JDBC timeouts” per se—they’re database concurrency timeouts. They usually surface as SQLException with:

  • SQLState like 40001 (serialization failure / deadlock, DB-dependent), or
  • vendor-specific error codes/messages (e.g., lock wait timeout exceeded).

Handling strategy:

  • Rollback the transaction.
  • Retry only if you can safely retry (best is retrying the whole transaction), and keep attempts small with backoff.

4) Pool acquisition timeout (you can’t get a Connection from the pool)

If you use a pool (HikariCP, DBCP, c3p0, etc.), also set a connection acquisition/checkout timeout. Otherwise, under load you’ll see “timeouts” that are actually “all connections are busy.”

This is configured on the pool, not via JDBC calls.


5) Catching and classifying timeouts correctly

Catch the specific subtype when possible

JDBC provides SQLTimeoutException:

try {
    // execute query/update
} catch (SQLTimeoutException e) {
    // query timeout bucket
} catch (SQLException e) {
    // everything else
}

Use SQLState for broad categories

If you need portability, SQLState prefixes help:

  • 08xxx → connection exception family (network/connection problems)
  • 40xxx → transaction rollback / concurrency issues (often retryable depending on DB)
static boolean isConnectionProblem(SQLException e) {
    String state = e.getSQLState();
    return state != null && state.startsWith("08");
}

6) Retry policy (only for the right failures)

Retries are useful for transient failures (deadlocks, lock timeouts, brief network blips), but dangerous for non-idempotent operations.

A safe baseline:

  • Retry 2–3 times max
  • Use jittered backoff
  • Retry only when:
    • you can retry the entire transaction, or
    • the operation is idempotent

Sketch:

package org.kodejava.jdbc;

import java.sql.SQLException;
import java.sql.SQLTimeoutException;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

public class RetrySupport {
    public static <T> T withRetry(SqlSupplier<T> work) throws SQLException {
        int maxAttempts = 3;
        SQLException last = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return work.get();
            } catch (SQLTimeoutException e) {
                // Query timed out: retry is usually NOT helpful unless you expect transient load.
                throw e;
            } catch (SQLException e) {
                last = e;
                if (!isRetryable(e) || attempt == maxAttempts) throw e;

                sleep(backoff(attempt));
            }
        }
        throw last; // unreachable
    }

    private static boolean isRetryable(SQLException e) {
        String state = e.getSQLState();
        if (state != null && state.startsWith("08")) return true;  // connection hiccup
        if (state != null && state.startsWith("40")) return true;  // tx rollback class (DB-dependent)
        return false;
    }

    private static Duration backoff(int attempt) {
        long baseMs = 100L * (1L << (attempt - 1)); // 100, 200, 400...
        long jitter = ThreadLocalRandom.current().nextLong(0, 100);
        return Duration.ofMillis(baseMs + jitter);
    }

    private static void sleep(Duration d) {
        try {
            Thread.sleep(d.toMillis());
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    @FunctionalInterface
    public interface SqlSupplier<T> {
        T get() throws SQLException;
    }
}

7) Practical checklist (what to set in real apps)

  1. Pool acquisition timeout (if using a pool)
  2. Connect timeout (driver property)
  3. Socket/read timeout (driver property)
  4. Query timeout (setQueryTimeout)
  5. For transactions:
    • keep transactions short
    • handle deadlocks/lock timeouts with rollback and bounded retry

How to Detect Deadlocks in JDBC?

Deadlocks in JDBC typically refer to database-level deadlocks, where two or more transactions block each other while waiting for locks on resources (e.g., rows or tables). JDBC itself doesn’t “detect” them proactively; instead, the database server signals them via exceptions. Here’s how to handle detection effectively:

1. Catch and Inspect SQLException

Wrap your JDBC operations (e.g., executeUpdate(), executeQuery()) in a try-catch block. When a deadlock occurs, the database driver throws an SQLException. Check its properties to confirm it’s a deadlock:

  • SQLState: A standard code (e.g., starts with “40” for serialization failures like deadlocks in many databases).
  • Error Code: Vendor-specific (e.g., database-dependent numbers).
  • Message: Often contains keywords like “deadlock” or “lock wait timeout”.

Example in Java:

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public void performDatabaseOperation(Connection conn) {
    try (PreparedStatement stmt = conn.prepareStatement("UPDATE table SET column = ? WHERE id = ?")) {
        stmt.setString(1, "value");
        stmt.setInt(2, 1);
        stmt.executeUpdate();
    } catch (SQLException e) {
        if (isDeadlock(e)) {
            // Handle deadlock: e.g., retry the transaction or log it
            System.out.println("Deadlock detected: " + e.getMessage());
            // Optional: retry logic here
        } else {
            throw new RuntimeException("Database error", e);
        }
    }
}

private boolean isDeadlock(SQLException e) {
    String sqlState = e.getSQLState();
    int errorCode = e.getErrorCode();

    // Common checks (adapt to your database)
    if (sqlState != null) {
        if (sqlState.startsWith("40")) { // General serialization failure (deadlock/timeout)
            return true;
        }
    }

    // Database-specific error codes
    // MySQL example: Deadlock (1213) or lock wait timeout (1205)
    if (errorCode == 1213 || errorCode == 1205) {
        return true;
    }
    // PostgreSQL example: 40P01 for deadlock
    // Oracle example: ORA-00060 (error code 60)

    return false; // Not a deadlock
}

2. Database-Specific Detection

Error codes vary by database—always check your DBMS docs for exact values:

  • MySQL: Error code 1213 (deadlock) or 1205 (lock wait timeout). SQLState “40001” or “HY000”.
  • PostgreSQL: SQLState “40P01”.
  • Oracle: Error code 60 (ORA-00060).
  • SQL Server: Error code 1205.

If using Spring Data JPA (from your project stack), exceptions are wrapped in DataAccessException subclasses like ConcurrencyFailureException. You can catch those for higher-level handling.

3. Prevention and Retry Strategies

  • Use Transactions Wisely: Keep them short, use appropriate isolation levels (e.g., READ_COMMITTED via conn.setTransactionIsolation(...)).
  • Retry Logic: For transient deadlocks, retry the entire transaction (e.g., 2-3 times with exponential backoff). Ensure operations are idempotent.
  • Monitoring: Enable database logging or use tools like Java’s ThreadMXBean for thread-level deadlocks (unrelated to DB), but for DB deadlocks, rely on JDBC exceptions.

How do I use SQLSTATE error codes in JDBC?

SQLState error codes are a standardized way in JDBC to categorize database errors, making it easier to handle exceptions programmatically. They’re part of the SQLException class and follow the SQL:2003 standard (like “23000” for integrity constraints). Unlike vendor-specific error codes (from getErrorCode()), SQLState is more portable across databases.

1. What is SQLState?

  • It’s a 5-character string returned by the database driver.
  • The first two characters indicate the error class (e.g., “08” for connection issues, “23” for integrity violations).
  • The last three are a subclass for more details.
  • Common examples:
    • “08001”: Can’t connect to the database.
    • “23000”: Integrity constraint violation (e.g., duplicate key).
    • “40001”: Serialization failure (often retryable in transactions).
    • “42S02”: Table not found (syntax-related).

This helps you write database-agnostic error handling, though some drivers add vendor twists.

2. Accessing SQLState in Code

When you catch an SQLException, simply call e.getSQLState(). It’s always a good idea to log or inspect both SQLState and the vendor code (e.getErrorCode()) for full context.

Here’s a basic example in Java:

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class JdbcErrorHandlingExample {
    public void executeQuery(Connection conn, String sql) {
        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.executeUpdate();
        } catch (SQLException e) {
            String sqlState = e.getSQLState();
            int errorCode = e.getErrorCode();
            System.out.println("Error: SQLState=" + sqlState + ", Vendor Code=" + errorCode);
            System.out.println("Message: " + e.getMessage());

            // Handle based on SQLState
            if (sqlState != null && sqlState.startsWith("23")) {
                // Integrity issue: e.g., duplicate entry – notify user or retry
                throw new IllegalArgumentException("Data integrity violation!", e);
            } else if (sqlState != null && sqlState.startsWith("08")) {
                // Connection problem: maybe retry or fail over
                throw new RuntimeException("Connection failed!", e);
            } else {
                // Generic handling
                throw new RuntimeException("Database error occurred!", e);
            }
        }
    }
}

3. Best Practices for Using SQLState

  • Conditional Logic: Use it sparingly for decisions like retries (e.g., “40001” often means a deadlock—safe to retry the transaction).
  • Chaining Exceptions: SQLExceptions can chain (via getNextException()). Walk the chain to check all SQLStates.
  • Logging: Always include SQLState in logs for easier debugging. In modern Java (like SDK 25), use try-with-resources to auto-close resources without leaks.
  • Portability Caveat: Not all drivers implement SQLState perfectly—test against your DB (e.g., MySQL, PostgreSQL).
  • Avoid Over-Reliance: Combine with getErrorCode() for vendor-specific details, but prefer SQLState for cross-DB code.

How do I handle SQL exceptions in JDBC for MySQL properly?

For MySQL (mysql-connector-j), “proper” SQLException handling is the same core approach as JDBC in general, plus a few MySQL-specific signals (SQLState + error code) that are worth using for translation/retry decisions.

1) Keep the important diagnostics (MySQL error code + SQLState)

MySQL gives you two invaluable fields:

  • e.getErrorCode()MySQL vendor error code (e.g., 1062 for duplicate key)
  • e.getSQLState()SQLState (often 23000, 40001, etc.)

A good pattern is: wrap once with context, but preserve those fields.

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class MySqlExceptions {
    public static RuntimeException translate(String operation, SQLException e) {
        String msg = operation
                     + " failed (SQLState=" + e.getSQLState()
                     + ", errorCode=" + e.getErrorCode() + ")";

        // Keep e as the cause.
        return switch (e.getErrorCode()) {
            case 1062 ->
                    new IllegalStateException(msg + " - duplicate key", e); // unique constraint violation
            case 1213 ->
                    new IllegalStateException(msg + " - deadlock", e);      // often retryable
            case 1205 ->
                    new IllegalStateException(msg + " - lock wait timeout", e); // often retryable
            default -> new RuntimeException(msg, e);
        };
    }
}

2) MySQL error codes you’ll commonly care about

These are the ones that usually drive different handling:

Situation MySQL error code Typical SQLState What to do
Unique constraint violation (“Duplicate entry”) 1062 23000 Return “already exists” / map to 409 / domain error
Deadlock found 1213 40001 Often safe to retry the whole transaction
Lock wait timeout exceeded 1205 often 41000 Often retry or surface “please retry”
Foreign key constraint fails 1451/1452 23000 Map to domain validation (cannot delete/insert due to FK)
Connection/link failure varies 08xxx / 08S01 Treat as transient infra failure; maybe retry with backoff

Rule of thumb: prefer the vendor error code for MySQL-specific branching (it’s the most consistent), and keep SQLState for general categorization/logging.

3) Retrying safely (only for the right failures)

Only retry if:

  • the operation is idempotent, or you’re retrying the entire transaction from the beginning, and
  • the failure is one of the known transient classes (deadlock / lock timeout / connection hiccup).

A minimal “should retry?” helper:

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class MySqlRetry {
    public static boolean isRetryable(SQLException e) {
        int code = e.getErrorCode();
        String state = e.getSQLState();

        // MySQL deadlock / lock wait timeout
        if (code == 1213 || code == 1205) return true;

        // Connection exception class (SQLState starts with "08")
        if (state != null && state.startsWith("08")) return true;

        return false;
    }
}

If you do retry, keep it small (e.g., 2–3 attempts) with jittered backoff, and log the final failure with the full chain (getNextException()) and suppressed exceptions.

4) Transactions: rollback without hiding the original error

With MySQL, rollback can also throw if the connection is broken. Best practice: attach rollback failure as suppressed so you don’t lose the root cause.

package org.kodejava.jdbc;

import java.sql.Connection;
import java.sql.SQLException;

public final class TxUtil {
    public static void rollbackQuietly(Connection con, SQLException original) {
        try {
            con.rollback();
        } catch (SQLException rb) {
            original.addSuppressed(rb);
        }
    }
}

5) MySQL Connector/J note: you usually don’t need Class.forName(...)

With modern JDBC drivers (including MySQL Connector/J 8+), the driver is auto-registered via the Service Provider mechanism. Calling Class.forName("com.mysql.cj.jdbc.Driver") is typically unnecessary unless you’re in a very unusual classloading environment.

6) What to log (and what not to log)

Log:

  • operation name (e.g., "insert user")
  • SQLState, error code
  • exception chain (getNextException())
  • safe parameter identifiers (e.g., user id), not secrets

Avoid logging:

  • credentials
  • sensitive values (passwords, tokens)
  • huge SQL strings with embedded data (use prepared statements so you don’t have that problem)

How do I handle SQL exceptions in JDBC properly?

Handling SQLException “properly” in JDBC is mostly about (1) not leaking resources, (2) preserving diagnostic detail, (3) rolling back safely, and (4) translating errors into something meaningful at your app boundary.

1) Always close JDBC resources (use try-with-resources)

This eliminates most error-handling bugs (leaks and double-closes), and it also handles exceptions thrown during close() by attaching them as suppressed exceptions.

package org.kodejava.jdbc;

import javax.sql.DataSource;
import java.sql.*;

public final class JdbcExample {
    public static void runQuery(DataSource ds, long id) {
        String sql = "select name from users where id = ?";

        try (Connection con = ds.getConnection();
             PreparedStatement ps = con.prepareStatement(sql)) {

            ps.setLong(1, id);

            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    String name = rs.getString(1);
                    // use name...
                }
            }

        } catch (SQLException e) {
            throw toDataAccessException("Failed running query: " + sql, e);
        }
    }

    static RuntimeException toDataAccessException(String message, SQLException e) {
        // Keep the SQLException as the cause so details are not lost.
        return new RuntimeException(message + " (SQLState=" + e.getSQLState() + ", code=" + e.getErrorCode() + ")", e);
    }
}

Key points

  • Prefer PreparedStatement over Statement (safety + plan reuse).
  • Wrap/translate once, near your data-access boundary, but keep e as the cause.

2) Log/inspect the full JDBC error chain (and suppressed exceptions)

JDBC drivers can chain multiple exceptions (e.g., one per batch item), and try-with-resources can add suppressed exceptions from close().

package org.kodejava.jdbc;

import java.sql.SQLException;

public final class SqlDiagnostics {
    public static String describe(SQLException e) {
        StringBuilder sb = new StringBuilder();
        for (Throwable t = e; t != null; t = (t instanceof SQLException se) ? se.getNextException() : null) {
            if (t instanceof SQLException se) {
                sb.append("SQLException: message=").append(se.getMessage())
                        .append(", SQLState=").append(se.getSQLState())
                        .append(", code=").append(se.getErrorCode())
                        .append('\n');
            } else {
                sb.append("Throwable: ").append(t).append('\n');
            }

            for (Throwable sup : t.getSuppressed()) {
                sb.append("  suppressed: ").append(sup).append('\n');
            }
        }
        return sb.toString();
    }
}

When this matters

  • Batch updates (BatchUpdateException)
  • Failures during resource cleanup (network drop during close())

3) Handle transactions: commit/rollback with a safe rollback path

If you manually manage transactions (setAutoCommit(false)), your exception handling must:

  1. rollback on failure,
  2. not mask the original exception if rollback also fails,
  3. restore state if you’re reusing connections (pools usually reset, but don’t rely on it blindly).
package org.kodejava.jdbc;

import java.sql.*;

public final class JdbcTxExample {
    public static void transfer(Connection con, long fromId, long toId, long amount) throws SQLException {
        boolean oldAutoCommit = con.getAutoCommit();
        con.setAutoCommit(false);

        try (PreparedStatement debit = con.prepareStatement("update acct set bal = bal - ? where id = ?");
             PreparedStatement credit = con.prepareStatement("update acct set bal = bal + ? where id = ?")) {

            debit.setLong(1, amount);
            debit.setLong(2, fromId);
            debit.executeUpdate();

            credit.setLong(1, amount);
            credit.setLong(2, toId);
            credit.executeUpdate();

            con.commit();

        } catch (SQLException e) {
            try {
                con.rollback();
            } catch (SQLException rb) {
                e.addSuppressed(rb); // keep original, attach rollback failure for debugging
            }
            throw e; // or translate here
        } finally {
            try {
                con.setAutoCommit(oldAutoCommit);
            } catch (SQLException ac) {
                // typically log; don't hide earlier failure
            }
        }
    }
}

4) Don’t swallow exceptions; translate them at the right layer

Common strategy:

  • DAO/repository layer: catch SQLException, add context (operation + key parameters), then rethrow as:
    • a checked app exception (if you want callers to handle), or
    • a runtime “data access” exception (common in service-oriented apps).
  • Service/controller boundary: map to user-safe messages (avoid exposing SQL text / internals).

Avoid:

  • catch (SQLException e) {} (silences failures)
  • Throwing a new exception without e as cause (loses SQLState/vendor code)

5) Use SQLState / vendor codes for decisions (sparingly)

If you need conditional handling (e.g., unique constraint violation), prefer SQLState classes when possible:

  • 23*** integrity constraint violation (many DBs)
  • 40*** transaction rollback / serialization failure (often retryable)

But keep it minimal: drivers/databases vary.

6) Be careful with retries

Only retry when you can justify it:

  • transient network issues
  • deadlocks / serialization failures (often safe to retry the whole transaction)

Never retry blindly on all SQLExceptions.


Quick checklist

  • try-with-resources for Connection/Statement/ResultSet
  • Keep SQLException as the cause; don’t lose SQLState / error code
  • Walk getNextException() and check suppressed exceptions
  • In manual transactions: rollback in catch, attach rollback failures via addSuppressed
  • Translate exceptions at the repository boundary; expose safe messages at the edge

How do I configure C3P0 connection pool in JDBC?

To configure c3p0 as a JDBC connection pool, you typically:

  1. create a DataSource (usually ComboPooledDataSource)
  2. tune pool parameters (min/max pool size, idle time, timeouts, statement cache)
  3. use the DataSource everywhere instead of DriverManager.getConnection(...)
  4. close Connection/Statement/ResultSet normally (c3p0 returns connections to the pool on close())

Below are the common ways to do it.


1) Programmatic configuration (most common for plain JDBC)

package org.kodejava.jdbc;

import com.mchange.v2.c3p0.ComboPooledDataSource;

import javax.sql.DataSource;
import java.beans.PropertyVetoException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public final class C3p0DataSourceFactory {
    private static final ComboPooledDataSource ds = new ComboPooledDataSource();

    static {
        try {
            ds.setDriverClass("org.postgresql.Driver");
        } catch (PropertyVetoException e) {
            throw new ExceptionInInitializerError(e);
        }

        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/<db>");
        ds.setUser("<db_user>");
        ds.setPassword("<db_password>");

        // Pool sizing
        ds.setMinPoolSize(5);
        ds.setInitialPoolSize(5);
        ds.setMaxPoolSize(20);
        ds.setAcquireIncrement(2);

        // Timeouts / lifecycles
        ds.setCheckoutTimeout(10_000);          // ms to wait for a connection
        ds.setMaxIdleTime(300);                 // seconds before idle conns are culled
        ds.setMaxConnectionAge(0);              // 0 = no limit (consider setting in prod)
        ds.setIdleConnectionTestPeriod(60);     // seconds between idle tests

        // Validation (pick ONE strategy; this is the most reliable)
        ds.setPreferredTestQuery("SELECT 1");
        ds.setTestConnectionOnCheckout(false);
        ds.setTestConnectionOnCheckin(true);

        // Statement caching (optional)
        ds.setMaxStatements(200);
        ds.setMaxStatementsPerConnection(20);
    }

    private C3p0DataSourceFactory() {}

    public static DataSource dataSource() {
        return ds;
    }

    // Optional: call on app shutdown (desktop apps / simple main())
    public static void shutdown() {
        ds.close();
    }

    // Example usage
    public static void main(String[] args) throws Exception {
        try (Connection c = dataSource().getConnection();
             PreparedStatement ps = c.prepareStatement("SELECT now()");
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                System.out.println(rs.getObject(1));
            }
        } finally {
            shutdown();
        }
    }
}

Notes on the key settings

  • minPoolSize / maxPoolSize: your main capacity knobs.
  • checkoutTimeout: prevents threads from waiting forever when the pool is exhausted.
  • Connection testing:
    • Prefer preferredTestQuery + testConnectionOnCheckin=true (or periodic tests) to avoid “broken connection” surprises.
    • testConnectionOnCheckout=true is safer but adds latency to every borrow.
  • Statement cache helps if you repeatedly run the same SQL.

2) Configure via c3p0.properties (cleaner config)

Create src/main/resources/c3p0.properties:

# Basic
c3p0.jdbcUrl=jdbc:postgresql://localhost:5432/<db>
c3p0.user=<db_user>
c3p0.password=<db_password>
c3p0.driverClass=org.postgresql.Driver

# Pool sizing
c3p0.minPoolSize=5
c3p0.initialPoolSize=5
c3p0.maxPoolSize=20
c3p0.acquireIncrement=2

# Timeouts / health checks
c3p0.checkoutTimeout=10000
c3p0.maxIdleTime=300
c3p0.idleConnectionTestPeriod=60
c3p0.preferredTestQuery=SELECT 1
c3p0.testConnectionOnCheckin=true

# Statement cache (optional)
c3p0.maxStatements=200
c3p0.maxStatementsPerConnection=20

Then your Java becomes minimal:

package org.kodejava.jdbc;

import com.mchange.v2.c3p0.ComboPooledDataSource;

import javax.sql.DataSource;

public final class DataSources {
    private static final ComboPooledDataSource ds = new ComboPooledDataSource(); // reads c3p0.properties

    public static DataSource c3p0() {
        return ds;
    }

    public static void shutdown() {
        ds.close();
    }
}

c3p0 will auto-load configuration from:

  • c3p0.properties on the classpath, or
  • c3p0-config.xml (more advanced, supports named configs)

3) Important usage rule: always close connections

With pooling, connection.close() does not close the physical DB connection; it returns it to the pool. So keep using try-with-resources:

try (Connection con = dataSource.getConnection();
     PreparedStatement ps = con.prepareStatement("SELECT * FROM t WHERE id = ?");
) {
  ps.setLong(1, 123L);
  // ...
}

4) Common pitfalls (quick checklist)

  • Don’t create a new ComboPooledDataSource per query. Create one and reuse it.
  • Set checkoutTimeout so you fail fast under load.
  • Use a validation query if your DB/network closes idle connections.
  • Tune pool sizes to match DB limits (max connections on the server) and your concurrency.

Maven dependencies

<dependencies>
  <dependency>
    <groupId>com.mchange</groupId>
    <artifactId>c3p0</artifactId>
    <version>0.11.1</version>
  </dependency>

  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.7</version>
  </dependency>
</dependencies>

Maven Central Maven Central

How to Configure Apache DBCP Connection Pool in JDBC?

To configure an Apache DBCP connection pool for “plain” JDBC, you typically create a pooled DataSource once at startup, then get connections from it (and always close them to return to the pool).

Below are the two most common approaches.

1) Recommended (DBCP2): BasicDataSource (simplest)

Create and configure the pool

package org.kodejava.jdbc;

import org.apache.commons.dbcp2.BasicDataSource;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public final class Dbcp2Example {
    public static DataSource dataSource() {
        BasicDataSource ds = new BasicDataSource();
        ds.setUrl("jdbc:postgresql://localhost:5432/app");
        ds.setUsername("<db_user>");
        ds.setPassword("<db_password>");

        ds.setMaxTotal(20);
        ds.setMaxIdle(10);
        ds.setMinIdle(2);

        ds.setValidationQuery("SELECT 1");
        ds.setTestOnBorrow(true);

        return ds;
    }

    public static void main(String[] args) throws Exception {
        DataSource ds = dataSource();
        try (Connection c = ds.getConnection();
             PreparedStatement ps = c.prepareStatement("SELECT 1");
             ResultSet rs = ps.executeQuery()) {
            while (rs.next()) System.out.println(rs.getInt(1));
        }
    }
}

Key idea: with pooling, conn.close() does not close the physical DB connection; it returns it to the pool.


2) Lower-level (more flexible): PoolingDataSource + Commons Pool

This approach wires DBCP to Commons Pool manually (useful when you want full control over the pool object/config).

package org.kodejava.jdbc;

import org.apache.commons.dbcp2.ConnectionFactory;
import org.apache.commons.dbcp2.DriverManagerConnectionFactory;
import org.apache.commons.dbcp2.PoolableConnection;
import org.apache.commons.dbcp2.PoolableConnectionFactory;
import org.apache.commons.dbcp2.PoolingDataSource;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import javax.sql.DataSource;

public final class ManualDbcpPoolFactory {

    public static DataSource createDataSource() {
        ConnectionFactory cf =
                new DriverManagerConnectionFactory("jdbc:postgresql://localhost:5432/app",
                        "<db_user>", "<db_password>");

        PoolableConnectionFactory pcf = new PoolableConnectionFactory(cf, null);
        pcf.setValidationQuery("SELECT 1");

        GenericObjectPoolConfig<PoolableConnection> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(20);
        config.setMaxIdle(10);
        config.setMinIdle(2);
        config.setTestOnBorrow(true);

        GenericObjectPool<PoolableConnection> pool = new GenericObjectPool<>(pcf, config);
        pcf.setPool(pool);

        return new PoolingDataSource<>(pool);
    }
}

Practical tuning checklist (what to set and why)

  • Sizing
    • maxTotal: hard cap of concurrent borrowed connections.
    • maxIdle / minIdle: how many connections to keep around to absorb spikes.
  • Validation
    • Use validationQuery (or validationQueryTimeout) and pick one strategy:
      • testOnBorrow=true (safer, slightly more overhead), or
      • testWhileIdle=true + eviction run (common for reducing borrow-time latency).
  • Timeouts
    • maxWaitMillis: how long callers wait for a free connection before failing.
  • Always close
    • Ensure try-with-resources everywhere; leaks will exhaust the pool.

Maven dependencies

<dependencies>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-dbcp2</artifactId>
        <version>2.14.0</version>
    </dependency>

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

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.7</version>
    </dependency>
</dependencies>

Maven Central Maven Central Maven Central