Why MyBatis SqlSession Fails with Multiple Threads and How Spring Guarantees Thread Safety

An in‑depth guide shows how running 100 concurrent MyBatis queries triggers a ClassCastException, explains the underlying cache placeholder issue, and demonstrates how Spring Boot’s SqlSessionTemplate and transaction management guarantee thread‑safe SqlSession usage through ThreadLocal binding.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Why MyBatis SqlSession Fails with Multiple Threads and How Spring Guarantees Thread Safety

1. Environment Setup

mybatis-config.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/testjpa?serverTimezone=GMT%2B8"/>
        <property name="username" value="root"/>
        <property name="password" value="123123"/>
      </dataSource>
    </environment>
  </environments>
  <mappers>
    <mapper resource="mappers/UsersMapper.xml"/>
  </mappers>
</configuration>

UsersMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pack.mapper.UsersMapper">
  <select id="selectList" resultType="com.pack.domain.Users">
    select * from t_users
  </select>
</mapper>

UsersMapper.java

package com.pack.mapper;

import java.util.List;
import com.pack.domain.Users;

public interface UsersMapper {
  List<Users> selectList();
}

Users.java

public class Users{
  private String id ;
  private String username ;
  private String password ;
}

UsersMapperTest.java (test class)

public class UsersMapperTest {

  private static final int MAX = 100;
  private SqlSessionFactory sqlSessionFactory ;
  private Thread[] threads = new Thread[MAX] ;
  private CountDownLatch cdl = new CountDownLatch(MAX) ;

  @Before
  public void init() throws Exception {
    String resource = "mybatis-config.xml";
    InputStream inputStream = Resources.getResourceAsStream(resource);
    sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
  }

  @Test
  public void testSelectList() throws Exception {
    SqlSession session = sqlSessionFactory.openSession() ;
    UsersMapper mapper = session.getMapper(UsersMapper.class) ;

    for (int i = 0; i < MAX; i++) {
      threads[i] = new Thread(() -> {
        try {
          cdl.await() ;
          System.out.println(mapper.selectList()) ;
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }) ;
    }
    for (int i = 0; i < MAX; i++) {
      threads[i].start() ;
      cdl.countDown() ;
    }
    System.in.read() ;
  }
}

Running 100 threads simultaneously produces a ClassCastException because the cached ExecutionPlaceholder enum is cast to List when another thread reads the cache.

2. Error Analysis

The stack trace points to DefaultSqlSession.selectList and BaseExecutor.query. In BaseExecutor.queryFromDatabase an ExecutionPlaceholder is stored in the local cache before the actual query runs. When a second thread reaches the cache read point, it retrieves the placeholder and attempts to cast it to List, causing the exception.

Solution: each thread must obtain its own SqlSession instance.

3. Default SqlSession Implementation

SqlSessionFactory creates a DefaultSqlSessionFactory which returns a DefaultSqlSession:

public class DefaultSqlSessionFactory implements SqlSessionFactory {
  @Override
  public SqlSession openSession() {
    return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
  }
  private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
      Environment environment = configuration.getEnvironment();
      TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
      tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
      Executor executor = configuration.newExecutor(tx, execType);
      return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
      closeTransaction(tx);
      throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }
}
DefaultSqlSession

is explicitly documented as **not thread‑safe**.

4. How Spring Handles It

4.1 Dependency

<dependency>
  <groupId>org.mybatis.spring.boot</groupId>
  <artifactId>mybatis-spring-boot-starter</artifactId>
  <version>2.1.4</version>
</dependency>

4.2 Auto‑configuration

public class MybatisAutoConfiguration implements InitializingBean {
  @Bean
  @ConditionalOnMissingBean
  public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
    // ... configure factory ...
    return factory.getObject();
  }

  @Bean
  @ConditionalOnMissingBean
  public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
    ExecutorType executorType = this.properties.getExecutorType();
    if (executorType != null) {
      return new SqlSessionTemplate(sqlSessionFactory, executorType);
    } else {
      return new SqlSessionTemplate(sqlSessionFactory);
    }
  }
}

