mirror of
https://github.com/zhwei820/learn.lianglianglee.com.git
synced 2025-09-30 23:26:43 +08:00
539 lines
36 KiB
HTML
539 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
|
||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||
<head>
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
|
||
<link rel="icon" href="/static/favicon.png">
|
||
<title>02 代码加锁:不要让“锁”事成为烦心事.md.html</title>
|
||
<!-- Spectre.css framework -->
|
||
<link rel="stylesheet" href="/static/index.css">
|
||
<!-- theme css & js -->
|
||
<meta name="generator" content="Hexo 4.2.0">
|
||
</head>
|
||
<body>
|
||
<div class="book-container">
|
||
<div class="book-sidebar">
|
||
<div class="book-brand">
|
||
<a href="/">
|
||
<img src="/static/favicon.png">
|
||
<span>技术文章摘抄</span>
|
||
</a>
|
||
</div>
|
||
<div class="book-menu uncollapsible">
|
||
<ul class="uncollapsible">
|
||
<li><a href="/" class="current-tab">首页</a></li>
|
||
</ul>
|
||
<ul class="uncollapsible">
|
||
<li><a href="../">上一级</a></li>
|
||
</ul>
|
||
<ul class="uncollapsible">
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/00 开篇词 业务代码真的会有这么多坑?.md.html">00 开篇词 业务代码真的会有这么多坑?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/01 使用了并发工具类库,线程安全就高枕无忧了吗?.md.html">01 使用了并发工具类库,线程安全就高枕无忧了吗?</a>
|
||
</li>
|
||
<li>
|
||
<a class="current-tab" href="/专栏/Java 业务开发常见错误 100 例/02 代码加锁:不要让“锁”事成为烦心事.md.html">02 代码加锁:不要让“锁”事成为烦心事</a>
|
||
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/03 线程池:业务代码最常用也最容易犯错的组件.md.html">03 线程池:业务代码最常用也最容易犯错的组件</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/04 连接池:别让连接池帮了倒忙.md.html">04 连接池:别让连接池帮了倒忙</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/05 HTTP调用:你考虑到超时、重试、并发了吗?.md.html">05 HTTP调用:你考虑到超时、重试、并发了吗?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/06 2成的业务代码的Spring声明式事务,可能都没处理正确.md.html">06 2成的业务代码的Spring声明式事务,可能都没处理正确</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/07 数据库索引:索引并不是万能药.md.html">07 数据库索引:索引并不是万能药</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/08 判等问题:程序里如何确定你就是你?.md.html">08 判等问题:程序里如何确定你就是你?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/09 数值计算:注意精度、舍入和溢出问题.md.html">09 数值计算:注意精度、舍入和溢出问题</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/10 集合类:坑满地的List列表操作.md.html">10 集合类:坑满地的List列表操作</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/11 空值处理:分不清楚的null和恼人的空指针.md.html">11 空值处理:分不清楚的null和恼人的空指针</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/12 异常处理:别让自己在出问题的时候变为瞎子.md.html">12 异常处理:别让自己在出问题的时候变为瞎子</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/13 日志:日志记录真没你想象的那么简单.md.html">13 日志:日志记录真没你想象的那么简单</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/14 文件IO:实现高效正确的文件读写并非易事.md.html">14 文件IO:实现高效正确的文件读写并非易事</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/15 序列化:一来一回你还是原来的你吗?.md.html">15 序列化:一来一回你还是原来的你吗?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/16 用好Java 8的日期时间类,少踩一些“老三样”的坑.md.html">16 用好Java 8的日期时间类,少踩一些“老三样”的坑</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/17 别以为“自动挡”就不可能出现OOM.md.html">17 别以为“自动挡”就不可能出现OOM</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/18 当反射、注解和泛型遇到OOP时,会有哪些坑?.md.html">18 当反射、注解和泛型遇到OOP时,会有哪些坑?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/19 Spring框架:IoC和AOP是扩展的核心.md.html">19 Spring框架:IoC和AOP是扩展的核心</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/20 Spring框架:框架帮我们做了很多工作也带来了复杂度.md.html">20 Spring框架:框架帮我们做了很多工作也带来了复杂度</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/21 代码重复:搞定代码重复的三个绝招.md.html">21 代码重复:搞定代码重复的三个绝招</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/22 接口设计:系统间对话的语言,一定要统一.md.html">22 接口设计:系统间对话的语言,一定要统一</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/23 缓存设计:缓存可以锦上添花也可以落井下石.md.html">23 缓存设计:缓存可以锦上添花也可以落井下石</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/24 业务代码写完,就意味着生产就绪了?.md.html">24 业务代码写完,就意味着生产就绪了?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/25 异步处理好用,但非常容易用错.md.html">25 异步处理好用,但非常容易用错</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/26 数据存储:NoSQL与RDBMS如何取长补短、相辅相成?.md.html">26 数据存储:NoSQL与RDBMS如何取长补短、相辅相成?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/27 数据源头:任何客户端的东西都不可信任.md.html">27 数据源头:任何客户端的东西都不可信任</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/28 安全兜底:涉及钱时,必须考虑防刷、限量和防重.md.html">28 安全兜底:涉及钱时,必须考虑防刷、限量和防重</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/29 数据和代码:数据就是数据,代码就是代码.md.html">29 数据和代码:数据就是数据,代码就是代码</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/30 如何正确保存和传输敏感数据?.md.html">30 如何正确保存和传输敏感数据?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/31 加餐1:带你吃透课程中Java 8的那些重要知识点(一).md.html">31 加餐1:带你吃透课程中Java 8的那些重要知识点(一)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/32 加餐2:带你吃透课程中Java 8的那些重要知识点(二).md.html">32 加餐2:带你吃透课程中Java 8的那些重要知识点(二)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/33 加餐3:定位应用问题,排错套路很重要.md.html">33 加餐3:定位应用问题,排错套路很重要</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/34 加餐4:分析定位Java问题,一定要用好这些工具(一).md.html">34 加餐4:分析定位Java问题,一定要用好这些工具(一)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/35 加餐5:分析定位Java问题,一定要用好这些工具(二).md.html">35 加餐5:分析定位Java问题,一定要用好这些工具(二)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/36 加餐6:这15年来,我是如何在工作中学习技术和英语的?.md.html">36 加餐6:这15年来,我是如何在工作中学习技术和英语的?</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/37 加餐7:程序员成长28计.md.html">37 加餐7:程序员成长28计</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/38 加餐8:Java程序从虚拟机迁移到Kubernetes的一些坑.md.html">38 加餐8:Java程序从虚拟机迁移到Kubernetes的一些坑</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(一).md.html">答疑篇:代码篇思考题集锦(一)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(三).md.html">答疑篇:代码篇思考题集锦(三)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(二).md.html">答疑篇:代码篇思考题集锦(二)</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:加餐篇思考题答案合集.md.html">答疑篇:加餐篇思考题答案合集</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:安全篇思考题答案合集.md.html">答疑篇:安全篇思考题答案合集</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:设计篇思考题答案合集.md.html">答疑篇:设计篇思考题答案合集</a>
|
||
</li>
|
||
<li>
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/结束语 写代码时,如何才能尽量避免踩坑?.md.html">结束语 写代码时,如何才能尽量避免踩坑?</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
|
||
<div class="sidebar-toggle-inner"></div>
|
||
</div>
|
||
<script>
|
||
function add_inner() {
|
||
let inner = document.querySelector('.sidebar-toggle-inner')
|
||
inner.classList.add('show')
|
||
}
|
||
function remove_inner() {
|
||
let inner = document.querySelector('.sidebar-toggle-inner')
|
||
inner.classList.remove('show')
|
||
}
|
||
function sidebar_toggle() {
|
||
let sidebar_toggle = document.querySelector('.sidebar-toggle')
|
||
let sidebar = document.querySelector('.book-sidebar')
|
||
let content = document.querySelector('.off-canvas-content')
|
||
if (sidebar_toggle.classList.contains('extend')) { // show
|
||
sidebar_toggle.classList.remove('extend')
|
||
sidebar.classList.remove('hide')
|
||
content.classList.remove('extend')
|
||
} else { // hide
|
||
sidebar_toggle.classList.add('extend')
|
||
sidebar.classList.add('hide')
|
||
content.classList.add('extend')
|
||
}
|
||
}
|
||
function open_sidebar() {
|
||
let sidebar = document.querySelector('.book-sidebar')
|
||
let overlay = document.querySelector('.off-canvas-overlay')
|
||
sidebar.classList.add('show')
|
||
overlay.classList.add('show')
|
||
}
|
||
function hide_canvas() {
|
||
let sidebar = document.querySelector('.book-sidebar')
|
||
let overlay = document.querySelector('.off-canvas-overlay')
|
||
sidebar.classList.remove('show')
|
||
overlay.classList.remove('show')
|
||
}
|
||
</script>
|
||
<div class="off-canvas-content">
|
||
<div class="columns">
|
||
<div class="column col-12 col-lg-12">
|
||
<div class="book-navbar">
|
||
<!-- For Responsive Layout -->
|
||
<header class="navbar">
|
||
<section class="navbar-section">
|
||
<a onclick="open_sidebar()">
|
||
<i class="icon icon-menu"></i>
|
||
</a>
|
||
</section>
|
||
</header>
|
||
</div>
|
||
<div class="book-content" style="max-width: 960px; margin: 0 auto;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;">
|
||
<div class="book-post">
|
||
<p id="tip" align="center"></p>
|
||
<div><h1>02 代码加锁:不要让“锁”事成为烦心事</h1>
|
||
<p>你好,我是朱晔。</p>
|
||
<p>在上一讲中,我与你介绍了使用并发容器等工具解决线程安全的误区。今天,我们来看看解决线程安全问题的另一种重要手段——锁,在使用上比较容易犯哪些错。</p>
|
||
<p>我先和你分享一个有趣的案例吧。有一天,一位同学在群里说“见鬼了,疑似遇到了一个 JVM 的 Bug”,我们都很好奇是什么 Bug。</p>
|
||
<p>于是,他贴出了这样一段代码:在一个类里有两个 int 类型的字段 a 和 b,有一个 add 方法循环 1 万次对 a 和 b 进行 ++ 操作,有另一个 compare 方法,同样循环 1 万次判断 a 是否小于 b,条件成立就打印 a 和 b 的值,并判断 a>b 是否成立。</p>
|
||
<pre><code>@Slf4j
|
||
public class Interesting {
|
||
volatile int a = 1;
|
||
volatile int b = 1;
|
||
public void add() {
|
||
log.info("add start");
|
||
for (int i = 0; i < 10000; i++) {
|
||
a++;
|
||
b++;
|
||
}
|
||
log.info("add done");
|
||
}
|
||
public void compare() {
|
||
log.info("compare start");
|
||
for (int i = 0; i < 10000; i++) {
|
||
//a始终等于b吗?
|
||
if (a < b) {
|
||
log.info("a:{},b:{},{}", a, b, a > b);
|
||
//最后的a>b应该始终是false吗?
|
||
}
|
||
}
|
||
log.info("compare done");
|
||
}
|
||
}
|
||
</code></pre>
|
||
<p>他起了两个线程来分别执行 add 和 compare 方法:</p>
|
||
<pre><code>Interesting interesting = new Interesting();
|
||
new Thread(() -> interesting.add()).start();
|
||
new Thread(() -> interesting.compare()).start();
|
||
</code></pre>
|
||
<p>按道理,a 和 b 同样进行累加操作,应该始终相等,compare 中的第一次判断应该始终不会成立,不会输出任何日志。但,执行代码后发现不但输出了日志,而且更诡异的是,compare 方法在判断 a<b 成立的情况下还输出了 a>b 也成立:</p>
|
||
<p><img src="assets/9ec61aada64ac6d38681dd199c0ee61d.png" alt="img" /></p>
|
||
<p>群里一位同学看到这个问题笑了,说:“这哪是 JVM 的 Bug,分明是线程安全问题嘛。很明显,你这是在操作两个字段 a 和 b,有线程安全问题,应该为 add 方法加上锁,确保 a 和 b 的 ++ 是原子性的,就不会错乱了。”随后,他为 add 方法加上了锁:</p>
|
||
<pre><code>public synchronized void add()
|
||
</code></pre>
|
||
<p>但,加锁后问题并没有解决。</p>
|
||
<p>我们来仔细想一下,为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所以加锁后的代码中的资源操作是线程安全的。但是,这个案例中的 add 方法始终只有一个线程在操作,显然只为 add 方法加锁是没用的。</p>
|
||
<p>之所以出现这种错乱,是因为两个线程是交错执行 add 和 compare 方法中的业务逻辑,而且这些业务逻辑不是原子性的:a++ 和 b++ 操作中可以穿插在 compare 方法的比较代码中;更需要注意的是,a<b 这种比较操作在字节码层面是加载 a、加载 b 和比较三步,代码虽然是一行但也不是原子性的。</p>
|
||
<p>所以,正确的做法应该是,为 add 和 compare 都加上方法锁,确保 add 方法执行时,compare 无法读取 a 和 b:</p>
|
||
<pre><code>public synchronized void add()
|
||
public synchronized void compare()
|
||
</code></pre>
|
||
<p>所以,使用锁解决问题之前一定要理清楚,我们要保护的是什么逻辑,多线程执行的情况又是怎样的。</p>
|
||
<h2>加锁前要清楚锁和被保护的对象是不是一个层面的</h2>
|
||
<p>除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加无效的方法锁外,还有一种比较常见的错误是,没有理清楚锁和要保护的对象是否是一个层面的。</p>
|
||
<p>我们知道静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护。</p>
|
||
<p>先看看这段代码有什么问题:在类 Data 中定义了一个静态的 int 字段 counter 和一个非静态的 wrong 方法,实现 counter 字段的累加操作。</p>
|
||
<pre><code>class Data {
|
||
@Getter
|
||
private static int counter = 0;
|
||
|
||
public static int reset() {
|
||
counter = 0;
|
||
return counter;
|
||
}
|
||
public synchronized void wrong() {
|
||
counter++;
|
||
}
|
||
}
|
||
</code></pre>
|
||
<p>写一段代码测试下:</p>
|
||
<pre><code>@GetMapping("wrong")
|
||
public int wrong(@RequestParam(value = "count", defaultValue = "1000000") int count) {
|
||
Data.reset();
|
||
//多线程循环一定次数调用Data类不同实例的wrong方法
|
||
IntStream.rangeClosed(1, count).parallel().forEach(i -> new Data().wrong());
|
||
return Data.getCounter();
|
||
}
|
||
</code></pre>
|
||
<p>因为默认运行 100 万次,所以执行后应该输出 100 万,但页面输出的是 639242:</p>
|
||
<p><img src="assets/777f520e9d0be89b66e814d3e7c1a30b.png" alt="img" /></p>
|
||
<p>我们来分析下为什么会出现这个问题吧。</p>
|
||
<p>在非静态的 wrong 方法上加锁,只能确保多个线程无法执行同一个实例的 wrong 方法,却不能保证不会执行不同实例的 wrong 方法。而静态的 counter 在多个实例中共享,所以必然会出现线程安全问题。</p>
|
||
<p>理清思路后,修正方法就很清晰了:同样在类中定义一个 Object 类型的静态字段,在操作 counter 之前对这个字段加锁。</p>
|
||
<pre><code>class Data {
|
||
@Getter
|
||
private static int counter = 0;
|
||
private static Object locker = new Object();
|
||
public void right() {
|
||
synchronized (locker) {
|
||
counter++;
|
||
}
|
||
}
|
||
}
|
||
</code></pre>
|
||
<p>你可能要问了,把 wrong 方法定义为静态不就可以了,这个时候锁是类级别的。可以是可以,但我们不可能为了解决线程安全问题改变代码结构,把实例方法改为静态方法。</p>
|
||
<p>感兴趣的同学还可以从字节码以及 JVM 的层面继续探索一下,代码块级别的 synchronized 和方法上标记 synchronized 关键字,在实现上有什么区别。</p>
|
||
<h2>加锁要考虑锁的粒度和场景问题</h2>
|
||
<p>在方法上加 synchronized 关键字实现加锁确实简单,也因此我曾看到一些业务代码中几乎所有方法都加了 synchronized,但这种滥用 synchronized 的做法:</p>
|
||
<p>一是,没必要。通常情况下 60% 的业务代码是三层架构,数据经过无状态的 Controller、Service、Repository 流转到数据库,没必要使用 synchronized 来保护什么数据。</p>
|
||
<p>二是,可能会极大地降低性能。使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题。</p>
|
||
<p>即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。</p>
|
||
<p>比如,在业务代码中,有一个 ArrayList 因为会被多个线程操作而需要保护,又有一段比较耗时的操作(代码中的 slow 方法)不涉及线程安全问题,应该如何加锁呢?</p>
|
||
<p>错误的做法是,给整段业务逻辑加锁,把 slow 方法和操作 ArrayList 的代码同时纳入 synchronized 代码块;更合适的做法是,把加锁的粒度降到最低,只在操作 ArrayList 的时候给这个 ArrayList 加锁。</p>
|
||
<pre><code>private List<Integer> data = new ArrayList<>();
|
||
//不涉及共享资源的慢方法
|
||
private void slow() {
|
||
try {
|
||
TimeUnit.MILLISECONDS.sleep(10);
|
||
} catch (InterruptedException e) {
|
||
}
|
||
}
|
||
//错误的加锁方法
|
||
@GetMapping("wrong")
|
||
public int wrong() {
|
||
long begin = System.currentTimeMillis();
|
||
IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
|
||
//加锁粒度太粗了
|
||
synchronized (this) {
|
||
slow();
|
||
data.add(i);
|
||
}
|
||
});
|
||
log.info("took:{}", System.currentTimeMillis() - begin);
|
||
return data.size();
|
||
}
|
||
//正确的加锁方法
|
||
@GetMapping("right")
|
||
public int right() {
|
||
long begin = System.currentTimeMillis();
|
||
IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
|
||
slow();
|
||
//只对List加锁
|
||
synchronized (data) {
|
||
data.add(i);
|
||
}
|
||
});
|
||
log.info("took:{}", System.currentTimeMillis() - begin);
|
||
return data.size();
|
||
}
|
||
</code></pre>
|
||
<p>执行这段代码,同样是 1000 次业务操作,正确加锁的版本耗时 1.4 秒,而对整个业务逻辑加锁的话耗时 11 秒。</p>
|
||
<p><img src="assets/1cb278c010719ee00d988dbb2a42c543.png" alt="img" /></p>
|
||
<p>如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。</p>
|
||
<p>一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:</p>
|
||
<p>对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能。</p>
|
||
<p>如果你的 JDK 版本高于 1.8、共享资源的冲突概率也没那么大的话,考虑使用 StampedLock 的乐观读的特性,进一步提高性能。</p>
|
||
<p>JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。</p>
|
||
<h2>多把锁要小心死锁问题</h2>
|
||
<p>刚才我们聊到锁的粒度够用就好,这就意味着我们的程序逻辑中有时会存在一些细粒度的锁。但一个业务逻辑如果涉及多把锁,容易产生死锁问题。</p>
|
||
<p>之前我遇到过这样一个案例:下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存操作,全部操作完成之后释放所有的锁。代码上线后发现,下单失败概率很高,失败后需要用户重新下单,极大影响了用户体验,还影响到了销量。</p>
|
||
<p>经排查发现是死锁引起的问题,背后原因是扣减库存的顺序不同,导致并发的情况下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是出现了死锁问题。</p>
|
||
<p>接下来,我们剖析一下核心的业务代码。</p>
|
||
<p>首先,定义一个商品类型,包含商品名、库存剩余和商品的库存锁三个属性,每一种商品默认库存 1000 个;然后,初始化 10 个这样的商品对象来模拟商品清单:</p>
|
||
<pre><code>@Data
|
||
@RequiredArgsConstructor
|
||
static class Item {
|
||
final String name; //商品名
|
||
int remaining = 1000; //库存剩余
|
||
@ToString.Exclude //ToString不包含这个字段
|
||
ReentrantLock lock = new ReentrantLock();
|
||
}
|
||
</code></pre>
|
||
<p>随后,写一个方法模拟在购物车进行商品选购,每次从商品清单(items 字段)中随机选购三个商品(为了逻辑简单,我们不考虑每次选购多个同类商品的逻辑,购物车中不体现商品数量):</p>
|
||
<pre><code>private List<Item> createCart() {
|
||
return IntStream.rangeClosed(1, 3)
|
||
.mapToObj(i -> "item" + ThreadLocalRandom.current().nextInt(items.size()))
|
||
.map(name -> items.get(name)).collect(Collectors.toList());
|
||
}
|
||
</code></pre>
|
||
<p>下单代码如下:先声明一个 List 来保存所有获得的锁,然后遍历购物车中的商品依次尝试获得商品的锁,最长等待 10 秒,获得全部锁之后再扣减库存;如果有无法获得锁的情况则解锁之前获得的所有锁,返回 false 下单失败。</p>
|
||
<pre><code>private boolean createOrder(List<Item> order) {
|
||
//存放所有获得的锁
|
||
List<ReentrantLock> locks = new ArrayList<>();
|
||
for (Item item : order) {
|
||
try {
|
||
//获得锁10秒超时
|
||
if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
|
||
locks.add(item.lock);
|
||
} else {
|
||
locks.forEach(ReentrantLock::unlock);
|
||
return false;
|
||
}
|
||
} catch (InterruptedException e) {
|
||
}
|
||
}
|
||
//锁全部拿到之后执行扣减库存业务逻辑
|
||
try {
|
||
order.forEach(item -> item.remaining--);
|
||
} finally {
|
||
locks.forEach(ReentrantLock::unlock);
|
||
}
|
||
return true;
|
||
}
|
||
</code></pre>
|
||
<p>我们写一段代码测试这个下单操作。模拟在多线程情况下进行 100 次创建购物车和下单操作,最后通过日志输出成功的下单次数、总剩余的商品个数、100 次下单耗时,以及下单完成后的商品库存明细:</p>
|
||
<pre><code>@GetMapping("wrong")
|
||
public long wrong() {
|
||
long begin = System.currentTimeMillis();
|
||
//并发进行100次下单操作,统计成功次数
|
||
long success = IntStream.rangeClosed(1, 100).parallel()
|
||
.mapToObj(i -> {
|
||
List<Item> cart = createCart();
|
||
return createOrder(cart);
|
||
})
|
||
.filter(result -> result)
|
||
.count();
|
||
log.info("success:{} totalRemaining:{} took:{}ms items:{}",
|
||
success,
|
||
items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
|
||
System.currentTimeMillis() - begin, items);
|
||
return success;
|
||
}
|
||
</code></pre>
|
||
<p>运行程序,输出如下日志:</p>
|
||
<p><img src="assets/141a5ed915e08e50c0f6b066bea36e05.png" alt="img" /></p>
|
||
<p>可以看到,100 次下单操作成功了 65 次,10 种商品总计 10000 件,库存总计为 9805,消耗了 195 件符合预期(65 次下单成功,每次下单包含三件商品),总耗时 50 秒。</p>
|
||
<p>为什么会这样呢?</p>
|
||
<p>使用 JDK 自带的 VisualVM 工具来跟踪一下,重新执行方法后不久就可以看到,线程 Tab 中提示了死锁问题,根据提示点击右侧线程 Dump 按钮进行线程抓取操作:</p>
|
||
<p><img src="assets/ff24ac10bd0635ef4bf5987038b622ce.png" alt="img" /></p>
|
||
<p>查看抓取出的线程栈,在页面中部可以看到如下日志:</p>
|
||
<p><img src="assets/c32cb32eb5433aae3b392738a80bca42.png" alt="img" /></p>
|
||
<p>显然,是出现了死锁,线程 4 在等待的一个锁被线程 3 持有,线程 3 在等待的另一把锁被线程 4 持有。</p>
|
||
<p>那为什么会有死锁问题呢?</p>
|
||
<p>我们仔细回忆一下购物车添加商品的逻辑,随机添加了三种商品,假设一个购物车中的商品是 item1 和 item2,另一个购物车中的商品是 item2 和 item1,一个线程先获取到了 item1 的锁,同时另一个线程获取到了 item2 的锁,然后两个线程接下来要分别获取 item2 和 item1 的锁,这个时候锁已经被对方获取了,只能相互等待一直到 10 秒超时。</p>
|
||
<p>其实,避免死锁的方案很简单,为购物车中的商品排一下序,让所有的线程一定是先获取 item1 的锁然后获取 item2 的锁,就不会有问题了。所以,我只需要修改一行代码,对 createCart 获得的购物车按照商品名进行排序即可:</p>
|
||
<pre><code>@GetMapping("right")
|
||
public long right() {
|
||
...
|
||
.
|
||
long success = IntStream.rangeClosed(1, 100).parallel()
|
||
.mapToObj(i -> {
|
||
List<Item> cart = createCart().stream()
|
||
.sorted(Comparator.comparing(Item::getName))
|
||
.collect(Collectors.toList());
|
||
return createOrder(cart);
|
||
})
|
||
.filter(result -> result)
|
||
.count();
|
||
...
|
||
return success;
|
||
}
|
||
</code></pre>
|
||
<p>测试一下 right 方法,不管执行多少次都是 100 次成功下单,而且性能相当高,达到了 3000 以上的 TPS:</p>
|
||
<p><img src="assets/a41d077eeecc8b922503409d13a465e4.png" alt="img" /></p>
|
||
<p>这个案例中,虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞的,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。</p>
|
||
<h2>重点回顾</h2>
|
||
<p>我们一起总结回顾下,使用锁来解决多线程情况下线程安全问题的坑吧。</p>
|
||
<p>第一,使用 synchronized 加锁虽然简单,但我们首先要弄清楚共享资源是类还是实例级别的、会被哪些线程操作,synchronized 关联的锁对象或方法又是什么范围的。</p>
|
||
<p>第二,加锁尽可能要考虑粒度和场景,锁保护的代码意味着无法进行多线程操作。对于 Web 类型的天然多线程项目,对方法进行大范围加锁会显著降级并发能力,要考虑尽可能地只为必要的代码块加锁,降低锁的粒度;而对于要求超高性能的业务,还要细化考虑锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案,可以在适当的场景下考虑使用 ReentrantReadWriteLock、StampedLock 等高级的锁工具类。</p>
|
||
<p>第三,业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等待。</p>
|
||
<p>此外,如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。</p>
|
||
<p>为演示方便,今天的案例是在 Controller 的逻辑中开新的线程或使用线程池进行并发模拟,我们当然可以意识到哪些对象是并发操作的。但对于 Web 应用程序的天然多线程场景,你可能更容易忽略这点,并且也可能因为误用锁降低应用整体的吞吐量。如果你的业务代码涉及复杂的锁操作,强烈建议 Mock 相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题。</p>
|
||
<p>今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。</p>
|
||
<h2>思考与讨论</h2>
|
||
<p>本文开头的例子里,变量 a、b 都使用了 volatile 关键字,你知道原因吗?我之前遇到过这样一个坑:我们开启了一个线程无限循环来跑一些任务,有一个 bool 类型的变量来控制循环的退出,默认为 true 代表执行,一段时间后主线程将这个变量设置为了 false。如果这个变量不是 volatile 修饰的,子线程可以退出吗?你能否解释其中的原因呢?</p>
|
||
<p>文末我们又提了两个坑,一是加锁和释放没有配对的问题,二是锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两种问题吗?</p>
|
||
<p>在使用锁的过程中,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style="float: left">
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/01 使用了并发工具类库,线程安全就高枕无忧了吗?.md.html">上一页</a>
|
||
</div>
|
||
<div style="float: right">
|
||
<a href="/专栏/Java 业务开发常见错误 100 例/03 线程池:业务代码最常用也最容易犯错的组件.md.html">下一页</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
|
||
</div>
|
||
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"709970134d743d60","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
|
||
</body>
|
||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
|
||
<script>
|
||
window.dataLayer = window.dataLayer || [];
|
||
function gtag() {
|
||
dataLayer.push(arguments);
|
||
}
|
||
gtag('js', new Date());
|
||
gtag('config', 'G-NPSEEVD756');
|
||
var path = window.location.pathname
|
||
var cookie = getCookie("lastPath");
|
||
console.log(path)
|
||
if (path.replace("/", "") === "") {
|
||
if (cookie.replace("/", "") !== "") {
|
||
console.log(cookie)
|
||
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
|
||
}
|
||
} else {
|
||
setCookie("lastPath", path)
|
||
}
|
||
function setCookie(cname, cvalue) {
|
||
var d = new Date();
|
||
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
|
||
var expires = "expires=" + d.toGMTString();
|
||
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
|
||
}
|
||
function getCookie(cname) {
|
||
var name = cname + "=";
|
||
var ca = document.cookie.split(';');
|
||
for (var i = 0; i < ca.length; i++) {
|
||
var c = ca[i].trim();
|
||
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
|
||
}
|
||
return "";
|
||
}
|
||
</script>
|
||
</html>
|