java对象的共享

内容简介

我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(CriticalSection)”。同步还有另一个重要的方面:内存可见性(MemoryVisibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。

可见性

读操作的线程并非可以一直获取到写线程写入的最新值,例如:

 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; //操作A
    ready = true; //操作B
}

以上实例代码可能存在如下问题:

  • 一直循环下去,因为读线程可能看不到写线程写入了ready。
  • 在读线程中输出0,因为主线程可能对number和ready的赋值顺序进行了改变。这种现象也叫做重排序。

重排序:
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:

失效数据

以上述例子来说,当读线程读取ready变量的时候,很可能该变量已经失效。失效的数据通常包括两类数据:

  • 值数据
  • 引用数据

值数据的失效例如一个计数器应用,如果不对count变量进行同步处理的话可能会导致计数不准的问题。如果对象的引用失效可能会导致一些莫名其妙的问题,比如意料之外的异常,被破坏的数据结果,不精确的计算及无限循环等。

非原子的64位操作

根据Java内存模型的要求,变量的读写操作必须是原子操作。对于非volatile类型的long和double变量,JVM允许将64位读写操作分解为两个32为的操作,这样在多线程环境中共享可变的long和double变量也是不安全的。

加锁和可见性

Java内存模型

CPU在运行的时候,不可能把所有的东西都放在寄存器里面,所有需要使用内存。这个内存就是我们知道的那个内存。

但是实际情况是,内存的读写速度于CPU的指令操作差了几个数量级。所以为了跟高效的使用CPU,就有高速缓存这么一个东西。

高速缓冲存储器概述:(摘抄自计算机组成原理)

在多体并行存储系统中,由千i/o设备向主存请求的级别高于CPU访存,这就出现了CPU等待I/o设备访存的现象,致使CPU空等一段时间,甚至可能等待几个主存周期,从而降低了CPU的工作效率。为了避免CPU与i/o设备争抢访存,可在CPU与主存之间加一级缓存,这样,主存可将CPU要取的信息提前送至缓存,一且主存在与I/o设备交换时,CPU可直接从缓存中读取所需信息,不必空等而影响效率。

从另一角度来看,主存速度的提高始终跟不上CPU的发展。据统计,CPU的速度平均每年改进60%,而组成主存的动态RAM速度平均每年只改进7%。结果是CPU和动态RAM之间的速度间隙平均每年增大50%。例如,100MHz的Pentium处理器平均每10ns就执行一条指令,而动态RAM的典型访问时间为60-120ns。这也希望由高速缓存Cache来解决主存与CPU速度的不匹配问题。

java的内存模型与物理结构非常相似,有一个主内存,对应我们计算机的内存,还有每个线程都有一个工作内存,对应于高速缓存。

可以看到,每个java线程都有自己独立的内存。
这也就解释了,为什么不同线程,如果不同步的话,变量就会有并发的问题。
这里关于工作内存和主内存的拷贝问题,是由JVM实现的,并不是正真意义上的内存复制。

内置锁和外置锁如何实现可见性

内置锁和显式锁的区别,可以参考博客内置锁和显式锁的区别–JCIP C13读书笔记
下面是锁释放-获取的示例代码(以内置锁为例):

class MonitorExample {
int a = 0;

public synchronized void writer() {  //1
    a++;                             //2
}                                    //3 

public synchronized void reader() {  //4 
    int i = a;                       //5     

  }                                  //6
}    

锁释放和获取的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的MonitorExample程序为例,A线程释放锁后,共享数据的状态示意图如下:

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。下面是锁获取的状态示意图:

下面对锁释放和锁获取的内存语义做个总结:

  1. 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  2. 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  3. 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile关键字如何实现可见性

Java内存间操作包括8种操作:

变量的修改对所有线程可见原理:
  线程中每次use变量时,都需要连续执行read->load->use几项操作,即所谓的每次使用都要从主内存更新变量值,这样其它线程的修改对该线程就是可见的。
  线程每次assign变量时,都需要连续执行assign->store->write几项操作,即所谓每次更新完后都会回写到主内存,这样使得其它线程读到的都是最新数据。
  
缺点: 加锁机制可以确保可见性又可以确保原子性,而volatile变量只能确保可见性

发布和逸出

