导航

02 | Java内存模型:看Java如何解决可见性和有序性问题
什么是 Java 内存模型?
- Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视 角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
- 具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。
- Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一 部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。
Happens-Before 规则
前面一个操作的结果对后续操作是可见的,比较正式的说法是:Happens-Before 约束了 编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 HappensBefore 规则。在现实世界里,如果 A 事件是导致 B 事件的起因,那么 A 事件一定是先于 (Happens-Before)B 事件发生的,这个就是 Happens-Before 语义的现实理解。 在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。 例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。
- 程序的顺序性规则
- volatile 变量规则
- 传递性

- 管程中锁的规则
管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现
- 线程start()规则
-
03 | 互斥锁(上):解决原子性问题
原子性问题到底该如何解决呢?
“同一时刻只有一个线程执行” 这个条件非常重要,我们称之为互斥。
我们把一段需要互斥执行的代码称为临界区。
锁和受保护资源的关系
受保护资源和锁之间的关联关系是 N:1 的关系
拿球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。
两把锁保护一个资源示例:
class SafeCalc {static long value = 0L;synchronized long get() {return value;}synchronized static void addOne() {value += 1;}}
如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。我们可以用下面这幅图来形象描述 这个关系。由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥 关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
05 | 一不小心就死锁了,怎么办?
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
如何预防死锁
那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资 源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都 是有办法破坏掉的,到底如何做呢?
破坏占用且等待条件
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待 了。
破坏不可抢占条件
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资 源。
java 在语言层次确实没有解决这个问题,不过在 SDK 层面还是解决了的, java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的
破坏循环等待条件
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是 有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性 化后自然就不存在循环了。
06 | 用“等待-通知”机制优化循环等待
等待 - 通知
在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待
如果 apply() 操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。
但是如果 apply() 一次性申请转出账户和转入账户,直到成功操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下可能要循环上万次才能获取到锁,太消耗 CPU 了。
其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账 本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免 循环等待消耗 CPU 的问题。用 synchronized 实现等待 - 通知机制
在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。
wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、 notify()、notifyAll() 都是在 synchronized{}内部被调用的,如果在 synchronized{}外部调 用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常: java.lang.IllegalMonitorStateException总结
等待 - 通知机制是一种非常普遍的线程间协作的方式。
- 除非经过深思熟虑,否则尽量使用 notifyAll()
实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
07 | 安全性、活跃性以及性能问题
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。
安全性问题
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。例如:两个线程循环累加1000次,期望结果是2000,实际结果是1000~2000之间任意数,这就是线程不安全问题!
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)活跃性问题
活锁
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相 撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种 情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了, 就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。饥饿
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源, 三就是避免持有锁的线程长时间执行。(方案二的适用场景相对来说更多一些)性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
从方案层面,我们可以这样来解决这个问题:
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;
- Java 并发包里面的原子类也是一种无锁的数据结构;
- Disruptor 则是一个无锁的内存队列,性能都非常好
第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。
- 这个方案具体的实现技术也有很多,例如使用细粒度的锁,
- 一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍) ;
- 还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。 S = 1 (1−p)+ p n
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。 所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟 是 50 毫秒。
总结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等 问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。08 | 管程:并发编程的万能钥匙
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块, 在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。
