并发编程2:如何进行对象共享?

目录

1、对象的可见性:Volatile 变量

2、发布和逸出

3、线程封闭:ThreadLocal

4、对象的不变性

5、安全发布

5.1 - 安全发布常用的模式

5.2 - 可变对象

5.3 - 安全地共享对象


        同步在用于实现原子性或者确定 “临界区(Critical Section)” 的同时,还有另一个重要的作用:内存可见性 (Memory Visibility)//一个线程对共享变量的修改操作对另一个线程可见

1、对象的可见性:Volatile 变量

        首先,贴一段代码:

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;
    }
}

        上边 NoVisibility 可能会持续循环下去,因为读线程可能永远都看不到 ready 的值。一种更奇怪的现象是,NoVisibility 可能会输出 0,因为读线程可能看到了后写入 ready 的值,但却没有看到先写入 number 的值,这种现象被称为 “重排序(Reordering)”//由于重排序的存在,使线程操作没有按照程序中定义的顺序来执行

       内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如下图所示。

        当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个同步代码块中的所有操作结果。

        现在,我们可以进一步理解为什么在访问某个共享且可变的变量要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值//同一个锁,保证可见性

        加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

        Volatile 变量

        Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。//轻量级同步,但是并不能保证互斥(原子操作,即读写不分离)

        当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。//禁止重排序和缓存

        volatile 变量通常用做某个操作完成、发生中断或者状态的标志//比如终结循环标志

        虽然 volatile 变量很方便,但也存在一些局限性,volatile 的语义不足以确保操作的原子性//多个线程对同一个 volatile 变量进行修改操作,仍然会出现数据安全问题

        加锁机制既可以确保可见性又可以确保原予性,而 volatile 变量只能确保可见性。

        当且仅当满足以下所有条件时,才应该使用 volatile 变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。//不同时更新
  • 该变量不会与其他状态变量一起纳入不变性条件中。//非不变性变量
  • 在访问变量时不需要加锁

2、发布和逸出

        “发布(Publish)”一个对象的思是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者将引用传递到其他类的方法中。

        发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出( Escape)

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

    //保存引用
    public static Set<Secret> knownSecrets;

    public void initialize() {
        //发布一个HashSet<Secret>对象
        fknownSecrets = new HashSet<Secret>();
    }

        当发布某个对象时,可能会间接地发布其他对象。如果将一个 Secret 对象添加到集合knownSecrets 中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新 Secret 对象的引用。

        不要在构造过程中使 this 引用逸出//对象逸出会存在线程安全问题

//ThisEscape对象逸出
public class ThisEscape {

    public ThisEscape(EventSource source) {
        //不要再构造函数中注册一个监听器或者启动一个线程
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}

        当 ThisEscape 发布 EventListener 时,也隐含地发布了 ThisEscape 实例本身,因为在这个内部类的实例中包含了对 ThisEscape 实例的隐含引用,如果 this 引用在构造过程中逸出,那么这种对象就被认为是不正确构造//内部类隐式包含了外部内的引用,对象可能还没创建完成就被使用

      在构造过程中使 this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传给构造函数)还是隐式创建(由于 Thread 或 Runnable 是该对象的一个内部类) this 引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个 start 或 initialize 方法来启动。

        如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程,如下所示。

public class SafeListener {

    private final EventListener listener;
    //1-私有构造
    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }
    //2-公共方法:防止this逸出
    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

3、线程封闭:ThreadLocal

        当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭 (ThreadConfinement),它是实现线程安全性的最简单方式之一。//即不进行数据共享

        栈封闭线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。//使用局部变量

        ThreadLocal 对象通常用于防止对可变的单实例变量 (Singleton)全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个 Connection 对象。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接。//封装数据源是ThreadLocal的经典应用

4、对象的不变性

        满足同步需求的另一种方法是使用不可变对象 (Immutable Object)

        如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。不可变对象一定是线程安全的

@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);
    }
}

        在不可变对象的内部仍可以使用可变对象来管理它们的状态,如上边代码 ThreeStooges 所示。尽管保存姓名的 Set 对象是可变的,但在 Set 对象构造完成后无法对其进行修改。//不可变对象在构造完成后就不能进行修改

5、安全发布

5.1 - 安全发布常用的模式

        可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。

        要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。//使用static关键字
  • 将对象的引用保存到 volatile 类型的成员变量或者 AtomicReferance 对象中。//保证可见性
  • 将对象的引用保存到某个正确构造对象的 final 类型域中//依赖注入用的就是 final 域
  • 将对象的引用保存到一个由锁保护的域中。//使用同步容器等

        如果线程 A 将对象 X 放入一个线程安全的容器,随后线程 B 读取这个对象,那么可以确保 B 看到 A 设置的 X 状态,即便在这段读/写 X 的应用程序代码中没有包含显式的同步。在 Java 线程安全库中的容器类提供了以下的安全发布保证://使用安全容器进行发布

  • 通过将一个键或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中,可以安全地将它发布给任何从这些容器中访问它的线程。
  • 通过将某个元素放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或 synchronizedSet 中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
  • 通过将某个元素放人 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

        通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器

public static Holder holder = new Holder(42);

        静态初始化器由 JVM 在类的初始化阶段执行。由于在 JVM 内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

5.2 - 可变对象

        如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次访向对象时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,该对象必须是线程安全的或者由某个锁保护起来。

        对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。//事实不可变:对象发布后不再被修改
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

5.3 - 安全地共享对象

        当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式//当对象发布时,就需要考虑它的同步问题

        在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

        线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。//对于成员变量的定义需要格外小心

        只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象

        线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。//同步容器的做法

        保护对象被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。//并发编程常使用的方式

        总结:如果需要安全的共享对象,那么需要安全的发布对象。安全的发布对象,需要保证对象的完整性以及可见性。

        //勉励:没有什么知识是一次性学会的,学习知识的过程就是对思想一次次的打磨过程

猜你喜欢

转载自blog.csdn.net/swadian2008/article/details/125162252