导言

事前声明:以下故事依据实在事情而改编,如有雷同,纯属巧合~

眼下这位正襟危坐的男人,名为小竹,他正是本次事情的主人公,也行将成为熊猫集团的被告,嗯?这终究怎样一回事?欲知内情怎样,且听下回分解……,啊不对,欲知内情怎样,还需将时刻拨回三周之前…….

哒、哒、哒,那是高跟鞋触摸地板发出的磕碰声,而此刻慵懒瘫坐在工位上的小竹浑然不知,身后有着一位女子正在快速挨近,只见此女身穿黑色职业装,定睛一看,本来正是熊猫集团董事长的千金!

一声呼叫忽然打破了正在悠哉游哉般摸鱼的小竹:

小竹小竹,这儿有个紧迫的商城项目,你快来搞一下,三周后要上线!

一个完整项目,三周时刻就要上线!!怎样办?为了按期交给出产品,小竹遵循着“能跑就行”的原则,动用了他的CV大法,平均日码3W行!不到三周时刻,小型商城项目快速完工。

需求验收时,小竹发现每点一个功用,浏览器都要转半分钟圈圈,此刻客户的眉头紧皱,小竹见状急忙开口道:

王总,尽管看着慢了点,可又不是不能用!三周时刻还要啥自行车,你说是吧?

于是就这样,产品在客户的千般不满下顺畅交给,小竹还贴心的送上了“上线布置”一条龙服务。

视角拉回三周后的今天……

王总再次来到了公司,并站在小竹身前大声质问道:“昨夜这事有必要得给我一个解说!不然你等着被我申述吧!”

嗯?详细咋回事?哦~,本来是上线的商城项目,忽然遇到了并发,导致王总丢失197.83元!

咳咳,看到这儿,不是Java开发的看官能够撤了,后续内容现已雨女无瓜~

PS:个人编写的《技能人求职攻略》小册已完结,其间从技能总结开端,到制定期望、技能突击、简历优化、面试预备、面试技巧、谈薪技巧、面试复盘、选Offer办法、新人入职、进阶提升、职业规划、技能管理、涨薪跳槽、仲裁赔偿、副业兼职……,为我们打造了一套“从求职到跳槽”的一条龙服务,一起也为诸位预备了七折优惠码:3DoleNaE,感兴趣的小伙伴能够点击:s./ds/USoa2R3/了解详情!

OK,下面开端本文的正式内容,看完假如有所收成,请记得点赞、收藏、重视三连支撑一下噢~

一、实在事情的来龙去脉

开局我提到一句话,上述故事依据实在事情改编,其实详细状况和故事差不多,这是来自于一位在微信找我处理Bug的读者,为了便利称号,这儿我们将其化名为:小帅

小帅是一位作业近三年的全栈开发,任职于一家小的项目型外包公司,平常作业便是担任开发Boss从外部接受过来的项目,由于近期项目过多,所以CV大法用上头了,写代码的技巧只需一个:梭哈

正因如此,给某位客户写的项目忽然遇到了并发,形成做项目的老板呈现丢失,先来看问题:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

做的是个商城类型的项目,但平台内一切的买卖,没有直接对接付出渠道,而是规划了“平台币”这种形式。用户能够经过常用付出渠道充值“平台币”,接着能够用“平台币”购买产品;一起,为了资金的合理性,“平台币”也能够反向提现,如提现到微信、付出宝、银行卡。

PS:实际项目中,现金平和台币的比率并非1:1,下面为了便于阐明,我们假定为1:1

所以现在的买卖种类,一共有“下单、提现”这两种(实际还有其他种类,这儿不打开),数据库里也有几张关于买卖的表,最核心的是有张账户表,省掉其他事务字段如下:

账户表:{账户ID、账户名、账户余额}

此刻问题来了,由于是接过来的小项目,开发时的榜首原则追求的是:速度,只需需求能完成,质量、安全啥的都不重要,所以“小帅”在开发项目时,结合MVC思维与三层架构模型,将整个项目分为了:

  • 数据层:便是mapper/dao包,供给操作数据库表的接口
  • 事务层:依据数据层的接口,结合实际事务,完成事务办法;
  • 体现层:调用事务层的办法,对外供给可访问的网络接口。

这个结构信任我们应该再了解不过了,接着小帅在事务层写了“提现、下单”两个办法,如下:

