互斥锁:解决原子性问题

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第18天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

上一篇文章我们说了Java内存模型可以解决并发编程中的可见性问题和有序性问题,但是原子性问题还得不到完全解决,那本章就说一下解决原子性问题的终极方案:

正文

回顾一下上上篇文章中说的原子性问题产生的主要原因是线程切换,而且高级语言中的认为是原子操作的语句,在CPU指令层面上可能是多个原子操作,而CPU线程切换会发生在CPU层次的指令完成后,所以就会导致问题所在。

互斥

既然知道原子性问题的源头是线程切换,那如果像Java内存模型思维一样禁用线程切换可以吗 操作系统做线程切换是通过CPU中断来实现的,所以禁止CPU中断能够禁止线程的切换。

在早期的单核CPU时代,这个方案还真是可行的,比如我们以32位CPU上执行long类型变量的写操作为例来说明问题,long类型是64位,在32位CPU上执行写操作会被分为2次写操作:

image.png

在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就禁止了线程切换,获得CPU使用权的线程可以不断地执行,所以2次写操作要不都执行,要不都没有执行,是具有原子性。

但是在多核场景下,同一时刻,有可能有2个线程同时执行,一个在CPU-1上,一个在CPU-2上,这时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,就会出现bug。

"同一时刻只有一个线程执行"这个条件非常重要,也就是互斥,如果我们能保证对共享变量的修改是互斥的,那么无论是单核CPU还是多核CPU,都能保证原子性

锁模型

谈到互斥,第一时间的方案就是锁,下图就是大概的模型:

image.png

我们把一段需要互斥执行的代码称为临界区,线程进入临界区之前,会尝试加锁lock(),如果成功,则进入临界区,此时我们称为这个线程有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区代码后,执行解锁unlock()。

这个可以类比为办公室高峰期抢坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。但是有没有仔细思考过:锁是什么 保护的又是什么

改进锁模型

在现实世界中,锁和所保护的资源是有对应关系的,比如我家的锁保护我家的东西,你家的锁保护你家的东西,在并发编程时间里,锁和资源也有这个关系,所以我们完善一下锁模型:

image.png

上图中受保护的资源是R,我们创建了一把对应的锁是LR,然后加锁和解锁就是对LR进行操作,这非常符合我们的正常思想。而且其中锁LR和资源R之间是有关系的。

synchronized

锁是一种通用的技术方案,Java语言提供的synchronized关键字就是对锁的一种实现,比如下面代码:

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
复制代码

你会发现并没有加锁lock()和解锁unlock()相关的代码呀,这是因为这2个操作是Java编译器帮我们自动加上的

但是synchronized关键字里的锁定的对象是啥呢 上面修饰代码块中,我们可以看见锁定的对象就是obj,而修饰方法锁定的是什么呢 这个也是Java的一条隐式规则:

  • 当修饰静态方法时,锁定的是当前类的Class对象。(Class对象在Java类加载到JVM中会被创建,保存在方法区中)
  • 当修饰非静态方法时,锁定的是当前实例对象。

synchronized解决count+=1问题

这时我们就可以使用synchronized关键字来解决之前说的count+=1问题,比如下面代码:

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
复制代码

当多个线程调用对象的addOne()方法时,只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题

根据上一篇的Happens-Before规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁,再综合Happens-Before的传递性原则,我们就能得出当前一个线程在临界区修改的共享变量(在解锁前),对后续进入临界区的线程是可见的。

按照这个规则,如果1000个线程执行addOne()方法,最终结果就是1000。看到这个结果,我们至于长舒一口气,问题终于解决了。

但是这里可能会忽略get()方法,根据Java内存模型的synchronized关键字以及Happens-Before规则来说,这里的get()方法的可见性是没法保证的。

(疑惑点:按照Java内存模型规则来说,这里的例子对value的++操作是HB后面临界区对value的读操作,而实现这个方案可能是禁用缓存,所以其实value的值是直接保存在内存中的,但是这不妨碍get()方法的可见性是没法保证的,因为这里很有可能使用别的方法来实现锁的HB原则,所以这里要以原则为准。)

还有一点,就是当addOne()方法正在执行时,这时调用get()方法,可能获取的是++之前的值,所以要确保一定正确,这里需要给get()方法也加锁,如下代码:

class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
复制代码

这时就会发现this对象这一把锁,锁了2个方法,所以get()和addOne()是互斥的,示意图如下:

image.png

锁和受保护资源的关系

由上面例子可以看出锁和受保护资源的关系应该是1:N的关系,也就是一个锁可以锁多个资源,而每次只能对一个资源进行解锁和操作,这就天然形成了互斥关系。

上面的例子稍做改动,把value改成静态变量,addOne()方法改成静态方法,如下:

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}
复制代码

这里会发现对value的++操作的锁是SafeCalc.class,而获取value的方法get()的锁却是this对象,这里就是2把锁锁了一个资源,而导致这2个临界区是没有互斥关系的,临界区addOn()对value的修改对临界区get()是没有可见性的,就会导致并发问题,示意图如下:

image.png

总结

解决原子性问题的根本是互斥即对共享变量的操作同一时刻只能有一个线程进行操作,而实现这个方案最简单的就是锁。

Java的synchronized关键字编译器自动加解锁了,同时要明白锁和受保护资源是1:N的关系,要明白一个锁模型代码中什么是锁,什么是受保护资源。

猜你喜欢

转载自juejin.im/post/7087908686097547272