Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

迭代苔原
• 阅读 3756

在 Java 中,锁好像是颗万能药,没什么问题是加锁解决不了的。的确,锁能解决绝大部分的并发问题。

然而,最简单的东西也往往最容易出现问题。你只要稍有不慎,不但 Bug 没有解决,还得花费大量的时间做各种排查。

既然如此,我们就来好好看看:为什么用了锁,程序还是出错了。

什么才是正确的锁模型

一说到锁,你的大脑中可能立马想起这个模型。

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

中间蓝色的一段代码,叫做:临界区。线程进入临界区前,先尝试加锁,如果成功,就进入临界区。此时,这个线程持有锁,等执行完临界区的代码后,持有锁的线程就会执行解锁

同样的道理,如果加锁失败,线程就会一直等待,直到持有锁的线程解锁后,再重新尝试加锁。

这是锁的简易模型,看起来简单明了,但这个模型是错的,它忽略了最最重要的一点:锁与资源的关系

你还记得吗?锁是为了实现互斥,即:在同一时刻,一个资源只能由一个线程操作。

换句话说,之所以要用锁,就是要保护某些资源。因此,锁与资源之间的关系,是重点中的重点。很多并发 Bug 就是忽略了这一点导致的。比如,你看下面这段代码:

class Account {
    private int balance;

    // 转账
    synchronized void transfer(Account target, int amt){
        if (this.balance > amt) {
            this.balance -= amt;
            target.balance += amt;
        }
    }
}

你可能会觉得,这段代码没问题呀?transfer() 方法不是加锁了吗?但在并发环境下,转账后,余额很可能对不上。原因也很简单,没考虑锁和资源的关系。

既然如此,那正确的锁模型是怎样的呢?

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

首先,我们要标注出受保护的资源 R;然后,为资源 R 创建一把锁 LR;最后,在进出临界区的时候,再加上加锁、解锁操作。

其中,锁 LR受保护资源 R 之间有一条关联线,这非常重要,代表的是:锁与资源之间,是有对应关系的。

打个比方,你家的锁只能保护你家的东西,我家的锁只能保护我家的东西。这个关系要搞清楚,不然,就是锁自家的门来保护他家的东西。

可以说,我们平时对锁的理解都是错的。虽然这一般不会有什么问题,但只要出现问题,就是公司破产之类的大事。

比如,我前东家的支付系统,就是因为锁的问题,巨亏了几十万,负责人引咎辞职。在濒临倒闭之际,我接手了,这个项目才起死回生。

所以,如果你不想重蹈覆辙,而是想升职加薪。那必须要掌握正确的锁模型,千万不能忽略锁和资源的关系

锁与资源的关系

你已经知道了,想要用好用锁,关键是搞清楚锁和资源的关系。那么,这两者的关系是怎样的呢?

资源和锁之间的关系是 N:1。

换句话说,你可以用一把锁,来保护多个资源。但是,你不能用多把锁,来保护一个资源。原因在于,如果你针对一个资源创建了多把锁,那么就达不到互斥的效果了。

打个比方,现实世界中,你可以用好几把锁,来保护你家的东西。但在编程世界中,你不能这样做。你看这个例子:

class SafeCalc {
    private long value = 0L;

    void addOne() {
        synchronized (this) {
            value += 1;
        }
    }

    void addTwo() {
        synchronized (SafeCalc.class) {
            value += 2;
        }
    }
}

你看下 addOne()addTwo() 两个方法,它们分别用了两把锁 thisSafeCalc.class。虽然都是保护同一个资源,但临界区被拆成了 2 个,而这 2 个临界区是没有互斥关系的,这导致了并发问题。

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

那问题该怎么解决呢?

很简单,不要用多把锁来保护一个资源,你可以只用 this,又或者只用 SafeCalc.class

当然,这个例子比较简单。在现实工作中,情况肯定更加复杂,我们要保护的资源不止一个,而是多个,这又该怎么处理呢?

如何保护多个资源

无论是单个资源,还是多个资源,关键都在于:锁要覆盖所有受保护的资源。

单个资源比较好理解,但如果要保护的资源不止一个,那我们首先要做的是,区分这些资源是否有关联,这分为两种情况。

