解决原子性问题?脑海中有这个模型就可以了
原子性问题的源头就是 线程切换,但在多核 CPU 的大背景下,不允许线程切换是不可能的,正所谓「魔高一尺,道高一丈」,新规矩来了:
互斥: 同一时刻只有一个线程执行
实际上,上面这句话的意思是: 对共享变量的修改是互斥的,也就是说线程 A 修改共享变量时其他线程不能修改,这就不存在操作被打断的问题了,那么如何实现互斥呢?
锁
对并发有所了解的小伙伴马上就能想到 锁 这个概念,并且你的第一反应很可能就是使用 synchronized,这里列出来你常见的 synchronized 的三种用法:
public class ThreeSync { private static final Object object = new Object(); public synchronized void normalSyncMethod(){ //临界区 } public static synchronized void staticSyncMethod(){ //临界区 } public void syncBlockMethod(){ synchronized (object){ //临界区 } } }
三种 synchronized 锁的内容有一些差别:
- 对于普通同步方法,锁的是当前实例对象,通常指 this
- 对于静态同步方法,锁的是当前类的 Class 对象,如 ThreeSync.class
- 对于同步方法块,锁的是 synchronized 括号内的对象
我特意在三种 synchronized 代码里面添加了「临界区」字样的注释,那什么是临界区呢?
临界区: 我们把需要互斥执行的代码看成为临界区
说到这里,和大家串的知识都是表层认知,如何用锁保护有效的临界区才是关键,这直接关系到你是否会写出并发的 bug,了解过本章内容后,你会发现无论是隐式锁/内置锁 (synchronized) 还是显示锁 (Lock) 的使用都是在找寻这种关系,关系对了,一切就对了,且看
上面锁的三种方式都可以用下图来表达:
线程进入临界区之前,尝试加锁 lock(), 加锁成功,则进入临界区(对共享变量进行修改),持有锁的线程执行完临界区代码后,执行 unlock(),释放锁。针对这个模型,大家经常用抢占厕所坑位来形容:
在学习 Java 早期我就是这样记忆与理解锁的,但落实到代码上,我们很容易忽略两点:
- 我们锁的是什么?
- 我们保护的又是什么?
将这两句话联合起来就是你的锁能否对临界区的资源起到保护的作用?所以我们要将上面的模型进一步细化
现实中,我们都知道自己的锁来锁自己需要保护的东西 ,这句话翻译成你的行动语言之后你已经明确知道了:
- 你锁的是什么
- 你保护的资源是什么
CPU 可不像我们大脑这么智能,我们要明确说明我们锁的是什么,我们要保护的资源是什么,它才会用锁保护我们想要保护的资源(共享变量)
拿上图来说,资源 R (共享变量) 就是我们要保护的资源,所以我们就要创建资源 R 的锁来保护资源 R,细心的朋友可能发现上图几个问题:
LR 和 R 之间有明确的指向关系
我们编写程序时,往往脑子中的模型是对的,但是忽略了这个指向关系,导致自己的锁不能起到保护资源 R 的作用(用别人家的锁保护自己家的东西或用自己家的锁保护别人家的东西),最终引发并发 bug,所以在你勾画草图时,要明确找到这个关系
左图 LR 虚线指向了非共享变量
我们写程序的时候很容易这么做,不确定哪个是要保护的资源,直接大杂烩,用 LR 将要保护的资源 R 和没必要保护的非共享变量一起保护起来了,举两个例子来说你就明白这么做的坏处了
- 编写串行程序时,是不建议 try...catch 整个方法的,这样如果出现问是很难定位的,道理一样,我们要用锁精确的锁住我们要保护的资源就够了,其他无意义的资源是不要锁的
- 锁保护的东西越多,临界区就越大,一个线程从走入临界区到走出临界区的时间就越长,这就让其他线程等待的时间越久,这样并发的效率就有所下降,其实这是涉及到锁粒度的问题,后续也都会做相关说明
作为程序猿还是简单拿代码说明一下心里比较踏实,且看:
public class ValidLock { private static final Object object = new Object(); private int count; public synchronized