Java实现多线程的方式:
synchronized、wait()/notify()
其实就是管程模型的不同实现而已。
信号量与管程可以互相实现彼此!
其实并发编程可以总结为三个核心问题:分工、同步、互斥。
所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥
则是保证同一时刻只允许一个线程访问共享资源。Java SDK 并发包很大部分内容都是按照
这三个维度组织的,例如 Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种
典型的同步方式,而可重入锁则是一种互斥手段
互斥
并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定
的。不确定,则意味着可能正确,也可能错误,事先是不知道的。而导致不确定的主要源头
是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java 语言引入了内存模
型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问
题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。
技术的本质是背后的理论模型,技术只是理论的一种实现手段!
学习方式
跳出来,看全景 和 钻进去,看本质
原子性问题起源
- 缓存导致的可见性问题
- 线程切换带来的原子性问题
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有
序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可
就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发
程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程
序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓
存和编译优化的方法即可。
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视
角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方
法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项Happens-Before 规则。
逸出
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给
了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0
的。因此我们一定要避免“逸出”。
我的理解:局部变量的改变影响到了全局变量(越权)
小结
Java 的内存模型是并发编程领域的一次重要创新,之后 C++、C#、Golang 等高级语言都开始支持内存模型。Java内存模型里面,最晦涩的部分就是 Happens-Before 规则了,Happens-Before 规则最初是在一篇叫做Time,Clocks, and the Ordering of Events ina Distributed System的论文中提出来的,在这篇论文中,Happens-Before 的语义是一种因果关系。在现实世界里,如果 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 事件的发生。
看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁 lock()
和解锁 unlock() 在哪里呢?
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解
锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记
解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
能用一把锁锁住不同的资源但不能多把锁锁住同一个资源
我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账
户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加
同步关键字 synchronized 就可以了,这里我就不一一展示了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四
个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护
资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
一把锁锁住不同对象的要点在于如何实现这一把锁可能共同作用于不同对象!
小结
如何保护多个资源,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
死锁
的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
如何预防死锁
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁
问题最好的办法还是规避死锁。
那如何避免死锁呢?
要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就
总结过了,只有以下这四个条件都发生时才会出现死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资
源 X; - 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
配置Notify(),NotifyAll(),wait()方法
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都
是有办法破坏掉的,到底如何做呢?
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待
了。 - 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不
到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。 - 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是
有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
尽量使用 notifyAll()
在上面的代码中,我用的是 notifyAll() 来实现通知机制,为什么不使用 notify() 呢?这二
者是有区别的,notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等
待队列中的所有线程。从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线
程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用
notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
等待 - 通知
等待 - 通知机制是一种非常普遍的线程间协作的方式
wait()方法与sleep()方法区别
wait()方法与sleep()方法的不同之处在于,wait()方法会释放对象的“锁标志”。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池
并发编程中需要注意的问题
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方
面,分别是:安全性问题、活跃性问题和性能问题
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等
等。
安全性
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执
行,不要让我们感到意外。
数据竞争:
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug。
竞态条件:
程序的执行结果依赖线程执行的顺序
活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃
性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个
例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间
后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。
由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等
待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中
也用到了它。
那“饥饿”该怎么去理解呢?所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去
的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先
级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时
间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,
三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有
限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩
短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后
到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
性能问题
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可
能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞
并发程序,为的就是提升性能。
性能指标
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:
吞吐量、延迟和并发量。
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。
所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟
是 50 毫秒
小结
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏
观则表现为安全性、活跃性以及性能问题。
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以
及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等
问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特
定的场景选择合适的数据结构和算法。
管程
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发
历史
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型
和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是
MESA 模型。所以今天我们重点介绍一下 MESA 模型。
各种模型的区别
Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束
了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
- Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒
T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤
醒操作。 - MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从
条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的
最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时
候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
notify() 何时可以使用
还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需
要满足以下三个条件:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
综上,notify()可以使用的限制很多,还是使用notifyAll()好!
小结
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,
在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发
包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操
作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上
所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,
就是相当于掌握了一把并发编程的万能钥匙。
等待条件;
2. 所有等待线程被唤醒后,执行相同的操作;
3. 只需要唤醒一个线程。
综上,notify()可以使用的限制很多,还是使用notifyAll()好!
小结
Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,
在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发
包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操
作。
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上
所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,
就是相当于掌握了一把并发编程的万能钥匙。