4.3 Mapper Scanning

public class MapperScannerRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware {
  @Override
  public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
    AnnotationAttributes mapperScanAttrs = AnnotationAttributes.fromMap(
        importingClassMetadata.getAnnotationAttributes(MapperScan.class.getName()));
    if (mapperScanAttrs != null) {
      // build and register MapperScannerConfigurer
    }
  }
}

The registrar creates a MapperScannerConfigurer that scans for mapper interfaces and registers each as a MapperFactoryBean.

4.4 Mapper Instantiation

public class MapperFactoryBean<T> extends SqlSessionDaoSupport implements FactoryBean<T> {
  @Override
  public T getObject() throws Exception {
    return getSqlSession().getMapper(this.mapperInterface);
  }
}

4.5 Thread‑Safe SqlSession

public class SqlSessionTemplate implements SqlSession, DisposableBean {
  private final SqlSession sqlSessionProxy;

  public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {
    notNull(sqlSessionFactory, "Property 'sqlSessionFactory' is required");
    notNull(executorType, "Property 'executorType' is required");
    this.sqlSessionFactory = sqlSessionFactory;
    this.executorType = executorType;
    this.exceptionTranslator = exceptionTranslator;
    this.sqlSessionProxy = (SqlSession) newProxyInstance(SqlSessionFactory.class.getClassLoader(),
        new Class[] { SqlSession.class }, new SqlSessionInterceptor());
  }

  public <T> T selectOne(String statement) {
    return this.sqlSessionProxy.selectOne(statement);
  }

  private class SqlSessionInterceptor implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      SqlSession sqlSession = SqlSessionUtils.getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
          SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
      try {
        Object result = method.invoke(sqlSession, args);
        return result;
      } finally {
        if (sqlSession != null) {
          SqlSessionUtils.closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
        }
      }
    }
  }
}
SqlSessionUtils.getSqlSession

first checks TransactionSynchronizationManager for a SqlSessionHolder bound to the current thread. If none exists, it opens a new session and registers it in a ThreadLocal, ensuring the same thread reuses the same SqlSession.

public final class SqlSessionUtils {
  public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {
    SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
    SqlSession session = sessionHolder(executorType, holder);
    if (session != null) {
      return session;
    }
    session = sessionFactory.openSession(executorType);
    registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
    return session;
  }
  // registration logic uses ThreadLocal resources
}

4.6 Transaction Management

public class SpringManagedTransactionFactory implements TransactionFactory {
  @Override
  public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) {
    return new SpringManagedTransaction(dataSource);
  }
  // other methods omitted
}

public class SpringManagedTransaction implements Transaction {
  private final DataSource dataSource;
  private Connection connection;
  private boolean autoCommit;
  private boolean isConnectionTransactional;

  public SpringManagedTransaction(DataSource dataSource) {
    notNull(dataSource, "No DataSource specified");
    this.dataSource = dataSource;
  }

  @Override
  public Connection getConnection() throws SQLException {
    if (this.connection == null) {
      openConnection();
    }
    return this.connection;
  }

  private void openConnection() throws SQLException {
    this.connection = DataSourceUtils.getConnection(this.dataSource);
    this.autoCommit = this.connection.getAutoCommit();
    this.isConnectionTransactional = DataSourceUtils.isConnectionTransactional(this.connection, this.dataSource);
  }

  @Override
  public void commit() throws SQLException {
    if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
      this.connection.commit();
    }
  }

  @Override
  public void rollback() throws SQLException {
    if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
      this.connection.rollback();
    }
  }

  @Override
  public void close() throws SQLException {
    DataSourceUtils.releaseConnection(this.connection, this.dataSource);
  }
}

If a SqlSession is not managed by Spring (no @Transactional), Spring forces a commit; otherwise the transaction lifecycle is handled by Spring’s synchronization mechanisms.

Images illustrating the flow of SqlSession acquisition and transaction binding:

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaSpring BootMyBatisORMThread SafetySqlSession
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.