tulip notes
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Star-Lord

希望一天成为大师的学徒
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 并发与锁相关

    • 代码加锁:不要让“锁”事成为烦心事
      • 加锁机制保证原子性
        • 2个方法都加锁
      • 弄清保护对象
      • 加锁要考虑锁的粒度和场景问题
        • 其他的方法
  • 对象与其他

  • 网络接口调用

  • 架构与设计

  • 《踩坑与提升》记录
  • 并发与锁相关
EffectTang
2024-11-24
目录

代码加锁:不要让“锁”事成为烦心事

# 代码加锁:不要让“锁”事成为烦心事

网上收集

# 加锁机制保证原子性

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

我先和你分享一个有趣的案例吧。有一天,一位同学在群里说“见鬼了,疑似遇到了一个 JVM 的 Bug”,我们都很好奇是什么 Bug。

于是,他贴出了这样一段代码:在一个类里有两个 int 类型的字段 a 和 b,有一个 add 方法循环 1 万次对 a 和 b 进行 ++ 操作,有另一个 compare 方法,同样循环 1 万次判断 a 是否小于 b,条件成立就打印 a 和 b 的值,并判断 a>b 是否成立。

@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");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

他起了两个线程来分别执行 add 和 compare 方法:

Interesting interesting = new Interesting();
new Thread(() -> interesting::add).start();
// 对于简单的lambda表达式 还可以用 方法引用 这种表达式
new Thread(() -> interesting.compare()).start();
1
2
3
4

按道理,a 和 b 同样进行累加操作,应该始终相等,compare 中的第一次判断应该始终不会成立,不会输出任何日志。但,执行代码后发现不但输出了日志,而且更诡异的是,compare 方法在判断 ab 也成立:

16:56:19.685 [Thread-0] INFO com.atguigu.cloud.juc.AB -- add start
16:56:19.685 [Thread-1] INFO com.atguigu.cloud.juc.AB -- compare start
16:56:19.687 [Thread-0] INFO com.atguigu.cloud.juc.AB -- add done
16:56:19.687 [Thread-1] INFO com.atguigu.cloud.juc.AB -- a:321,b721,result:false
16:56:19.688 [Thread-1] INFO com.atguigu.cloud.juc.AB -- compare done

Process finished with exit code 0
1
2
3
4
5
6
7

群里一位同学看到这个问题笑了,说:“这哪是 JVM 的 Bug,分明是线程安全问题嘛。很明显,你这是在操作两个字段 a 和 b,有线程安全问题,应该为 add 方法加上锁,确保 a 和 b 的 ++ 是原子性的,就不会错乱了。”随后,他为 add 方法加上了锁:

public synchronized void add()
1

但,加锁后问题并没有解决。

我们来仔细想一下,为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所以加锁后的代码中的资源操作是线程安全的。但是,这个案例中的 add 方法始终只有一个线程在操作,显然只为 add 方法加锁是没用的。

之所以出现这种错乱,是因为两个线程是交错执行 add 和 compare 方法中的业务逻辑,而且这些业务逻辑不是原子性的:a++ 和 b++ 操作中可以穿插在 compare 方法的比较代码中;更需要注意的是,a 这种比较操作在字节码层面是加载 a、加载 b 和比较三步,代码虽然是一行但也不是原子性的。

  1. 中间状态:
    • 在 add 方法中,a++ 和 b++ 是原子操作,但在 add 方法执行过程中,compare 方法可能会读取到 a 和 b 的中间状态。例如,当 a 已经增加了1,而 b 还没有增加时,compare 方法可能会观察到 a > b。
  2. 数据不一致:
    • 由于 compare 方法不加锁,它可以在 add 方法的任意时刻读取 a 和 b 的值,导致可能观察到不一致的状态。

# 2个方法都加锁

所以,正确的做法应该是,为 add 和 compare 都加上方法锁,确保 add 方法执行时,compare 无法读取 a 和 b:

public synchronized void add()
public synchronized void compare()
1
2

所以,使用锁解决问题之前一定要理清楚,我们要保护的是什么逻辑,多线程执行的情况又是怎样的。

如果对 add 和 compare 方法都加锁,那么在任何时候,只有一个线程可以进入这两个方法中的一个,另一个线程必须等待。这是因为锁是作用于同一个对象实例上的,确保了在同一时间只有一个线程可以持有该锁。

# 弄清保护对象

  • 加锁前要清楚锁和被保护的对象是不是一个层面的

除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加无效的方法锁外,还有一种比较常见的错误是,没有理清楚锁和要保护的对象是否是一个层面的。

我们知道静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护。

先看看这段代码有什么问题:在类 Data 中定义了一个静态的 int 字段 counter 和一个非静态的 wrong 方法,实现 counter 字段的累加操作。

