我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
3.1 可见性
通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些问题想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。**解决方法:**只要有数据在多个线程之间共享,就使用正确的同步。
3.1.1失效数据
失效数据更好的解释是:过时的数据。
例:
public class NoVisibilty{
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;
}
}
失效数据更好的解释是:过时的数据。以上面NoVisibilty类为例,main主线程更改number值后,更改后的值只是停留在主线程的高速缓存中,在number值从高速缓存区更新至内存,再更新到ReaderThread 线程这段时间中,number的值相对于ReaderThread 就是过时的、失效的。
3.1.2非原子的64位操作
最低安全性:在没有同步的情况下,变量的值至少是一个线程设置过的值,而不是随机的。
对于非volatile类型的64位数值变量(double和long)变量,JVM可能会将64位的读、写操作分解成两个32位操作。如果对该变量的读和写操作在不同的线程中,那么很有可能会读取到某个值的高32位和低32位。除非用关键字volatile或者锁保护起来。
3.1.3加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
例:
**分析:**当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。
在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
3.1.4 Volatile变量
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
访问volatile变量不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量的同步机制。
从内存可见性的角度来看,写人volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入不依赖当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
3.2 发布与逸出
发布一个对象是指:使对象能够在当前作用域之外的代码中使用。
逸出:某个不应该发布的对象被发布。
当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
安全的对象构建过程
先看一个出问题的构造过程
public class ThisEscape{
public ThisEscape(EventSource source){
source.registerListener(
new EventListener(){
public void onEvent(Event e){
doSomething(e);
}
});
}
}
分析
内部类在编译完成后会隐含保存一个它外围类的引用,"ThisEscape.this”,然而构造函数还没完成,ThisEscape在执行构造函数,其本身对象还没构造完,this引用就立刻间接被传递到其他类的方法中。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造。
建议:不要在构造过程中使this引用逸出。
上面例子的解决方法
public class SafeListener{
private final EventListener listener;
private SafeListener(){
listener = new EventListener(){
public void onEvent(Event e)
{
doSomething(e);
}; //在构造函数结束之前,外部类this的引用并没有被其他类的方法引用,并没有被发布,所以没有逸出
}
public static SafeListener newInstance(EventSource source){
SafeListener safe = new SafeListener();
souce registerListener(safe.listener);
return safe;
}
}
**分析:**只有构造函数返回后,外部类的this才能被引用,此时外部类的对象已经构造完整。
3.3线程封闭
**线程封闭:**当访问共享的可变数据一般都需要使用同步,避免使用同步的方式就是不共享数据,即在单线程中访问数据,这种技术称为线程封闭。线程安全性实现的最简单方式之一。
线程封闭的常见应用:Swing的可视化组件和数据模型对象实现线程安全、JDBC的Connection对象的线程安全实现
线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。
JAVA中的实现机制:局部变量和ThreadLocal类。
3.3.1 Ad-hoc线程封闭
维护线程封装性的职责完全由程序实现来承担。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术,例如栈封闭、ThreadLocal类。
3.3.2 栈封闭
栈封闭是线程封闭的一种特例
在栈封闭中,只用局部变量访问对象,而且这个对象也是单个线程中的局部对象。
如果在线程内部(Within-Thread)上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。
3.3.3 ThreadLocal类
ThreadLocal类能使线程中的某个值与保存值的对象关联起来。TheadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
例子
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>{
public Connection initialValue(){
return DriverManager.getConnection(DB_URL);
}
public static Connection getConnection(){
return connectionHolder.get();
}
}
当某个线程初次调用TheadLocal.get方法时,就会调用initialValue来获取初始值。这些特定于线程的值保存在Thead对象中,当线程终止后,这些值会作为垃圾回收。
假设将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为TheadLocal对象,可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大的作用。
ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
3.4 不变性
满足同步需求的另一种方法就是使用不可变对象。
如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的。
不能改变状态的意思是,不能改变对象内的成员变量,包括基本数据类型的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变。
一个对象的状态始终不改变,多个线程访问某个不可变对象,就不存在什么同步问题,因此,不可变对象一定是线程安全的。
当满足以下条件时,对象是不可变的:
对象创建以后其状态就不能修改
对象的所有域都是final类型
对象是正确创建的(在对象创建期间,this引用没有逸出)
3.4.1 Final域
关键字final用于构造不可变对象。
即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。
正如"除非需要更高的可见性,否则应将所有的域都声明为私有域"是一个良好的习惯,除非需要某个域是可变的,否则应将其声明为final域,也是一个良好的习惯。
3.4.2 示例:使用volatile类型来发布不可变对象
使用指向不可变容器对象的volatile类型引用以缓存最新的结果。
例子
@ThreadSafe
public class VolatileCachedFactorizer 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);
}
}
与cache相关的操作不会相互干扰,因为对象是不可变的,并且在每条相应的代码路径中只会访问一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可变见,因此也是线程安全的。
3.5 安全发布
在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。
3.5.1 不正确地发布:正确的对象被破坏
例子:
public class Holder {
private int n;
public Holder(int n) {this.n = n;}
public void assertSanity(){
if(n != n){
throw new AssertionError("this statement is false.....");
}
}
}
尚未完全创建的对象没有完整性。由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为"未被正确发布"。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder的引用的值是最新的,但Holder状态的值却是失效的。
3.5.2 不可变对象与初始化安全性
Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。
一方面,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。另一方面,即使在发布不可变对象的引用是没有使用同步,也你仍然可以安全地访问该对象。
为了维持这种初始化安全性保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。这种保证可以延续到被正确创建对象中所有的final类型的域。在没有额外同步情况下,也可以安全访问final类型的域。然而,如果final类型的域指向的是可变对象,那么在访问这些域所指向的状态时仍然需要同步。
3.5.3 安全发布的常用模式
要安全地发布一个对象,对象的引用发及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReference对象中
- 将对象的引用保存到某个正确构造对象的final类型域中
- 将对象的引用保存到一个由锁保护的域中
java提供了一些线程安全库中的容器类,提供了安全发布的保证。例如:Hashtable、ConcurrentMap、synchronizedMap、Vector、BlockingQueue等。最简单的线程安全的对象发布,采用的是通过静态方法创建,类似于工程方法:public static Holder hold=new Holder(42);
对于安全发布对象的总结:
-
线程封闭,对象范围是只在1个thread范围中,采用线程封闭技术,那么不需要同步机制,因为该对象是thread独有的
-
只读共享,如果对象是只读的,那么也不需要同步机制,没有任何修改操作。
-
线程安全类,如果对象是在Thread-safe结构中进行共享,如Hashtable等,那么该结构已经提供了同步机制,可以放心使用
-
其它。则该对象如果存在读写操作,需要相应的进行锁机制、同步机制,公用同样的锁来保证数据的完整一致
3.5.4 事实不可变对象
如果对象从技术上看是可变的,但其状态在发布后不会再改变,那么把这种对象称为事实不可变对象(Effectively Immutable Object)。
在没有额外的同步情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
多个线程之间将一个Date对象(本身可变)作为不可变对象来使用,那么在多个线程共享Date对象时,可以省去锁的使用,假设需要维护一个Map对象,保存每个用户的最近登录时间:
public Map<String,Date> lastlogin =
Collections.synchronizedMap(new HashMap<String,Date>());
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。
3.5.5 可变对象
对于可变对象,不仅发布时要同步,而且在每次访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,必须安全地发布,并且必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须 是线程安全的或者由某个锁保护起来。
3.5.6 安全地共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口进行访问而不需要进一步同步。
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封闭在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。