学习笔记(3)——Java如何解决并发编程线程切换导致的原子性问题

  上一篇笔记写了如何解决并发导致的三个问题其中两个:缓存导致的可见性问题和编译优化导致的顺序性问题,我们可以通过按需进行禁用缓存和编译优化来解决。指导我们如何按需禁用引出java内存模型的概念。那么这篇笔记的主要目的是讨论如何解决线程切换导致的原子性问题。


思考如何解决原子性问题:

原子性:一个或者多个操作在CPU上执行不被中断的特性

很显然,原子性问题是线程切换导致的,我们只要禁用线程切换就可以解决原子性问题。而线程切换依赖于CPU中断,所以我们禁用线程切换需要禁用CPU中断来实现。

在早起单核CPU场景下这个方案是可行的,但多核场景就不能适用了。举个栗子:

32位CPU写long类型的数据,我们知道long类型的数据是64位的,在32位CPU上写操作被分成两部分,一次写高32位,一次写低32位:

1.单核场景下,我们禁用了CPU中断,操作系统不会调度其他线程来完成两次写操作,要么都被执行,要么都没被执行。我没说这具有原子性。

2.多核场景下,加入两个线程同时在CPU1和CPU2上执行long类型变量在32未机器上的高32位写操作,此时我们禁止CPU中断只是保证了同一CPU上只有一个线程持续执行。但是由于这个变量指向的内存空间是同一块,所以可能会出现交叉执行的情况出现,最终导致我们读的值跟写的不一样的诡异情况。

其实其他两个问题解决的原理都一样,就是保证多核并发情况下程序执行的结果跟在单核单线程执行结果情况保持一致。那么原子性问题的解决方案就很明显了,不管是多核还是单核我们只需要保证同一时间只有一个线程在运行, 这种方案叫做互斥。对同一共享变量的修改是互斥的。这样就能保证操作的原子性。

一、互斥锁

简易锁模型

我们把需要互斥执行的代码叫临界区代码,在临界区代码执行之前尝试加锁lock(),如果成功获得锁则进入临界区,否则等待获得锁的线程释放锁。执行完成unlock()释放锁。

这里有两个重要的问题需要弄清楚:

1.我们锁的是什么?

2.我们保护的是什么?

改进后的锁模型

以现实中的锁为例,我们的锁和被保护的对象是对应的。程序中的锁和保护的共享资源应该是对应的。上面的简易锁模型是没有体现出来的。下面介绍一种改进后的锁模型:

我们要保护的资源是R,我们创建一把锁lock(LR)用来保护R,执行完临界区后解锁unlock(LR)。这点很重要,我们要明确哪把锁锁的是那块资源,这是对应的。

Java语言提供的锁 synchronized

synchronized关键字可以修饰方法和代码块

具体使用方法这里可以举个栗子:


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

                             注:对synchronized的用法详细介绍,可以看之前的博文介绍。

我们上文所说的lock(),unlock()是java自动加上的,没有显式操作。

从例子可以看出,修饰代码块的时候锁了一个Object。那么修饰方法的时候锁的是什么呢?这儿引出Java的一个默认规则:

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;

当修饰非静态方法的时候,锁定的是当前实例对象 this。

上面的例子修饰静态方法时相当于:


class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}

意思就是Class X的所有对象都共用一把锁,是公有的。

修饰非静态方法时:


class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

意思就是说每个Class X的对象都有一把锁。

 

用 synchronized 解决 count+=1 问题

上一篇介绍这个问题导致的原子性问题,下面用一个例子:


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

synchronized 是修饰一般方法,说明它是个对象锁就是this。也就是说同一时间只可能有一个线程访问该对象的addOne()方法。也就是说synchronized 的临界区是互斥的。

这里涉及到一个之前写过的内存模型的Happens-before原则的其中一条

管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

这个规则的意思就是上一个解锁操作对下一个加锁操作是可见的。所以上一个线程进入临界区对变量的操作对下一个线程进入临界区是可见的。但是这里我们忽略了一点就是这些操作是否对get()方法也是可见的?当然我们无法保证。

只需要给get()方法也加上锁:


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

这样get()方法也加上了this这把锁,与addOne()方法使用同一把锁。这样就能保证get()和addOne()互斥,也就能保证,addOne()中对变量的操作对get()可见。

如果我们把变量和addOne()方法换成静态的,会发生什么呢?


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

这样是不是就实现了用两把锁保护同一块资源static long value。也就是说两个方法不存在互斥关系,也就是说addOne()里的操作是无法保证对get()里的操作可见的。很显然这样是不对的。

好吧,来总结一下:

多核CPU中禁止CPU切换来达到禁止线程切换,从而保证原子性是行不通的。然后就有了互斥锁的应用,互斥锁保护临界区的资源,而且保证对释放锁后重新获得锁的线程的可见性。

锁和共享资源之间的对应关系是1:N一把锁可以锁住一整箱的金子,一把钥匙能否开这把锁,是锁来决定的,而且同一时间只有一把钥匙能开这把锁,去拿里面的金子。这把钥匙开了锁后取走的或者放进去的金子是对后来开锁的人可见的。

这里涉及了synchronized的用法,以及内存模型的Happens-before原则。

 

声明:该文章是学习总结文章,文中图片已经代码块都来自于极客时间,王宝令老师的并发编程课。如有侵权,立即删除。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值