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

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

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<Secret> knownSecrets;
public void initialize() {
      knownSecrets = new HashSet<Secret>();
}

发布内部状态可能会破坏封装性,并使程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。
当某个不应该发布的对象被发布时,这种情况就成为逸出(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 {
    }
 }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值