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

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

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

在一个类里有两个 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");
  }
}

Interesting interesting = new Interesting();
new Thread(() -> interesting.add()).start();
new Thread(() -> interesting.compare()).start();

结果自然五花八门,而且在a<b的情况下,还能输出 a>b的数

于是就在add方法里加上synchronized关键字。但是问题没有解决
add 和 compare 方法中的业务逻辑并不是原子性的,a<b,在字节码层面分为加载a、加载b、比较,这三步。
所以需要在两个方法上加锁才可以

public synchronized void add()
public synchronized void 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++;
  }
}

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

运行 100 万次,所以执行后应该输出 100 万,但页面输出的是 639242
在非静态的 wrong 方法上加锁,只能确保多个线程无法执行同一个实例的 wrong 方法,却不能保证不会执行不同实例的 wrong 方法。而静态的 counter 在多个实例中共享,所以必然会出现线程安全问题。
解决思路

class Data {
  @Getter
  private static int counter = 0;
  private static Object locker = new Object();
  public void right() {
    synchronized (locker) {
    	counter++;
    }
  }
}

把 wrong 方法定义为静态也可以,但我们不可能为了解决线程安全问题改变代码结构,把实例方法改为静态方法。
在普通的业务代码里面,基本不需要加synchronized。
一是,没必要。通常情况下 60% 的业务代码是三层架构,数据经过无状态的Controller、Service、Repository 流转到数据库,没必要使用 synchronized 来保护什么数据。
二是,可能会极大地降低性能。使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题。

锁粒度

即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁
在业务代码中,有一个 ArrayList 因为会被多个线程操作而需要保护,又有一段比较耗时的操作(代码中的 slow 方法)不涉及线程安全问题,应该如何加锁呢?
错误的做法是,给一大段业务逻辑加锁,正确的是给需要并发操作的逻辑加锁

private List<Integer> data = new ArrayList<>();
//不涉及共享资源的慢方法
private void slow() {
  // 耗时逻辑
}
//错误的加锁方法
@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();
}

如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
读写比例差距明显的可以考虑 ReentrantReadWriteLock 细化区分读写锁
共享资源的冲突概率也没那么大的话,考虑使用StampedLock 的乐观读的特性

多把锁要小心死锁问题

业务逻辑涉及到多把锁。
案例:下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存操作,全部操作完成之后释放所有的锁。但是发现有死锁问题,原因是扣减库存的顺序不同,导致并发的情况下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是出现了死锁问题。
假设一个购物车中的商品是 item1 和 item2,另一个购物车中的商品是 item2 和 item1,一个线程先获取到了item1 的锁,同时另一个线程获取到了 item2 的锁,然后两个线程接下来要分别获取item2 和 item1 的锁,这个时候锁已经被对方获取了,只能相互等待一直到 10 秒超时。
避免死锁的方案很简单,为购物车中的商品排一下序,让所有的线程一定是先获取item1 的锁然后获取 item2 的锁,就不会有问题了

如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
如果你的业务代码涉及复杂的锁操作,强烈建议 Mock 相关外部接口或数据库操作后对应用代码进行压
测,通过压测排除锁误用带来的性能问题和死锁问题。
锁超时自动释放导致重复执行的话,可以用锁续期,如redisson的watchdog;或者保证业务的幂等性,重复执行也没问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值