likes
comments
collection
share

【并发问题】这段代码会不会造成死锁?

作者站长头像
站长
· 阅读数 4

环境:Spring5.3.23


1. 前言

      在多线程编程中,锁是保证数据一致性和避免并发访问的重要机制。JVM锁和MySQL行锁是两种常见的锁类型,它们在处理并发操作时可能会引发死锁问题。本文将结合一个示例讨论JVM锁和MySQL行锁是否会造成死锁,分析它们引发死锁的原因,并提出相应的解决方案。通过了解JVM锁和MySQL行锁的工作机制以及死锁的解决方法,我们可以更好地处理并发编程中的问题,提高程序的性能和稳定性。

2. 锁介绍

JVM锁       JVM锁是Java虚拟机中的一种同步机制,也称为内置锁或监视器锁。它是通过synchronized关键字来实现的,可以用来保护临界区代码,确保同一时刻只有一个线程可以访问被保护的代码块。 在Java中,使用synchronized关键字可以创建临界区,临界区是一段程序代码,只能被一个线程执行。当一个线程进入临界区时,其他线程将被阻塞,直到当前线程退出临界区。JVM锁是基于对象的,每个对象都有一个与之关联的内置锁。当一个线程获取到某个对象的内置锁时,其他线程必须等待该线程释放锁后才能获取。

MySQL行锁

      MySQL行锁是MySQL数据库中用于保护数据一致性和避免并发访问的一种锁机制。行锁是针对数据库中的每一行数据进行锁定的,它可以防止多个事务同时对同一行数据进行修改或删除。

      在MySQL中,行锁是通过在存储引擎层实现的,不同的存储引擎可能采用不同的行锁实现方式。InnoDB存储引擎是MySQL默认的存储引擎(MySQL5.5之后),它支持行锁功能。

MySQL行锁与索引之间的关系

      首先,行锁的粒度通常取决于所使用的索引类型。在MySQL中,如果对某个字段进行了索引,那么在对该字段进行查询时,将会使用该索引并锁住相应的行。这种锁住行的行为可以避免对整表进行锁定,从而提高了并发性能。 其次,索引的使用还可以影响锁的冲突情况。例如,如果在多个事务中都使用了相同的索引来访问相同的数据行,那么可能会出现锁冲突的情况。这种情况下,如果一个事务正在对某行进行修改,其他事务就需要等待该事务释放锁后才能继续操作。 此外,索引的类型也会影响锁的粒度。例如,主键索引是唯一的,因此在对主键索引进行操作时,锁住的行数通常只有一行。而如果使用非主键索引进行操作,锁住的行数可能会更多,因为非主键索引可能并不唯一。

了解完JVM锁与MySQL基础知识后,接下来我们结合一段代码来分析下代码是否存在并发问题而导致死锁问题。

3. 示例代码

下面代码实现了2个功能,批量删除数据和根据id删除数据都非常的简单。

static class PersonService {
    @Resource
    private JdbcTemplate jdbcTemplate;
    @Resource
    private DataSource dataSource ;
    @Resource
    private DataSourceTransactionManager tm ;

    // 这里模拟了批量删除数据,当前数据库有id:[1,2,3]的数据
    @Transactional
    public void batcherOperator() {
      IntStream.of(1, 2, 3).forEach(id -> {
        delete(id) ;
      }) ;
    }
    private void delete(int id) {
      synchronized (this) {
        this.jdbcTemplate.update("delete from t_person where id = ?", id) ;
      }
    }
    // 更新数据,同时在方法上加了锁
    @Transactional
    public synchronized void update(int id) {
      // XXOO还有一堆其它的操作
      this.jdbcTemplate.update("update t_person t set t.name = 'xxx' where t.id = 1") ;
    } 
  }

上面代码是否存在问题?大家可以先想想

启动两个线程测试一个线程执行批量操作,一个线程执行更新操作

new Thread(() -> {
  ps.batcherOperator() ;
}, "T1").start() ;
// 这里休眠保证T1线程先执行
TimeUnit.MILLISECONDS.sleep(500) ;
new Thread(() -> {
  ps.update(1) ;
}, "T2").start() ;

上面代码执行后,如果运气不好,你执行多少遍好像程序也没有什么问题。id为1,2,3的数据都被删除了。接下来我们将程序稍微修改下

// 在批量操作遍历时加入条件休眠
IntStream.of(1, 2, 3).forEach(id -> {
  delete(id) ;
  // 当删除id=2时让线程休眠2s。
  // 保证更新线程在当前线程没有执行完之前就能执行
  if (id == 2) {
    try {
      TimeUnit.SECONDS.sleep(2) ;
    }
  }
}) ;

再次进行测试,最终在等待50s(mysql默认锁等待超时时间是50s)后程序抛出了如下错误:

org.springframework.dao.CannotAcquireLockException: StatementCallback; SQL [update t_person t set t.name = 'xxx' where t.id = 1]; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
  at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:267)
  at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:70)
  at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1541)

数据库抛出了异常,等待锁超时了。到这来,大家可以先自己想想原因是什么?


接下来分析原因,分析原因前,我们先通过数据库来演示锁超时的问题;分别开2个mysql窗口,一个更新操作,一个删除操作。

T1 窗口

【并发问题】这段代码会不会造成死锁?

T2 窗口,在等待了50s后,发生了异常

【并发问题】这段代码会不会造成死锁?

锁等待超时,这与上面我们程序中发生的错误是一致的。

现在我们来通过时间线来分析上面的代码

【并发问题】这段代码会不会造成死锁?

这样就造成了死锁,jvm锁与MySQL行锁。

如何优化修改呢?


将代码做如下修改,将同步范围放大到循环的外面

@Transactional
public void batcherOperator() {
  synchronized (this) {
    IntStream.of(1, 2, 3).forEach(id -> {
      delete(id) ;
      if (id == 2) {
        try {
          TimeUnit.SECONDS.sleep(2) ;
        } catch (Exception e) {
          e.printStackTrace();
        }
      }
    }) ;
  }
}

这样修改后基本就没有死锁问题了,但是这样修改一定就没有其它的并发问题吗?

通过本文的学习,读者可以了解JVM锁和MySQL行锁的工作原理以及死锁问题的产生原因和解决方法。这些知识对于开发高效、稳定的并发程序至关重要。希望本文能够帮助读者在实际开发中更好地应用这些技术,提高程序的性能和稳定性。