概述
要编写正确的并发操作,关键问题在于:在访问共享的可变状态时需要进行正确的管理。我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定临界区。同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。
可见性
可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。在单线程环境中,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然。然而,当读操作和写操作在不同的线程中执行时,情况却并非如此。通常,我们无法确认执行读操作的线程能适时地看到其他线程写入值,有时甚至根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确结论。因此,只要有数据在多个线程之间共享,就使用正确的同步。
失效数据
非原子的64位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为2个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
现在我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
volatile变量
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
发布与逸出
发布一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法返回该引用,或者将引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程的安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread-Confinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。
ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get和set方法等访问接口或方法,这些方法为每个使用该变量的线程都存有一个独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的单例变量或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有一个属于自己的连接。
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。
当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上看,你可以将ThreadLocal<T>视为包含了Map<Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。
不变性
满足同步需求的另一种方法是使用不可变对象。到目前为止,我们介绍了许多与原子性和可见性相关的问题,例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等,都与多线程试图同时访问同一个可改变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。
当满足一下条件时,对象才是不可变的:
对象创建以后其状态就不能修改。
对象的所有域都是final类型。
对象是正确创建的(在对象的创建期间,this引用没有逸出)。
Final域
关键字final可以视为C++中const机制的一种受限版本,用于构造不可变性对象。final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在Java内存模型中,final域还有着特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而。如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用。
将对象的引用保存到volatile类型的域或者AtomicReference对象中。
将对象的引用保存到某个正确构造对象的final类型域中。
将对象的引用保存到一个由锁保护的域中。
在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或SynchronizedList时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的应用程序代码中没有包含显式的同步。尽管Javadoc在这个主题上没有给出很清晰的说明,但线程安全库中的容器类提供了以下安全发布保证:
- 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素访问Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
- 通过将元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
可变对象
如果对象在构造后可以修改,那么安全发布只能确保发布当时状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问的同时需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
不可变对象可以通过任意机制来发布。
事实不可变对象必须通过安全方式来发布。
可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
总结
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。