// --------------数据层---------------
public interface AccountMapper {
    // 修正账户表的接口
    int update(Account account);
    // 依据账户名查询账户信息的接口
    Account getAccountByName(String accountName);
    // 省掉其他接口......
}
// --------------事务层---------------
public class AccountServiceImpl implements IAccountService {
    @Autowired
    private AccountMapper accountMapper;
    // 提现的事务办法
    public boolean cashWithdrawal(String accountName, int type,
                                  String target, BigDecimal money){
        // 先依据账户名查询出账户信息
        Account account = accountMapper.getAccountByName(accountName);
        // 省掉提现的其他前置处理,如余额检查、买卖方式处理......
        // 调用数据层update办法,用余额减去提现金额,然后修正数据表
        account.setBalance(account.getBalance().subtract(money));
        int rowNumber = accountMapper.update(account);
        // 省掉提现的其他后置处理,如提现消息告知、流水记载......
        if (rowNumber > 0){
            return true;
        }
        return false;
    }
    // 下单的事务办法
    public boolean placeAnOrder(Order order, String accountName){
        // 先依据账户名查询出账户信息
        Account account = accountMapper.getAccountByName(accountName);
        // 省掉下单其他的前置处理,如库存检测、账户余额查询......
        // 调用数据层update办法,用余额减去订单金额,然后修正数据表
        account.setBalance(account.getBalance().subtract(order.getMoney()));
        int rowNumber = accountMapper.update(account);
        // 省掉提现的其他后置处理,如物流告知、流水记载......
        if (rowNumber > 0){
            return true;
        }
        return false;
    }
    // 省掉其他办法......
}

为了避免事务逻辑的搅扰,这儿抽取了核心代码,AccountServiceImpl类中有两个办法:

  • cashWithdrawal()提现,参数依次为账户名、提现类型、方针账户、提现金额;
  • placeAnOrder()下单,参数依次为订单实体目标,下单用户对应的账户名。

其他方面无需关怀,要点重视两个办法中都有的一行代码:

int rowNumber = accountMapper.update(account);

提现、下单,都是依靠数据层的修正办法,来减去相应账户的余额,搞了解这点后,来考虑一个问题:

一个用户的余额为100元,一起触发下单100元、提现100元操作,会产生什么?

信任研讨过并发编程的小伙伴,就应该能揣度出问题,在之前的《一个恳求的网络之旅》中,曾提到过一个概念:一个客户端的恳求来到服务端后,会分配一条对应的线程来处理。为此,下单、提现两个恳求,也会对应两条线程,两条线程一起履行update()100元余额的操作,此刻钱会被扣两次,余额终究变成-100元!

了解上述这段话后,接着再来说说“小帅”其时遇到的状况,他所遇的状况也非常类似,某一天夜里,平台的一个用户在张狂重复“充值、下单、提现、退款”这个动作,嗯?平台是怎样发觉出来的呢?这是由于项目的客户在检查买卖流水时,发现流水记载里,同一个用户呈现很多买卖记载,所以才发觉了反常。

PS:由于是小的外包项目,平台用户量不大,所以流水记载里才显得特别突出。

客户其时把问题反应过来之后,小帅介入排查,回溯该用户的买卖流水,成果发现是由于这个用户,在机缘巧合之下,用47.83元买了一个产品,一起又提现了47.83元(其时余额只需50元左右),然后或许是该用户闲的没事做,就利用这个缝隙开端张狂薅羊毛……

依据流水记载来揣度,这个用户其时的操作是这样的:

  • ①先经过充值进口,用微信充值了50元,得到了50个平台币;
  • ②再开两个网页窗口,一起触发下单50元、提现50元的操作;
  • ③假如提现成功,下单失利,则再经过充值进口,重新充50元进来;
  • ④假如提现失利,下单成功,则在订单中心里边,对相应订单建议退款;
  • ⑤假如提现成功,下单成功,则回到前面的第②步重复操作。

信任我们应该能了解这个羊毛党的薅法,不过为何会呈现提现、下单,其间一方失利的现象呢?由于该用户是经过手动点击,模仿一起触发下单、提现操作,而网络推迟是不可控因素,有时这两个恳求,会一前一后的来到服务端,其时一个恳求把余额扣掉之后,后一个恳求就过不了“余额检测”进程,无法进行扣款,然后导致提现/下单失利。

这个用户在后台的流水高达上百条,不过由于提现、退款都需求时刻,所以加上最开端的一次,一共也就成功了四次,分别为47.83、50、50、50元,为此项目方的老板,在一夜时刻里一共被薅197.83元!

得亏这小子不会编程,不然写个程序张狂调用,老板的房子都能被薅走一套~

有人也许会好奇:为什么这小子不直接充1w甚至更大的金额,然后进行操作呢?我的猜测是:或许害怕被发现,然后半途冻住他的账户,怕充进去就真提不出来了~

二、并发缝隙的修复计划

经过上一阶段的叙述,信任我们对本次事情的来龙去脉,都有了全面认知,那又该怎样修复这个并发缝隙呢?下面一起来聊聊这个论题。

2.1、传统的大局锁

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

当我看到小帅这个问题后,我告知他要加锁,确保同一时刻内,只能有一条线程修正账户余额,这样就不会呈现前面的并发问题:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

然后我给他演示了一下ReetrantLock的用法,伪代码如下:

// 先界说一把大局的lock锁
private ReetrantLock lock = new ReetrantLock();
// 提现办法
public boolean cashWithdrawal(String accountName, int type,
                              String target, BigDecimal money){
    // 用finally来确保锁的开释
    try{
        // 获取锁
        lock.lock();
        // 模仿余额检测代码……
        // 模仿修正余额代码……
        // 开释锁
        lock.unlock();
    } finally {
        // 确保反常也能开释锁
        lock.unlock();
    }
    // 省掉其他代码......
}
// 下单办法
public boolean placeAnOrder(Order order, String accountName){
    // 用finally来确保锁的开释
    try{
        // 获取锁
        lock.lock();
        // 模仿余额检测代码……
        // 模仿修正余额代码……
        // 开释锁
        lock.unlock();
    } finally {
        // 确保反常也能开释锁
        lock.unlock();
    }
    // 省掉其他代码......
}

