Java并发编程实战(学习笔记二 第三章 对象的共享)

(转自:https://blog.csdn.net/ahaha413525642/article/details/76574351 侵删)

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

3.1 可见性(Visibility)

通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至不可能。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

下面的 NoVisibility说明了当多个线程在没有同步的情况下共享数据时出现的错误。主线程和读线程都将访问共享变量ready和number。主线程启动读线程,然后将number设为42,并将ready设为true。读线程一直循环知道发现ready的值变为true,然后输出number的值。虽然我们试了多次,结果都输出42,但事实上很可能输出0甚至无法停止,这是因为代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number值对于读线程来说是可见的。

//          3-1   在没有同步的情况下共享变量(不要这么做)
public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {   //继承Thread
        public void run() {
            while (!ready)          //发现ready为true时才执行
                Thread.yield();  //Thread.yield( )方法,线程让步, 暂停当前正在执行的线程对象,并执行自己或其他线程。
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

Thread.yield( )方法,线程让步, 暂停当前正在执行的线程对象,并执行自己或其他线程。 
打个比方:现在有很多人在排队上厕所,好不容易轮到这个人上厕所了,突然这个人说:“我要和大家来个竞赛,看谁先抢到厕所!”,然后所有的人在同一起跑线冲向厕所,有可能是别人抢到了,也有可能他自己有抢到了。

NoVisibility可能会一直循环下去,因为读线程可能永远都看不到ready的值。也可能输出为0,因为读线程可能看到了写入的ready值,却没有看到写入的number的值,这种现象被成为“重排序(Reordering)”。 
只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显得看到该线程的重排序),那么就无法确保线程中的操作将按照程序中制定的顺序来执行。当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到顺序可能与写入的顺序完全相反。

多线程之指令重排序

①编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。 
也就是说,对于下面两条语句: 
int a = 10; 
int b = 20; 
在计算机执行上面两句话的时候,有可能第二条语句会先于第一条语句执行。所以,千万不要随意假设指令执行的顺序。

②不是所有的语句的执行顺序都可以重排 
为了讲清楚这个问题,先讲解另一个概念:数据依赖性 
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial semantics (as-if-serial语义)

③重排序对多线程的影响


class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;          // 1
        flag = true;    // 2
    }

    public void reader() {
        if (flag) {            // 3
            int i = a * a; // 4
        }
    }
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入? 
答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图: 
这里写图片描述 
上图的执行顺序是:2 -> 3 -> 4 -> 1 (这是完全存在并且合理的一种顺序,如果你不能理解,请先了解CPU是如何对多个线程进行时间分配的)

如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

下面再让我们看看,当操作3和操作4重排序时会产生什么效果。下面是操作3和操作4重排序后,程序的执行时序图: 
这里写图片描述

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

在没有同步的情况下,编译器,处理器以及运行时等都有可能对操作的执行顺序进行一些想不到额调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确答案。

重排序看似是一种失败的设计,却能使JVM充分地利用现代多核处理器的强大性能。例如,在缺少同步的情况下,Java内存模型允许编译器对操作顺序进行重排序,并将数据缓存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。

3.1.1 失效数据(Stale Data)

当读线程查看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都是用同步,否则很可能获得该变量的一个失效值。失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的最新值。是小数据可能会导致一些可严重的安全问题或活跃性问题。在NoVisibility可能输出错误值或者程序无法结束。如果对象的引用(例如链表中的指针)失效,情况会更复杂。失效数据还可能导致其他故障:意外之外的异常,被破坏的数据结构,不准确额计算以及无限循环等。

下面的例子MutableInteger不是线程安全的,因为get和set都是在没有同步的情况下访问value的。失效值问题容易出现:如果某个线程调用了set,那么里一个正在调用get的线程可能会看到更新后的value,也可能看不到。

//       3-2  非线程安全的可变整数类
@NotThreadSafe
public class MutableInteger {
    private int value;

    public int get() {
        return value;
    }

    public void set(int value) {
        this.value = value;
    }
}

要解决这个问题,需要对set和get用synchronized关键字修饰进行同步。仅对set方法进行同步是不够的,调用get的线程仍然会看到失效值。

3.1.2 非原子的64操作(Nonatomic 64-bit Operations)

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被成为最低安全性(out-of-thin-air satety)

最低安全性适用于绝大多数变量,但存在例外:非volatile(不稳定的,易变的)类型的64位数值变量(double和long),java内存模型要求,变量的读取曹组和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据的问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来.

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义: 
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。 
2)禁止进行指令重排序。

