对象的共享《java并发编程实战》

概述

本章介绍如何通过共享和发布对象,从而使他们能够安全地由多个线程同时访问。

可见性

在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,想要对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
没有同步情况下共享变量例子
    失效数据:
NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。
非原子的64位操作:
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
     最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作分别在不同的线程中执行,那么可能会读取到某个值的高32位和另一个值的低32位。因此,即是不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型变量特使不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
加锁与可见性:
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile变量:
java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
     把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起冲排序。
     volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,就不要使用volatile变量。
     volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象状态的可见性,以及标识一些重要的程序声明周期事件的发生(例如,初始化或关闭)。
虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心,例如volatile的语义不足以确保递增操作(count++)的原子性。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
该变量不会与其他状态变量一起纳入不变性条件中
在访问变量时不需要加锁

发布与逸出

“发布”一个对象指的是,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象呗发布时,这种情况就被称为逸出
package chapter3;

/**
 * @author zhen
 * @Date 2018/10/11 10:20
 * 使内部的可变状态逸出(不要这么做)
 */
public class UnsafeStates {

    private String[] states = new String[] {
        "AK", "AL" //...
    };

    public String[] getStates() {
        return states;
    }

}
使得内部可变状态逸出的发布例子
    发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。封装能够使得对程序的正确性分析变得可能,并使得无意中破坏设计约束条件变得更难。
最后一种发布对象或者其内部状态的机制就是发布一个内部的类实例。
package chapter3;

import java.awt.*;
import java.util.EventListener;

/**
 * @author zhen
 * @Date 2018/10/11 10:25
 */
public class ThisEscape {
    /*public ThisEscape(EventSource source) {
        source.registerListener(
            new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            }
        );
    }*/
}
This引用逸出的例子
   在ThisEscape中给出了逸出的一个特殊示例,即this引用在构造函数中逸出。在构造过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程。如果想在构造函数中注册一个事件监听器或者启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。具体来说,只有构造函数返回的时候,this引用才应该从线程中逸出。
   在构造函数中使this逸出的一个常见错误是:在构造函数中启动一个线程。当对象在其构造函数中创造一个线程的时候,无论是显式还是隐式,this引用都会被新线程共享。在构造中创造线程并没有错,但最好不要立即启动,而是通过一个初始化方法来启动。在构造中调用一个可改写的实例方法也会造成this逸出。
package chapter3;

import java.awt.*;
import java.util.EventListener;

/**
 * @author zhen
 * @Date 2018/10/23 15:50
 */
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逸出的例子

 

线程封闭

    当访问共享的可变变数据时,通常需要使用同步,一种避免使用同步的方式就是不提供共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭。例子:swing、jdbc的connection对象。jdbc是因为大多数请求都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会将它分配给其他线程。
线程封闭式在程序设计中的一个考虑因素,必须在程序中实现。java语言以及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类。
1、Ad-hoc线程封闭:
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。例如validate关键字当只有一个线程进行修改的时候实现了线程封闭。
2、栈封闭:
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象
栈封闭要注意对象逸出的情况
3、ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对相关联起来。
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都分配该临时对象,就可以使用这项技术。
从概念上看,你可以将ThreadLocal<T>视为包含了Map<Thread,T>独享,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此,这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。

不变性

满足同步需求的另一种方法是使用不可变对象。
不可变对象一定是线程安全的
虽然在java规范和java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有域都声明为final域,即使对象是final的,对象也有仍然是可变的,因为在final类型域中可以保存可变对象的引用。
当满足以下条件时,对象才是不可变的:
对象创建后其状态就不能修改;
对象的所有域都是final类型;
对象是正确创建的(在对象创建期间,this引用没有逸出)
1、final域
final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的);
final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并且在共享这些兑现时无须同步。
除非需要更高的可见性,否则应将所有的域都声明为私有域
2、使用Volatile类型来发布不可变对象
对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来保证原子性。

安全发布

1、不正确的发布:正确的对象被破坏
2、不可变对象与初始化安全性
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步
3、安全发布的常用模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用;
将对象的引用保存到volatile类型域或者AtomicReference对象中;
将对象的引用保存到某个正确构造对象的final类型域中;
将对象引用保存到一个由锁保护的域中
线程安全库中的容器类:
Hashtable、synchronizedMap、ConcurrentMap、
Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList、stnchronizedSet
BlockingQueue、ConcurrentLinkedQueue
4、事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”
5、可变对象
如果对象在构造后可以修改,那么安全发布只能确保“发布当时状态的可见性”。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。
对象的发布需求取决于它的可变性:
不可变对象可以通过任意机制来发布
事实不可变对象必须通过安全方式来发布
可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来的
安全地共享对象
很多并发错误都是由于没有理解共享对象这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
posted @ 2018-11-06 15:25  guodaxia  阅读(152)  评论(0)    收藏  举报