class Data {
    @Getter
    private static int counter = 0;
    
    public static int reset() {
        counter = 0;
        return counter;
    }
    public synchronized void wrong() {
        counter++;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

写一段代码测试下:

@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();
}
1
2
3
4
5
6
7

因为默认运行 100 万次,所以执行后应该输出 100 万,但页面输出的是 639242:

img

我们来分析下为什么会出现这个问题吧。

在非静态的 wrong 方法上加锁,只能确保多个线程无法执行同一个实例的 wrong 方法,却不能保证不会执行不同实例的 wrong 方法。而静态的 counter 在多个实例中共享,所以必然会出现线程安全问题。

理清思路后,修正方法就很清晰了:同样在类中定义一个 Object 类型的静态字段,在操作 counter 之前对这个字段加锁。

class Data {
    @Getter
    private static int counter = 0;
    private static Object locker = new Object();
    public void right() {
        synchronized (locker) {
            counter++;
        }
    }
}
1
2
3
4
5
6
7
8
9
10

你可能要问了,把 wrong 方法定义为静态不就可以了,这个时候锁是类级别的。可以是可以,但我们不可能为了解决线程安全问题改变代码结构,把实例方法改为静态方法。

感兴趣的同学还可以从字节码以及 JVM 的层面继续探索一下,代码块级别的 synchronized 和方法上标记 synchronized 关键字,在实现上有什么区别。

以下是使用效果上的区别:

  • 在方法声明前加上 synchronized 关键字。
  1. 锁对象:
    • 对于实例方法,锁对象是当前实例对象 (this)。
    • 对于静态方法,锁对象是该类的 Class 对象。
  2. 作用范围:
    • 整个方法体都被锁定,即在整个方法执行期间,只有一个线程可以进入该方法。
    • 如果有多个线程尝试调用同一个对象的同步方法,只有第一个线程可以进入,其他线程必须等待。
  3. 缺点:
    • 如果方法体较长或包含不必要的同步操作,可能会导致性能下降。
    • 无法对特定的代码块进行更细粒度的同步。
  • 使用 synchronized 关键字包裹需要同步的代码块。
public void method() {
    synchronized (lockObject) {
        // 需要同步的代码块
    }
}
1
2
3
4
5
  1. 锁对象:
  • 锁对象可以是任意对象,通常是一个私有的对象实例,用于确保锁的唯一性。
  1. 作用范围:
  • 只有被 synchronized 包裹的代码块会被锁定,而不是整个方法。
  • 允许在方法内部对特定的代码块进行更细粒度的同步。
  1. 缺点:
  • 代码相对复杂,需要显式地指定锁对象。
  • 如果锁对象选择不当,可能会导致死锁或其他并发问题。

# 加锁要考虑锁的粒度和场景问题

在方法上加 synchronized 关键字实现加锁确实简单,也因此我曾看到一些业务代码中几乎所有方法都加了 synchronized,但这种滥用 synchronized 的做法:

  • 一是,没必要。通常情况下 60% 的业务代码是三层架构,数据经过无状态的 Controller、Service、Repository 流转到数据库,没必要使用 synchronized 来保护什么数据。
  • 二是,可能会极大地降低性能。使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题。

即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。

比如,在业务代码中,有一个 ArrayList 因为会被多个线程操作而需要保护,又有一段比较耗时的操作(代码中的 slow 方法)不涉及线程安全问题,应该如何加锁呢?

错误的做法是,给整段业务逻辑加锁,把 slow 方法和操作 ArrayList 的代码同时纳入 synchronized 代码块;更合适的做法是,把加锁的粒度降到最低,只在操作 ArrayList 的时候给这个 ArrayList 加锁。

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();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

执行这段代码,同样是 1000 次业务操作,正确加锁的版本耗时 1.3秒,而对整个业务逻辑加锁的话耗时 12 秒。

并且他们都是线程安全的,耗时区别却如此之大,所以控制锁的粒度很重要。

21:01:52.762 [wrong] INFO com.atguigu.cloud.juc.Details -- took:12191
21:02:36.625 [right] INFO com.atguigu.cloud.juc.Details -- took:1329
1
2

# 其他的方法

如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:

  • 区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。

一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:

  • 对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能。
  • 如果你的 JDK 版本高于 1.8、共享资源的冲突概率也没那么大的话,考虑使用 StampedLock 的乐观读的特性,进一步提高性能。
  • JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。
#实战与踩坑#并发
上次更新: 2025/04/23, 16:23:16
判等问题:程序里如何确定你就是你

判等问题:程序里如何确定你就是你→

最近更新
01
面向切面跟自定义注解的结合
05-22
02
时间跟其他数据的序列化
05-19
03
数据加密与安全
05-17
更多文章>
Theme by Vdoing | Copyright © 2023-2025 EffectTang
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式