3.1.3 加锁与可见性(Locking and Visibility)

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。如下图,当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁释放之前,A看到的变量值在B获得锁后同样可以由B看到。即当B执行由锁保护的同步代码块时,可以看到A之前在同一个同步代码块中的所有操作。如果没有同步,那么将无法实现上述保证。 
这里写图片描述

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

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

3.1.4 Volatile变量(Volatile Variables)

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义: 
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。 
2)禁止进行指令重排序。

volatile变量能确保可见性 
先看一段代码,假如线程1先执行,线程2后执行:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}

//线程2
stop = true;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了: 
第一:使用volatile关键字会强制将修改的值立即写入主存; 
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效); 
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。那么线程1读取到的就是最新的正确的值。

volatile变量是一种比synchronized关键字更轻量级的同步机制。

volatile变量对可见性的影响比volatile变量本身更重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入一个volatile变量相当于退出一个同步代码块,读去volatile变量相当于进入同步代码块。

如果在代码中以来volatie变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难理解。仅当volatile变量能简化代码的实现以及同步策略的验证时,才应该使用它们。如果在验证正确性需要对可见性进行复杂的判断,就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及一些重要的程序生命周期事件的发生(例如初始化或关闭)。

下面例子给出了volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。这个例子中线程通过类似数绵羊的方法进入休眠状态。asleep必须用volatile修饰。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了。这里也可以用锁来确保asleep更新操作的可见性,但这将使代码复杂。

//     3-4 数绵羊
volatile boolean asleep;
...
      while (!asleep)
          countSomeSheep();

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。volatile也无法保证对变量的任何操作都是原子性的。

当且仅当满足一下所有条件时,才应该使用volatile变量: 
①对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。 
②该变量不会与其他状态变量一起纳入不变性条件中。 
③在访问变量时不需要加锁。

3.2 发布与逸出(Publication and Escape)

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

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

//     3-5    发布一个对象
public static Set knownSecrets;
public void initialize() {
      knownSecrets = new HashSet();
}

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

当发布某个对象时,可能会间接地发布其他对象。如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。下面的代码发布了本应为私有的状态数组。

发布第二种简单的方式就是在一个公共方法内直接return 对象的引用

//     3-6   使内部可变状态逸出(不要这样做)
class UnsafeStates {
   private String[] states = new String[] {
            "AK", "AL" ...
   };
   public String[] getStates() { return states; }
   }

如果按照上面方法来发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。

当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。

最后一种发布对象或其内部状态的机制就是发布一个内部的类实例。当ThisEscape发布内部类EvnetLister时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中也包含了对ThisEscape实例的隐含引用。 
ThisEscape尝试在构造函数中注册一个事件监听器。

//     3-7  隐式地使this引用逸出(不要这么做)
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 {
    }
}

3.2.1 安全的对象构造过程(Safe Construction Practices)

在ThisEscape中给出了逸出的一种特殊示例,即this引用在构造函数中逸出。因此,不要在构造过程中使this引用逸出。当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。

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

在构造函数中调用一个可改写的实例方法(既不是private方法,也不final方法),同样会导致this引用在构造过程逸出。

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

//     3-8   使用工厂方法来防止this引用在构造过程中逸出
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) {  //公共的工厂方法,使用newInstance方法来创建对象
        SafeListener safe = new SafeListener(); //调用私有构造函数
        source.registerListener(safe.listener);
        return safe;
    }
    void doSomething(Event e) {
    }
    interface EventSource {
        void registerListener(EventListener e);
    }
    interface EventListener {
        void onEvent(Event e);
    }
    interface Event {
    }
 }

满足同步需求的两种方法。

3.3 线程封闭(Thread Confinement)

可以避免多个线程共享的(线程封闭)

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方法就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这个技术称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方法之一。当某个对象封闭在一个线程中,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

