乐趣区

关于后端:quarkus依赖注入之九bean读写锁

欢送拜访我的 GitHub

这里分类和汇总了欣宸的全副原创(含配套源码):https://github.com/zq2599/blog_demos

本篇概览

  • 本篇是《quarkus 依赖注入》的第九篇,指标是在轻松的氛围中学习一个小技能:bean 锁
  • quarkus 的 bean 锁自身很简略:用两个注解润饰 bean 和办法即可,但波及到多线程同步问题,欣宸违心花更多篇幅与各位 Java 程序员一起畅谈多线程,聊个痛快,本篇由以下内容组成
  1. 对于多线程同步问题
  2. 代码复现多线程同步问题
  3. quarkus 的 bean 读写锁

对于读写锁

  • java 的并发包中有读写锁 ReadWriteLock:在多线程场景中,如果某个对象处于扭转状态,能够用写锁加锁,这样所有做读操作对象的线程,在获取读锁时就会 block 住,直到写锁开释
  • 为了演示 bean 锁的成果,咱们先来看一个经典的多线程同步问题,如下图,余额 100,充值 10 块,扣费 5 块,失常状况下最终余额应该是 105,但如果充值和扣费是在两个线程同时进行,而且各算各的,再别离用本人的计算结果去笼罩余额,最终会导致计算不精确

代码复现多线程同步问题

  • 咱们用代码来复现上图中的问题,AccountBalanceService 是个账号服务类,其成员变量 accountBalance 示意余额,另外有三个办法,性能别离是:
  1. get:返回余额,相当于查问余额服务
  2. deposit:充值,入参是充值金额,办法内将余额放入长期变量,而后期待 100 毫秒模仿耗时操作,再将长期变量与入参的和写入成员变量accountBalance
  3. deduct:扣费,入参是扣费金额,办法内将余额放入长期变量,而后期待 100 毫秒模仿耗时操作,再将长期变量与入参的差写入成员变量accountBalance
  • AccountBalanceService.java 源码如下,deposit 和 deduct 这两个办法各算各的,丝毫没有思考过后其余线程对 accountBalance 的影响
package com.bolingcavalry.service.impl;

import io.quarkus.logging.Log;
import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class AccountBalanceService {

    // 账户余额,假如初始值为 100
    int accountBalance = 100;

    /**
     * 查问余额
     * @return
     */
    public int get() {
        // 模仿耗时的操作
        try {Thread.sleep(80);
        } catch (InterruptedException e) {e.printStackTrace();
        }
        return accountBalance;
    }

    /**
     * 模仿了一次充值操作,* 将账号余额读取到本地变量,* 通过一秒钟的计算后,将计算结果写入账号余额,* 这一秒内,如果账号余额产生了变动,就会被此办法的本地变量笼罩,* 因而,多线程的时候,如果其余线程批改了余额,那么这里就会笼罩掉,导致多线程同步问题,* AccountBalanceService 类应用了 Lock 注解后,执行此办法时,其余线程执行 AccountBalanceService 的办法时就会 block 住,防止了多线程同步问题
     * @param value
     * @throws InterruptedException
     */
    public void deposit(int value) {
        // 先将 accountBalance 的值存入 tempValue 变量
        int tempValue  = accountBalance;
        Log.infov("start deposit, balance [{0}], deposit value [{1}]", tempValue, value);

        // 模仿耗时的操作
        try {Thread.sleep(100);
        } catch (InterruptedException e) {e.printStackTrace();
        }

        tempValue += value;

        // 用 tempValue 的值笼罩 accountBalance,// 这个 tempValue 的值是基于 100 毫秒前的 accountBalance 计算出来的,// 如果这 100 毫秒期间其余线程批改了 accountBalance,就会导致 accountBalance 不精确的问题
        // 例如最后有 100 块,这里存了 10 块,所以余额变成了 110,
        // 然而这期间如果另一线程取了 5 块,那余额应该是 100-5+10=105,然而这里并没有聚拢 100-5,而是很暴力的将 110 写入到 accountBalance
        accountBalance = tempValue;

        Log.infov("end deposit, balance [{0}]", tempValue);
    }

    /**
     * 模仿了一次扣费操作,* 将账号余额读取到本地变量,* 通过一秒钟的计算后,将计算结果写入账号余额,* 这一秒内,如果账号余额产生了变动,就会被此办法的本地变量笼罩,* 因而,多线程的时候,如果其余线程批改了余额,那么这里就会笼罩掉,导致多线程同步问题,* AccountBalanceService 类应用了 Lock 注解后,执行此办法时,其余线程执行 AccountBalanceService 的办法时就会 block 住,防止了多线程同步问题
     * @param value
     * @throws InterruptedException
     */
    public void deduct(int value) {
        // 先将 accountBalance 的值存入 tempValue 变量
        int tempValue  = accountBalance;
        Log.infov("start deduct, balance [{0}], deposit value [{1}]", tempValue, value);

        // 模仿耗时的操作
        try {Thread.sleep(100);
        } catch (InterruptedException e) {e.printStackTrace();
        }

        tempValue -= value;

        // 用 tempValue 的值笼罩 accountBalance,// 这个 tempValue 的值是基于 100 毫秒前的 accountBalance 计算出来的,// 如果这 100 毫秒期间其余线程批改了 accountBalance,就会导致 accountBalance 不精确的问题
        // 例如最后有 100 块,这里存了 10 块,所以余额变成了 110,
        // 然而这期间如果另一线程取了 5 块,那余额应该是 100-5+10=105,然而这里并没有聚拢 100-5,而是很暴力的将 110 写入到 accountBalance
        accountBalance = tempValue;

        Log.infov("end deduct, balance [{0}]", tempValue);
    }
}
  • 接下来是单元测试类 LockTest.java,有几处须要留神的中央稍后会阐明
