《Java Concurrency in Practice》学习总结(Part I)

《Java Concurrency in Practice》的前言已经写明了如何使用这本书:提供一个简化的并发程序编写规则,使开发人员无须掌握Java内存模型底层细节就能编写正确的并发程序。而该书的第一部分(Part I)就介绍了其中最重要的一些规则。下面来列出这一部分各章节的主要内容:

  • 第二、三章:给出了几乎所有用于避免并发危险、构造线程安全的类以及验证线程安全的规则;
  • 第四章:介绍如何将一些小的线程安全类组合成更大的线程安全类;
  • 第五章:介绍了在平台库中提供的一些基础的并发构建模块,包括线程安全的容器类和同步工具类;

其中关于发布逸出安全发布线程安全类添加功能构建高效且可伸缩的结果内存等内容让人醍醐灌顶,本篇将对这些进行笔记。

第一章 简介

简要介绍了并发历史、线程优势、线程风险以及线程应用。

线程风险

  • 安全性问题:存在竞态条件,线程无法预料数据变化所产生的错误;
  • 活跃性问题:死锁、饥饿、活锁;
  • 性能问题:保存恢复线程执行的上下文、CPU时间花费在调度上、同步机制会清空锁定内存和高速缓存并在共享内存总线创建同步通信;

安全性含义是“不能错”,活跃性是“正确事的最终会发生”,性能说的则是“正确的事更好更快的发生”。

第二章 线程安全

编写安全代码的本质:管理对状态的访问,而且通常是共享、可变的状态;

共享:变量被多个线程访问;

可变:变量的值在生命周期内可变;

访问同一可变状态的变量没有使用合适的同步而出现了错误,修复的方式:

  • 不在线程间共享该状态变量;
  • 状态变量修改为不可变变量;
  • 访问状态变量时使用同步;

编写并发应用程序时,一种正确的编程方法:首先使代码正确运行,然后再提高代码的速度;

线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的;

无状态对象一定是线程安全的;

多个线程交替执行时序时,会发生竞态条件,最常见的竞态条件是“先检查后执行”操作;

“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”,这与pthread(POSIX线程)互斥体默认加锁行为不同,后者是以“调用”为粒度;

如果持有锁的时间过长,就会带来活跃性和性能的问题,比如执行中会有I/O操作,那么一定不要持有锁;

第三章 对象的共享

当前大多数架构中,读取volatile变量的开销只比读取非volatile变量的开销略高一点;

逸出:当某个不应该发布的对象被发布时,这种状况就被称为逸出;

发布对象的最简单方法:将对象的引用保存在一个公有的静态变量中,以便任何类和线程都能看到该对象;

当发布一个对象时,对该对象的非私有域中引用的所有对象同样会被发布;

不要在构造过程中使this引用逸出;

构造过程中使this引用逸出的错误有:

  • 构造函数中启动一个线程;
  • 在构造函数中调用可被改写的实例方法;

这里用代码解释在构造函数中调用可被改写的实例方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ThisEscape {
private final int i;
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
}
);
i = 7;
}
// result can be 0 or 10
int doSomething(Event e) {
return i;
}
}

在构造函数中注册一个监听器来监听事件,doSomething()是内部类触发事件调用的外围类的实例方法,所以存在外围类this逸出的错误,当监听器监听到事件并最终调用doSomething(e)的时候,外部类可能还没有完全构造。

解决此错误的方法:使用工厂方法。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
}

这里之所以可以避免在构造函数中出现this逸出,是因为final能确保初始化的过程的安全性。

栈封闭只能通过局部变量才能访问对象,比Ad-hoc线程封闭更易于维护,也更加健壮;

某个对象在创建后状态就不能被更改,那么这个对象就称为不可变对象;

不可变对象一定是线程安全的;

除非需要某个域是可变的,否则将其声明为final是一个良好的编程习惯;

安全发布

手边的童云兰版对英文版3.5.3小节部分内容的翻译与原意有出入,这里先放上英文原文:

Safe_Publication_Idioms

这里无非是要处理对象修改状态对其它线程的可见性问题:

首先可以通过静态初始化器初始化对象的引用(翻译版翻译为“在静态初始化函数中初始化一个对象引用”);

在此之后可以通过三种方式将对象的引用保存,属于选择关系,而不是分步串行,即:

  • 要么保存在volatile字段或者原子引用中;
  • 要么保存在可以正确构造对象的final字段中;
  • 要么保存在加了锁的字段中;

《深入理解Java虚拟机》一书已经介绍了静态字段初始化的时机,这里需要强调的是,JVM内部存在同步机制,所以通过静态初始化器初始化对象的引用可以被安全的发布。

第四章 对象的组合