线程封闭技术的常用应用是Swing和JDBC(Java Database Connectivity)的Connection对象。

3.3.1 Ad-hoc线程封闭

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。

在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在共享的volatile变量上执行“读入-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个操作中以防止发生竞态条件,并且volation变量的可见性保证还确保了其他线程能看到最新的值。

由于Ad-hoc线程封闭技术的脆弱性,在程序中应尽量少用它。

3.3.2 栈封闭(Stack Confinement)

栈封闭(也称为线程内部使用或者线程局部使用)是线程封闭的一种特例。只能通过局部变量才能访问对象。封装能耐使代码更容易维持不变形条件,而同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。

比Ad-hoc线程封闭更易于维护,也更加健壮。

对于基本类型的局部变量,如下面的numPairs无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此Java的这种语义就确保了基本类型的局部变量始终封闭在线程内。

//          3-9    基本类型的局部变量与引用变量的线程封闭性
public int loadTheArk(Collection candidates) {
       SortedSet animals;
       int numPairs = 0;     //基本类型的局部变量始终封闭在线程内
       Animal candidate = null;
       // animals被封闭在方法中,不要使它们逸出
       animals = new TreeSet(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;
}

在loadTheArk中实例化一个TreeSet对象,并将指向对象的一个引用保存到animals中。此时,只有一个引用指向结合animals,这个引用被封装在局部变量中,因此也被封闭在执行线程中。(局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。)然而,如果发布了对集合animals(或者对该对象中的任何内部数据)的引用,那么封闭性将被破坏,导致对象animals的逸出。

3.3.3 ThreadLocal类

维护线程封闭性更规范的方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。

ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。

以connectionHolder方法来讲解:在单线程应该程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应该用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个对象都会用于属于自己的连接。

//            3-10    使用ThreadLocal来维持线程封闭性
private static ThreadLocal connectionHolder     
       = new ThreadLocal() {           //将JDBC的连接保存到ThreadLocal对象中
          public Connection initialValue() { 
             return DriverManager.getConnection(DB_URL);
          }
        };
public static Connection getConnection() {
     return connectionHolder.get();
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,都可以使用ThreadLocal来维持线程封闭性。


3.4 不变性(Immutability)

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

如果某个对象在被创建后其状态就不能被改变,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数床架的,只要它们的状态不改变,这些不变性条件就能维持。

不可变对象一定是线程安全的。.

即使对象中所有的域都是final类型,这个对象也依然是可变的,因为在final类型的域中可以保存对可变对象的引用。

当满足以下条件时,对象才是不可变的: 
①对象创建以后其状态就不能修改 
②对象的所有域都是final类型 
③对象都是证券创建的(在对象的创建期间,this引用没有逸出)

在不可变对象的内部仍可以使用可变对象来管理它们的状态。 
在ThreeStooges,尽管保存姓名的Set对象是可变的,但Set对象构造完成后无法对其进行修改(final)。stooges是一个final类型的引用变量,所有的对象状态都通过一个final域来访问

//          3-11    在可变对象基础上构建的不可变类
@Immutable
 public final class ThreeStooges {
    private final Set stooges = new HashSet();//被final修饰,stooges是一个final类型的引用变量,所有的对象状态都通过一个final域来访问

    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

3.4.1 Final域(Final Fields)

关键字final用于构造不可变对象。final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象的时候无需同步。

除非需要更高的可见性,否则应该将所有的域都声明为私有域,除非需要某个域是可变的,否则应该将其声明为final域,这是良好的编程习惯。

3.4.2 利用Volatile类型来发布不可变对象

因数分解Servlet将执行两个原子动作:更新缓存的结果,以及判断缓存中的数值是否等于请求的数值来决定是否可以直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据。

//           3-12   对数值及其因数分解结果进行缓存的不可变容器类
@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);  //Arrays的copyOf()方法传回的数组是新的数组对象
    }

    public BigInteger[] getFactors(BigInteger i) {      //缓存因数分解的结果
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

如果是一个可变的对象,就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不用担心另一个线程会修改对象的状态。如果要更新这些变量,可以创建一个新的容器对象,但其他使用原有对象的线程仍会看到对象处于一致的状态。

当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程可以立即看到新缓存的数据。

//        3-13   使用指向不可变容器对象的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);    //判断是否有该数缓存,有就返回结果,这都在不可变容器类OneValueCache中完成
        if (factors == null) {   //如果没有缓存或该数没缓存
            factors = factor(i);       //得到因数分解的结果   
            cache = new OneValueCache(i, factors);  //存入缓存区
        }
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[]{i};
    }
}