package com.bolingcavalry;

import com.bolingcavalry.service.impl.AccountBalanceService;
import io.quarkus.logging.Log;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
import java.util.concurrent.CountDownLatch;

@QuarkusTest
public class LockTest {

    @Inject
    AccountBalanceService account;

    @Test
    public void test() throws InterruptedException {CountDownLatch latch = new CountDownLatch(3);
        int initValue = account.get();

        final int COUNT = 10;

        // 这是个只负责读取的线程,循环读 10 次,每读一次就期待 50 毫秒
        new Thread(() -> {for (int i=0;i<COUNT;i++) {
                // 读取账号余额
                Log.infov("current balance {0}", account.get());
            }

            latch.countDown();}).start();

        // 这是个充值的线程,循环充 10 次,每次存 2 元
        new Thread(() -> {for (int i=0;i<COUNT;i++) {account.deposit(2);
            }
            latch.countDown();}).start();

        // 这是个扣费的线程,循环扣 10 次,每取 1 元
        new Thread(() -> {for (int i=0;i<COUNT;i++) {account.deduct(1);
            }
            latch.countDown();}).start();

        latch.await();

        int finalValue = account.get();
        Log.infov("finally, current balance {0}", finalValue);
        Assertions.assertEquals(initValue + COUNT, finalValue);
    }
}
  • 上述代码中,有以下几点须要留神
  1. 在主线程中新增了三个子线程,别离执行查问、充值、扣费的操作,可见 deposit 和 deduct 办法是并行执行的
  2. 初始余额 100,充值一共 20 元,扣费一共 10 元,因而最终正确后果应该是 110 元
  3. 为了确保三个子线程全副执行结束后主线程才退出,这里用了 CountDownLatch,在执行 latch.await() 的时候主线程就开始期待了,等到三个子线程把各自的 latch.await() 都执行后,主线程才会继续执行
  4. 最终会查看余额是否等于 110,如果不是则单元测试不通过
  • 执行单元测试,后果如下图,果然失败了
  • 来分析测试过程中的日志,有助于咱们了解问题的起因,如下图,充值和扣费同时开始,充值先实现,此时余额是 102,然而扣费忽视 102,仍旧应用 100 作为余额去扣费,而后将扣费后果 99 写入余额,导致余额与正确的逻辑产生差距
  • 重复运行上述单元测试,能够发现每次失去的后果都不一样,这算是典型的多线程同步问题了吧 …
  • 看到这里,经验丰富的您应该想到了多种解决形式,例如上面这五种都能够:
  1. 用传统的 synchronized 关键字润饰三个办法
  2. java 包的读写锁
  3. deposit 和 deduct 办法外部,不要应用长期变量 tempValue,将余额的类型从 int 改成 AtomicInteger,再应用 addAndGet 办法计算并设置
  4. 用 MySQL 的乐观锁
  5. 用 Redis 的分布式锁
  • 没错,上述办法都能解决问题,当初除了这些,quarku 还从 bean 的维度为咱们提供了一种新的办法:bean 读写锁,接下来细看这个 bean 读写锁

