Crab213's Blog.

Java并发编程实践笔记2:对象共享

2016/03/30

写出正确的并发程序的关键问题就是管理对线程共享的可变状态的操作。我们需要处理两个问题:原子性和可视性。

可视性(visibility)问题

当一个单线程程序执行的时候,我们自然而然认为我们对内存的操作是连贯的。比如,我们对一个变量x赋值,当我们紧接着这个赋值语句读取这个变量的值的时候,我们自然而然的认为这个x的值就是我们上一条赋值语句赋给他的值,但这在多线程环境下是行不通的。在多线程环境下,没有同步机制,我们无法确保在其他线程更改的状态能被当前线程立刻看到,这个操作已经不能基于简单的时序模型。如果一个线程在某个时刻改变了一个共享的状态,那么在这个时刻之后其他线程读取的这个状态值是无法保证被立刻更新的,甚至其他线程可能永远无法读取到被更新的状态。

过期数据(stale data)问题由此而生。如果缺乏有效同步机制,每个线程共享的数据就有可能是过期数据。过期数据会造成程序的异常行为,使程序无法正常执行。还有一个需要注意的问题是64位的类型doublelong,不管数据是不是过期数据,至少jvm可以保证你拿到是一个曾经存在过的数据,除了doublelong。jvm允许将64位类型doublelong的操作拆分成两个32位的数据的操作,因此对doublelong的操作不是原子的,多线程环境下一个线程可能读取到一个只被写入了一半的double或者long类型。

这时候就轮到volatile关键字出场了。volatile是java提供的比synchronized弱一些的同步机制,它保证了可视性,亦即对一个变量的更新一定能被其他线程看到。volatile变量不会被缓存在寄存器或会被其他处理器看不到的地方,所以读取一个volatile变量时一定可以读取到哪个变量的最新数值。volatile只能保证可视性,当变量的安全性需要根据可视性推断的时候不要使用volatile。典型的volatile用法是保证一个表征特定状态的变量的可视性:

1
2
3
4
5
6
volatile boolean isTerminated;
...

while (!isTerminated) {
// busy looping here
}

volatile变量只能保证可视性,有很大局限,比如它并不能保证count++这样的操作是原子操作。需要注意的是java中的锁可以同时保证可视性和原子性。当使用volatile变量时,以下三点要求需要同时满足:

  • 对变量的写入操作不依赖原先变量的值,或者能够保证只有一个线程可以对变量进行写入
  • 变量不与其他状态变量组成不变式
  • 对这个变量进行操作时没有其他任何理由需要一个锁进行同步

对象发布(publication)及对象逃逸(escape)

发布一个对象意味着使这个变量被外部可见。例如,保存一个指向这个变量的引用使外部的代码可以找到这个对象,或者在一个非私有的方法中返回这个对象,或者将这个对象作为参数提交给另一个类的方法。在很多情况下,我们不希望对象被发布,然而有些情况下,我们希望发布这个对象。经由一个线程安全的方式发布一个对象可能需要同步。如果一个不该被发布的对象被发布了,我么就成这个对象逃逸了。

值得注意的一点是绝对不要让this指针在构造函数中逃逸。哪怕是在构造函数的最后一条语句,我们也不能保证这个对象是被正确构造成功的。在构造函数中让this指针逃逸,在多线程环境中会造成严重的问题。因为线程使用的可能是只被构造了一半的对象。

线程封闭(thread confinement)

操作被共享的可变数据需要同步。如果我们不在线程中共享这个可变的数据,那我们自然不要要同步。因此线程封闭是可以达成线程安全的最简单方式。如果一个对象的操作被限制到一个线程之内,那么这种用法自然而然的是线程安全的,哪怕这个对象本身不是线程安全的。

就像java语言中没有机制是一个变量强制被一个锁同步一样,也没有使一个对象强制线程封闭的机制。线程封闭只能是你的程序设计的一个元素。虽然java中提供了ThreadLocal类,但是实现线程封闭让然还是程序员的责任。实现线程封闭有三种方式:

