思维导图:
引言:
本文主要介绍类的域变量被多个线程共享时所导致的可见性问题。我所理解的可见性是指类的域变量在某一线程中变化时能够及时准确的被其他线程所看见。文章还是分成两个部分进行描述:
- 理论部分:包括如何判断域变量是否可见,会导致什么样的问题,又该如何解决。
- 使用部分:通过 不发布对象>>发布但不可修改>>安全发布对象的次序逐渐深入的探讨如何构建一个线程安全的类。
一.可见性
同步操作不仅可以实现操作的原子性,还可以保证内存可见性。即线程在修改状态后,状态的改变对其他线程是可见的。在这一小节中,我们会介绍如何判断类的域变量是否可见,会导致什么问题,又该如何解决的理论方法。
1.1 对象的发布
发布的定义是使对象能够在当前作用域之外的代码起作用。也就是说发布的对象能够被其他线程所看到,但是已发布对象的变换可能并不会被其他线程所观测到。
判断一个对象是否被发布常用以下四种手段:
- 将对象的引用保存到一个非private的static变量中,那么任何类和线程都可以使用此对象
- 非private方法所引用的对象一般认定为已发布的对象
- 当把对象传递给一个外部方法(其他类的方法或者类中非private和final的方法)时,也认定此对象已被发布
- 通过内部类实例发布(隐式的发布类的引用),如下代码:匿名内部类EventListenner再被注册时其实附带着将其外部类ThisEscape的this引用也发布了,一般来说不推荐这么做。
public class ThisEscape {
public ThisEscape(EventSource source) {
// 发布EventListener时也发布了他所拥有的ThisEscape的this引用
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
}
像上述第四点一样,本来不想被发布的对象在无意间被发布的情况我们称之为逸出;
1.2 对象发布后的常见错误
发布后的对象在多线程的环境下会发生一些平时在单线程环境中意想不到的错误。比如现在两个线程A,B,他们同时使用了已发布的对象x,y。
一种常见的错误称为失效数据,描述如下:当A线程修改了对象x和y后,B线程开始读取x,y的值,发现他们的值还是A线程修改以前的值,这是B线程所观测的x,y的值其实是失效的,所以称之为失效数据。
另一种常见的错误称之为指令重排,指令重排其实是JVM优化代码的一种手段,但是这回导致这样的并发问题:A线程修改x后,又修改了y,但是B线程在读取x和y的值时发现,只有y被修改了,x没变。这是因为JVM优化时进行指令重排先修改了y,在修改x,而x的改变有没有被B线程所观测到所导致的。
例如以下代码:number和ready在改变后,可能被另一线程观测到输出42,也可能由于number的改变没有被观测到输出0,还有可能在循环使number的值其实已经是42了,但是一直在循环。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
1.3 确保发布对象的可见性
保证发布对象的可见性常用两种办法
- 加锁:加锁是可以保证原子操作,其修改对其他线程也是可见的。
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
- volatile关键字:使用volatile关键字修饰的变量不会发生指令重排,而其状态的修改对其他线程也是可见的。
@ThreadSafe
public class SynchronizedInteger {
volatile boolean asleep;
private void isAsleep(){
while(asleep){
//do something
}
}
}
在这个小节的最后,保证类的线程安全性除了保证其域变量的可见性。我们还可以通过保证域变量的不可见或者域变量的不可变来实现。
二.线程封闭
所谓的线程封闭是指只在单线程内访问数据,其他线程是不可见的,所以变量也不需要进行同步。
2.1 Ad-hoc 线程封闭
Ad-hoc线程封闭指单纯的在代码逻辑上实现线程封闭,即维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭非常脆弱,没有语言特性和编译器检查进行支持,所以尽量不要使用Ad-hoc线程封闭。
2.2 栈封闭
栈封闭即是指只能通过局部变量才能访问对象,而局部变量的固有属性就是封闭在执行线程中(JVM的虚拟机栈),其他线程无法访问。其实就是在方法内部定义变量进行使用,不要使对象逸出即可。
2.3 ThreadLocal类
一种使用线程封闭技术更加规范的方式是使用ThreadLocal类。ThreadLocal类可以使线程中的某个值和保存值得对象关联起来。每个线程都拥有该变量的一个副本,所以ThreadLocal里的变量是线程封闭的。如下代码,我们将JDBC连接保存到ThreadLocal中,那么每个线程都可以拥有属于自己的连接:
public class ConnectionDispenser {
static String DB_URL = "jdbc:mysql://localhost/mydatabase";
private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
public Connection initialValue() {
try {
return DriverManager.getConnection(DB_URL);
} catch (SQLException e) {
throw new RuntimeException("Unable to acquire Connection, e");
}
}
};
public Connection getConnection() {
return connectionHolder.get();
}
}
三.不可变对象
如果我们必须发布某个对象的话,那么可以考虑发布一个不会改变的对象以保证线程安全性,这被称之为不变性。
3.1 构造不可变对象
我们可以直接构造一个永远不可改变的对象,如下代码所示:
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
如上所述的不可变对象一定是线程安全的,一般来说不可变对象具有如下几个特性:
- 对象创建以后其状态不可修改
- 对象所有的域都是final类型
- 对象是正确创建的,即创建期间,this引用没有逸出。
3.2 发布不可变对象
虽然不可变对象是线程安全的,但是指向不可变对象的引用则不一定是线程安全的,所以需要使用volatile关键字修饰不可变对象以保证线程安全性。在下列代码中,我们先构建一个储存因式分解的数及其结果的不可变对象,在使用volatile修饰以发布一个线程安全的类:
- 不可变对象容器类(@Immutable表示对象是不可变的)
@Immutable
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i,
BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
- 使用volatile修饰不可变对象以线程安全的更换储存的因式分解结果
@ThreadSafe
public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}
四.安全发布
在前两节中我们描述的都是如何不发布或者发布不能改变的对象以保证线程安全性。终于,在这一小节中,我们还是不得不发布任何线程都可以修改的对象了。
一般的,要安全的发布引用必须保证对象的引用即对象的状态同时对其他线程可见,如下四种方式用于发布对象是安全的:
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中
- 将对象的引用保存到某个正确构造的final域中
- 将对象的引用保存到一个由锁保护的域中,例如放入线程安全的容器中,比如ConcurrentMap。