mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
del
This commit is contained in:
@@ -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 < len) {
|
||||
if (val[i] == oldChar) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
//未找到oldChar,无需替换
|
||||
if (i >= len) {
|
||||
return this;
|
||||
}
|
||||
//创建一个buf[],这是关键
|
||||
//用来保存替换后的字符串
|
||||
char buf[] = new char[len];
|
||||
for (int j = 0; j < i; j++) {
|
||||
buf[j] = val[j];
|
||||
}
|
||||
while (i < 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.8,valueOf()方法就用到了LongCache这个缓存,你可以结合着来加深理解。
|
||||
|
||||
```
|
||||
Long valueOf(long l) {
|
||||
final int offset = 128;
|
||||
// [-128,127]直接的数字做了缓存
|
||||
if (l >= -128 && l <= 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<cache.length; i++)
|
||||
cache[i] = new Long(i-128);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
前面我们在[《13 | 理论基础模块热点问题答疑》](https://time.geekbang.org/column/article/87749)中提到“Integer 和 String 类型的对象不适合做锁”,其实基本上所有的基础类型的包装类都不适合做锁,因为它们内部用到了享元模式,这会导致看上去私有的锁,其实是共有的。例如在下面代码中,本意是A用锁al,B用锁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="abc";
|
||||
}
|
||||
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="abc";
|
||||
}
|
||||
//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<WMRange>
|
||||
rf = new AtomicReference<>(
|
||||
new WMRange(0,0)
|
||||
);
|
||||
// 设置库存上限
|
||||
void setUpper(int v){
|
||||
while(true){
|
||||
WMRange or = rf.get();
|
||||
// 检查参数合法性
|
||||
if(v < 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 "user"+user;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
@@ -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)、aufs(advanced 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<String, CopyOnWriteArraySet<Router>>`这个数据结构来描述路由表,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) &&
|
||||
ip.equals(r.ip) &&
|
||||
port.equals(r.port);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public int hashCode() {
|
||||
//省略hashCode相关代码
|
||||
}
|
||||
}
|
||||
//路由表信息
|
||||
public class RouterTable {
|
||||
//Key:接口名
|
||||
//Value:路由集合
|
||||
ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>
|
||||
rt = new ConcurrentHashMap<>();
|
||||
//根据接口名获取路由表
|
||||
public Set<Router> get(String iface){
|
||||
return rt.get(iface);
|
||||
}
|
||||
//删除路由
|
||||
public void remove(Router router) {
|
||||
Set<Router> set=rt.get(router.iface);
|
||||
if (set != null) {
|
||||
set.remove(router);
|
||||
}
|
||||
}
|
||||
//增加路由
|
||||
public void add(Router router) {
|
||||
Set<Router> set = rt.computeIfAbsent(
|
||||
route.iface, r ->
|
||||
new CopyOnWriteArraySet<>());
|
||||
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呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
168
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/30 | 线程本地存储模式:没有共享,就没有伤害.md
Normal file
168
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/30 | 线程本地存储模式:没有共享,就没有伤害.md
Normal 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<Long>
|
||||
tl=ThreadLocal.withInitial(
|
||||
()->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<DateFormat>
|
||||
tl=ThreadLocal.withInitial(
|
||||
()-> new SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
static DateFormat get(){
|
||||
return tl.get();
|
||||
}
|
||||
}
|
||||
//不同线程执行下面代码
|
||||
//返回的df是不同的
|
||||
DateFormat df =
|
||||
SafeDateFormat.get();
|
||||
|
||||
```
|
||||
|
||||
通过上面两个例子,相信你对ThreadLocal的用法以及应用场景都了解了,下面我们就来详细解释ThreadLocal的工作原理。
|
||||
|
||||
## ThreadLocal的工作原理
|
||||
|
||||
在解释ThreadLocal的工作原理之前, 你先自己想想:如果让你来实现ThreadLocal的功能,你会怎么设计呢?ThreadLocal的目标是让不同的线程有不同的变量V,那最直接的方法就是创建一个Map,它的Key是线程,Value是每个线程拥有的变量V,ThreadLocal内部持有这样的一个Map就可以了。你可以参考下面的示意图和示例代码来理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/34/6a93910f748ebc5b984ae7ac67283034.png" alt="">
|
||||
|
||||
```
|
||||
class MyThreadLocal<T> {
|
||||
Map<Thread, T> locals =
|
||||
new ConcurrentHashMap<>();
|
||||
//获取线程变量
|
||||
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,其类型就是ThreadLocalMap,ThreadLocalMap的Key是ThreadLocal。你可以结合下面的示意图和精简之后的Java实现代码来理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/02/3cb0a8f15104848dec63eab269bac302.png" alt="">
|
||||
|
||||
```
|
||||
class Thread {
|
||||
//内部持有ThreadLocalMap
|
||||
ThreadLocal.ThreadLocalMap
|
||||
threadLocals;
|
||||
}
|
||||
class ThreadLocal<T>{
|
||||
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<ThreadLocal>{
|
||||
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(()->{
|
||||
//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的事务管理器呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
@@ -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("1","{...}");
|
||||
//发送消息
|
||||
send(msg1);
|
||||
//如何等待MQ返回的消息呢?
|
||||
String result = ...;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里,相信你一定有点似曾相识的感觉,这不就是前面我们在[《15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?》](https://time.geekbang.org/column/article/88487)中曾介绍过的异步转同步问题吗?仔细分析,的确是这样,不过在那一篇文章中我们只是介绍了最终方案,让你知其然,但是并没有介绍这个方案是如何设计出来的,今天咱们再仔细聊聊这个问题,让你知其所以然,遇到类似问题也能自己设计出方案来。
|
||||
|
||||
## Guarded Suspension模式
|
||||
|
||||
上面小灰遇到的问题,在现实世界里比比皆是,只是我们一不小心就忽略了。比如,项目组团建要外出聚餐,我们提前预订了一个包间,然后兴冲冲地奔过去,到那儿后大堂经理看了一眼包间,发现服务员正在收拾,就会告诉我们:“您预订的包间服务员正在收拾,请您稍等片刻。”过了一会,大堂经理发现包间已经收拾完了,于是马上带我们去包间就餐。
|
||||
|
||||
我们等待包间收拾完的这个过程和小灰遇到的等待MQ返回消息本质上是一样的,都是**等待一个条件满足**:就餐需要等待包间收拾完,小灰的程序里要等待MQ返回消息。
|
||||
|
||||
那我们来看看现实世界里是如何解决这类问题的呢?现实世界里大堂经理这个角色很重要,我们是否等待,完全是由他来协调的。通过类比,相信你也一定有思路了:我们的程序里,也需要这样一个大堂经理。的确是这样,那程序世界里的大堂经理该如何设计呢?其实设计方案前人早就搞定了,而且还将其总结成了一个设计模式:**Guarded Suspension**。所谓Guarded Suspension,直译过来就是“保护性地暂停”。那下面我们就来看看,Guarded Suspension模式是如何模拟大堂经理进行保护性地暂停的。
|
||||
|
||||
下图就是Guarded Suspension模式的结构图,非常简单,一个对象GuardedObject,内部有一个成员变量——受保护的对象,以及两个成员方法——`get(Predicate<T> 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<T>{
|
||||
//受保护的对象
|
||||
T obj;
|
||||
final Lock lock =
|
||||
new ReentrantLock();
|
||||
final Condition done =
|
||||
lock.newCondition();
|
||||
final int timeout=1;
|
||||
//获取受保护对象
|
||||
T get(Predicate<T> 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("1","{...}");
|
||||
//发送消息
|
||||
send(msg1);
|
||||
//利用GuardedObject实现等待
|
||||
GuardedObject<Message> go
|
||||
=new GuardObjec<>();
|
||||
Message r = go.get(
|
||||
t->t != null);
|
||||
}
|
||||
void onMessage(Message msg){
|
||||
//如何找到匹配的go?
|
||||
GuardedObject<Message> 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<T>{
|
||||
//受保护的对象
|
||||
T obj;
|
||||
final Lock lock =
|
||||
new ReentrantLock();
|
||||
final Condition done =
|
||||
lock.newCondition();
|
||||
final int timeout=2;
|
||||
//保存所有GuardedObject
|
||||
final static Map<Object, GuardedObject>
|
||||
gos=new ConcurrentHashMap<>();
|
||||
//静态方法创建GuardedObject
|
||||
static <K> GuardedObject
|
||||
create(K key){
|
||||
GuardedObject go=new GuardedObject();
|
||||
gos.put(key, go);
|
||||
return go;
|
||||
}
|
||||
static <K, T> void
|
||||
fireEvent(K key, T obj){
|
||||
GuardedObject go=gos.remove(key);
|
||||
if (go != null){
|
||||
go.onChanged(obj);
|
||||
}
|
||||
}
|
||||
//获取受保护对象
|
||||
T get(Predicate<T> 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,"{...}");
|
||||
//创建GuardedObject实例
|
||||
GuardedObject<Message> go=
|
||||
GuardedObject.create(id);
|
||||
//发送消息
|
||||
send(msg1);
|
||||
//等待MQ消息
|
||||
Message r = go.get(
|
||||
t->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<T> 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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
251
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/32 | Balking模式:再谈线程安全的单例模式.md
Normal file
251
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/32 | Balking模式:再谈线程安全的单例模式.md
Normal 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(()->{
|
||||
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<String, CopyOnWriteArraySet<Router>>
|
||||
rt = new ConcurrentHashMap<>();
|
||||
//路由表是否发生变化
|
||||
volatile boolean changed;
|
||||
//将路由表写入本地文件的线程池
|
||||
ScheduledExecutorService ses=
|
||||
Executors.newSingleThreadScheduledExecutor();
|
||||
//启动定时任务
|
||||
//将变更后的路由表写入本地文件
|
||||
public void startLocalSaver(){
|
||||
ses.scheduleWithFixedDelay(()->{
|
||||
autoSave();
|
||||
}, 1, 1, MINUTES);
|
||||
}
|
||||
//保存路由表到本地文件
|
||||
void autoSave() {
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
changed = false;
|
||||
//将路由表写入本地文件
|
||||
//省略其方法实现
|
||||
this.save2Local();
|
||||
}
|
||||
//删除路由
|
||||
public void remove(Router router) {
|
||||
Set<Router> set=rt.get(router.iface);
|
||||
if (set != null) {
|
||||
set.remove(router);
|
||||
//路由表已发生变化
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
//增加路由
|
||||
public void add(Router router) {
|
||||
Set<Router> set = rt.computeIfAbsent(
|
||||
route.iface, r ->
|
||||
new CopyOnWriteArraySet<>());
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
@@ -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(()->{...}).start()。
|
||||
|
||||
```
|
||||
final ServerSocketChannel =
|
||||
ServerSocketChannel.open().bind(
|
||||
new InetSocketAddress(8080));
|
||||
//处理请求
|
||||
try {
|
||||
while (true) {
|
||||
// 接收请求
|
||||
SocketChannel sc = ssc.accept();
|
||||
// 每个请求都创建一个线程
|
||||
new Thread(()->{
|
||||
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(()->{...}).start()换成 Fiber.schedule(()->{})就可以了。
|
||||
|
||||
```
|
||||
final ServerSocketChannel ssc =
|
||||
ServerSocketChannel.open().bind(
|
||||
new InetSocketAddress(8080));
|
||||
//处理请求
|
||||
try{
|
||||
while (true) {
|
||||
// 接收请求
|
||||
final SocketChannel sc =
|
||||
ssc.accept();
|
||||
Fiber.schedule(()->{
|
||||
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,那有什么办法可以快速解决呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
@@ -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(()->{
|
||||
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<Runnable>(2000),
|
||||
//建议根据业务需求实现ThreadFactory
|
||||
r->{
|
||||
return new Thread(r, "echo-"+ 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<2; i++){
|
||||
System.out.println("L1");
|
||||
//执行L1阶段任务
|
||||
es.execute(()->{
|
||||
//L2阶段的闭锁
|
||||
CountDownLatch l2=new CountDownLatch(2);
|
||||
//执行L2阶段子任务
|
||||
for (int j=0; j<2; j++){
|
||||
es.execute(()->{
|
||||
System.out.println("L2");
|
||||
l2.countDown();
|
||||
});
|
||||
}
|
||||
//等待L2阶段任务执行完
|
||||
l2.await();
|
||||
l1.countDown();
|
||||
});
|
||||
}
|
||||
//等着L1阶段任务执行完
|
||||
l1.await();
|
||||
System.out.println("end");
|
||||
|
||||
```
|
||||
|
||||
当应用出现类似问题时,首选的诊断方法是查看线程栈。下图是上面示例代码停止响应后的线程栈,你会发现线程池中的两个线程全部都阻塞在 `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(() -> {
|
||||
try {
|
||||
String qq=pool.submit(()->"QQ").get();
|
||||
System.out.println(qq);
|
||||
} catch (Exception e) {
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
191
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/35 | 两阶段终止模式:如何优雅地终止线程?.md
Normal file
191
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/35 | 两阶段终止模式:如何优雅地终止线程?.md
Normal 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(()->{
|
||||
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(()->{
|
||||
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(()->{
|
||||
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;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
195
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/36 | 生产者-消费者模式:用流水线思想提高效率.md
Normal file
195
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/36 | 生产者-消费者模式:用流水线思想提高效率.md
Normal 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<Task> bq=new
|
||||
LinkedBlockingQueue<>(2000);
|
||||
//启动5个消费者线程
|
||||
//执行批量任务
|
||||
void start() {
|
||||
ExecutorService es=executors
|
||||
.newFixedThreadPool(5);
|
||||
for (int i=0; i<5; i++) {
|
||||
es.execute(()->{
|
||||
try {
|
||||
while (true) {
|
||||
//获取批量任务
|
||||
List<Task> ts=pollTasks();
|
||||
//执行批量任务
|
||||
execTasks(ts);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//从任务队列中获取批量任务
|
||||
List<Task> pollTasks()
|
||||
throws InterruptedException{
|
||||
List<Task> ts=new LinkedList<>();
|
||||
//阻塞式获取一条任务
|
||||
Task t = bq.take();
|
||||
while (t != null) {
|
||||
ts.add(t);
|
||||
//非阻塞式获取一条任务
|
||||
t = bq.poll();
|
||||
}
|
||||
return ts;
|
||||
}
|
||||
//批量执行任务
|
||||
execTasks(List<Task> ts) {
|
||||
//省略具体代码无数
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 支持分阶段提交以提升性能
|
||||
|
||||
利用生产者-消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:
|
||||
|
||||
1. ERROR级别的日志需要立即刷盘;
|
||||
1. 数据积累到500条需要立即刷盘;
|
||||
1. 存在未刷盘数据,且5秒钟内未曾刷盘,需要立即刷盘。
|
||||
|
||||
这个日志组件的异步刷盘操作本质上其实就是一种**分阶段提交**。下面我们具体看看用生产者-消费者模式如何实现。在下面的示例代码中,可以通过调用 `info()`和`error()` 方法写入日志,这两个方法都是创建了一个日志任务LogMsg,并添加到阻塞队列中,调用 `info()`和`error()` 方法的线程是生产者;而真正将日志写入文件的是消费者线程,在Logger这个类中,我们只创建了1个消费者线程,在这个消费者线程中,会根据刷盘规则执行刷盘操作,逻辑很简单,这里就不赘述了。
|
||||
|
||||
```
|
||||
class Logger {
|
||||
//任务队列
|
||||
final BlockingQueue<LogMsg> bq
|
||||
= new BlockingQueue<>();
|
||||
//flush批量
|
||||
static final int batchSize=500;
|
||||
//只需要一个线程写日志
|
||||
ExecutorService es =
|
||||
Executors.newFixedThreadPool(1);
|
||||
//启动写日志线程
|
||||
void start(){
|
||||
File file=File.createTempFile(
|
||||
"foo", ".log");
|
||||
final FileWriter writer=
|
||||
new FileWriter(file);
|
||||
this.es.execute(()->{
|
||||
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 <= 0) {
|
||||
continue;
|
||||
}
|
||||
//根据规则刷盘
|
||||
if (log!=null && log.level==LEVEL.ERROR ||
|
||||
curIdx == batchSize ||
|
||||
System.currentTimeMillis()-preFT>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(()->{
|
||||
try {
|
||||
//未刷盘日志数量
|
||||
int curIdx = 0;
|
||||
long preFT=System.currentTimeMillis();
|
||||
while (true) {
|
||||
......
|
||||
}
|
||||
} catch(Exception e) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
169
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/37 | 设计模式模块热点问题答疑.md
Normal file
169
极客时间专栏/geek/Java并发编程实战/第三部分:并发设计模式/37 | 设计模式模块热点问题答疑.md
Normal 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不同于String,StringBuffer不具备不可变性,通过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 "user"+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<T> 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(() -> {
|
||||
try {
|
||||
//提交子任务并等待其完成,
|
||||
//会导致线程死锁
|
||||
String qq=pool.submit(()->"QQ").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, "");
|
||||
//任务队列
|
||||
final BlockingQueue<LogMsg> bq
|
||||
= new BlockingQueue<>();
|
||||
//只需要一个线程写日志
|
||||
ExecutorService es =
|
||||
Executors.newFixedThreadPool(1);
|
||||
//启动写日志线程
|
||||
void start(){
|
||||
File file=File.createTempFile(
|
||||
"foo", ".log");
|
||||
final FileWriter writer=
|
||||
new FileWriter(file);
|
||||
this.es.execute(()->{
|
||||
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多线程设计模式》这本书来深入学习这个模块,这是一本不错的并发编程入门书籍,虽然重点是讲解设计模式,但是也详细讲解了设计模式中涉及到的方方面面的基础知识,而且深入浅出,非常推荐入门的同学认真学习一下。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user