Java锁相关辨析

Posted by Haiming on April 13, 2022

参考:https://learn.lianglianglee.com/%E4%B8%93%E6%A0%8F/Java%20%E4%B8%9A%E5%8A%A1%E5%BC%80%E5%8F%91%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%20100%20%E4%BE%8B/02%20%E4%BB%A3%E7%A0%81%E5%8A%A0%E9%94%81%EF%BC%9A%E4%B8%8D%E8%A6%81%E8%AE%A9%E2%80%9C%E9%94%81%E2%80%9D%E4%BA%8B%E6%88%90%E4%B8%BA%E7%83%A6%E5%BF%83%E4%BA%8B.md

一行代码不代表原子性

错误原因

我们会隐含的认为一行代码就是原子性,比如 a++这种,但是实际上 a++经历了三个部分:

  1. 取得 a 的值
  2. 将 a+1
  3. 放回 a 的值

再加上操作系统本身的调度是随时可能发生的,那么可能第一步取得 a 的值和第二步将 a+1之间相隔很久,已经被插入了很多的步骤。

还有比如:

更需要注意的是,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");

    }

}

单独起两个线程来执行此方法:

Interesting interesting = new Interesting();

new Thread(() -> interesting.add()).start();

new Thread(() -> interesting.compare()).start();

随便找一点结果:

add start
compare start
a:37313, b:37760, false
a:1969242, b:1969366, true
a:1983911, b:1983944, true
a:1996311, b:1996337, false
a:2007836, b:2007862, false
a:2019057, b:2019085, false
a:2029753, b:2029774, false

可以看到不仅总有 a 和 b 值不一样(相差还不小),还有的时候后面是 true,意味着在进入 if 的时候 a<b,但是比较的时候 a又大于 b 了。

这也是因为比较 a<b 的时候,是先加载 a,再加载 b,再比较 a 和 b。

正确做法

将两个方法都变成 synchronized的,这种对于方法加上synchronized 的原理,是在方法的字节码前后都加上 monitor,这个 monitor 是实例级别的,这样就能够保证在两个方法之中只有一个线程。

锁和被保护的对象不是一个层面

错误原因:没弄清楚自己上锁的对象和要保护的对象是否一致

比如要保护的对象是类之中的一个静态对象 A,但是方法f本身没有 static 修饰。f 在调用 A的时候就会有问题,因为 A 是 static,我们对 f 进行 synchronized 的修饰,只能保证多个线程不能同时执行一个实例的方法,但是不能保证多个线程不可以同时执行不同实例方法

错误代码

class Data {

    @Getter

    private static int counter = 0;

    

    public static int reset() {

        counter = 0;

        return counter;

    }

    public synchronized void wrong() {

        counter++;

    }

}

@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();

}

返回的值不是100000,而是一个小于这个值的数字。这就是因为锁加在了对象上面而不是类上面。

正确代码

作者给出的:新建一个静态的属性,然后专门用来做锁。

class Data {

    @Getter

    private static int counter = 0;

    private static Object locker = new Object();

    public void right() {

        synchronized (locker) {

            counter++;

        }

    }

}

个人想法:这个绝对可行,而且很好。但是我认为这种只是在一个类之中多个不同业务都需要上锁且相互不影响的情况。如果像我们这个类,本身只有一种业务需要做上锁,那么可以直接锁住类,也可以得到一样的结果。

直接锁住类代码就是把synchronized (locker)改成synchronized (Data.class).

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

错误原因:不分用法的加锁,导致性能极大下降

滥用 synchronized 的做法:

  1. 没必要:通常情况下,Controller,Service 和 Repository 是无状态的,没必要保护数据
  2. 极大可能降低性能:使用 Spring 框架的时候,默认 Controller 这些都是单例的,这些加上 synchronized 会导致程序几乎只能支持单线程,造成性能问题

解决办法

  1. 要尽量小的降低锁的粒度,不需要被同步保护的方法就不用加锁。

  2. 如果精细化锁的粒度还不行: 可以区分读写场景和资源的访问冲突:

    1. 乐观锁?悲观锁?
    2. 读写哪个多?

    参考:https://www.cnblogs.com/zmhjay1999/p/15183390.html

    StampedLock简介

    StampedLock是比ReentrantReadWriteLock更快的一种锁,支持乐观读、悲观读锁和写锁。和ReentrantReadWriteLock不同的是,StampedLock支持多个线程申请乐观读的同时,还允许一个线程申请写锁。

    乐观读并不加锁

    StampedLock的底层并不是基于AQS的。

多把锁要小心死锁问题

错误原因

在循环获取锁的情况下,如果没有设计好锁拿取的顺序,就会造成死锁。

个人思考

为什么用 volatile 来循环变量的 flag

本文开头的例子里,变量 a、b 都使用了 volatile 关键字,你知道原因吗?我之前遇到过这样一个坑:我们开启了一个线程无限循环来跑一些任务,有一个 bool 类型的变量来控制循环的退出,默认为 true 代表执行,一段时间后主线程将这个变量设置为了 false。如果这个变量不是 volatile 修饰的,子线程可以退出吗?你能否解释其中的原因呢?

因为 bool 的变量是基本类型,其本身就在栈上,那么在主线程修改这个变量之后,子线程的值并没有同步改变,而是会再执行一阵,等到整个程序的 context 刷新的时候才会修改。这种情况就会造成程序错误