这个示例也特别简略,无非界说了一个lock大局变量,这意味着是一把大局锁,两个操作余额的办法,一起被调用时,两条线程竞赛同一把锁,只会有一个成功,另一个会堵塞。成功的线程去修正账户余额,修正完结并开释锁后,另一条线程会拿到锁,但是由于上条线程现已把余额扣掉了,第二条线程就无法经过“余额检测”。

听着上述答复,是不是感觉问题处理了?但是小帅其时又补了一句:

我在多个事务类里,都有直接调用AccountMapper来修正账户余额!

此刻怎样做?榜首种做法是把“修正余额”操作独自抽成一个办法,然后在其间运用ReetrantLock加锁,其他一切要修正余额的事务,都调用这个办法来完结。不过这种做法明显不可,究竟他这个项目现已交给了,前面的“屎山”现已堆起来了,想要独自抽出一个办法,会触及很多处代码改变。

来看看第二种做法,有经验的小伙伴应该猜到了,怎样做呢?用synchronized关键字,将AccountServiceImpl.class作为锁目标,一切触及到“修正余额”的地方,都加上这个锁,如:

// 提现办法
public boolean cashWithdrawal(String accountName, int type,
                              String target, BigDecimal money){
    // 运用synchronized大局锁
    synchronized (AccountServiceImpl.class){
        // 模仿余额检测代码……
        // 模仿修正余额代码……
    }
    // 省掉其他代码......
}
// 下单办法
public boolean placeAnOrder(Order order, String accountName){
    // 运用synchronized大局锁
    synchronized (AccountServiceImpl.class){
        // 模仿余额检测代码……
        // 模仿修正余额代码……
    }
    // 省掉其他代码......
}

至于为什么用AccountServiceImpl.class作为锁目标,就能够完成不同类中的“大局锁”,如若不了解的小伙伴,能够去看:《剖析Synchronized关键字-应用方式与锁类型》这一末节。

其实还有第三种办法,便是界说一个static静态的大局ReetrantLock锁,但不说了。

这些方式听起来是不是很不错?假如你点头,那可就大错特错了!!!

原因是什么?由于这是传统意义上的大局锁,尽管能锁住同一个用户形成的并发恳求,但是也能锁住不同用户的并发恳求啊!来举个比如:

小明正鄙人单一件产品、小红正在提现余额、小李正在充值余额……

这三个用户的操作,是不是都会触及到“修正账户表余额”的动作?所以问题就来了,小明下单需求获取大局锁、小红提现也需求获取大局锁、小李充值还需求获取大局锁……

可一把锁,一起只能由一个恳求(一条线程)持有啊,假定这儿小明拿到了锁,那么小红、小李都需求堵塞等候,这合理吗?并不合理,由于小明修正自己的余额,压根不会对小红、小李的余额产生影响。现在这样完成,严峻的状况下,会导致整个体系悉数夯住。

说人话:小明拿到锁后,小红、小李的网页,都会一向不停的转圈圈,由于在等候锁……

好了,也正是由于这个原因,所以我又补充了一句:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

大局锁有必要得用,假如不必必定无法处理并发问题,不过要操控好大局锁的粒度,不能让一切用户抢一把锁!那怎样操控锁的粒度呀?我们考虑一下。

考虑一分钟……

2.2、Redis分布式锁

OK,有人或许会想到:能够用Redis来完成分布式锁呀!然后以用户ID作为Key,以此完成不同的用户,获取不同的大局锁!就像下面这样:

// redis分布式锁伪代码,参数为:key、value、超时时刻
redis.set("其时恳求的用户ID","xxx","10s");

没错,Redis分布式锁的确能完美的处理问题,所以我们看我上面截图的终究一句:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

来看看小帅的答复:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

“没”,一个特别干脆明晰的答复,有人或许会拍桌子:“没Redis还做什么商城项目啊”!仍是那句话:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

好了,我们持续考虑,假如没有Redis,怎样操控大局锁的粒度?

考虑一分钟……

考虑两分钟……

有没有想到?没想到的话接着往下看!

已然是个单体项目,而且还没有Redis,分布式锁就用不上,现在有必要得用单机锁来处理,有什么单机锁支撑细粒度嘛?经验足够丰富的小伙伴,应该能联想到MySQL行锁、达观锁!

2.3、MySQL行锁、达观锁

已然是操作数据表引起的问题,那假如让每次update句子加锁,多条线程并发update时,堵塞其他线程只让单条线程履行,是不是就能完成细粒度的锁,以此到达精准并发操控的方针呢?来试试看:

-- 达观锁
update 
    zz_account 
set 
    balance = balance - 100(这儿写详细要修正的金额), .......
