Java并发编程笔记(二):对象的共享
一、可见性
先看一个简单的例子:
A线程
A.close()
open = false;
B线程
while (open) {
B.dosomething();
}
上述代码开启有两个线程A,B,open为true的时候表示文件或者资源被打开,可以操作。B线程在监控开启状态,如果open为true,就可以进行相关操作。代码的问题是,如果A线程关闭了资源,设置open为false并写回主内存,此时切换到B线程,B线程并没有从主内存拉取到open新的值,也就是读取了“失效”的数据,B线程还认为open是true,最终导致BUG。
例子暴露的问题关键就是B线程不能立即看到A线程对open这个共享变量的改变。为什么会这样呢?这其实是由JMM(Java内存模型)的机制导致的。
JMM(Java内存模型)
在JMM中存在工作内存和主内存的概念(逻辑上),每个线程都有自己的工作内存。工作内存和主内存的交互又JMM控制。如下图(自己画的,有点简陋):
![](https://img.haomeiwen.com/i4880496/501da0e26062ae70.png)
从上图可以看到,线程A和线程B仅仅是用于open这个共享变量的一个副本,线程对open的修改,其他线程是不受影响的。假设线程A对open做了修改,然后写回内存,但是B还没有从内存将open同步到线程B的工作内存,如果此时B来读取open,他只能获得没有修改的open,所以就出现了问题。那怎么解决这个问题呢?同步可以解决,例如内置锁等。但对于Java提供了更加轻量级的同步机制来解决可见性的问题:volatile关键字。
这里对JMM的描述并不完整和严谨,后面会详细讨论。
volatile
volatile修饰的变量有一个显著的特点就是每次对变量的访问都会强迫线程访问主内存。例如A对open进行了修改,此时不仅仅在线程本地工作内存修改,还把该值立即同步到主内存中,而当B线程获取open的值是,就会被强迫到主内存中读取该值。这样就解决了可见性的问题。
volatile还有另一个功能就是禁止 “重排序”。JVM在编译的时候会对一些无依赖的代码重新排序以提高性能。这在单线程环境下是没有影响的,但是在多线程环境下可能会导致一些问题。JVM 对volatile修饰的变量相关的代码不做重排序来避免重排序带来的问题。
但是,volatile不能保证原子性。所以常说volatile是一种弱的同步机制,如果场景需要保证原子性,那么volatile是无法做到的,切记这一点,避免误用volatile。
二、 发布对象和对象的逸出
发布对象常见的有三种类型:
- 将一个指向对象的引用保存到其他代码可以访问的地方,例如共有静态变量。
- 在一个非私有方法中返回指向对象的引用。
- 在构造函数中隐式的将this发布出去。
前两种比较好理解,主要看看第三种方式,下面是《Java并发编程实战》里的样例代码:
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
void doSomething(Event e) {
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {
}
}
这里有隐式的this逸出,究竟在哪发生逸出了呢?我的理解是:在source调用注册监听器的时候启动了一个线程,此时这个线程发布了一个EventListenner类,这类是ThisEscape 的内部类,内部类是持有外部类的this的,所以当内部类被发布的时候,外部类的this也就会被发布,这就可能导致在ThisEscape 还没有构造完成的时候被“提前”使用。这显然会导致很严重的错误。
为了避免这个问题,书中给出了建议: 只有当构造函数返回时,this引用才应该从线程中逸出。构造函数可以将this引用保存到某个地方,只要其他线程不会在构造函数完成之前使用它。
三 、线程封闭
上一篇文章中提到过ThreadLocal这个工具类,也简单讲了ThreadLocal的原理,现在,可以对这种实现方式提供一个更加专业严谨的名字了:线程封闭。线程封闭简单来说就是保证数据的单线程访问,即对共享变量采取“不共享”的方式,那么对共享变量的访问自然是线程安全的。一种应用就是JDBC,JDBC规范中不要求Connection是线程安全的,但是实现的时候又不得不把他弄成线程安全的,如果使用锁等同步机制会有很大的性能开销,所以将其做成线程封闭,既能保证线程安全,也不会导致过大的开销。
线程封闭以下几种方式:
- Ad-hoc 线程封闭。
- 栈封闭。
- ThreadLocal类包装共享变量实现线程封闭。
栈封闭简单来说就是只能通过局部变量访问对象,并且不要让对象逸出。如书中的代码:
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals被封闭在方法中,不要使它们逸出!
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
这里animals被封闭在栈上,即其他线程是无法访问到当前线程的animals引用的,这就实现了线程封闭。
ThreadLocal的原理其实和栈封闭很相似,不同的是栈封闭依赖栈这种数据结构,ThreadLocal依赖的是一个共享的Map。
还有一个就是Ad-hoc,名字很奇怪,其实很简单,就是线程封闭的实现完全有程序控制,即程序员必须自己写一些代码来实现线程封闭的功能,栈封闭和ThreadLocal多多少少都借助了其他数据结构。关于Ad-hoc这个名字的由来,比较有意思,可以上网查查。
四、不可变对象
首先表明:不可变对象一定是线程安全的。
不可变对象就是一旦对象构造完毕,其内部状态不能再被改变。例如Java里的String类。这样的对象在多线程环境下共享是没有问题的,因为无法改变对象的状态,多个线程访问操作只能是读操作,读操作是没有太大危害的。所以不可变对象肯定是线程安全的。
但不可变对象不等同于对象的所有域都是final修饰的对象。因为final修饰的只是引用不可变,其引用的对象仍然是可变的,所以为了使一个类变成不可变类,还需要程序控制其域不能被修改。总结为三点:
- 对象创建之后其状态(域)不能被修改。
- 对象的所有状态(域)都有final修饰。
- 保证对象的正确构造,防止this逸出。
只有同时满足上述三个条件,一个类才能算是一个不可变的类。
小结
对象的安全共享需要注意这几个点:
- 保证对象的可见性
- 正确的发布对象和避免对象逸出
- 使用线程封闭来保证共享对象的线程安全
- 不可变对象肯定是线程安全的