第一,如何保护没有关联的多个资源,这和处理单个资源差不多。如果不考虑性能,你可以用一把锁来保护;如果想提高性能,你可以用不同的锁来保护。

你看下面的代码:

class Account {
    // 余额
    private Integer balance;
    // 密码
    private String password;

    // 锁:保护余额
    private final Object balLock = new Object();
    // 锁:保护密码
    private final Object pwLock = new Object();

    // 取款
    void withdraw(Integer amt) {
        synchronized (balLock) {
            if (this.balance > amt) {
                this.balance -= amt;
            }
        }
    }

    // 更改密码
    void updatePassword(String pw) {
        synchronized (pwLock) {
            this.password = pw;
        }
    }
}

账户类-Account有两个方法:取款-withdraw()更改密码-updatePassword()。从功能上看,这两个方法没什么关联。所以,为了提升性能,我用了两把锁,让它们各管各的。

当然,你也可以用一把锁来管理所有资源,就像下面这样:

class Account {
    // 余额
    private Integer balance;
    // 密码
    private String password;

    // 取款
    synchronized void withdraw(Integer amt) {
        if (this.balance > amt) {
            this.balance -= amt;
        }
    }

    // 更改密码
    synchronized void updatePassword(String pw) {
        this.password = pw;
    }
}

在这里,我对性能没有要求,所以只要用 this 这一把锁,直接管理账户的所有资源。

第二,如何保护有关联的多个资源,这个问题就有点复杂了。

比如,银行的转账操作,不同的账户是有关联的,账户 A 如果减少 100 元,账户 B 就得增加 100 元。这时候,该怎么避免转账的并发问题呢?

你可能想到加锁,用 synchronized 关键词修饰一下,就像下面这样:

class Account {
    // 余额
    private Integer balance;

    // 转账
    synchronized void transfer(Account target, Integer amt) {
        if (this.balance > amt) {
            this.balance -= amt;
            target.balance += amt;
        }
    }

}

一把锁可以保护多个资源,这看上去没问题。然而,却是错误的做法。

你想象一下这样的场景,A、B、C 三个账户的余额都是 100 元。这时候,有两笔转账操作:账户 A 转 100 元到账户 B,账户 B 转 100 元到账户 C。照理说,结果应该是:账户A-0元,账户B-100元,账户C-200元。

然而,如果是并发转账,最终的结果还有两种可能。一种是:账户A-0元,账户B-200元,账户C-200元;另一种是:账户A-0元,账户B-0元,账户C-200元。

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

简单来说,账户 B 的余额很可能是错的,问题就出在this这把锁上。

还记得吗?锁要覆盖所有受保护的资源。

在这个例子中,却没能做到这一点。临界区内有两个资源:this.balancetarget.balance。但this这把锁只能保护this.balance,不能保护target.balance

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

打个比方,你家的锁就算再厉害,也没法保护邻居家的东西吧?

因此,我们要找到一把锁,同时覆盖所有账号。

方案还是挺多的,最简单的一个就是:Account.classAccount.class 共享给所有 Account 对象。而且,Account.class 由 Java 虚拟机创建,具有唯一性。

Java并发编程-用锁的正确姿势:为什么加了锁,但余额还是出错?

这样一来,转账的并发问题就解决了,代码也特别简单。

class Account {
    // 余额
    private Integer balance;

    // 转账
    void transfer(Account target, Integer amt) {
        synchronized (Account.class) {
            if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

写在最后

在 Java 中,锁是解决并发的万能药,但我们却往往用不好,这是因为我们忽略了锁与资源的关系

一般情况下,这不会有什么大问题。

然而,一旦出现多个资源,这些资源还相互关联,就很可能出现公司破产之类的大事。因此,你要时刻记住 3 点:

  1. 锁与资源有对应关系;
  2. 资源和锁之间的关系是 N:1;
  3. 锁要覆盖所有受保护的资源;

你只要检查好这三点,坏事就轮不到你头上。

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
4年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Easter79 Easter79
4年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
4年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
4年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Python进阶者 Python进阶者
2年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这