Container-managed Concurrency:quarkus 基于 bean 的读写锁计划

  • quarkus 为 bean 提供了读写锁计划:Lock注解,借助它,能够为 bean 的所有办法增加同一把写锁,再手动将读锁增加到指定的读办法,这样在多线程操作的场景下,也能保证数据的正确性
  • 来看看 Lock 注解源码,很简略的几个属性,要重点留神的是:默认属性为 Type.WRITE,也就是写锁,被 Lock 润饰后,锁类型有三种抉择:读锁,写锁,无锁
@InterceptorBinding
@Inherited
@Target(value = { TYPE, METHOD})
@Retention(value = RUNTIME)
public @interface Lock {

    /**
     * 
     * @return the type of the lock
     */
    @Nonbinding
    Type value() default Type.WRITE;

    /**
     * If it's not possible to acquire the lock in the given time a {@link LockException} is thrown.
     * 
     * @see java.util.concurrent.locks.Lock#tryLock(long, TimeUnit)
     * @return the wait time
     */
    @Nonbinding
    long time() default -1l;

    /**
     * 
     * @return the wait time unit
     */
    @Nonbinding
    TimeUnit unit() default TimeUnit.MILLISECONDS;

    public enum Type {
        /**
         * Acquires the read lock before the business method is invoked.
         */
        READ,
        /**
         * Acquires the write (exclusive) lock before the business method is invoked.
         */
        WRITE,
        /**
         * Acquires no lock.
         * <p>
         * This could be useful if you need to override the behavior defined by a class-level interceptor binding.
         */
        NONE
    }

}
  • 接下来看看如何用 bean 锁解 AccountBalanceService 的多线程同步问题
  • 为 bean 设置读写锁很简略,如下图红框 1,给类增加 Lock 注解后,AccountBalanceService 的每个办法都默认增加了写锁,如果想批改某个办法的锁类型,能够像红框 2 那样指定,Lock.Type.READ示意将 get 办法改为读锁,如果不想给办法上任何锁,就应用Lock.Type.NONE
  • 这里预测一下批改后的成果
  1. 在 deposit 和 deduct 都没有被调用时,get 办法能够被调用,而且能够多线程同时调用,因为每个线程都能顺利拿到读锁
  2. 一旦 deposit 或者 deduct 被调用,其余线程在调用 deposit、deduct、get 办法时都被阻塞了,因为此刻不管读锁还是写锁都拿不到,必须等 deposit 执行结束,它们才从新去抢锁
  3. 有了上述逻辑,再也不会呈现 deposit 和 deduct 同时批改余额的状况了,预测单元测试应该能通过
  4. 这种读写锁的办法尽管能够确保逻辑正确,然而代价不小(一个线程执行,其余线程期待),所以在并发性能要求较高的场景下要慎用,能够思考乐观锁、AtomicInteger 这些形式来升高期待代价
  • 再次运行单元测试,如下图,测试通过
  • 再来看看测试过程中的日志,如下图,之前的几个办法同时执行的状况曾经隐没了,每个办法在执行的时候,其余线程都在期待
  • 至此,bean 锁知识点学习结束,心愿本篇能给您一些参考,为您的并发编程中增加新的计划

源码下载

  • 本篇实战的残缺源码可在 GitHub 下载到,地址和链接信息如下表所示(https://github.com/zq2599/blog_demos)
名称 链接 备注
我的项目主页 https://github.com/zq2599/blog_demos 该我的项目在 GitHub 上的主页
git 仓库地址(https) https://github.com/zq2599/blog_demos.git 该我的项目源码的仓库地址,https 协定
git 仓库地址(ssh) git@github.com:zq2599/blog_demos.git 该我的项目源码的仓库地址,ssh 协定
  • 这个 git 我的项目中有多个文件夹,本次实战的源码在 quarkus-tutorials 文件夹下,如下图红框
  • quarkus-tutorials是个父工程,外面有多个 module,本篇实战的 module 是basic-di,如下图红框

欢送关注思否:程序员欣宸

学习路上,你不孤独,欣宸原创一路相伴 …

退出移动版