本章主要是介绍一些构造类的模式,将线程安全的组件安全的组合成更大的组件或程序。

如果一个对象的域引用了其它对象,那么它的状态也同时包含被引用对象的域,比如一些容器类;

实例限制

被限制的对象一定不能逸出到它期望可用的范围之外,可以把对象限制在类实例(私有类成员)、lexical scope(比如本地变量)或线程(比如对象从线程内的一个方法传递给另一个方法,但不支持跨线程分享);

限制性使构造线程安全的类变得更容易,因为类的状态被限制后,分析它的线程安全性时,就不必检查完整的程序;

Java监视器模式

遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护;

使用私有锁对象而不是对象的内置锁(或任何可以被公共访问的锁)有许多优点;

在现有线程安全类中添加功能

要添加一个新的原子操作,最安全的方法是修改原始的类,“but this is not always possible because you may not have access to the source code or may not be free to modify it. ”;

另外的方法是extend the class,“but not all classes expose enough of their state to subclasses to admit this approach. ”

第三种策略是扩展类的功能,但并不扩展类本身,而是将扩展类放在一个“辅助类”中;

这种方法常常会出现在错误的锁上进行同步的问题,例如:

1
2
3
4
5
6
7
8
9
10
11
public class ListHelper<E> {
public List<E> list = ...;
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if (absent) {
list.add(x);
}
return absent;
}
}

该代码不能达到线程安全性的原因就是没有对list对象的monitor锁进行同步,修改的方式是添加synchronized (list),但这种方法比之前的方法更脆弱,因为将加锁的代码放到了无关的地方;

第四种:组合,这是一种更好的方法;

第五章 基础构建模块

本章主要是涵盖了最有用的并发构建块,特别是Java 5.0和Java 6引入的,以及它们构造并发程序时用到的一些模式。

本章首先要注意区分同步容器和并发容器的区别;

容器复合操作包括:迭代、导航(根据顺序寻找下一个元素)以及条件运算(put-if-absent),通常情况下这些复合操作即便没有客户端加锁保护,也是线程安全的,但是当其它线程能够并发修改容器的时候,就会产生线程安全问题;

通过for迭代的时候,如果并发进行删除操作,可能会抛出数组越界异常,通过客户端加锁解决该问题,会使程序牺牲伸缩性,比如没有进行删除操作,仍然要去获取锁增加开销;同时,这样做也会造成for迭代的时候其它线程无法访问,降低了并发性;

用Iterator迭代的过程如果发生了修改操作会抛出ConcurrentModificationException异常,如果不想采用客户端加锁来解决,则可以采用克隆容器在副本上迭代的方法,采用该方法同样要考虑性能;

隐藏迭代器:一个容器作为另一个容器的元素或者键值,就要注意可能发生的隐藏迭代操作,包括调用hashCode()和equals()等,甚至是toString();

同步容器通过将容器状态的访问串行化来实现线程安全;

通过并发容器来替代同步容器,可以极大的提高伸缩性并降低风险;

关于ConcurrentHashMap将会单独进行源码分析;

ConcurrentHashMap不能加锁独占访问,所以可以采用ConcurrentMap接口;

关于JUC提供的队列将单独进行源码分析;

构建高效且可伸缩的结果缓存

就是实现并发下的享元模式。

首先,使用HashMap组合加锁的方式,并发效率非常低,因为每次只有一个线程能够执行compute(),相当于为了同步而串行:

m1

由图可见,纵向每次只有一个线程在执行。

改进的方法是将HashMap替换为ConcurrentHashMap,考虑下图这种情形:

m2

在图中当线程A正在执行compute f(1)的时候,线程B发现缓存不中,就也开始调用compute f(1),如此这般最终相当于计算了两次。而且还要看具体的应用场景,有些场景只要求缓存对象初始化一次,那么这就产生了安全风险。

继续方法改进,增加FutureTask,这样当上图中B线程查找f(1)的值时,会发现f(1)的Future,从而不会认为缓存不中,但是此时会遇到新的问题,如下图:

m3

A线程检测到f(1)没有在缓存,于是开始put Future for f(1),在成功之前,线程B也检测到f(1)没有在缓存,于是也开始put Future for f(1)。这是“检测再运行”带来的重复计算的问题。

改进的方法是利用ConcurrentMap,该接口提供了原子操作putIfAbsent(),最终代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Memoizer<A, V> implements Computable<A, V> {
private final ConcurrentMap<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
private final Computable<A, V> c;
public Memoizer(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(arg, ft);
if (f == null) { f = ft; ft.run(); }
}
try {
return f.get();
} catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}

代码中的异常处理用来应对缓存污染,考虑了计算被取消或者失败的情形。