4

由于当前的项目中由于多线程操作同一个实体,会出现数据覆盖的问题,后保存的实体把先保存的实体的数据给覆盖了。

于是查找了锁的实现的几种方式。

但写到最后发现,其实自己可以写sql 更新需要更新的字段即可,这个操作放在文章尾部。

先来说一下实现锁的几种方式。

对方法加锁

不同于对数据库数据加锁,这种方式是对类中的某个方法加锁

synchronized

java中已经有了内置锁:synchronized, 它的特点是使用简单,一切交给JVM去处理,不需要显示释放

示例:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

如代码可见,只要方法上加上synchronized关键字就可以了。

现在来测试一下

不使用synchronized时

测试: test字段初始化为0,通过new Thread测试100个线程高并发量下test每次 + 1。

controller:

public void batchSave() {
        for (int i = 0; i < 100; i++) {
            int finalI = i;
            new Thread(() -> {
                logService.test();
            }).start();
        }

service:

 @Override
  public void test() {
    Client client = clientRepository.findById(9L).get();
    client.setTest(client.getTest() + 1);
    clientRepository.save(client);
  }

测试结果:为15image.png

说明在高并发量的情况下,出现了数据覆盖的情况,线程并没有等待上一个线程完成再进行操作,100个线程进行+1操作,最终只加了15.

使用synchronized时

service:

 @Override
  public synchronized void test() {
    Client client = clientRepository.findById(9L).get();
    client.setTest(client.getTest() + 1);
    clientRepository.save(client);
  }

测试结果:image.png

测试结果为100符合预期,高并发量下,每个线程都排队进行访问修改,100个线程进行+1操作,最终加了100.

原理:从语法上讲,Synchronized可以把任何一个非null对象作为"锁",这个锁的名字是monitor。

当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;

简单来说,该方法的monitor只能被一个线程占用,如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor不被占用。这就完成了锁的实现。

ReentrantLock

java.util.concurrent.locks包提供的ReentrantLock加锁。

ReentrantLock示例:

private final Lock lock = new ReentrantLock();
    public void add() {
        lock.lock();
        try {
          // 代码
        } finally {
            lock.unlock();
        }
    }

加锁之后,我们需要在finally中释放锁

这种锁的其实就是在类中设置一个变量,我们可以对它加锁和解锁。当锁定状态时,其他线程需要等待。

相比synchronized,显示锁可以用非阻塞的方式获取锁,可以响应程序中断,可以设定程序的阻塞时间,拥有更加灵活的操作。相关具体操作可以去查询,在此不过多展示。

例如: 可以设置tryLock,如果1秒未获取到锁则不执行

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

ReentrantLock有两个构造方法。

public ReentrantLock()
public ReentrantLock(boolean fair)

参数fair表示是否保证公平,在不指定的情况下默认值为false,表示不保证公平。

公平表示:等待时间最长的线程优先获取锁。


事务和锁发生的异常

注意:这两种锁在在和@Transational同一个方法一起使用的情况下会出现,锁已经去除,但是事务还没提交的情况,造成脏读和数据不一致性等情况.
image.png

由于事务的提交在方法运行结束之后,并且事务真正启动在执行到它们之后的第一个操作 InnoDB 表的语句的时候

所以会出现这种情况
image.png

例如上一个线程还没来得及提交事务,所以当前线程访问到的数据库还是原来的数据,这就造成了读取的库存数据不是最新的。

解决:可以把@Transational和业务逻辑的代码单独提到一个service里。

public void test() {
    lock.lock();
    try {
      service.update
    } finally {
      lock.unlock();
    }
  }
   @Transactional
    public void update(int id) {
        /*
          业务代码
         */
    }

总结:

锁的实现:
Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。

性能的区别:在Synchronized优化后,其实性能差不多。

ReenTrantLock独有的能力:
ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。还可以使用tryLock等操作。

对这两种来说,我比较推荐用ReenTrantLock,更灵活,并且代码可读性更高。如果用synchronized,别人读代码可能容易忽略。

对数据库数据加锁

相比上面的对方法加锁来说,这种方式是对数据库加锁。对于多个不同的service都操作同一数据的话,我们就需要对数据库上锁了。

又分为悲观锁和乐观锁:

悲观锁

悲观锁顾名思义就是悲观的认为自己操作的数据都会被其他线程操作,所以就必须自己独占这个数据,可以理解为”独占锁“。上面提到的中synchronized和ReentrantLock等锁就是悲观锁,数据库中表锁、行锁、读写锁等也是悲观锁。

实现原理很简单: 在 where语句后面加上for update就行了。这样就能锁住这条数据。不过要注意的是,注意查询条件必须要是索引列(这里设置的是id),如果不是索引就会变成表锁,把整个表都锁住。

@Query(value = "select * from client a where a.id = :id for update", nativeQuery = true)
Optional<Client> findClientForUpdate(Long id);

JPA有提供一个更简洁的方式,就是@Lock注解

    /**
     * 查询时加上悲观锁
     * 在我们没有将其提交事务之前,其他线程是不能获取修改的,需要等待
     * @param id clientId
     * @return
     */
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Client a where a.id = :id")
    Optional<Client> findClientByIdWithPessimisticLock(Long id);

原理还是for update,只不过是JPA帮我们加了而已。

具体测试悲观锁的过程可以看我这篇文章:https://segmentfault.com/a/11...

乐观锁

原理就是在实体中加一个字段当作版本号,比如我们加个字段version。

@Data
@Entity
public class Client {
    ...
    private Long version;
}

当我们client进行更新的时候,例如我们拿到version是1,操作完成之后要插入的时候,发现version变成2了,哎这就不对了,肯定是有其他人更改了数据,那这时候我就不能插入了。

@Query(value = "update client set name = :name, version = version + 1 where id = :id and version = :version", nativeQuery = true)
int updateClinetWithVersion(Long id, String naem, Long version);

可以看到update的where有一个判断version的条件,并且会set version = version + 1。这就保证了只有当数据库里的版本号和要更新的实体类的版本号相同的时候才会更新数据。

这个返回值代表更新了的数据库行数,如果值为0的时候没有更新,说明版本号发生错误。

与悲观锁相同,jpa也提供了乐观锁的实现方式。

@Data
@Entity
public class Article {
    ...
    
    @Version
    private Long version;
}

使用了@version之后,我们不需要再自己写仓库层, 正常使用findById,save方法即可。

public void update(Long id, String name) {
    Client client = repository.findById(id).get();
    client.setName(name);
    repository.save(client);
}

实现乐观锁之后,如果没有成功更新数据则抛出异常回滚保证数据

乐观锁适合写少读多的场景,写多的情况会经常回滚,消耗性能。

悲观锁适合写多读少的场景,使用的时候该线程会独占这个资源。

参考文章:https://segmentfault.com/a/11...

回到开头

开头讲到,数据覆盖的时候不一定需要使用锁,也可以自己写sql,更新需要更新的字段即可。

例如一个线程更新的是age字段,一个是name字段,那我们可以写更新语句,只更新实体的名字。

@Modifying
@Query (value = "update client set name = :name where id = :id")
public void updateNameById(String name, int id);

public update(int id){
 
   Client client = repository.findById(id);
   client.setName("name");//注意不要这么写
   repository.updateNameById("name",id);    

注意这里不要使用set,否则发现数据还是会被覆盖。

这是jpa自己的特性,自动更新

当实体对象属于托管状态下时,往这个对象里面的某个属性set新的值,jpa检测到有变化,就会自动更新entity 的数据到db中。

具体可以查看这篇文章:https://blog.csdn.net/janet11...


weiweiyi
1k 声望123 粉丝