This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="28 | Immutability模式如何利用不变性解决并发问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/d1/5be77153cdd85307c18a142dfab22dd1.mp3"></audio>
我们曾经说过,“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。
解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:**不变性Immutability模式**。所谓**不变性,简单来讲,就是对象一旦被创建之后,状态就不再发生变化**。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操作);没有修改操作,也就是保持了不变性。
## 快速实现具备不可变性的类
实现一个具备不可变性的类,还是挺简单的。**将一个类所有的属性都设置成final的并且只允许存在只读方法那么这个类基本上就具备不可变性了**。更严格的做法是**这个类本身也是final的**,也就是不允许继承。因为子类可以覆盖父类的方法,有可能改变不可变性,所以推荐你在实际工作中,使用这种更严格的做法。
Java SDK里很多类都具备不可变性只是由于它们的使用太简单最后反而被忽略了。例如经常用到的String和Long、Integer、Double等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的。如果你仔细翻看这些类的声明、属性和方法你会发现它们都严格遵守不可变类的三点要求**类和属性都是final的所有方法均是只读的**。
看到这里你可能会疑惑Java的String方法也有类似字符替换操作怎么能说所有方法都是只读的呢我们结合String的源代码来解释一下这个问题下面的示例代码源自Java 1.8 SDK我略做了修改仅保留了关键属性value[]和replace()方法你会发现String这个类以及它的属性value[]都是final的而replace()方法的实现就的确没有修改value[],而是将替换后的字符串作为返回值返回了。
```
public final class String {
private final char value[];
// 字符替换
String replace(char oldChar,
char newChar) {
//无需替换直接返回this
if (oldChar == newChar){
return this;
}
int len = value.length;
int i = -1;
/* avoid getfield opcode */
char[] val = value;
//定位到需要替换的字符位置
while (++i &lt; len) {
if (val[i] == oldChar) {
break;
}
}
//未找到oldChar无需替换
if (i &gt;= len) {
return this;
}
//创建一个buf[],这是关键
//用来保存替换后的字符串
char buf[] = new char[len];
for (int j = 0; j &lt; i; j++) {
buf[j] = val[j];
}
while (i &lt; len) {
char c = val[i];
buf[i] = (c == oldChar) ?
newChar : c;
i++;
}
//创建一个新的字符串返回
//原字符串不会发生任何变化
return new String(buf, true);
}
}
```
通过分析String的实现你可能已经发现了如果具备不可变性的类需要提供类似修改的功能具体该怎么操作呢做法很简单那就是**创建一个新的不可变对象**,这是与可变对象的一个重要区别,可变对象往往是修改自己的属性。
所有的修改操作都创建一个新的不可变对象,你可能会有这种担心:是不是创建的对象太多了,有点太浪费内存呢?是的,这样做的确有些浪费,那如何解决呢?
## 利用享元模式避免创建重复对象
如果你熟悉面向对象相关的设计模式,相信你一定能想到**享元模式Flyweight Pattern。利用享元模式可以减少创建对象的数量从而减少内存占用。**Java语言里面Long、Integer、Short、Byte等这些基本数据类型的包装类都用到了享元模式。
下面我们就以Long这个类作为例子看看它是如何利用享元模式来优化对象的创建的。
享元模式本质上其实就是一个**对象池**,利用享元模式创建对象的逻辑也很简单:创建之前,首先去对象池里看看是不是存在;如果已经存在,就利用对象池里的对象;如果不存在,就会新创建一个对象,并且把这个新创建出来的对象放进对象池里。
Long这个类并没有照搬享元模式Long内部维护了一个静态的对象池仅缓存了[-128,127]之间的数字这个对象池在JVM启动的时候就创建好了而且这个对象池一直都不会变化也就是说它是静态的。之所以采用这样的设计是因为Long这个对象的状态共有 2<sup>64</sup> 种,实在太多,不宜全部缓存,而[-128,127]之间的数字利用率最高。下面的示例代码出自Java 1.8valueOf()方法就用到了LongCache这个缓存你可以结合着来加深理解。
```
Long valueOf(long l) {
final int offset = 128;
// [-128,127]直接的数字做了缓存
if (l &gt;= -128 &amp;&amp; l &lt;= 127) {
return LongCache
.cache[(int)l + offset];
}
return new Long(l);
}
//缓存,等价于对象池
//仅缓存[-128,127]直接的数字
static class LongCache {
static final Long cache[]
= new Long[-(-128) + 127 + 1];
static {
for(int i=0; i&lt;cache.length; i++)
cache[i] = new Long(i-128);
}
}
```
前面我们在[《13 | 理论基础模块热点问题答疑》](https://time.geekbang.org/column/article/87749)中提到“Integer 和 String 类型的对象不适合做锁”其实基本上所有的基础类型的包装类都不适合做锁因为它们内部用到了享元模式这会导致看上去私有的锁其实是共有的。例如在下面代码中本意是A用锁alB用锁bl各自管理各自的互不影响。但实际上al和bl是一个对象结果A和B共用的是一把锁。
```
class A {
Long al=Long.valueOf(1);
public void setAX(){
synchronized (al) {
//省略代码无数
}
}
}
class B {
Long bl=Long.valueOf(1);
public void setBY(){
synchronized (bl) {
//省略代码无数
}
}
}
```
## 使用Immutability模式的注意事项
在使用Immutability模式的时候需要注意以下两点
1. 对象的所有属性都是final的并不能保证不可变性
1. 不可变对象也需要正确发布。
在Java语言中final修饰的属性一旦被赋值就不可以再修改但是如果属性的类型是普通对象那么这个普通对象的属性是可以被修改的。例如下面的代码中Bar的属性foo虽然是final的依然可以通过setAge()方法来设置foo的属性age。所以**在使用Immutability模式的时候一定要确认保持不变性的边界在哪里是否要求属性对象也具备不可变性**。
```
class Foo{
int age=0;
int name=&quot;abc&quot;;
}
final class Bar {
final Foo foo;
void setAge(int a){
foo.age=a;
}
}
```
下面我们再看看如何正确地发布不可变对象。不可变对象虽然是线程安全的但是并不意味着引用这些不可变对象的对象就是线程安全的。例如在下面的代码中Foo具备不可变性线程安全但是类Bar并不是线程安全的类Bar中持有对Foo的引用foo对foo这个引用的修改在多线程中并不能保证可见性和原子性。
```
//Foo线程安全
final class Foo{
final int age=0;
final int name=&quot;abc&quot;;
}
//Bar线程不安全
class Bar {
Foo foo;
void setFoo(Foo f){
this.foo=f;
}
}
```
如果你的程序仅仅需要foo保持可见性无需保证原子性那么可以将foo声明为volatile变量这样就能保证可见性。如果你的程序需要保证原子性那么可以通过原子类来实现。下面的示例代码是合理库存的原子化实现你应该很熟悉了其中就是用原子类解决了不可变对象引用的原子性问题。
```
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference&lt;WMRange&gt;
rf = new AtomicReference&lt;&gt;(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
while(true){
WMRange or = rf.get();
// 检查参数合法性
if(v &lt; or.lower){
throw new IllegalArgumentException();
}
WMRange nr = new
WMRange(v, or.lower);
if(rf.compareAndSet(or, nr)){
return;
}
}
}
}
```
## 总结
利用Immutability模式解决并发问题也许你觉得有点陌生其实你天天都在享受它的战果。Java语言里面的String和Long、Integer、Double等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的。Immutability模式是最简单的解决并发问题的方法建议当你试图解决一个并发问题时可以首先尝试一下Immutability模式看是否能够快速解决。
具备不变性的对象,只有一种状态,这个状态由对象内部所有的不变属性共同决定。其实还有一种更简单的不变性对象,那就是**无状态**。无状态对象内部没有属性,只有方法。除了无状态的对象,你可能还听说过无状态的服务、无状态的协议等等。无状态有很多好处,最核心的一点就是性能。在多线程领域,无状态对象没有线程安全问题,无需同步处理,自然性能很好;在分布式领域,无状态意味着可以无限地水平扩展,所以分布式领域里面性能的瓶颈一定不是出在无状态的服务节点上。
## 课后思考
下面的示例代码中Account的属性是final的并且只有get方法那这个类是不是具备不可变性呢
```
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return &quot;user&quot;+user;
}
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="29 | Copy-on-Write模式不是延时策略的COW" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/61/d897d55342923e76238d1bd93216ab61.mp3"></audio>
在上一篇文章中我们讲到Java里String这个类在实现replace()方法的时候并没有更改原字符串里面value[]数组的内容,而是创建了一个新字符串,这种方法在解决不可变对象的修改问题时经常用到。如果你深入地思考这个方法,你会发现它本质上是一种**Copy-on-Write方法**。所谓Copy-on-Write经常被缩写为COW或者CoW顾名思义就是**写时复制**。
不可变对象的写操作往往都是使用Copy-on-Write方法解决的当然Copy-on-Write的应用领域并不局限于Immutability模式。下面我们先简单介绍一下Copy-on-Write的应用领域让你对它有个更全面的认识。
## Copy-on-Write模式的应用领域
我们前面在[《20 | 并发容器:都有哪些“坑”需要我们填?》](https://time.geekbang.org/column/article/90201)中介绍过CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器它们背后的设计思想就是Copy-on-Write通过Copy-on-Write这两个容器实现的读操作是无锁的由于无锁所以将读操作的性能发挥到了极致。
除了Java这个领域Copy-on-Write在操作系统领域也有广泛的应用。
我第一次接触Copy-on-Write其实就是在操作系统领域。类Unix的操作系统中创建进程的API是fork()传统的fork()函数会创建父进程的一个完整副本例如父进程的地址空间现在用到了1G的内存那么fork()子进程的时候要复制父进程整个进程的地址空间占有1G内存给子进程这个过程是很耗时的。而Linux中的fork()函数就聪明得多了fork()子进程的时候,并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间;只用在父进程或者子进程需要写入的时候才会复制地址空间,从而使父子进程拥有各自的地址空间。
本质上来讲父子进程的地址空间以及数据都是要隔离的使用Copy-on-Write更多地体现的是一种**延时策略,只有在真正需要复制的时候才复制,而不是提前复制好**同时Copy-on-Write还支持按需复制所以Copy-on-Write在操作系统领域是能够提升性能的。相比较而言Java提供的Copy-on-Write容器由于在修改的同时会复制整个容器所以在提升读操作性能的同时是以内存复制为代价的。这里你会发现同样是应用Copy-on-Write不同的场景对性能的影响是不同的。
在操作系统领域除了创建进程用到了Copy-on-Write很多文件系统也同样用到了例如Btrfs (B-Tree File System)、aufsadvanced multi-layered unification filesystem等。
除了上面我们说的Java领域、操作系统领域很多其他领域也都能看到Copy-on-Write的身影Docker容器镜像的设计是Copy-on-Write甚至分布式源码管理系统Git背后的设计思想都有Copy-on-Write……
不过,**Copy-on-Write最大的应用领域还是在函数式编程领域**。函数式编程的基础是不可变性Immutability所以函数式编程里面所有的修改操作都需要Copy-on-Write来解决。你或许会有疑问“所有数据的修改都需要复制一份性能是不是会成为瓶颈呢”你的担忧是有道理的之所以函数式编程早年间没有兴起性能绝对拖了后腿。但是随着硬件性能的提升性能问题已经慢慢变得可以接受了。而且Copy-on-Write也远不像Java里的CopyOnWriteArrayList那样笨整个数组都复制一遍。Copy-on-Write也是可以按需复制的如果你感兴趣可以参考Purely Functional Data Structures这本书里面描述了各种具备不变性的数据结构的实现。
CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器在修改的时候会复制整个数组所以如果容器经常被修改或者这个数组本身就非常大的时候是不建议使用的。反之如果是修改非常少、数组数量也不大并且对读性能要求苛刻的场景使用Copy-on-Write容器效果就非常好了。下面我们结合一个真实的案例来讲解一下。
## 一个真实案例
我曾经写过一个RPC框架有点类似Dubbo服务提供方是多实例分布式部署的所以服务的客户端在调用RPC的时候会选定一个服务实例来调用这个选定的过程本质上就是在做负载均衡而做负载均衡的前提是客户端要有全部的路由信息。例如在下图中A服务的提供方有3个实例分别是192.168.1.1、192.168.1.2和192.168.1.3客户端在调用目标服务A前首先需要做的是负载均衡也就是从这3个实例中选出1个来然后再通过RPC把请求发送选中的目标实例。
<img src="https://static001.geekbang.org/resource/image/71/1e/713c0fb87154ee6fbb58f71b274b661e.png" alt="">
RPC框架的一个核心任务就是维护服务的路由关系我们可以把服务的路由关系简化成下图所示的路由表。当服务提供方上线或者下线的时候就需要更新客户端的这张路由表。
<img src="https://static001.geekbang.org/resource/image/dc/60/dca6c365d689f2316ca34de613b3fd60.png" alt="">
我们首先来分析一下如何用程序来实现。每次RPC调用都需要通过负载均衡器来计算目标服务的IP和端口号而负载均衡器需要通过路由表获取接口的所有路由信息也就是说每次RPC调用都需要访问路由表所以访问路由表这个操作的性能要求是很高的。不过路由表对数据的一致性要求并不高一个服务提供方从上线到反馈到客户端的路由表里即便有5秒钟很多时候也都是能接受的5秒钟对于以纳秒作为时钟周期的CPU来说那何止是一万年所以路由表对一致性的要求并不高。而且路由表是典型的读多写少类问题写操作的量相比于读操作可谓是沧海一粟少得可怜。
通过以上分析你会发现一些关键词对读的性能要求很高读多写少弱一致性。它们综合在一起你会想到什么呢CopyOnWriteArrayList和CopyOnWriteArraySet天生就适用这种场景啊。所以下面的示例代码中RouteTable这个类内部我们通过`ConcurrentHashMap&lt;String, CopyOnWriteArraySet&lt;Router&gt;&gt;`这个数据结构来描述路由表ConcurrentHashMap的Key是接口名Value是路由集合这个路由集合我们用是CopyOnWriteArraySet。
下面我们再来思考Router该如何设计服务提供方的每一次上线、下线都会更新路由信息这时候你有两种选择。一种是通过更新Router的一个状态位来标识如果这样做那么所有访问该状态位的地方都需要同步访问这样很影响性能。另外一种就是采用Immutability模式每次上线、下线都创建新的Router对象或者删除对应的Router对象。由于上线、下线的频率很低所以后者是最好的选择。
Router的实现代码如下所示是一种典型Immutability模式的实现需要你注意的是我们重写了equals方法这样CopyOnWriteArraySet的add()和remove()方法才能正常工作。
```
//路由信息
public final class Router{
private final String ip;
private final Integer port;
private final String iface;
//构造函数
public Router(String ip,
Integer port, String iface){
this.ip = ip;
this.port = port;
this.iface = iface;
}
//重写equals方法
public boolean equals(Object obj){
if (obj instanceof Router) {
Router r = (Router)obj;
return iface.equals(r.iface) &amp;&amp;
ip.equals(r.ip) &amp;&amp;
port.equals(r.port);
}
return false;
}
public int hashCode() {
//省略hashCode相关代码
}
}
//路由表信息
public class RouterTable {
//Key:接口名
//Value:路由集合
ConcurrentHashMap&lt;String, CopyOnWriteArraySet&lt;Router&gt;&gt;
rt = new ConcurrentHashMap&lt;&gt;();
//根据接口名获取路由表
public Set&lt;Router&gt; get(String iface){
return rt.get(iface);
}
//删除路由
public void remove(Router router) {
Set&lt;Router&gt; set=rt.get(router.iface);
if (set != null) {
set.remove(router);
}
}
//增加路由
public void add(Router router) {
Set&lt;Router&gt; set = rt.computeIfAbsent(
route.iface, r -&gt;
new CopyOnWriteArraySet&lt;&gt;());
set.add(router);
}
}
```
## 总结
目前Copy-on-Write在Java并发编程领域知名度不是很高很多人都在无意中把它忽视了但其实Copy-on-Write才是最简单的并发解决方案。它是如此简单以至于Java中的基本数据类型String、Integer、Long等都是基于Copy-on-Write方案实现的。
Copy-on-Write是一项非常通用的技术方案在很多领域都有着广泛的应用。不过它也有缺点的那就是消耗内存每次修改都需要复制一个新的对象出来好在随着自动垃圾回收GC算法的成熟以及硬件的发展这种内存消耗已经渐渐可以接受了。所以在实际工作中如果写操作非常少那你就可以尝试用一下Copy-on-Write效果还是不错的。
## 课后思考
Java提供了CopyOnWriteArrayList为什么没有提供CopyOnWriteLinkedList呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,168 @@
<audio id="audio" title="30 | 线程本地存储模式:没有共享,就没有伤害" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/05/2883f8de5a7e734241350a7716a4f105.mp3"></audio>
民国年间某山东省主席参加某大学校庆演讲,在篮球场看到十来个人穿着裤衩抢一个球,观之实在不雅,于是怒斥学校的总务处长贪污,并且发话:“多买几个球,一人发一个,省得你争我抢!”小时候听到这个段子只是觉得好玩,今天再来看,却别有一番滋味。为什么呢?因为其间蕴藏着解决并发问题的一个重要方法:**避免共享**。
我们曾经一遍一遍又一遍地重复,多个线程同时读写同一共享变量存在并发问题。前面两篇文章我们突破的是写,没有写操作自然没有并发问题了。其实还可以突破共享变量,没有共享变量也不会有并发问题,正所谓是**没有共享,就没有伤害**。
那如何避免共享呢?思路其实很简单,多个人争一个球总容易出矛盾,那就每个人发一个球。对应到并发编程领域,就是每个线程都拥有自己的变量,彼此之间不共享,也就没有并发问题了。
我们在[《11 | Java线程为什么局部变量是线程安全的](https://time.geekbang.org/column/article/86695)中提到过**线程封闭**,其本质上就是避免共享。你已经知道通过局部变量可以做到避免共享,那还有没有其他方法可以做到呢?有的,**Java语言提供的线程本地存储ThreadLocal就能够做到**。下面我们先看看ThreadLocal到底该如何使用。
## ThreadLocal的使用方法
下面这个静态类ThreadId会为每个线程分配一个唯一的线程Id如果**一个线程**前后两次调用ThreadId的get()方法两次get()方法的返回值是相同的。但如果是**两个线程**分别调用ThreadId的get()方法那么两个线程看到的get()方法的返回值是不同的。若你是初次接触ThreadLocal可能会觉得奇怪为什么相同线程调用get()方法结果就相同而不同线程调用get()方法结果就不同呢?
```
static class ThreadId {
static final AtomicLong
nextId=new AtomicLong(0);
//定义ThreadLocal变量
static final ThreadLocal&lt;Long&gt;
tl=ThreadLocal.withInitial(
()-&gt;nextId.getAndIncrement());
//此方法会为每个线程分配一个唯一的Id
static long get(){
return tl.get();
}
}
```
能有这个奇怪的结果都是ThreadLocal的杰作不过在详细解释ThreadLocal的工作原理之前我们再看一个实际工作中可能遇到的例子来加深一下对ThreadLocal的理解。你可能知道SimpleDateFormat不是线程安全的那如果需要在并发场景下使用它你该怎么办呢
其实有一个办法就是用ThreadLocal来解决下面的示例代码就是ThreadLocal解决方案的具体实现这段代码与前面ThreadId的代码高度相似同样地不同线程调用SafeDateFormat的get()方法将返回不同的SimpleDateFormat对象实例由于不同线程并不共享SimpleDateFormat所以就像局部变量一样是线程安全的。
```
static class SafeDateFormat {
//定义ThreadLocal变量
static final ThreadLocal&lt;DateFormat&gt;
tl=ThreadLocal.withInitial(
()-&gt; new SimpleDateFormat(
&quot;yyyy-MM-dd HH:mm:ss&quot;));
static DateFormat get(){
return tl.get();
}
}
//不同线程执行下面代码
//返回的df是不同的
DateFormat df =
SafeDateFormat.get()
```
通过上面两个例子相信你对ThreadLocal的用法以及应用场景都了解了下面我们就来详细解释ThreadLocal的工作原理。
## ThreadLocal的工作原理
在解释ThreadLocal的工作原理之前 你先自己想想如果让你来实现ThreadLocal的功能你会怎么设计呢ThreadLocal的目标是让不同的线程有不同的变量V那最直接的方法就是创建一个Map它的Key是线程Value是每个线程拥有的变量VThreadLocal内部持有这样的一个Map就可以了。你可以参考下面的示意图和示例代码来理解。
<img src="https://static001.geekbang.org/resource/image/6a/34/6a93910f748ebc5b984ae7ac67283034.png" alt="">
```
class MyThreadLocal&lt;T&gt; {
Map&lt;Thread, T&gt; locals =
new ConcurrentHashMap&lt;&gt;();
//获取线程变量
T get() {
return locals.get(
Thread.currentThread());
}
//设置线程变量
void set(T t) {
locals.put(
Thread.currentThread(), t);
}
}
```
那Java的ThreadLocal是这么实现的吗这一次我们的设计思路和Java的实现差异很大。Java的实现里面也有一个Map叫做ThreadLocalMap不过持有ThreadLocalMap的不是ThreadLocal而是Thread。Thread这个类内部有一个私有属性threadLocals其类型就是ThreadLocalMapThreadLocalMap的Key是ThreadLocal。你可以结合下面的示意图和精简之后的Java实现代码来理解。
<img src="https://static001.geekbang.org/resource/image/3c/02/3cb0a8f15104848dec63eab269bac302.png" alt="">
```
class Thread {
//内部持有ThreadLocalMap
ThreadLocal.ThreadLocalMap
threadLocals;
}
class ThreadLocal&lt;T&gt;{
public T get() {
//首先获取线程持有的
//ThreadLocalMap
ThreadLocalMap map =
Thread.currentThread()
.threadLocals;
//在ThreadLocalMap中
//查找变量
Entry e =
map.getEntry(this);
return e.value;
}
static class ThreadLocalMap{
//内部是数组而不是Map
Entry[] table;
//根据ThreadLocal查找Entry
Entry getEntry(ThreadLocal key){
//省略查找逻辑
}
//Entry定义
static class Entry extends
WeakReference&lt;ThreadLocal&gt;{
Object value;
}
}
}
```
初看上去我们的设计方案和Java的实现仅仅是Map的持有方不同而已我们的设计里面Map属于ThreadLocal而Java的实现里面ThreadLocalMap则是属于Thread。这两种方式哪种更合理呢很显然Java的实现更合理一些。在Java的实现方案里面ThreadLocal仅仅是一个代理工具类内部并不持有任何与线程相关的数据所有和线程相关的数据都存储在Thread里面这样的设计容易理解。而从数据的亲缘性上来讲ThreadLocalMap属于Thread也更加合理。
当然还有一个更加深层次的原因,那就是**不容易产生内存泄露**。在我们的设计方案中ThreadLocal持有的Map会持有Thread对象的引用这就意味着只要ThreadLocal对象存在那么Map中的Thread对象就永远不会被回收。ThreadLocal的生命周期往往都比线程要长所以这种设计方案很容易导致内存泄露。而Java的实现中Thread持有ThreadLocalMap而且ThreadLocalMap里对ThreadLocal的引用还是弱引用WeakReference所以只要Thread对象可以被回收那么ThreadLocalMap就能被回收。Java的这种实现方案虽然看上去复杂一些但是更加安全。
Java的ThreadLocal实现应该称得上深思熟虑了不过即便如此深思熟虑还是不能百分百地让程序员避免内存泄露例如在线程池中使用ThreadLocal如果不谨慎就可能导致内存泄露。
## ThreadLocal与内存泄露
在线程池中使用ThreadLocal为什么可能导致内存泄露呢原因就出在线程池中线程的存活时间太长往往都是和程序同生共死的这就意味着Thread持有的ThreadLocalMap一直都不会被回收再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用WeakReference所以只要ThreadLocal结束了自己的生命周期是可以被回收掉的。但是Entry中的Value却是被Entry强引用的所以即便Value的生命周期结束了Value也是无法被回收的从而导致内存泄露。
那在线程池中我们该如何正确使用ThreadLocal呢其实很简单既然JVM不能做到自动释放对Value的强引用那我们手动释放就可以了。如何能做到手动释放呢估计你马上想到**try{}finally{}方案**了,这个简直就是**手动释放资源的利器**。示例的代码如下,你可以参考学习。
```
ExecutorService es;
ThreadLocal tl;
es.execute(()-&gt;{
//ThreadLocal增加变量
tl.set(obj);
try {
// 省略业务逻辑代码
}finally {
//手动清理ThreadLocal
tl.remove();
}
});
```
## InheritableThreadLocal与继承性
通过ThreadLocal创建的线程变量其子线程是无法继承的。也就是说你在线程中通过ThreadLocal创建了线程变量V而后该线程创建了子线程你在子线程中是无法通过ThreadLocal来访问父线程的线程变量V的。
如果你需要子线程继承父线程的线程变量那该怎么办呢其实很简单Java提供了InheritableThreadLocal来支持这种特性InheritableThreadLocal是ThreadLocal子类所以用法和ThreadLocal相同这里就不多介绍了。
不过我完全不建议你在线程池中使用InheritableThreadLocal不仅仅是因为它具有ThreadLocal相同的缺点——可能导致内存泄露更重要的原因是线程池中线程的创建是动态的很容易导致继承关系错乱如果你的业务逻辑依赖InheritableThreadLocal那么很可能导致业务逻辑计算错误而这个错误往往比内存泄露更要命。
## 总结
线程本地存储模式本质上是一种避免共享的方案,由于没有共享,所以自然也就没有并发问题。如果你需要在并发场景中使用一个线程不安全的工具类,最简单的方案就是避免共享。避免共享有两种方案,一种方案是将这个工具类作为局部变量使用,另外一种方案就是线程本地存储模式。这两种方案,局部变量方案的缺点是在高并发场景下会频繁创建对象,而线程本地存储方案,每个线程只需要创建一个工具类的实例,所以不存在频繁创建对象的问题。
线程本地存储模式是解决并发问题的常用方案所以Java SDK也提供了相应的实现ThreadLocal。通过上面我们的分析你应该能体会到Java SDK的实现已经是深思熟虑了不过即便如此仍不能尽善尽美例如在线程池中使用ThreadLocal仍可能导致内存泄漏所以使用ThreadLocal还是需要你打起精神足够谨慎。
## 课后思考
实际工作中有很多平台型的技术方案都是采用ThreadLocal来传递一些上下文信息例如Spring使用ThreadLocal来传递事务信息。我们曾经说过异步编程已经很成熟了那你觉得在异步场景中是否可以使用Spring的事务管理器呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,243 @@
<audio id="audio" title="31 | Guarded Suspension模式等待唤醒机制的规范实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/47/20fec0307714e6780fdcee5a224c7047.mp3"></audio>
前不久同事小灰工作中遇到一个问题他开发了一个Web项目Web版的文件浏览器通过它用户可以在浏览器里查看服务器上的目录和文件。这个项目依赖运维部门提供的文件浏览服务而这个文件浏览服务只支持消息队列MQ方式接入。消息队列在互联网大厂中用的非常多主要用作流量削峰和系统解耦。在这种接入方式中发送消息和消费结果这两个操作之间是异步的你可以参考下面的示意图来理解。
<img src="https://static001.geekbang.org/resource/image/d1/21/d1ad5ce1df66d85698308c41e4e93a21.png" alt="">
在小灰的这个Web项目中用户通过浏览器发过来一个请求会被转换成一个异步消息发送给MQ等MQ返回结果后再将这个结果返回至浏览器。小灰同学的问题是给MQ发送消息的线程是处理Web请求的线程T1但消费MQ结果的线程并不是线程T1那线程T1如何等待MQ的返回结果呢为了便于你理解这个场景我将其代码化了示例代码如下。
```
class Message{
String id;
String content;
}
//该方法可以发送消息
void send(Message msg){
//省略相关代码
}
//MQ消息返回后会调用该方法
//该方法的执行线程不同于
//发送消息的线程
void onMessage(Message msg){
//省略相关代码
}
//处理浏览器发来的请求
Respond handleWebReq(){
//创建一消息
Message msg1 = new
Message(&quot;1&quot;,&quot;{...}&quot;);
//发送消息
send(msg1);
//如何等待MQ返回的消息呢
String result = ...;
}
```
看到这里,相信你一定有点似曾相识的感觉,这不就是前面我们在[《15 | Lock和ConditionDubbo如何用管程实现异步转同步](https://time.geekbang.org/column/article/88487)中曾介绍过的异步转同步问题吗?仔细分析,的确是这样,不过在那一篇文章中我们只是介绍了最终方案,让你知其然,但是并没有介绍这个方案是如何设计出来的,今天咱们再仔细聊聊这个问题,让你知其所以然,遇到类似问题也能自己设计出方案来。
## Guarded Suspension模式
上面小灰遇到的问题,在现实世界里比比皆是,只是我们一不小心就忽略了。比如,项目组团建要外出聚餐,我们提前预订了一个包间,然后兴冲冲地奔过去,到那儿后大堂经理看了一眼包间,发现服务员正在收拾,就会告诉我们:“您预订的包间服务员正在收拾,请您稍等片刻。”过了一会,大堂经理发现包间已经收拾完了,于是马上带我们去包间就餐。
我们等待包间收拾完的这个过程和小灰遇到的等待MQ返回消息本质上是一样的都是**等待一个条件满足**就餐需要等待包间收拾完小灰的程序里要等待MQ返回消息。
那我们来看看现实世界里是如何解决这类问题的呢?现实世界里大堂经理这个角色很重要,我们是否等待,完全是由他来协调的。通过类比,相信你也一定有思路了:我们的程序里,也需要这样一个大堂经理。的确是这样,那程序世界里的大堂经理该如何设计呢?其实设计方案前人早就搞定了,而且还将其总结成了一个设计模式:**Guarded Suspension**。所谓Guarded Suspension直译过来就是“保护性地暂停”。那下面我们就来看看Guarded Suspension模式是如何模拟大堂经理进行保护性地暂停的。
下图就是Guarded Suspension模式的结构图非常简单一个对象GuardedObject内部有一个成员变量——受保护的对象以及两个成员方法——`get(Predicate&lt;T&gt; p)``onChanged(T obj)`方法。其中对象GuardedObject就是我们前面提到的大堂经理受保护对象就是餐厅里面的包间受保护对象的get()方法对应的是我们的就餐就餐的前提条件是包间已经收拾好了参数p就是用来描述这个前提条件的受保护对象的onChanged()方法对应的是服务员把包间收拾好了通过onChanged()方法可以fire一个事件而这个事件往往能改变前提条件p的计算结果。下图中左侧的绿色线程就是需要就餐的顾客而右侧的蓝色线程就是收拾包间的服务员。
<img src="https://static001.geekbang.org/resource/image/63/dc/630f3eda98a0e6a436953153c68464dc.png" alt="">
GuardedObject的内部实现非常简单是管程的一个经典用法你可以参考下面的示例代码核心是get()方法通过条件变量的await()方法实现等待onChanged()方法通过条件变量的signalAll()方法实现唤醒功能。逻辑还是很简单的,所以这里就不再详细介绍了。
```
class GuardedObject&lt;T&gt;{
//受保护的对象
T obj;
final Lock lock =
new ReentrantLock();
final Condition done =
lock.newCondition();
final int timeout=1;
//获取受保护对象
T get(Predicate&lt;T&gt; p) {
lock.lock();
try {
//MESA管程推荐写法
while(!p.test(obj)){
done.await(timeout,
TimeUnit.SECONDS);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally{
lock.unlock();
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
lock.lock();
try {
this.obj = obj;
done.signalAll();
} finally {
lock.unlock();
}
}
}
```
## 扩展Guarded Suspension模式
上面我们介绍了Guarded Suspension模式及其实现这个模式能够模拟现实世界里大堂经理的角色那现在我们再来看看这个“大堂经理”能否解决小灰同学遇到的问题。
Guarded Suspension模式里GuardedObject有两个核心方法一个是get()方法一个是onChanged()方法。很显然在处理Web请求的方法handleWebReq()中可以调用GuardedObject的get()方法来实现等待在MQ消息的消费方法onMessage()中可以调用GuardedObject的onChanged()方法来实现唤醒。
```
//处理浏览器发来的请求
Respond handleWebReq(){
//创建一消息
Message msg1 = new
Message(&quot;1&quot;,&quot;{...}&quot;);
//发送消息
send(msg1);
//利用GuardedObject实现等待
GuardedObject&lt;Message&gt; go
=new GuardObjec&lt;&gt;();
Message r = go.get(
t-&gt;t != null);
}
void onMessage(Message msg){
//如何找到匹配的go
GuardedObject&lt;Message&gt; go=???
go.onChanged(msg);
}
```
但是在实现的时候会遇到一个问题handleWebReq()里面创建了GuardedObject对象的实例go并调用其get()方等待结果那在onMessage()方法中如何才能够找到匹配的GuardedObject对象呢这个过程类似服务员告诉大堂经理某某包间已经收拾好了大堂经理如何根据包间找到就餐的人。现实世界里大堂经理的头脑中有包间和就餐人之间的关系图所以服务员说完之后大堂经理立刻就能把就餐人找出来。
我们可以参考大堂经理识别就餐人的办法来扩展一下Guarded Suspension模式从而使它能够很方便地解决小灰同学的问题。在小灰的程序中每个发送到MQ的消息都有一个唯一性的属性id所以我们可以维护一个MQ消息id和GuardedObject对象实例的关系这个关系可以类比大堂经理大脑里维护的包间和就餐人的关系。
有了这个关系我们来看看具体如何实现。下面的示例代码是扩展Guarded Suspension模式的实现扩展后的GuardedObject内部维护了一个Map其Key是MQ消息id而Value是GuardedObject对象实例同时增加了静态方法create()和fireEvent()create()方法用来创建一个GuardedObject对象实例并根据key值将其加入到Map中而fireEvent()方法则是模拟的大堂经理根据包间找就餐人的逻辑。
```
class GuardedObject&lt;T&gt;{
//受保护的对象
T obj;
final Lock lock =
new ReentrantLock();
final Condition done =
lock.newCondition();
final int timeout=2;
//保存所有GuardedObject
final static Map&lt;Object, GuardedObject&gt;
gos=new ConcurrentHashMap&lt;&gt;();
//静态方法创建GuardedObject
static &lt;K&gt; GuardedObject
create(K key){
GuardedObject go=new GuardedObject();
gos.put(key, go);
return go;
}
static &lt;K, T&gt; void
fireEvent(K key, T obj){
GuardedObject go=gos.remove(key);
if (go != null){
go.onChanged(obj);
}
}
//获取受保护对象
T get(Predicate&lt;T&gt; p) {
lock.lock();
try {
//MESA管程推荐写法
while(!p.test(obj)){
done.await(timeout,
TimeUnit.SECONDS);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally{
lock.unlock();
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
lock.lock();
try {
this.obj = obj;
done.signalAll();
} finally {
lock.unlock();
}
}
}
```
这样利用扩展后的GuardedObject来解决小灰同学的问题就很简单了具体代码如下所示。
```
//处理浏览器发来的请求
Respond handleWebReq(){
int id=序号生成器.get();
//创建一消息
Message msg1 = new
Message(id,&quot;{...}&quot;);
//创建GuardedObject实例
GuardedObject&lt;Message&gt; go=
GuardedObject.create(id);
//发送消息
send(msg1);
//等待MQ消息
Message r = go.get(
t-&gt;t != null);
}
void onMessage(Message msg){
//唤醒等待的线程
GuardedObject.fireEvent(
msg.id, msg);
}
```
## 总结
Guarded Suspension模式本质上是一种等待唤醒机制的实现只不过Guarded Suspension模式将其规范化了。规范化的好处是你无需重头思考如何实现也无需担心实现程序的可理解性问题同时也能避免一不小心写出个Bug来。但Guarded Suspension模式在解决实际问题的时候往往还是需要扩展的扩展的方式有很多本篇文章就直接对GuardedObject的功能进行了增强Dubbo中DefaultFuture这个类也是采用的这种方式你可以对比着来看相信对DefaultFuture的实现原理会理解得更透彻。当然你也可以创建新的类来实现对Guarded Suspension模式的扩展。
Guarded Suspension模式也常被称作Guarded Wait模式、Spin Lock模式因为使用了while循环去等待这些名字都很形象不过它还有一个更形象的非官方名字多线程版本的if。单线程场景中if语句是不需要等待的因为在只有一个线程的条件下如果这个线程被阻塞那就没有其他活动线程了这意味着if判断条件的结果也不会发生变化了。但是多线程场景中等待就变得有意义了这种场景下if判断条件的结果是可能发生变化的。所以用“多线程版本的if”来理解这个模式会更简单。
## 课后思考
有同学觉得用done.await()还要加锁太啰嗦还不如直接使用sleep()方法,下面是他的实现,你觉得他的写法正确吗?
```
//获取受保护对象
T get(Predicate&lt;T&gt; p) {
try {
while(!p.test(obj)){
TimeUnit.SECONDS
.sleep(timeout);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
this.obj = obj;
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="32 | Balking模式再谈线程安全的单例模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/c0/bda6f597c73273fbbf49069addaaa0c0.mp3"></audio>
上一篇文章中我们提到可以用“多线程版本的if”来理解Guarded Suspension模式不同于单线程中的if这个“多线程版本的if”是需要等待的而且还很执着必须要等到条件为真。但很显然这个世界不是所有场景都需要这么执着有时候我们还需要快速放弃。
需要快速放弃的一个最常见的例子是各种编辑器提供的自动保存功能。自动保存功能的实现逻辑一般都是隔一定时间自动执行存盘操作存盘操作的前提是文件做过修改如果文件没有执行过修改操作就需要快速放弃存盘操作。下面的示例代码将自动保存功能代码化了很显然AutoSaveEditor这个类不是线程安全的因为对共享变量changed的读写没有使用同步那如何保证AutoSaveEditor的线程安全性呢
```
class AutoSaveEditor{
//文件是否被修改过
boolean changed=false;
//定时任务线程池
ScheduledExecutorService ses =
Executors.newSingleThreadScheduledExecutor();
//定时执行自动保存
void startAutoSave(){
ses.scheduleWithFixedDelay(()-&gt;{
autoSave();
}, 5, 5, TimeUnit.SECONDS);
}
//自动存盘操作
void autoSave(){
if (!changed) {
return;
}
changed = false;
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
changed = true;
}
}
```
解决这个问题相信你一定手到擒来了读写共享变量changed的方法autoSave()和edit()都加互斥锁就可以了。这样做虽然简单但是性能很差原因是锁的范围太大了。那我们可以将锁的范围缩小只在读写共享变量changed的地方加锁实现代码如下所示。
```
//自动存盘操作
void autoSave(){
synchronized(this){
if (!changed) {
return;
}
changed = false;
}
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
synchronized(this){
changed = true;
}
}
```
如果你深入地分析一下这个示例程序你会发现示例中的共享变量是一个状态变量业务逻辑依赖于这个状态变量的状态当状态满足某个条件时执行某个业务逻辑其本质其实不过就是一个if而已放到多线程场景里就是一种“多线程版本的if”。这种“多线程版本的if”的应用场景还是很多的所以也有人把它总结成了一种设计模式叫做**Balking模式**。
## Balking模式的经典实现
Balking模式本质上是一种规范化地解决“多线程版本的if”的方案对于上面自动保存的例子使用Balking模式规范化之后的写法如下所示你会发现仅仅是将edit()方法中对共享变量changed的赋值操作抽取到了change()中,这样的好处是将并发处理逻辑和业务逻辑分开。
```
boolean changed=false;
//自动存盘操作
void autoSave(){
synchronized(this){
if (!changed) {
return;
}
changed = false;
}
//执行存盘操作
//省略且实现
this.execSave();
}
//编辑操作
void edit(){
//省略编辑逻辑
......
change();
}
//改变状态
void change(){
synchronized(this){
changed = true;
}
}
```
## 用volatile实现Balking模式
前面我们用synchronized实现了Balking模式这种实现方式最为稳妥建议你实际工作中也使用这个方案。不过在某些特定场景下也可以使用volatile来实现但**使用volatile的前提是对原子性没有要求**。
在[《29 | Copy-on-Write模式不是延时策略的COW》](https://time.geekbang.org/column/article/93154)中有一个RPC框架路由表的案例在RPC框架中本地路由表是要和注册中心进行信息同步的应用启动的时候会将应用依赖服务的路由表从注册中心同步到本地路由表中如果应用重启的时候注册中心宕机那么会导致该应用依赖的服务均不可用因为找不到依赖服务的路由表。为了防止这种极端情况出现RPC框架可以将本地路由表自动保存到本地文件中如果重启的时候注册中心宕机那么就从本地文件中恢复重启前的路由表。这其实也是一种降级的方案。
自动保存路由表和前面介绍的编辑器自动保存原理是一样的也可以用Balking模式实现不过我们这里采用volatile来实现实现的代码如下所示。之所以可以采用volatile来实现是因为对共享变量changed和rt的写操作不存在原子性的要求而且采用scheduleWithFixedDelay()这种调度方式能保证同一时刻只有一个线程执行autoSave()方法。
```
//路由表信息
public class RouterTable {
//Key:接口名
//Value:路由集合
ConcurrentHashMap&lt;String, CopyOnWriteArraySet&lt;Router&gt;&gt;
rt = new ConcurrentHashMap&lt;&gt;();
//路由表是否发生变化
volatile boolean changed;
//将路由表写入本地文件的线程池
ScheduledExecutorService ses=
Executors.newSingleThreadScheduledExecutor();
//启动定时任务
//将变更后的路由表写入本地文件
public void startLocalSaver(){
ses.scheduleWithFixedDelay(()-&gt;{
autoSave();
}, 1, 1, MINUTES);
}
//保存路由表到本地文件
void autoSave() {
if (!changed) {
return;
}
changed = false;
//将路由表写入本地文件
//省略其方法实现
this.save2Local();
}
//删除路由
public void remove(Router router) {
Set&lt;Router&gt; set=rt.get(router.iface);
if (set != null) {
set.remove(router);
//路由表已发生变化
changed = true;
}
}
//增加路由
public void add(Router router) {
Set&lt;Router&gt; set = rt.computeIfAbsent(
route.iface, r -&gt;
new CopyOnWriteArraySet&lt;&gt;());
set.add(router);
//路由表已发生变化
changed = true;
}
}
```
Balking模式有一个非常典型的应用场景就是单次初始化下面的示例代码是它的实现。这个实现方案中我们将init()声明为一个同步方法这样同一个时刻就只有一个线程能够执行init()方法init()方法在第一次执行完时会将inited设置为true这样后续执行init()方法的线程就不会再执行doInit()了。
```
class InitTest{
boolean inited = false;
synchronized void init(){
if(inited){
return;
}
//省略doInit的实现
doInit();
inited=true;
}
}
```
线程安全的单例模式本质上其实也是单次初始化所以可以用Balking模式来实现线程安全的单例模式下面的示例代码是其实现。这个实现虽然功能上没有问题但是性能却很差因为互斥锁synchronized将getInstance()方法串行化了,那有没有办法可以优化一下它的性能呢?
```
class Singleton{
private static
Singleton singleton;
//构造方法私有化
private Singleton(){}
//获取实例(单例)
public synchronized static
Singleton getInstance(){
if(singleton == null){
singleton=new Singleton();
}
return singleton;
}
}
```
办法当然是有的,那就是经典的**双重检查**Double Check方案下面的示例代码是其详细实现。在双重检查方案中一旦Singleton对象被成功创建之后就不会执行synchronized(Singleton.class){}相关的代码也就是说此时getInstance()方法的执行路径是无锁的从而解决了性能问题。不过需要你注意的是这个方案中使用了volatile来禁止编译优化其原因你可以参考[《01 | 可见性、原子性和有序性问题并发编程Bug的源头》](https://time.geekbang.org/column/article/83682)中相关的内容。至于获取锁后的二次检查,则是出于对安全性负责。
```
class Singleton{
private static volatile
Singleton singleton;
//构造方法私有化
private Singleton() {}
//获取实例(单例)
public static Singleton
getInstance() {
//第一次检查
if(singleton==null){
synchronize(Singleton.class){
//获取锁后二次检查
if(singleton==null){
singleton=new Singleton();
}
}
}
return singleton;
}
}
```
## 总结
Balking模式和Guarded Suspension模式从实现上看似乎没有多大的关系Balking模式只需要用互斥锁就能解决而Guarded Suspension模式则要用到管程这种高级的并发原语但是从应用的角度来看它们解决的都是“线程安全的if”语义不同之处在于Guarded Suspension模式会等待if条件为真而Balking模式不会等待。
Balking模式的经典实现是使用互斥锁你可以使用Java语言内置synchronized也可以使用SDK提供Lock如果你对互斥锁的性能不满意可以尝试采用volatile方案不过使用volatile方案需要你更加谨慎。
当然你也可以尝试使用双重检查方案来优化性能,双重检查中的第一次检查,完全是出于对性能的考量:避免执行加锁操作,因为加锁操作很耗时。而加锁之后的二次检查,则是出于对安全性负责。双重检查方案在优化加锁性能方面经常用到,例如[《17 | ReadWriteLock如何快速实现一个完备的缓存](https://time.geekbang.org/column/article/88909)中实现缓存按需加载功能时,也用到了双重检查方案。
## 课后思考
下面的示例代码中init()方法的本意是仅需计算一次count的值采用了Balking模式的volatile实现方式你觉得这个实现是否有问题呢
```
class Test{
volatile boolean inited = false;
int count = 0;
void init(){
if(inited){
return;
}
inited = true;
//计算count的值
count = calc();
}
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="33 | Thread-Per-Message模式最简单实用的分工方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/f1/298896d4f26f0cec234f4005eb96b8f1.mp3"></audio>
我们曾经把并发编程领域的问题总结为三个核心问题:分工、同步和互斥。其中,同步和互斥相关问题更多地源自微观,而分工问题则是源自宏观。我们解决问题,往往都是从宏观入手,在编程领域,软件的设计过程也是先从概要设计开始,而后才进行详细设计。同样,**解决并发编程问题,首要问题也是解决宏观的分工问题**。
并发编程领域里解决分工问题也有一系列的设计模式比较常用的主要有Thread-Per-Message模式、Worker Thread模式、生产者-消费者模式等等。今天我们重点介绍Thread-Per-Message模式。
## 如何理解Thread-Per-Message模式
现实世界里很多事情我们都需要委托他人办理一方面受限于我们的能力总有很多搞不定的事比如教育小朋友搞不定怎么办呢只能委托学校老师了另一方面受限于我们的时间比如忙着写Bug哪有时间买别墅呢只能委托房产中介了。委托他人代办有一个非常大的好处那就是可以专心做自己的事了。
在编程领域也有很多类似的需求比如写一个HTTP Server很显然只能在主线程中接收请求而不能处理HTTP请求因为如果在主线程中处理HTTP请求的话那同一时间只能处理一个请求太慢了怎么办呢可以利用代办的思路创建一个子线程委托子线程去处理HTTP请求。
这种委托他人办理的方式,在并发编程领域被总结为一种设计模式,叫做**Thread-Per-Message模式**,简言之就是为每个任务分配一个独立的线程。这是一种最简单的分工方法,实现起来也非常简单。
## 用Thread实现Thread-Per-Message模式
Thread-Per-Message模式的一个最经典的应用场景是**网络编程里服务端的实现**,服务端为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁,这是一种最简单的并发处理网络请求的方法。
网络编程里最简单的程序当数echo程序了echo程序的服务端会原封不动地将客户端的请求发送回客户端。例如客户端发送TCP请求"Hello World",那么服务端也会返回"Hello World"。
下面我们就以echo程序的服务端为例介绍如何实现Thread-Per-Message模式。
在Java语言中实现echo程序的服务端还是很简单的。只需要30行代码就能够实现示例代码如下我们为每个请求都创建了一个Java线程核心代码是new Thread(()-&gt;{...}).start()。
```
final ServerSocketChannel =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try {
while (true) {
// 接收请求
SocketChannel sc = ssc.accept();
// 每个请求都创建一个线程
new Thread(()-&gt;{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
Thread.sleep(2000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip();
sc.write(wb);
// 关闭Socket
sc.close();
}catch(Exception e){
throw new UncheckedIOException(e);
}
}).start();
}
} finally {
ssc.close();
}
```
如果你熟悉网络编程相信你一定会提出一个很尖锐的问题上面这个echo服务的实现方案是不具备可行性的。原因在于Java中的线程是一个重量级的对象创建成本很高一方面创建线程比较耗时另一方面线程占用的内存也比较大。所以为每个请求创建一个新的线程并不适合高并发场景。
于是你开始质疑Thread-Per-Message模式而且开始重新思索解决方案这时候很可能你会想到Java提供的线程池。你的这个思路没有问题但是引入线程池难免会增加复杂度。其实你完全可以换一个角度来思考这个问题语言、工具、框架本身应该是帮助我们更敏捷地实现方案的而不是用来否定方案的Thread-Per-Message模式作为一种最简单的分工方案Java语言支持不了显然是Java语言本身的问题。
Java语言里Java线程是和操作系统线程一一对应的这种做法本质上是将Java线程的调度权完全委托给操作系统而操作系统在这方面非常成熟所以这种做法的好处是稳定、可靠但是也继承了操作系统线程的缺点创建成本高。为了解决这个缺点Java并发包里提供了线程池等工具类。这个思路在很长一段时间里都是很稳妥的方案但是这个方案并不是唯一的方案。
业界还有另外一种方案,叫做**轻量级线程**。这个方案在Java领域知名度并不高但是在其他编程语言里却叫得很响例如Go语言、Lua语言里的协程本质上就是一种轻量级的线程。轻量级的线程创建的成本很低基本上和创建一个普通对象的成本相似并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升所以基于轻量级线程实现Thread-Per-Message模式就完全没有问题了。
Java语言目前也已经意识到轻量级线程的重要性了OpenJDK有个Loom项目就是要解决Java语言的轻量级线程问题在这个项目中轻量级线程被叫做**Fiber**。下面我们就来看看基于Fiber如何实现Thread-Per-Message模式。
## 用Fiber实现Thread-Per-Message模式
Loom项目在设计轻量级线程时充分考量了当前Java线程的使用方式采取的是尽量兼容的态度所以使用上还是挺简单的。用Fiber实现echo服务的示例代码如下所示对比Thread的实现你会发现改动量非常小只需要把new Thread(()-&gt;{...}).start()换成 Fiber.schedule(()-&gt;{})就可以了。
```
final ServerSocketChannel ssc =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try{
while (true) {
// 接收请求
final SocketChannel sc =
ssc.accept();
Fiber.schedule(()-&gt;{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
LockSupport.parkNanos(2000*1000000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip()
sc.write(wb);
// 关闭Socket
sc.close();
} catch(Exception e){
throw new UncheckedIOException(e);
}
});
}//while
}finally{
ssc.close();
}
```
那使用Fiber实现的echo服务是否能够达到预期的效果呢我们可以在Linux环境下做一个简单的实验步骤如下
1. 首先通过 `ulimit -u 512` 将用户能创建的最大进程数包括线程设置为512
1. 启动Fiber实现的echo程序
1. 利用压测工具ab进行压测ab -r -c 20000 -n 200000 [http://测试机IP地址:8080/](http://xn--IP-im8ckc884ihkivx9c:8080/)
压测执行结果如下:
```
Concurrency Level: 20000
Time taken for tests: 67.718 seconds
Complete requests: 200000
Failed requests: 0
Write errors: 0
Non-2xx responses: 200000
Total transferred: 16400000 bytes
HTML transferred: 0 bytes
Requests per second: 2953.41 [#/sec] (mean)
Time per request: 6771.844 [ms] (mean)
Time per request: 0.339 [ms] (mean, across all concurrent requests)
Transfer rate: 236.50 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 557 3541.6 1 63127
Processing: 2000 2010 31.8 2003 2615
Waiting: 1986 2008 30.9 2002 2615
Total: 2000 2567 3543.9 2004 65293
```
你会发现即便在20000并发下该程序依然能够良好运行。同等条件下Thread实现的echo程序512并发都抗不过去直接就OOM了。
如果你通过Linux命令 `top -Hp pid` 查看Fiber实现的echo程序的进程信息你可以看到该进程仅仅创建了16不同CPU核数结果会不同个操作系统线程。
<img src="https://static001.geekbang.org/resource/image/ae/e9/aebe9691be206fb88f45e4f763bcb7e9.png" alt="">
如果你对Loom项目感兴趣也想上手试一把可以下载源代码自己构建构建方法可以参考[Project Loom的相关资料](https://wiki.openjdk.java.net/display/loom/Main)不过需要注意的是构建之前一定要把代码分支切换到Fibers。
## 总结
并发编程领域的分工问题指的是如何高效地拆解任务并分配给线程。前面我们在并发工具类模块中已经介绍了不少解决分工问题的工具类例如Future、CompletableFuture 、CompletionService、Fork/Join计算框架等这些工具类都能很好地解决特定应用场景的问题所以这些工具类曾经是Java语言引以为傲的。不过这些工具类都继承了Java语言的老毛病太复杂。
如果你一直从事Java开发估计你已经习以为常了习惯性地认为这个复杂度是正常的。不过这个世界时刻都在变化曾经正常的复杂度现在看来也许就已经没有必要了例如Thread-Per-Message模式如果使用线程池方案就会增加复杂度。
Thread-Per-Message模式在Java领域并不是那么知名根本原因在于Java语言里的线程是一个重量级的对象为每一个任务创建一个线程成本太高尤其是在高并发领域基本就不具备可行性。不过这个背景条件目前正在发生巨变Java语言未来一定会提供轻量级线程这样基于轻量级线程实现Thread-Per-Message模式就是一个非常靠谱的选择。
当然对于一些并发度没那么高的异步场景例如定时任务采用Thread-Per-Message模式是完全没有问题的。实际工作中我就见过完全基于Thread-Per-Message模式实现的分布式调度框架这个框架为每个定时任务都分配了一个独立的线程。
## 课后思考
使用Thread-Per-Message模式会为每一个任务都创建一个线程在高并发场景中很容易导致应用OOM那有什么办法可以快速解决呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,161 @@
<audio id="audio" title="34 | Worker Thread模式如何避免重复创建线程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/34/4750018e86e3711c0ee2b0cc391bd934.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/95098)中我们介绍了一种最简单的分工模式——Thread-Per-Message模式对应到现实世界其实就是委托代办。这种分工模式如果用Java Thread实现频繁地创建、销毁线程非常影响性能同时无限制地创建线程还可能导致OOM所以在Java领域使用场景就受限了。
要想有效避免线程的频繁创建、销毁以及OOM问题就不得不提今天我们要细聊的也是Java领域使用最多的Worker Thread模式。
## Worker Thread模式及其实现
Worker Thread模式可以类比现实世界里车间的工作模式车间里的工人有活儿了大家一起干没活儿了就聊聊天等着。你可以参考下面的示意图来理解Worker Thread模式中**Worker Thread对应到现实世界里其实指的就是车间里的工人**。不过这里需要注意的是,车间里的工人数量往往是确定的。
<img src="https://static001.geekbang.org/resource/image/9d/c3/9d0082376427a97644ad7219af6922c3.png" alt="">
那在编程领域该如何模拟车间的这种工作模式呢或者说如何去实现Worker Thread模式呢通过上面的图你很容易就能想到用阻塞队列做任务池然后创建固定数量的线程消费阻塞队列中的任务。其实你仔细想会发现这个方案就是Java语言提供的线程池。
线程池有很多优点例如能够避免重复创建、销毁线程同时能够限制创建线程的上限等等。学习完上一篇文章后你已经知道用Java的Thread实现Thread-Per-Message模式难以应对高并发场景原因就在于频繁创建、销毁Java线程的成本有点高而且无限制地创建线程还可能导致应用OOM。线程池则恰好能解决这些问题。
那我们还是以echo程序为例看看如何用线程池来实现。
下面的示例代码是用线程池实现的echo服务端相比于Thread-Per-Message模式的实现改动非常少仅仅是创建了一个最多线程数为500的线程池es然后通过es.execute()方法将请求处理的任务提交给线程池处理。
```
ExecutorService es = Executors
.newFixedThreadPool(500);
final ServerSocketChannel ssc =
ServerSocketChannel.open().bind(
new InetSocketAddress(8080));
//处理请求
try {
while (true) {
// 接收请求
SocketChannel sc = ssc.accept();
// 将请求处理任务提交给线程池
es.execute(()-&gt;{
try {
// 读Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
Thread.sleep(2000);
// 写Socket
ByteBuffer wb =
(ByteBuffer)rb.flip();
sc.write(wb);
// 关闭Socket
sc.close();
}catch(Exception e){
throw new UncheckedIOException(e);
}
});
}
} finally {
ssc.close();
es.shutdown();
}
```
## 正确地创建线程池
Java的线程池既能够避免无限制地**创建线程**导致OOM也能避免无限制地**接收任务**导致OOM。只不过后者经常容易被我们忽略例如在上面的实现中就被我们忽略了。所以强烈建议你**用创建有界的队列来接收任务**。
当请求量大于有界队列的容量时,就需要合理地拒绝请求。如何合理地拒绝呢?这需要你结合具体的业务场景来制定,即便线程池默认的拒绝策略能够满足你的需求,也同样建议你**在创建线程池时,清晰地指明拒绝策略**。
同时,为了便于调试和诊断问题,我也强烈建议你**在实际工作中给线程赋予一个业务相关的名字**。
综合以上这三点建议echo程序中创建线程可以使用下面的示例代码。
```
ExecutorService es = new ThreadPoolExecutor(
50, 500,
60L, TimeUnit.SECONDS,
//注意要创建有界队列
new LinkedBlockingQueue&lt;Runnable&gt;(2000),
//建议根据业务需求实现ThreadFactory
r-&gt;{
return new Thread(r, &quot;echo-&quot;+ r.hashCode());
},
//建议根据业务需求实现RejectedExecutionHandler
new ThreadPoolExecutor.CallerRunsPolicy());
```
## 避免线程死锁
使用线程池过程中,还要注意一种**线程死锁**的场景。如果提交到相同线程池的任务不是相互独立的,而是有依赖关系的,那么就有可能导致线程死锁。实际工作中,我就亲历过这种线程死锁的场景。具体现象是**应用每运行一段时间偶尔就会处于无响应的状态,监控数据看上去一切都正常,但是实际上已经不能正常工作了**。
这个出问题的应用,相关的逻辑精简之后,如下图所示,该应用将一个大型的计算任务分成两个阶段,第一个阶段的任务会等待第二阶段的子任务完成。在这个应用里,每一个阶段都使用了线程池,而且两个阶段使用的还是同一个线程池。
<img src="https://static001.geekbang.org/resource/image/f8/b8/f807b0935133b315870d2d7db5477db8.png" alt="">
我们可以用下面的示例代码来模拟该应用,如果你执行下面的这段代码,会发现它永远执行不到最后一行。执行过程中没有任何异常,但是应用已经停止响应了。
```
//L1、L2阶段共用的线程池
ExecutorService es = Executors.
newFixedThreadPool(2);
//L1阶段的闭锁
CountDownLatch l1=new CountDownLatch(2);
for (int i=0; i&lt;2; i++){
System.out.println(&quot;L1&quot;);
//执行L1阶段任务
es.execute(()-&gt;{
//L2阶段的闭锁
CountDownLatch l2=new CountDownLatch(2);
//执行L2阶段子任务
for (int j=0; j&lt;2; j++){
es.execute(()-&gt;{
System.out.println(&quot;L2&quot;);
l2.countDown();
});
}
//等待L2阶段任务执行完
l2.await();
l1.countDown();
});
}
//等着L1阶段任务执行完
l1.await();
System.out.println(&quot;end&quot;);
```
当应用出现类似问题时,首选的诊断方法是查看线程栈。下图是上面示例代码停止响应后的线程栈,你会发现线程池中的两个线程全部都阻塞在 `l2.await();` 这行代码上了也就是说线程池里所有的线程都在等待L2阶段的任务执行完那L2阶段的子任务什么时候能够执行完呢永远都没那一天了为什么呢因为线程池里的线程都阻塞了没有空闲的线程执行L2阶段的任务了。
<img src="https://static001.geekbang.org/resource/image/43/83/43c663eedd5b0b75b6c3022e26eb1583.png" alt="">
原因找到了,那如何解决就简单了,最简单粗暴的办法就是将线程池的最大线程数调大,如果能够确定任务的数量不是非常多的话,这个办法也是可行的,否则这个办法就行不通了。其实**这种问题通用的解决方案是为不同的任务创建不同的线程池**。对于上面的这个应用L1阶段的任务和L2阶段的任务如果各自都有自己的线程池就不会出现这种问题了。
最后再次强调一下:**提交到相同线程池中的任务一定是相互独立的,否则就一定要慎重**。
## 总结
我们曾经说过解决并发编程里的分工问题最好的办法是和现实世界做对比。对比现实世界构建编程领域的模型能够让模型更容易理解。上一篇我们介绍的Thread-Per-Message模式类似于现实世界里的委托他人办理而今天介绍的Worker Thread模式则类似于车间里工人的工作模式。如果你在设计阶段发现对业务模型建模之后模型非常类似于车间的工作模式那基本上就能确定可以在实现阶段采用Worker Thread模式来实现。
Worker Thread模式和Thread-Per-Message模式的区别有哪些呢从现实世界的角度看你委托代办人做事往往是和代办人直接沟通的对应到编程领域其实现也是主线程直接创建了一个子线程主子线程之间是可以直接通信的。而车间工人的工作方式则是完全围绕任务展开的一个具体的任务被哪个工人执行预先是无法知道的对应到编程领域则是主线程提交任务到线程池但主线程并不关心任务被哪个线程执行。
Worker Thread模式能避免线程频繁创建、销毁的问题而且能够限制线程的最大数量。Java语言里可以直接使用线程池来实现Worker Thread模式线程池是一个非常基础和优秀的工具类甚至有些大厂的编码规范都不允许用new Thread()来创建线程的,必须使用线程池。
不过使用线程池还是需要格外谨慎的除了今天重点讲到的如何正确创建线程池、如何避免线程死锁问题还需要注意前面我们曾经提到的ThreadLocal内存泄露问题。同时对于提交到线程池的任务还要做好异常处理避免异常的任务从眼前溜走从业务的角度看有时没有发现异常的任务后果往往都很严重。
## 课后思考
小灰同学写了如下的代码本义是异步地打印字符串“QQ”请问他的实现是否有问题呢
```
ExecutorService pool = Executors
.newSingleThreadExecutor();
pool.submit(() -&gt; {
try {
String qq=pool.submit(()-&gt;&quot;QQ&quot;).get();
System.out.println(qq);
} catch (Exception e) {
}
});
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="35 | 两阶段终止模式:如何优雅地终止线程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/b2/28f883182e84fb3136727446cc549fb2.mp3"></audio>
前面两篇文章我们讲述的内容,从纯技术的角度看,都是**启动**多线程去执行一个异步任务。既启动,那又该如何终止呢?今天咱们就从技术的角度聊聊如何优雅地**终止**线程,正所谓有始有终。
在[《09 | Java线程Java线程的生命周期》](https://time.geekbang.org/column/article/86366)中我曾讲过线程执行完或者出现异常就会进入终止状态。这样看终止一个线程看上去很简单啊一个线程执行完自己的任务自己进入终止状态这的确很简单。不过我们今天谈到的“优雅地终止线程”不是自己终止自己而是在一个线程T1中终止线程T2这里所谓的“优雅”指的是给T2一个机会料理后事而不是被一剑封喉。
Java语言的Thread类中曾经提供了一个stop()方法,用来终止线程,可是早已不建议使用了,原因是这个方法用的就是一剑封喉的做法,被终止的线程没有机会料理后事。
既然不建议使用stop()方法那在Java领域我们又该如何优雅地终止线程呢
## 如何理解两阶段终止模式
前辈们经过认真对比分析,已经总结出了一套成熟的方案,叫做**两阶段终止模式**。顾名思义就是将终止过程分成两个阶段其中第一个阶段主要是线程T1向线程T2**发送终止指令**而第二阶段则是线程T2**响应终止指令**。
<img src="https://static001.geekbang.org/resource/image/a5/5c/a5ea3cb2106f11ef065702f34703645c.png" alt="">
那在Java语言里终止指令是什么呢这个要从Java线程的状态转换过程说起。我们在[《09 | Java线程Java线程的生命周期》](https://time.geekbang.org/column/article/86366)中曾经提到过Java线程的状态转换图如下图所示。
<img src="https://static001.geekbang.org/resource/image/3f/8c/3f6c6bf95a6e8627bdf3cb621bbb7f8c.png" alt="">
从这个图里你会发现Java线程进入终止状态的前提是线程进入RUNNABLE状态而实际上线程也可能处在休眠状态也就是说我们要想终止一个线程首先要把线程的状态从休眠状态转换到RUNNABLE状态。如何做到呢这个要靠Java Thread类提供的**interrupt()方法**它可以将休眠状态的线程转换到RUNNABLE状态。
线程转换到RUNNABLE状态之后我们如何再将其终止呢RUNNABLE状态转换到终止状态优雅的方式是让Java线程自己执行完 run() 方法,所以一般我们采用的方法是**设置一个标志位**然后线程会在合适的时机检查这个标志位如果发现符合终止条件则自动退出run()方法。这个过程其实就是我们前面提到的第二阶段:**响应终止指令**。
综合上面这两点,我们能总结出终止指令,其实包括两方面内容:**interrupt()方法**和**线程终止的标志位**。
理解了两阶段终止模式之后,下面我们看一个实际工作中的案例。
## 用两阶段终止模式终止监控操作
实际工作中,有些监控系统需要动态地采集一些数据,一般都是监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令之后,从监控目标收集数据,然后回传给监控系统,详细过程如下图所示。出于对性能的考虑(有些监控项对系统性能影响很大,所以不能一直持续监控),动态采集功能一般都会有终止操作。
<img src="https://static001.geekbang.org/resource/image/11/5f/11e3b0a4a9cf743124091b22e10d275f.png" alt="">
下面的示例代码是**监控代理**简化之后的实现start()方法会启动一个新的线程rptThread来执行监控数据采集和回传的功能stop()方法需要优雅地终止线程rptThread那stop()相关功能该如何实现呢?
```
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()-&gt;{
while (true) {
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//如何实现?
}
}
```
按照两阶段终止模式我们首先需要做的就是将线程rptThread状态转换到RUNNABLE做法很简单只需要在调用 `rptThread.interrupt()` 就可以了。线程rptThread的状态转换到RUNNABLE之后如何优雅地终止呢下面的示例代码中我们选择的标志位是线程的中断状态`Thread.currentThread().isInterrupted()` 需要注意的是我们在捕获Thread.sleep()的中断异常之后,通过 `Thread.currentThread().interrupt()` 重新设置了线程的中断状态因为JVM的异常处理会清除线程的中断状态。
```
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()-&gt;{
while (!Thread.currentThread().isInterrupted()){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
rptThread.interrupt();
}
}
```
上面的示例代码的确能够解决当前的问题但是建议你在实际工作中谨慎使用。原因在于我们很可能在线程的run()方法中调用第三方类库提供的方法而我们没有办法保证第三方类库正确处理了线程的中断异常例如第三方类库在捕获到Thread.sleep()方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以强烈建议你**设置自己的线程终止标志位**例如在下面的代码中使用isTerminated作为线程终止标志位此时无论是否正确处理了线程的中断异常都不会影响线程优雅地终止。
```
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
terminated = false;
rptThread = new Thread(()-&gt;{
while (!terminated){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//设置中断标志位
terminated = true;
//中断线程rptThread
rptThread.interrupt();
}
}
```
## 如何优雅地终止线程池
Java领域用的最多的还是线程池而不是手动地创建线程。那我们该如何优雅地终止线程池呢
线程池提供了两个方法:**shutdown()<strong>和**shutdownNow()</strong>。这两个方法有什么区别呢?要了解它们的区别,就先需要了解线程池的实现原理。
我们曾经讲过Java线程池是生产者-消费者模式的一种实现,提交给线程池的任务,首先是进入一个阻塞队列中,之后线程池中的线程从阻塞队列中取出任务执行。
shutdown()方法是一种很保守的关闭线程池的方法。线程池执行shutdown()后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
而shutdownNow()方法相对就激进一些了线程池执行shutdownNow()后会拒绝接收新的任务同时还会中断线程池中正在执行的任务已经进入阻塞队列的任务也被剥夺了执行的机会不过这些被剥夺执行机会的任务会作为shutdownNow()方法的返回值返回。因为shutdownNow()方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。
如果提交到线程池的任务不允许取消那就不能使用shutdownNow()方法终止线程池。不过如果提交到线程池的任务允许后续以补偿的方式重新执行也是可以使用shutdownNow()方法终止线程池的。[《Java并发编程实战》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw)这本书第7章《取消与关闭》的“shutdownNow的局限性”一节中提到一种将已提交但尚未开始执行的任务以及已经取消的正在执行的任务保存起来以便后续重新执行的方案你可以参考一下方案很简单这里就不详细介绍了。
其实分析完shutdown()和shutdownNow()方法你会发现,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。
## 总结
两阶段终止模式是一种应用很广泛的并发设计模式在Java语言中使用两阶段终止模式来优雅地终止线程需要注意两个关键点一个是仅检查终止标志位是不够的因为线程的状态可能处于休眠态另一个是仅检查线程的中断状态也是不够的因为我们依赖的第三方类库很可能没有正确处理中断异常。
当你使用Java的线程池来管理线程的时候需要依赖线程池提供的shutdown()和shutdownNow()方法来终止线程池。不过在使用时需要注意它们的应用场景尤其是在使用shutdownNow()的时候,一定要谨慎。
## 课后思考
本文的示例代码中线程终止标志位isTerminated被声明为volatile你觉得是否有必要呢
```
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
......
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="36 | 生产者-消费者模式:用流水线思想提高效率" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/4c/3be76f3ed1e163a2aa767b74dd7c264c.mp3"></audio>
前面我们在[《34 | Worker Thread模式如何避免重复创建线程](https://time.geekbang.org/column/article/95525)中讲到Worker Thread模式类比的是工厂里车间工人的工作模式。但其实在现实世界工厂里还有一种流水线的工作模式类比到编程领域就是**生产者-消费者模式**。
生产者-消费者模式在编程领域的应用也非常广泛前面我们曾经提到Java线程池本质上就是用生产者-消费者模式实现的,所以每当使用线程池的时候,其实就是在应用生产者-消费者模式。
当然,除了在线程池中的应用,为了提升性能,并发编程领域很多地方也都用到了生产者-消费者模式例如Log4j2中异步Appender内部也用到了生产者-消费者模式。所以今天我们就来深入地聊聊生产者-消费者模式,看看它具体有哪些优点,以及如何提升系统的性能。
## 生产者-消费者模式的优点
生产者-消费者模式的核心是一个**任务队列**,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。下面是生产者-消费者模式的一个示意图,你可以结合它来理解。
<img src="https://static001.geekbang.org/resource/image/df/15/df72a9769cec7a25dc9093e160dbbb15.png" alt="">
从架构设计的角度来看,生产者-消费者模式有一个很重要的优点,就是**解耦**。解耦对于大型系统的设计非常重要,而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者-消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以**生产者-消费者模式是一个不错的解耦方案**。
除了架构设计上的优点之外,生产者-消费者模式还有一个重要的优点就是**支持异步,并且能够平衡生产者和消费者的速度差异**。在生产者-消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费是异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。
你或许会有这样的疑问,异步化处理最简单的方式就是创建一个新的线程去处理,那中间增加一个“**任务队列**”究竟有什么用呢?我觉得主要还是用于**平衡生产者和消费者的速度差异**。我们假设生产者的速率很慢而消费者的速率很高比如是1:3如果生产者有3个线程采用创建新的线程的方式那么会创建3个子线程而采用生产者-消费者模式消费线程只需要1个就可以了。Java语言里Java线程和操作系统线程是一一对应的线程创建得太多会增加上下文切换的成本所以Java线程不是越多越好适量即可。而**生产者-消费者模式恰好能支持你用适量的线程**。
## 支持批量执行以提升性能
前面我们在[《33 | Thread-Per-Message模式最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)中讲过轻量级的线程,如果使用轻量级线程,就没有必要平衡生产者和消费者的速度差异了,因为轻量级线程本身就是廉价的,那是否意味着生产者-消费者模式在性能优化方面就无用武之地了呢?当然不是,有一类并发场景应用生产者-消费者模式就有奇效,那就是**批量执行**任务。
例如我们要在数据库里INSERT 1000条数据有两种方案第一种方案是用1000个线程并发执行每个线程INSERT一条数据第二种方案是用1个线程执行一个批量的SQL一次性把1000条数据INSERT进去。这两种方案显然是第二种方案效率更高其实这样的应用场景就是我们上面提到的批量执行场景。
在[《35 | 两阶段终止模式:如何优雅地终止线程?》](https://time.geekbang.org/column/article/95847)文章中我们提到一个监控系统动态采集的案例其实最终回传的监控数据还是要存入数据库的如下图。但被监控系统往往有很多如果每一条回传数据都直接INSERT到数据库那么这个方案就是上面提到的第一种方案每个线程INSERT一条数据。很显然更好的方案是批量执行SQL那如何实现呢这就要用到生产者-消费者模式了。
<img src="https://static001.geekbang.org/resource/image/15/29/155d861702a047bd20b5708e06c6fd29.png" alt="">
利用生产者-消费者模式实现批量执行SQL非常简单将原来直接INSERT数据到数据库的线程作为生产者线程生产者线程只需将数据添加到任务队列然后消费者线程负责将任务从任务队列中批量取出并批量执行。
在下面的示例代码中我们创建了5个消费者线程负责批量执行SQL这5个消费者线程以 `while(true){}` 循环方式批量地获取任务并批量地执行。需要注意的是从任务队列中获取批量任务的方法pollTasks()中,首先是以阻塞方式获取任务队列中的一条任务,而后则是以非阻塞的方式获取任务;之所以首先采用阻塞方式,是因为如果任务队列中没有任务,这样的方式能够避免无谓的循环。
```
//任务队列
BlockingQueue&lt;Task&gt; bq=new
LinkedBlockingQueue&lt;&gt;(2000);
//启动5个消费者线程
//执行批量任务
void start() {
ExecutorService es=executors
.newFixedThreadPool(5);
for (int i=0; i&lt;5; i++) {
es.execute(()-&gt;{
try {
while (true) {
//获取批量任务
List&lt;Task&gt; ts=pollTasks();
//执行批量任务
execTasks(ts);
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
//从任务队列中获取批量任务
List&lt;Task&gt; pollTasks()
throws InterruptedException{
List&lt;Task&gt; ts=new LinkedList&lt;&gt;();
//阻塞式获取一条任务
Task t = bq.take();
while (t != null) {
ts.add(t);
//非阻塞式获取一条任务
t = bq.poll();
}
return ts;
}
//批量执行任务
execTasks(List&lt;Task&gt; ts) {
//省略具体代码无数
}
```
## 支持分阶段提交以提升性能
利用生产者-消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:
1. ERROR级别的日志需要立即刷盘
1. 数据积累到500条需要立即刷盘
1. 存在未刷盘数据且5秒钟内未曾刷盘需要立即刷盘。
这个日志组件的异步刷盘操作本质上其实就是一种**分阶段提交**。下面我们具体看看用生产者-消费者模式如何实现。在下面的示例代码中,可以通过调用 `info()``error()` 方法写入日志这两个方法都是创建了一个日志任务LogMsg并添加到阻塞队列中调用 `info()``error()` 方法的线程是生产者而真正将日志写入文件的是消费者线程在Logger这个类中我们只创建了1个消费者线程在这个消费者线程中会根据刷盘规则执行刷盘操作逻辑很简单这里就不赘述了。
```
class Logger {
//任务队列
final BlockingQueue&lt;LogMsg&gt; bq
= new BlockingQueue&lt;&gt;();
//flush批量
static final int batchSize=500;
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
&quot;foo&quot;, &quot;.log&quot;);
final FileWriter writer=
new FileWriter(file);
this.es.execute(()-&gt;{
try {
//未刷盘日志数量
int curIdx = 0;
long preFT=System.currentTimeMillis();
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//写日志
if (log != null) {
writer.write(log.toString());
++curIdx;
}
//如果不存在未刷盘数据,则无需刷盘
if (curIdx &lt;= 0) {
continue;
}
//根据规则刷盘
if (log!=null &amp;&amp; log.level==LEVEL.ERROR ||
curIdx == batchSize ||
System.currentTimeMillis()-preFT&gt;5000){
writer.flush();
curIdx = 0;
preFT=System.currentTimeMillis();
}
}
}catch(Exception e){
e.printStackTrace();
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){
e.printStackTrace();
}
}
});
}
//写INFO级别日志
void info(String msg) {
bq.put(new LogMsg(
LEVEL.INFO, msg));
}
//写ERROR级别日志
void error(String msg) {
bq.put(new LogMsg(
LEVEL.ERROR, msg));
}
}
//日志级别
enum LEVEL {
INFO, ERROR
}
class LogMsg {
LEVEL level;
String msg;
//省略构造函数实现
LogMsg(LEVEL lvl, String msg){}
//省略toString()实现
String toString(){}
}
```
## 总结
Java语言提供的线程池本身就是一种生产者-消费者模式的实现,但是线程池中的线程每次只能从任务队列中消费一个任务来执行,对于大部分并发场景这种策略都没有问题。但是有些场景还是需要自己来实现,例如需要批量执行以及分阶段提交的场景。
生产者-消费者模式在分布式计算中的应用也非常广泛。在分布式场景下你可以借助分布式消息队列MQ来实现生产者-消费者模式。MQ一般都会支持两种消息模型一种是点对点模型一种是发布订阅模型。这两种模型的区别在于点对点模型里一个消息只会被一个消费者消费和Java的线程池非常类似Java线程池的任务也只会被一个线程执行而发布订阅模型里一个消息会被多个消费者消费本质上是一种消息的广播在多线程编程领域你可以结合观察者模式实现广播功能。
## 课后思考
在日志组件异步刷盘的示例代码中,写日志的线程以 `while(true){}` 的方式执行,你有哪些办法可以优雅地终止这个线程呢?
```
this.writer.execute(()-&gt;{
try {
//未刷盘日志数量
int curIdx = 0;
long preFT=System.currentTimeMillis();
while (true) {
......
}
} catch(Exception e) {}
}
```
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="37 | 设计模式模块热点问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/e4/182e9c98c3f15dba1035af1defc512e4.mp3"></audio>
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
在这个模块我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结同时也对前面各章的课后思考题做个答疑。
## 避免共享的设计模式
**Immutability模式**、**Copy-on-Write模式**和**线程本地存储模式**本质上都是**为了避免共享**只是实现手段不同而已。这3种设计模式的实现都很简单但是实现过程中有些细节还是需要格外注意的。例如**使用Immutability模式需要注意对象属性的不可变性使用Copy-on-Write模式需要注意性能问题使用线程本地存储模式需要注意异步执行问题**。所以,每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
[《28 | Immutability模式如何利用不变性解决并发问题](https://time.geekbang.org/column/article/92856)的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现而实质上这个实现是有问题的原因在于StringBuffer不同于StringStringBuffer不具备不可变性通过getUser()方法获取user之后是可以修改user的。一个简单的解决方案是让getUser()方法返回String对象。
```
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
//返回的StringBuffer并不具备不可变性
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return &quot;user&quot;+user;
}
}
```
[《29 | Copy-on-Write模式不是延时策略的COW》](https://time.geekbang.org/column/article/93154)的课后思考题是讨论Java SDK中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题没有标准答案但是性能问题一定是其中一个很重要的原因毕竟完整地复制LinkedList性能开销太大了。
[《30 | 线程本地存储模式:没有共享,就没有伤害》](https://time.geekbang.org/column/article/93745)的课后思考题是在异步场景中,是否可以使用 Spring 的事务管理器。答案显然是不能的Spring 使用 ThreadLocal 来传递事务信息,因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的,这种场景下如果有异步操作,一定要注意上下文信息是不能跨线程共享的。
## 多线程版本IF的设计模式
**Guarded Suspension模式**和**Balking模式**都可以简单地理解为“多线程版本的if”但它们的区别在于前者会等待if条件变为真而后者则不需要等待。
Guarded Suspension模式的经典实现是使用**管程**很多初学者会简单地用线程sleep的方式实现比如[《31 | Guarded Suspension模式等待唤醒机制的规范实现》](https://time.geekbang.org/column/article/94097)的思考题就是用线程sleep方式实现的。但不推荐你使用这种方式最重要的原因是性能如果sleep的时间太长会影响响应时间sleep的时间太短会导致线程频繁地被唤醒消耗系统资源。
同时示例代码的实现也有问题由于obj不是volatile变量所以即便obj被设置了正确的值执行 `while(!p.test(obj))` 的线程也有可能看不到从而导致更长时间的sleep。
```
//获取受保护对象
T get(Predicate&lt;T&gt; p) {
try {
//obj的可见性无法保证
while(!p.test(obj)){
TimeUnit.SECONDS
.sleep(timeout);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
this.obj = obj;
}
```
实现Balking模式最容易忽视的就是**竞态条件问题**。比如,[《32 | Balking模式再谈线程安全的单例模式》](https://time.geekbang.org/column/article/94604)的思考题就存在竞态条件问题。因此在多线程场景中使用if语句时一定要多问自己一遍是否存在竞态条件。
```
class Test{
volatile boolean inited = false;
int count = 0;
void init(){
//存在竞态条件
if(inited){
return;
}
//有可能多个线程执行到这里
inited = true;
//计算count的值
count = calc();
}
}
```
## 三种最简单的分工模式
**Thread-Per-Message模式**、**Worker Thread模式**和**生产者-消费者模式**是三种**最简单实用的多线程分工方法**。虽说简单,但也还是有许多细节需要你多加小心和注意。
Thread-Per-Message模式在实现的时候需要注意是否存在线程的频繁创建、销毁以及是否可能导致OOM。在[《33 | Thread-Per-Message模式最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)文章中最后的思考题就是关于如何快速解决OOM问题的。在高并发场景中最简单的办法其实是**限流**。当然限流方案也并不局限于解决Thread-Per-Message模式中的OOM问题。
Worker Thread模式的实现需要注意潜在的线程**死锁问题**。[《34 | Worker Thread模式如何避免重复创建线程](https://time.geekbang.org/column/article/95525)思考题中的示例代码就存在线程死锁。有名叫vector的同学关于这道思考题的留言我觉得描述得很贴切和形象“工厂里只有一个工人他的工作就是同步地等待工厂里其他人给他提供东西然而并没有其他人他将等到天荒地老海枯石烂”因此共享线程池虽然能够提供线程池的使用效率但一定要保证一个前提那就是**任务之间没有依赖关系**。
```
ExecutorService pool = Executors
.newSingleThreadExecutor();
//提交主任务
pool.submit(() -&gt; {
try {
//提交子任务并等待其完成,
//会导致线程死锁
String qq=pool.submit(()-&gt;&quot;QQ&quot;).get();
System.out.println(qq);
} catch (Exception e) {
}
});
```
Java线程池本身就是一种生产者-消费者模式的实现所以大部分场景你都不需要自己实现直接使用Java的线程池就可以了。但若能自己灵活地实现生产者-消费者模式会更好,比如可以实现批量执行和分阶段提交,不过这过程中还需要注意如何优雅地终止线程,[《36 | 生产者-消费者模式:用流水线思想提高效率》](https://time.geekbang.org/column/article/96168)的思考题就是关于此的。
如何优雅地终止线程?我们在[《35 | 两阶段终止模式:如何优雅地终止线程?》](https://time.geekbang.org/column/article/95847)有过详细介绍,两阶段终止模式是一种通用的解决方案。但其实终止生产者-消费者服务还有一种更简单的方案,叫做**“毒丸”对象**。[《Java并发编程实战》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw)第7章的7.2.3节对“毒丸”对象有过详细的介绍。简单来讲,“毒丸”对象是生产者生产的一条特殊任务,然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。
下面是用“毒丸”对象终止写日志线程的具体实现整体的实现过程还是很简单的类Logger中声明了一个“毒丸”对象poisonPill 当消费者线程从阻塞队列bq中取出一条LogMsg后先判断是否是“毒丸”对象如果是则break while循环从而终止自己的执行。
```
class Logger {
//用于终止日志执行的“毒丸”
final LogMsg poisonPill =
new LogMsg(LEVEL.ERROR, &quot;&quot;);
//任务队列
final BlockingQueue&lt;LogMsg&gt; bq
= new BlockingQueue&lt;&gt;();
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
&quot;foo&quot;, &quot;.log&quot;);
final FileWriter writer=
new FileWriter(file);
this.es.execute(()-&gt;{
try {
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//如果是“毒丸”,终止执行
if(poisonPill.equals(logMsg)){
break;
}
//省略执行逻辑
}
} catch(Exception e){
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){}
}
});
}
//终止写日志线程
public void stop() {
//将“毒丸”对象加入阻塞队列
bq.add(poisonPill);
es.shutdown();
}
}
```
## 总结
到今天为止“并发设计模式”模块就告一段落了多线程的设计模式当然不止我们提到的这9种不过这里提到的这9种设计模式一定是最简单实用的。如果感兴趣你也可以结合《图解Java多线程设计模式》这本书来深入学习这个模块这是一本不错的并发编程入门书籍虽然重点是讲解设计模式但是也详细讲解了设计模式中涉及到的方方面面的基础知识而且深入浅出非常推荐入门的同学认真学习一下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。