所谓发布对象是指使一个对象能够被当前范围之外的代码所使用。所谓逸出是指一种错误的发布情况,当一个对象还没有构造完成时,就使它被其他线程所见,这种情况就称为对象逸出。

下面列举几个逸出的示例。
第一,通过静态变量引用逸出:

public static Set<Secret> knownSecrets;
public void initialize() {
    knowsSecrets = new HashSet<Secret>();
}

上边代码示例中,调用initialize方法,发布了knowSecrets对象。当你向knowSecrets中添加一个Secret时,会同时将Secret对象发布出去,原因是可以通过遍历knowSecrets获取到Secret对象的引用,然后进行修改。

第二,通过非静态(私有)方法:

class UnsafeStates {
    private String[] states = new String[]{"AK", "AL"};
    public String[] getStates() {
        return states;
    }
}

以这种方式发布的states会出问题,任何一个调用者都能修改它的内容。数组states已经逸出了它所属的范围,这个本应该私有的数据,事实上已经变成共有的了。

第三,this逸出:

public class ThisEscape {
  public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
              public void onEvent(Event e) {
                    doSomething(e);
              }
        });
  }
}

在上边代码中,当我们实例化ThisEscape对象时,会调用source的registerListener方法时,便启动了一个线程,而且这个线程持有了ThisEscape对象(调用了对象的doSomething方法),但此时ThisEscape对象却没有实例化完成(还没有返回一个引用),所以我们说,此时造成了一个this引用逸出,即还没有完成的实例化ThisEscape对象的动作,却已经暴露了对象的引用,使其他线程可以访问还没有构造好的对象,可能会造成意料不到的问题。

一个博客上看到对逸出的概念理解:

一个对象,超出了它原本的作用域,而可以被其它对象进行修改,而这种修改及修改的结果是无法预测的。换句话说:一个对象发布后,它的状态应该是稳定的,修改是可被检测到的。如果在其它线程修改(或做其它操作)一个对象后导致对象的状态未知,就可以说这个对象逸出了。

线程封闭

实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。

什么是线程封闭呢?

就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。

实现线程封闭有哪些方法呢?

1:ad-hoc线程封闭
这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。也是最糟糕的一种线程封闭。所以我们直接把他忽略掉吧。

2:栈封闭
栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量。多个线程访问一个方法,此方法中的
局部变量都会被拷贝一分儿到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

3:ThreadLocal封闭
使用ThreadLocal是实现线程封闭的最好方法,有兴趣的朋友可以研究一下ThreadLocal的源码,其实ThreadLocal内部维护了一个Map,Map的key是每个线程的名称,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。

不变性

保证并发安全性的策略之一 —— 不变性

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不可修改
  • 对象的所有域都是 final 类型
  • 对象是正确创建的(在对象的构造期间,this 引用没有逸出)

从技术上来看,不可变对象并不需要将其所有的域都声明为 final 类型,例如 String 就是这种情况,这就要对类的良性数据竞争情况做精确的分析,因此需要深入理解 Java 的内存模型。

Java中字符串的不变性
一旦一个String对象在内存(堆)中被创建出来,他就无法被修改。特别要注意的是,String类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。

安全发布

到目前为止,我们重点讨论的是如何确保对象不被发布,假如让对象封闭在线程(线程封闭)或者另一个对象的内部(不变性)。

但是在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享,要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。

安全发布的常用模式

  • 最简单最安全的方式:在静态初始化函数中初始化一个对象引用。静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的对象都可以被安全地发布。
  • 将对象引用保存到volatile类型的域或者AtomicReference对象中
  • 将对象引用保存到某个正确构造对象的final类型域中
  • 将对象引用保存到一个由锁保护的域中——通过将对象放入到某个线程安全库的容器类(thread-safe library collections),可以安全地将他发布给任何从这些容器中访问它的线程

参考

《Java并发编程实战》

《计算机组成原理》

《深入理解Java虚拟机》(第2版)

Java Thread.yield详解

Java并发编程学习——对象的共享

深入理解java虚拟机(6)—内存模型与线程 & Volatile

内置锁和显式锁的区别–JCIP C13读书笔记

Java锁(一)之内存模型

Java线程工作内存与主内存变量交换过程及volatile关键字理解

深入理解java内存模型(五)锁_1

三张图彻底了解Java中字符串的不变性

欢迎大家关注:huazi's微信公众号