where
    version = version + 1;
-- 行锁(独占式行锁)
update 
    zz_account 
set 
    balance = balance - 100(这儿写详细要修正的金额), .......
for update; -- 行锁

PS:假如对MySQL锁机制还不了解的小伙伴,能够参阅《MySQL锁机制详解》。

来看现在的计划,这样能处理问题吗?还不能,假定余额100,现在有下单-99、提现-2两个操作,尽管有行锁、达观锁来堵塞另一个操作履行,但另一个操作早晚会履行,依旧会把账户余额扣到-1元。怎样才干确保另一个堵塞的操作,在重新取得履行权、余额缺乏时,不再履行呢?这儿得用到状况机,什么是状况机这个问题,我们能够参阅《被登录/注册吊打日记-多IP并发操作问题》的内容。

在我们现在的比如中,状况机字段不需求额定规划,而是运用balance余额字段即可:

-- 达观锁+状况机
update 
    zz_account 
set 
    balance = balance - 100, .......
where
    version = version + 1 and balance = [修正前的余额];
-- 行锁+状况机
update 
    zz_account 
set 
    balance = balance - 100, .......
where
    balance = [修正前的余额]
for update;

也便是多加了一个where条件:balance=[修正前的余额],仍是前面那个举例,假定下单-99这个操作先拿到锁,提现-2这个操作就会堵塞;当下单操作履行完结后,余额会变成100-99=1元;接着提现操作拿到履行权,但是在履行时,会发现余额并不是起先的100元了,因而提现的update句子不能满足条件,终究无法履行。

好!问题再次以完美的形状处理了,但是来看下面这幕:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

小帅用的是MyBatis-Plus!不是MyBatis,所以很多数据库操作,都是直接用MP的快捷办法来编写的,而且MP不支撑行锁,只支撑达观锁(不过MySQL默认会对写操作加行锁)。

尽管现在这种方式能够处理问题,而且也是最好的计划,但由于代码量和其他方面的原因(主要是事务和表结构),形成这个计划又被停滞了~

兜兜转转,又得回到ReetrantLock、synchronized、……这些Java单机锁,而传统的单机锁,好像不支撑更细粒度呀?为此需求我们额定拓宽,接着来说说。

2.4、细粒度的ReetrantLock大局锁

先来说说依据ReetrantLock做拓宽,这需求自己保护一个Map锁容器,如下:

public class IdLockUtils {
    // 界说一个大局的锁容器
    private static Map<String, Lock> lockMap = new ConcurrentHashMap<>();
    // 拓宽获取锁办法
    public static void lock(String id){
        // 先依据id往容器里添加一把锁
        lockMap.putIfAbsent(id, new ReentrantLock());
        // 再依据id从容器里拿锁并加锁
        lockMap.get(id).lock();
    }
    // 拓宽开释锁办法
    public static void unlock(String id){
        // 先依据id从容器中找到锁
        Lock lock = lockMap.get(id);
        // 接着运用lock目标开释锁
        lock.unlock();
    }
}

这儿界说了一个大局锁容器,为了确保线程安全,这儿运用了ConcurrentHashMap作为锁容器确保安全,key在我们这次的比如里,便是账户ID,而value则是一个Lock目标。紧接着拓宽了lock/unlock两个办法,unlock()开释锁的代码很简略,诸位自行看注释吧,要点说说获取锁的lock()办法。

lock()办法中,运用了putIfAbsent()办法往锁容器中,添加一个与id对应的锁目标,put、putIfAbsent两个办法的差异在于:

  • put():假如容器中不存在就刺进,假如存在则替换;
  • putIfAbsent():假如容器中不存在就刺进,假如存在则获取已有目标回来。

总之,经过这一步后必定会有一把与id对应的锁,接着经过id拿到了对应的锁,并用这把锁完结了加锁动作(假如一个id对应的锁,现已有线程持有,那么其时线程就会堵塞等候)。

经过这样一番拓宽,我们就能得到支撑细粒度的ReentrantLock锁,运用方式如下:

// 获取锁
IdLockUtils.lock("xxx");
// 开释锁
IdLockUtils.unlock("xxx");

不过现在有两个问题:

①假如一个用户创立的锁就用过一次,后续该用户再也没登录过平台,相应的锁目标,会一向在内存中存活,这等价于产生了“内存泄漏”。为此,想要规划好这个细粒度的锁,还需求完成定时整理+淘汰策略。

②锁容器为了线程安全,运用了ConcurrentHashMap,在《并发容器-锁分段容器剖析》中聊过,尽管ConcurrentHashMap1.7之后做过优化,可终究的底层仍是会依赖于synchronized,所以在些许特别状况下,运用lockMap或许要加两层锁。

那我们能不能直接用synchronized完成细粒度的大局锁呢?答案是能够的,怎样做呀?下面来聊聊。

2.4、细粒度的Synchronized大局锁

我们都知道,synchronized是依据目标上锁的,而Java言语中万事万物皆目标,那有没有一种特别的目标,能帮我们完成更细粒度的synchronized锁呀?每次直接new新目标行不可?不可,假如每个恳求都new新目标,那么每条线程持有各自的锁,依旧会呈现并行履行。