临时线程封闭(ad-hoc thread confinement)。临时线程封闭是一种完全依靠约定的线程封闭方式。完全依靠约定意味着线程封闭完全由程序实现完成,因此这样的线程封闭比较脆弱,我们无法强制其他线程不操作共享的变量,因为他是所有线程可见的。

栈线程封闭(stack thread confinement)。每个线程都有自己的函数调用栈,如果我们把一个对象的状态完全限制在local变量中,那么这些状态就是对线程不可见的。

ThreadLocal线程封闭(ThreadLocal thread confinement)。我们还可以使用java提供的ThreadLocalThreadLocal是java提供的一个容器,对于每一个线程,我们从这个容器中拿出来的值都是独立的,有点像Map<Thread, T>的感觉。

不变性(immutability)变量

原子操作可以处理可变变量带来的资源竞争和安全问题,那么显然,如果变量本身不可变,那么这些问题从一开始就不存在了。函数式编程语言如haskell非常讲究引用透明和不变性变量,不变性变量,就是内部状态不会改变的变量,有点java中String对象的那种感觉。String对象是不可变的,对String对象的操作都会产生一个新的String对象。一个不变性变量是指在构造出来之后他的内部状态不可便的对象。由于这样的对象的不变式是由构造函数建立的,既然他的内部状态不可一改变,那么不变式永远成立。不可变变量永远是线程安全的。一个变量是不变性变量如果:

  • 它的状态在构造出来后不可以改变
  • 它所有的域都是final修饰的
  • 它是正确构造出来的(this指针没有在构造函数泄露)

final关键字在java内存模型中有特殊的语义,正是final关键字保证了不可便对象能够被安全的初始化,使其线程安全。我们可以使用volatile变量来保存一个不可便变量,这个变量可以由多个状态组成,对着个变量个更新可以一次更改所有的状态,这样就可使其同时达成原子操作以及可视性。

安全发布(safe publication)

如果我们采取用一个引用储存被发布的变量的方式发布变量,我们可能遇到两个问题。如果我们用一个变量存储对被发布对象的引用比如holder变量,那么问题一,我们会遭遇可视性问题,我们对holder的读取可能得到一个过期数据即null或者一个以前的什么值。问题二,就算没有得到过期数据,我们得到了被发布的对象的引用,那么我们仍然可能得到的是一个没有被完全构造好的对象,这个对象可能在接下来“突然地”变成了一个成功构造的对象,亦即我们得到对象的时候对象可能还在跑构造函数。

因为不可变变量非常重要,java内存模型对其的安全初始化做了保证,只要达到了上面提到的不可便变量的三个条件,即所有域为final,状态在初始化之后不可便,以及正确的被构造。不可变变量在没有同步安全发布情况之下,依然可以安全的被使用,即线程安全

为了安全发布一个对象,保存对象的引用变量及,被发布的对象本身的状态都要能被接收的线程看到。我们可以用以下的方法安全发布一个对象:

  • 静态初始化一个对象的引用
  • 使用volatile变量或者AtomicReference存储对象引用
  • 使用final域保存一个正确构造的对象
  • 使用一个被锁保护的域来保存对象引用

有效不可变变量(effective immutable object)是指被发布之后不会被改变内部状态的对象。被安全发布的有效不可变变量可以在没有同步的情况下安全使用。

对发布一个对象的要求取决于它是否可变:

  • 不可变对象可以被任何方式发布
  • 有效不可变对象必须被安全地发布
  • 可变对象必须被安全发布,并且必须线程安全或者使用锁来同步

安全共享对象

由此,在线程中安全共享对象的方式有:

  1. 线程封闭
  2. 只读共享,由不可变和有效不可变对象完成
  3. 线程安全共享,共享一个线程安全的对象,由对象内部的同步机制完成
  4. 被锁保护的共享,线程使用额外的锁来同步这个被共享的对象
CATALOG
  1. 1. 可视性(visibility)问题
  2. 2. 对象发布(publication)及对象逃逸(escape)
  3. 3. 线程封闭(thread confinement)
  4. 4. 不变性(immutability)变量
  5. 5. 安全发布(safe publication)
  6. 6. 安全共享对象