与cache相关的操作不会互相干扰,因为OneVauleCache是不可变的,并且在每条相应的代码路径中只会访问它一次。


3.5 安全发布(Safe Publication)

//          3-14    在没有足够同步的情况下发布对象(不要这么做)
public Holder holder;

public void initialize() {
   holder = new Holder(42);
}

由于可见性问题,其他线程看到的Holder对象将处于不一致的状态,即使在该对象的构造函数中已经正确地构建了不变性条件。

3.5.1 不正确的发布:正确的对象被破坏

某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态发生变化,即使线程在对象发布后还没有修改过它。使用下面不安全发布方式,另一个线程会调用assertSanity时会抛出AsserionError(使用错误,声明错误),如果用final修饰n则不会出现这种问题。

//      3-15  由于未被正确的发布,因此这个类可能出现故障
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对象对其他线程可见,所以称为“未被正确发布”。

3.5.2 不可变对象与初始化安全性(Immutable Objects and Initialization Safety)

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。为了维持这种初始化安全性的保证,必须满足不变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。

在没有额外同步额情况下,也可以安全地访问final类型的域。然而,如果final类型的域指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

3.5.3 安全发布的常用模式(Safe Publication Idioms)

可变对象必须通过安全的方式来发布。要安全地发布一个对象,对象的引用以及对象 的状态必须同时对其他线程可见。一个正确构造的对象可以通过一下方式来安全地发布: 
①在静态初始化函数中初始化一个对象引用 
②将对象的引用保存到volatile类型的域或者AtomicReferace对象中。 
③将对象的引用保存到某个正确构造对象的final类型域中。 
④将对象的引用保存到一个由锁保护的域中。(例如Vector或synchronizedList )

线程安全库中的容器类提供了以下的发布安全保证: 
· 通过将一个键或值放入Hashtable,synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是迭代器访问) 
· 通过将某个元素放入Vector,CopyOnWriteArrayList,CopyOnWriteArraySet,synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程 
· 通过将某个元素放入BlockingQueue或ConcurrentLinkedQueue,可以将该元素安全地发布到任何从这些容器中访问该元素的线程

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器: 
public static Holder holder = new Holder(42);

3.5.4 事实不可变对象(Effectively Immutable Objects)

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么这种对象称为事实不可变对象(Effectively Immutable Objects)。这些对象不需满足不可变性的严格定义。

在没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

例如,Data本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享Data对象时,就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

public Map<String, Date> lastLogin =
   Collections.synchronizedMap(new HashMap<String, Date>());

这些Data对象的值在被放入Map后就不会被改变,那么synchronizedMap中的同步机制就足够Data值被安全发布,并且在访问这些Data值时不需要额外的同步。

3.5.5 可变对象(Mutable Objects)

如果对象在构造后可以修改,那么安全发布只能确保“安全发布”状态的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者某个锁保护起来。

对象的发布需求取决与它的可见性: 
①不可变对象可以通过任意机制来发布 
②事实不可变对象必须通过安全方式来发布 
③可变对象必须被安全地发布,并且必须是线程安全的或者某个锁保护起来。

3.5.6 安全地共享对象(Sharing Objects Safely)

当发布一个对象时,必须明确说明对象的访问方式。

在并发程序中使用和共享对象时,可以使用一些实用的策略,例如: 
线程封闭 
线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改(栈封闭和ThreadLocal) 
只读共享 
在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程不能修改它,共享的只读对象包括不可变对象和事实不可变对象。 
线程安全共享 
线程安全的对象在其内部实现同步,因此多个线程可以通过对象的共有接口来访问而不需要进一步的同步。 
保护对象 
被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

你可能感兴趣的:(Java,Java并发编程实战)