各位好好想想,Java里有没有某种特别的目标呢?等等,String!!!

来看个比如:

String s1 = "竹子";
String s2 = "熊猫";
String s3 = "竹子";
System.out.println(s1 == s2);
System.out.println(s1 == s3);
// --------运转成果--------
false
true

==是比较地址,此刻s1、s2相比较,成果是false天经地义。可看来s1、s3的比照成果,竟然是true,这是什么原因形成的呢?想要弄了解这个问题,就有必要得聊到JVM的常识了。

诸位应该听说过“字符串常量池”这个名词,不过跟着JDK的更新,它的位置也一向在变:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

1.7及之后的JDK中,字符串常量池挪到了堆中,为什么要移动呢?JDK1.7之前,有个永久代空间名为办法区,所谓永久代,便是指里边的数据会从程序启动到停止时,才会开释所占用的空间,而之前的字符串常量池,也位于这块区域里边!字符串常量池会跟着程序运转,不断变大,终究把整个办法区撑满。

办法区满了之后怎样办?为了确保程序正常运转,只能在办法区里产生GC,但是办法区的定位是“永久代”,意味着不需求收回,但没办法,办法区满了总不能不论吧?所以JVM又给办法区规划了一种PermGenGC,专门针对办法区进行废物收回。

我们看上面这个进程,办法区一般不会撑满,假如触发了GC,多半是由于字符串常量池导致的,所以JDK1.7时干脆一不做二不休,直接把字符串常量池丢到了堆空间,尔后字符串常量池会像一般目标相同参加废物收回。

其实到了JDK1.8,用元空间代替办法区时,还把静态变量池丢到了堆空间,原本针对办法区规划的PermGenGC,由于或许导致GC的两玩意儿都不在了,所以元空间就没有持续保留这种GC,假如某一天元空间满了,只会触发FullGC来进行收回。

好了,回归正题,已然JVM里有个字符串常量池,它是干啥用的?简略来说,便是用来存储相同的字符串,示意图如下:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

当界说两个值为“竹子”的变量s1、s2时,这时并不会在内存中挖两个坑,然后埋进去两个竹子,而是去字符串常量池里找一找,看看有没有“竹子”这个字面量,假如现已存在,直接把s1、s2的指针指向常量池里的“竹子”。假如没找到,就先在常量池里创立一个“竹子”,然后再修正s1、s2的指针。

讲到这儿,前置常识就现已足够了,我们怎样依据这些特性,完成细粒度的synchronized大局锁呢?很简略,已然String也是目标,那我们能不能依据它来作为锁目标?当然能够,如下:

// 界说锁目标
String lock = "竹子爱熊猫";
// 依据String目标上锁
synchronized (lock){
    // .......
}

结合前面的ID思维,我们能不能像下面这样做?下面写个伪代码:

// 提现办法
public static void cashWithdrawal(String accountId){
    // 运用传入的id作为锁目标
    synchronized (accountId){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行提现逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
// 下单办法
public static void placeAnOrder(String accountId){
    // 运用传入的id作为锁目标
    synchronized (accountId){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行下单逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
public static void main(String[] args) {
    // 用三条线程模仿并发调用
    new Thread(() -> cashWithdrawal("123456"), "AAA").start();
    new Thread(() -> placeAnOrder("123456"), "BBB").start();
    new Thread(() -> cashWithdrawal("666666"), "CCC").start();
}

上面写了提现、下单两个办法,两个办法都要求外部传入一个accountId,稍后会以这个ID作为锁目标。鄙人面的测试中,创立了AAA、BBB、CCC三条线程:

  • AAA调用提现办法,传入ID=123456
  • BBB调用下单办法,传入ID=123456
  • CCC调用提现办法,传入ID=666666

从上述调用状况来看,AAA、BBB由于传入的ID相同,阐明这是共一个用户形成的并发恳求,而CCC传入的ID=666666,阐明这是另一个用户在提现,此刻来看运转成果:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

OK,是不是能够完成我们的需求?按ID进行细粒度的并发操控,只需相同用户的并发恳求才会堵塞,不同用户的并发恳求不会有任何影响。那也许有人会问,我idLong类型的咋办?这很简略呀,直接123456L + ""转化一下即可。

好了,问题好像大功告成,我们看到这儿,是不是认为就结束了?不,这才是刚刚开端呢~

三、字符串作为锁目标的坑

经过小帅不懈努力,终于把每个触及到“修正的余额”的事务办法,加上了细粒度的synchronized,但是来看他给的回复:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

3.1、问题现场康复

修正、打包、启动、测试,哦豁!发现计划不太行,为啥呀?来看它给我截图的代码:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

由于他的uidlong类型,无法依据字符串常量池完成大局锁,为此要先将Long转为String,才干持续进行加锁操作,不过上述截图看的不是很明晰,抛开事务逻辑,我将其做了精简:

// 提现办法
public static void cashWithdrawal(Long accountId){
    String lock = accountId + "";
    // 运用传入的id作为锁目标
    synchronized (lock){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行提现逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
// 下单办法
public static void placeAnOrder(Long accountId){
    String lock = accountId + "";
    // 运用传入的id作为锁目标
    synchronized (lock){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行下单逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
public static void main(String[] args) {
    // 模仿三条线程,并发进行操作
    new Thread(() -> cashWithdrawal(123456L), "AAA").start();
    new Thread(() -> placeAnOrder(123456L), "BBB").start();
    new Thread(() -> cashWithdrawal(666666L), "CCC").start();
}

代码很上一次比照,并未呈现太大改变,只是只是修正了办法入参类型,然后在办法内部将Long转为了String,然后再进行加锁作业,但是便是这一点细微改变,履行成果却大不相同:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

看这个履行成果,我们应该能够看的非常了解,将代码改成上述那样后,两个传递传递相同的ID的线程,竟然一起拿到了锁,也便是锁不住了!为啥啊?再来看个比如:

// 提现办法
public static void cashWithdrawal(String accountId){
    // 运用传入的id作为锁目标
    synchronized (accountId){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行提现逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
// 下单办法
public static void placeAnOrder(String accountId){
    // 运用传入的id作为锁目标
    synchronized (accountId){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行下单逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
public static void main(String[] args) {
    // 模仿三条线程,并发进行操作
    new Thread(() -> cashWithdrawal(123456L+""), "AAA").start();
    new Thread(() -> placeAnOrder(123456L+""), "BBB").start();
    new Thread(() -> cashWithdrawal(666666L+""), "CCC").start();
}

这儿又将入参改回了String类型,此刻在外部先将Long转成String,然后再调用事务办法,再来看看成果:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

很好,此刻细粒度的锁又收效了,Why?凭什么在外部转化类型就收效,在办法里边转化就失效啊?此刻小小的脑袋里有着大大的疑惑……。为了更好的搞清楚背面原因,就先讲了解:为什么依据相同字符串,就能够做到细粒度的并发操控呢?

3.2、字符串完成细粒度锁的原理

先回到之前的这幅图:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

前面讲过,假如两个字符串变量,界说的字面量相同,终究都会指向字符串常量池中的同一个地址,这个地址上存放着一个String目标。以上图为例,假定这儿运用s1、s2进行加锁:

synchronized (s1) { ...... }
synchronized (s2) { ...... }

加锁的进程是什么姿态的?经过《Synchronized原理》叙述,我们应该能得知一点,synchronized关键字是依据目标头完成的锁机制。那么在上面的比如中,首要会依据s1、s2指针,找到字符串常量池中的某个地址,然后得到String("竹子")这个目标。

由于s1、s2都指向同一个String目标,为此,当有多条线程一起运用s1、s2获取锁时,终究也就只会有一条线程获取锁成功,其余线程都堕入堵塞等候。

上述便是细粒度synchronized锁的原理,一句话总结便是:这是依据字符串常量池,相同的字面量只会存在一个String目标来完成的。了解这些原理后,接着再看小帅现在碰到的问题。

3.3、剖析类型转化产生的问题

public static void xxx(Long accountId){
    String lock = accountId + "";
    synchronized (lock){
        ......
    }
}

将类型转化放在办法内部完结,然后引发了新的问题:传入相同ID的线程锁不住。经过前面一番琢磨后,其实就能大致得到该问题的产生原因:应该是在办法内部转化时,产生了新的String目标,所以两个不同的办法中,各自依据各自的String目标上锁,终究诱发了问题。终究是不是这个原因,我们还需做番试验:

public static void xxx(Long accountId){
    String idLock = accountId + "";
    String s1 = "123456";
    String s2 = "123456";
    System.out.println("idLock:" + idLock);
    System.out.println("s1:" + s1 + "、s2:" +s2);
    System.out.println(s1 == s2);
    System.out.println(idLock == s1);
}
public static void main(String[] args) {
    xxx(123456L);
}

这个代码信任特别简单读懂,界说了一个xxx办法,该办法接受一个Long类型的入参。

调用xxx()办法时传入了123456这个Long值,接着在办法内部将其转化成了String类型的idLock变量;随即又界说了两个s1、s2字符串变量,接着将三个变量的值输出了,终究在三个变量之间进行了==比对,来看成果:

// --------运转成果--------
idLock:123456
s1:123456、s2:123456
true
false

三个字符串变量的值都为123456s1==s2的成果为true,阐明它俩是同一个String目标,而idLock==s1比照后的成果为:false!这阐明什么?阐明idLock是另一个String目标!!!其实这行转化代码:

String idLock = accountId + "";

就和八大根本数据类型相同,相同存在类似的拆/装箱机制,实际的完整代码为:

String idLock = new String(accountId + "");

这样就创立出了一个新的String目标,由于newString目标,不会去字符串常量池里找,而是直接分配到堆空间上,视作为一个一般类型的目标,怎样证明呢?看个比如:

String s1 = new String("竹子");
String s2 = new String("竹子");
System.out.println(s1 == s2);

上述这段代码,尽管s1、s2的字面量相同,但由于运用了new形式来创立,所以终究的运转成果为false,这阐明s1、s2是两个不同的目标了。

到这儿,信任我们就了解了小帅为什么会遇到“锁不住”的问题,便是由于办法内部转化类型时,会触发类似于“装箱机制”的操作,将转化后的String目标分配到堆空间,而不是从字符串常量池中查找。

不过值得一提的是:只需在一个被调用办法的内部,对办法入参进行String类型转化时,才会触发这个“装箱机制”,再来看个比如:

public static void main(String[] args) {
    String s1 = 123L + "";
    String s2 = 123L + "";
    System.out.println(s1 == s2);
}

这时我直接在main办法中,将两个Long123转化为s1、s2后,再运用==来比照,成果竟然又成了true,阐明s1、s2又指向了字符串常量池中的同一个String目标,这是不是挺奇特的?终究是什么原因导致的呢?其实问题源自于编译器优化技能。

3.4、编辑器优化-常量折叠技能

一切编程言语,为了尽或许的进步言语功能,都会想法设法的做优化,而在AST编译阶段,有一种广泛运用的优化手法,即:常量折叠,它能够帮助程序无缝移除无用的代码,啥意思呢?来看个比如:

int n = 1 + 1;

从上述代码来看,n这个变量的值,固定为2,假如运转期间,每次都要履行一遍1+1的逻辑,这无疑会多出一些开销。正因如此,在javac编译阶段,上述代码就会被优化成:

int n = 2;

经过这种优化手法,后续在运用n时,都不需求履行1+1这步计算作业。

Java中,这种优化手法也被应用在了String这个特别的类上,如下:

String s1 = 123456L + "";

这段代码和前面的举例含义相同,s1这个变量的值,实则在运转期间也是固定的,假如每次运用s1时,都需求先履行+拼接运算,亦会损耗程序功能,为此AST编译也会将其优化为:

String s1 = "123456";

怎样证明呢?我们来看个比如:

public static void xxx(Long id){
    String s1 = id + "";
    String s2 = 66666666666L + "";
    System.out.println(s1 == s2);
}
public static void main(String[] args) {
    xxx(66666666666L);
}

这依旧是那段了解的代码,主要看xxx()的办法体,里边界说了s1、s2这两个字符串变量,不过s1是依据入参id来拼接转化的,而s2则是依据固定的Long值转化,接着能够在IDEA中运转一下代码。

当经过IDEA这类东西运转一个Java类时,它会主动调用javac为你编译.java文件,然后得到.class再运转,这时我们别关怀程序的运转成果,要点来凭借IDEA东西,看看编译后的.class文件。IDEA东西会将编译后生成的class文件,放到项目根目录的target/classes文件夹中,如下所示:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

从图中不难发现.java源码与.class文件的差异,其间界说s2变量的这行代码,被编译器优化了一下:

String s2 = 66666666666L + "";
// 优化前 vs 优化后
String s2 = "66666666666";

的确跟前面剖析的相同,s2触发了常量折叠,为此这也是为什么在同一个办法中,经过+号拼接固定值时,不会创立两个String目标的原因。

接着再把目光放到图中的s1变量,我们会发现这行代码未曾被优化,为什么呢?由于s1的值,要依据入参id来转化,而一个办法的入参值并不固定,究竟一个办法有或许在多处被调用,这时编译器就无法对其进行常量折叠,终究就会触发一开端我们所说的那种“装箱机制”。

Java中,String类型的这种“装箱机制”也一向在变,前面我们说的是,这行代码:

String idLock = accountId + "";

会被优化成下面这行代码:

String idLock = new String(accountId + "");

不过这是Java一开端的做法,到了JDK1.0.2引进StringBuffer类后,这个“装箱”动作会被优化成:

String idLock = new StringBuffer().append(id).append("").toString();

也便是经过StringBufferappend()办法,来代替最原始的+字符串拼接。不过到JDK1.5引进StringBuilder后,该动作又被优化成了:

String idLock = new StringBuilder().append(id).append("").toString();

至于为什么要用StringBuffer、StringBuilder,代替最原始的String拼接呢?由于假如是原始的String拼接,进程中会生成新的String目标,然后丢弃,尤其是拼接句子过长时,这个问题越明显,然后给内存中留下很多“字符串废物”。

3.5、处理类型转化带来的问题

OK,问题产生的原因搞了解之后,我们得想办法处理问题呀!咋处理呢?已然问题是由于“在办法内部转化类型时,会将转化后的String目标分配到堆空间”产生的,那我们是不是只需想办法,让这个类型转化后生成的String目标,再次回到字符串常量池,是不是就能够啦?

答案是Yes,可又该怎样把转化后的String目标,从一般堆空间丢到字符串常量池里边去呢?答案是String.intern()办法!这个办法研讨过JVM的小伙伴应该触摸过,作用如下:

new一个String目标时,调用intern()办法,首要会去字符串常量池找;
假如在字符串常量池中,找到了相同字面量的String目标,此刻回来该目标的引用地址;
假如字符串常量池中,没有相同字面量的String目标时,就把其时newString目标加入到字符串常量池,然后回来该目标的引用地址。

简略来说,这个办法的作用便是:new之前先去常量池转一圈,假如现已有相同的目标,我就拿着直接用;假如没有,我就在池子创立一个,今后他人也能够用

了解该办法的作用,我们就能够凭借该办法完成我们的需求,代码如下:

// 提现办法
public static void cashWithdrawal(Long accountId){
    String lock = new String(accountId + "").intern();
    // 运用传入的id作为锁目标
    synchronized (lock){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行提现逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
// 下单办法
public static void placeAnOrder(Long accountId){
    String lock = new String(accountId + "").intern();
    // 运用传入的id作为锁目标
    synchronized (lock){
        System.out.println(Thread.currentThread().getName() + "拿到锁啦!");
        System.out.println("正在履行下单逻辑......");
        try {
            // 模仿事务耗时
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "开释锁啦!");
    }
}
public static void main(String[] args) {
    // 模仿三条线程,并发进行操作
    new Thread(() -> cashWithdrawal(123456L), "AAA").start();
    new Thread(() -> placeAnOrder(123456L), "BBB").start();
    new Thread(() -> cashWithdrawal(666666L), "CCC").start();
}

上述代码依旧是没做太大改动,办法的入参依旧是Long,只是换了一行代码:

String lock = accountId + "";
// 替换成:
String lock = new String(accountId + "").intern();

这时再来看终究的履行成果:

单体项目偶遇并发漏洞!短短一夜时间竟让老板蒸发197.83元!

好啦,到这儿真正大功告成!问题自此完美处理!剩下只需将这个思维,套入到详细的事务中即可。

四、总结

有人或许会说:“用Synchronized不是很慢吗?”

其实经过JDK的不断优化,现在的synchronized只需未胀大到重量级锁,功能反而更好(由于有倾向锁和锁消除技能);一起这儿还以ID作为了锁目标,简直很少有相同ID的并发恳求呈现,所以每个不同用户的恳求,都会获取一把各自的锁,相互之间并不搅扰,也不会形成堵塞现象产生。

有人或许还会问:“但是创立这么多的String目标,不会把内存撑爆吗?”

首要呢要记住,这儿的锁目标,是运用了体系内本身就存在的用户ID、账户ID……,就算不以这些ID作为锁目标,内存中依旧会存在这些String目标,所以我们并没有额定添加内存的负担,只不过把String目标换了个位置而已,放到字符串常量池里边去了。

其次呢,synchronized重量级锁会生成Monitor目标,但是我们这个场景下,synchronized压根胀大不到重量级锁状况,究竟前面讲过,这儿的每把锁,大概率下只会有一条线程竞赛。

终究呢,由于官方现已把字符串常量池丢到堆空间来了,所以字符串常量池中的String目标,相同会参加堆空间的废物收回,所以也不必忧虑内存爆掉,JVM的废物收回体系,会妥善帮你处理好这个问题。除非你的体系有千万级在线用户,导致字符串常量池呈现1000W个目标,一下又无法收回,终究OOM(不过能到达千万日活的体系,谁又会用单体架构呢~)。

再来聊聊为什么小帅榜首次交给时,没考虑到这个问题呢?其实这跟我们常常挂在嘴边的那句“天天作业打螺丝”有关,小帅尽管作业近三年时刻了,但是每天的作业便是做简略的外包项目,一切都以开发速度为条件,在这样的环境下,天然不会考虑太多,满足“能跑起来”这个标准就行,终究给体系遗留了这个“并发缝隙”。

或许类似于小帅这样的伙伴不在少数,作业年限不算短,可每天便是重复的事务开发,生长天然就很有限了。假如你也处于这样的状况,正如我在《进阶提升篇》中所说:“上班既能带来经济收入,还能带来技能提升,这种机会可遇不可求”,更多状况下,我们更需求靠自律打破这种困境,不然一朝一夕,就成了温水煮青蛙,把自己给耗废了。

终究的终究,其实这篇文章我早就想写了,我们从文中的聊天记载截图也能看出,这件事情产生在两个多月以前,只不过其时我正在忙着写《技能人求职攻略》这本小册,因而一向没有抽出时刻来写。好在现在小册完结了,所以就立马写出了这篇文章,信任这篇文章应该能对一些日常只做事务CRUD、未处理过高并发的小伙伴带来些许感受。

许多人或许去学习过很多关于高并发相关的常识,那终究什么叫高并发呢?本文所说的状况叫并发,所谓的高并发便是指呈现很多这样的状况,诸位听说过的处理手法,估计有缓存、体系拆分解耦、MQ削峰填谷、数据库分库分表、服务限流/熔断/降级……等一大堆。这些计划其实很对,可如若你连本篇中这类最根本的并发都没处理过,那暂时就不要去想高并发啦,好高骛远并不是件功德~

好了,话就提到这儿,终究的终究的终究,最近想起了我公众号的密码,也会同步一切技能文章曩昔,假如对更多技能文章感兴趣的小伙伴,也能够重视一下同名公众号:竹子爱熊猫