:::info 💡 根据 遗忘曲线:如果没有记录和回顾,6天后便会忘记75%的内容
读书笔记正是帮助你记录和回顾的工具,不必拘泥于形式,其核心是:记录、翻看、思考 :::

书名 Java 并发编程实战
作者 Doug Lea
状态 阅读中
简介 要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

思维导图

用思维导图,结构化记录本书的核心观点。

第三章对象的共享 - 图1

第二章 线程安全性

第三章 对象的共享

书摘

  • 第二章的开头曾指出,要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。
  • 第二章介绍了如何通过同步来避免多个线程在同一个时刻访问相同的数据,而本章将介绍如何共享和发布对象,从而使他们能够安全地被多个线程同时访问。
  • 这两章合在一起,就形成了构建线程安全类以及通过java.util.concurrent类库来构建并发应用程序的重要基础。
  • 我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解:认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。
  • 同步还有另一个重要的方面:内存可见性(Memory Visibility)。
  • 我们不仅希望防止某个线程正在使用对象状态的同时而另一个线程也在修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。

    3.1 可见性

    1. 可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。在单线程环境中,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然。<br />然而,当读操作和写操作在不同的线程中执行时,情况却并非如此。<br /> 通常,我们无法确保执行读操作的线程能在合适的时间看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
    1. public class NoVisibility{
    2. private static boolean ready;
    3. private static int number;
    4. private static class ReaderThread extends Thread{
    5. public void run(){
    6. while(!ready){
    7. Thread.yield();
    8. }
    9. System.out.println(number);
    10. }
    11. }
    12. public static void main(String[] args){
    13. new ReaderThread().start();
    14. number = 42;
    15. ready = true;
    16. }
    17. }

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

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

    1. 方法:幸运的是,有一种简单的方法能避免这些复杂的问题:**只要有数据在多个线程之间共享,就正确的使用同步**。

    3.1.1 失效数据

    NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时,可能会得到一个失效的值。除非在每次访问变量时都使用同步,否则很可能得到该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能得到某个变量的最新值,而另一个获得变量的失效值。
    程序3-2中的MutableInteger不是线程安全的,因为get和set都是在没有同步的情况下访问value的,与其他问题相比,失效值问题更容易出现:如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的value值,也可能看不到。 ```java public class MutableInteger{ private int value; public int get(){ return value; } public void set(int value){ this.value = value; } }

  1. 在程序3-3SynchronizedInteger中,通过对getset等方法进行同步,可以使MutableInteger成为一个线程安全的类。仅对set方法进行同步是不够的,调用get的线程仍然会看到失效值。
  2. ```java
  3. public class SynchronizedInteger{
  4. private int value;
  5. public synchronized int get(){ return value; }
  6. public synchronized void set(int value){ this.value = value; }
  7. }

3.1.2 非原子的64位操作

  1. 当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-airsafety)。<br />最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(doublelong)。java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的longdouble变量,**JVM允许将64位的读操作或者写操作分解为两个32位的操作。**

除非用关键字volatile来声明它们,或者用锁保护起来。

3.1.3 加锁与可见性

内置锁可以用于确保某个线程以一种可预测得方式来查看另一个线程的执行结果,如下图所示。当线程 A 执行某个同步代码块时,线程 B 随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A 看到的变量值在 B 获得锁后同样可以由 B 看到。换句话说,当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个同步代码块中的所有操作结果。
第三章对象的共享 - 图2
现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

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

3.1.4 volatile 变量

Java语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。当变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
理解volatile变量的一种有效方法是,将他们的行为想象成程序清单3-3中SynchronizedInteger的类似行为,并将volatile变量的读操作和写操作分别替换为get方法和set方法PS:这种类比并不准确,SynchronizedInteger在内存可见性上的作用比volatile变量更强。请参见第16章。。然而,在访问volatile变量时并不会执行加锁操作,因此也就不会执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。PS:在当前大多数处理器架构上,读取volatile变量的开销只比读取非volatile变量的开销略高一些。
volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量值,在B读取volatile变量时,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。

volatile 变量的正确使用方式包括:

  1. 确保它们自身状态的可见性;
  2. 确保它们所引用对象的状态的可见性;
  3. 标识一些重要的程序生命周期事件的发生(初始化或关闭)

程序3-4给出了volatile变量的一种典型用法:检查某个状态标记判断是否退出循环。在这个示例中,线程试图通过类似数绵羊的传统方法进入休眠状态。为了使这个示例能正确执行,asleep必须为volatile变量。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了PS:调试小提示:对于服务器应用程序,无论在开发阶段还是在测试阶段,当启动JVM时一定都要指定-server命令行选项。server模式的JVM将比client模式的JVM进行更多的优化,例如将循环中未被修改的变量提升到循环外部,因此在开发环境(client模式的JVM)中能正确运行的代码,可能会在部署环境(server模式的JVM)中运行失败。例如,如果在程序3-4中“忘记”把asleep变量声明为volatile类型,那么server模式的JVM会将asleep的判断条件提升到循环体外部(这将导致一个无限循环),但client模式的JVM不会这么做。。我们也可以用锁来确保asleep更新操作的可见性。但这将使代码更加复杂。

  1. volatile boolean asleep;
  2. // ...
  3. while (!asleep)
  4. countSomeSheep();

虽然 volatile 变量使用很方便,但它只能确保可见性,而加锁机制既可以确保可见性又可以确保原子性。

那么说了这么多,什么场景下我们才应该使用 volatile 变量呢? 当且仅当满足以下条件:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量不会与其他状态变量一起纳入不变性条件中。
  3. 在访问变量时不需要加锁。
  4. PS:在java内存模型中,对于volatile变量,会在其读写时加入内存屏障,从而将其实时地从CPU cache中刷到Memory中。

image.png
图片来源:百度云华章视频2022-06-15-19-15-35.mp4

3.2 发布与逸出

3.2.1 发布(Publication)

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

Publishing an object means making it available to code outside of its current scope, such as by storing a reference to it where other code can find it, returning it from a nonprivate method, or passing it to a method in another class.

3.2.2 逸出(Escape)

当一个不应该发布的对象被发布时称为逸出。

An object that is published when it should not have been is said to have escaped.

this逸出(this escape):

  1. public class ThisEscape {
  2. public ThisEscape(EventSource source) {
  3. source.registerListener(
  4. new EventListener() {
  5. public void onEvent(Event e) {
  6. doSomething(e);
  7. }
  8. });
  9. }
  10. void doSomething(Event e) {
  11. }
  12. interface EventSource {
  13. void registerListener(EventListener e);
  14. }
  15. interface EventListener {
  16. void onEvent(Event e);
  17. }
  18. interface Event {
  19. }
  20. }

3.2.3 安全发布(safe publish)

  1. public class SafeListener{
  2. private final EventListener listener;
  3. private safeListener(){
  4. listener = new EventListener(){
  5. public void onEvent(Event e){
  6. doSomething(e);
  7. }
  8. }
  9. }
  10. public static SafeListener newInstance(EventSource source){
  11. SafeListener safeListener = new SafeListener();
  12. safeListener.registerListener(safeListener.listener);
  13. return safeListener;
  14. }
  15. }

我们首先将构造函数设定为private,其次我们在构造函数未完成时不将对象进行发布,而是使用工厂方法,在工厂方法newInstance中待构造函数执行完毕后再将对象进行发布(代码中即为registenerListener注册监听)。这实际上就是修改为了构造完毕->发布对象的串行执行模式,而不是之前的异步模式,这样就不会给我们带来线程安全性的问题。

3.3线程封闭

当访问共享地可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的[CPJ 2.3.2]。
线程封闭技术的一种常见应用是JDBC(Java Database Connectivity)的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求(例如Servlet请求或EJB调用)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。
在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

3.3.1 Ad-hoc线程封闭

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(例如,GUI应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。
在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享地volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。

3.3.2 栈封闭

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

3.3.3 ThreadLocal类

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

3.4 不变性

如果某个对象在被创建后其状态就不能被修改,那么这个对象就被成为不可变对象。

3.4.1 Final域

在Java中,final类型的域是不能修改的。
不可变性不等于将对象中的所有域都声明为final类型,即使对象中的所有域都是final类型的,这个对象仍然是可变的,因为在final类型的域中保存对可变对象的引用。当满足以下条件时,对象才是不可变的:

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

final表示无法将对象的引用更改为指向另一个引用或另一个对象,但仍然可以改变它的状态(例如使用setter方法)。不可变对象表示对象的实际值无法更改,但可以将其引用更改为另一个。
final修饰符适用于变量但不适用于对象,而不可变性适用于对象但不适用于变量。
声明final的类不可被继承。
Java中String对象的不可变性:
string对象在内存创建后就不可改变,不可变对象的创建一般满足以上5个原则,我们看看String代码是如何实现的。

  1. public final class String
  2. implements java.io.Serializable, Comparable<String>, CharSequence
  3. {
  4. /** The value is used for character storage. */
  5. private final char value[];
  6. /** The offset is the first index of the storage that is used. */
  7. private final int offset;
  8. /** The count is the number of characters in the String. */
  9. private final int count;
  10. /** Cache the hash code for the string */
  11. private int hash; // Default to 0
  12. ....
  13. public String(char value[]) {
  14. this.value = Arrays.copyOf(value, value.length); // deep copy操作
  15. }
  16. ...
  17. public char[] toCharArray() {
  18. // Cannot use Arrays.copyOf because of class initialization order issues
  19. char result[] = new char[value.length];
  20. System.arraycopy(value, 0, result, 0, value.length);
  21. return result;
  22. }
  23. ...
  24. }

如上代码所示,可以观察到以下设计细节:

  1. String类被final修饰,不可继承
  2. string内部所有成员都设置为私有变量
  3. 不存在value的setter
  4. 并将value和offset设置为final。
  5. 当传入可变数组value[]时,进行copy而不是直接将value[]复制给内部变量.
  6. 获取value时不是直接返回对象引用,而是返回对象的copy.

这都符合上面总结的不变类型的特性,也保证了String类型是不可变的类。
String对象的不可变性的优缺点:
1.字符串常量池的需要.
字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。
2. 线程安全考虑
同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
3. 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载
譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。
4. 支持hash映射和缓存。
因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
缺点:如果有对String对象值改变的需求,那么会创建大量的String对象。
String对象的是否真的不可变?
虽然String对象将value设置为final,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。例如:

  1. //创建字符串"Hello World", 并赋给引用s
  2. String s = "Hello World";
  3. System.out.println("s = " + s); //Hello World
  4. //获取String类中的value字段
  5. Field valueFieldOfString = String.class.getDeclaredField("value");
  6. //改变value属性的访问权限
  7. valueFieldOfString.setAccessible(true);
  8. //获取s对象上的value属性的值
  9. char[] value = (char[]) valueFieldOfString.get(s);
  10. //改变value所引用的数组中的第5个字符
  11. value[5] = '_';
  12. System.out.println("s = " + s); //Hello_World

3.4.2 示例:使用Volatile类型发布不可变对象

在前面的UnsafeCachingFactorizer类中,我们尝试用两个AtomicReferences变量来保存最新的数值及其因数分解结果,但这种方式并非是线程安全的,因为我们无法以原子方式来同时读取或更新这两个相关的值。同样,用volatile类型的变量来保存这些值也不是线程安全的。然而,在某些情况下,不可变对象能提供一种弱形式的原子性。
因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单3-12中的OneValueCache。

  1. @Immutable
  2. class OneValueCache {
  3. private final BigInteger lastNumber;
  4. private final BigInteger[] lastFactors;
  5. public OneValueCache(BigInteger i,
  6. BigInteger[] factors) {
  7. lastNumber = i;
  8. lastFactors = Arrays.copyOf(factors, factors.length);
  9. }
  10. public BigInteger[] getFactors(BigInteger i) {
  11. if (lastNumber == null || !lastNumber.equals(i))
  12. return null;
  13. else
  14. return Arrays.copyOf(lastFactors, lastFactors.length);
  15. }
  16. }

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。
程序清单3-13中的VolatileCachedFactorizer使用了OneValueCache来保存缓存的数值及其因数。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。

  1. @ThreadSafe
  2. public class VolatileCachedFactorizer implements Servlet {
  3. private volatile OneValueCache cache =
  4. new OneValueCache(null, null);
  5. public void service(ServletRequest req, ServletResponse resp) {
  6. BigInteger i = extractFromRequest(req);
  7. BigInteger[] factors = cache.getFactors(i);
  8. if (factors == null) {
  9. factorfactors = factor(i);
  10. cache = new OneValueCache(i, factors);
  11. }
  12. encodeIntoResponse(resp, factors);
  13. }
  14. }

与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。
通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得VolatileCachedFactorizer在没有显式地使用锁的情况下仍然是线程安全的。

分析
  • 程序清单3-13中存在『先检查后执行』(Check-Then-Act)的竞态条件。
  • OneValueCache类的不可变性仅保证了对象的原子性。
  • volatile仅保证可见性,无法保证线程安全性。

综上,对象的不可变性+volatile可见性,并不能解决竞态条件的并发问题,所以原文的这段结论是错误的。
结论:
cache对象在service()中只有一处写操作(创建新的cache对象),其余都是读操作,这里符合volatile的应用场景,确保cache对象对其他线程的可见性,不会出现并发读的问题。并且返回的结果是factors对象,factors是局部变量,并未使cache对象逸出,所以这里也是线程安全的。

3.5 安全发布

由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确地发布导致其他线程看到尚未创建完成的对象。

  1. public Holder holder;
  2. public void initialize(){
  3. holder = new Holder(42);
  4. }

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

你不能指望一个尚未被完全创建的对象拥有完整性,某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改它。事实上,如果程序3-15中的Holder使用程序清单3-14中的不安全发布方式,那么另一个线程在调用assertSanity时将抛出AssertionErrorPS:问题并不在Holder类本身,而是在于Holder类未被正确地发布。然而,如果将n声明为final类型,那么Holder将不可变,从而避免出现不正确发布的问题。

  1. public class Holder {
  2. private int n;
  3. public Holder(int n) {
  4. this.n = n;
  5. }
  6. public void assertSanity() {
  7. if (n != n)
  8. throw new AssertionError("This statement is false.");
  9. }
  10. }

由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的PS:尽管在构造函数中设置的域值似乎是第一次向这些域中写入的值,因此不会有“更旧的”值被视为失效值,但Object的构造函数会在子类构造函数运行之前先将默认值写入所有的域。因此,某个域的默认值可能被视为失效值。情况变得更不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSanity抛出AssertError的原因。
如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

3.5.2 不可变对象与初始化安全性

由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线城是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。
另一方面,即使在发布不可变对象的引用没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态的不可修改,所有域都是final类型,以及正确的构造过程。(如果程序清单3-15中的Holder对象是不可变的,那么即使Holder没有被正确的发布,在assertSanity中也不会抛出AssertionErrot。)

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象没有使用同步。

这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

3.5.3 安全发布的常用模式

可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。那么如何确保使用对象的线程能够看到该对象处于已发布的状态:

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或者synchronizedList时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读写X的应用程序代码中没有包含显式的同步。尽管Javadoc在这个主题上没有给出清晰的说明,但线程安全库中的容器类提供了以下的安全发布保证:

  • 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
  • 通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
  • 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素地线程。

3.5.4 事实不可变对象

如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么就足以确保任何访问都是安全的。
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。这些对象不需要满足之前提出的不可变性的严格定义。在这些对象发布后,程序只需将它们视为不可变对象即可。通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。

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

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

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

如果Date对象的值在被放入Map后就不会改变PS: 这或许是类库设计中的一个错误,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。

3.5.5 可变对象

对于可变对象,不仅在发布对象是需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,而且必须是线程安全的或者用某个锁保护起来。

    3.5.6 安全地共享对象

    当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些”既定规则“而导致的。当发布一个对象时,必须明确地说明对象地访问方式。

    在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

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

第四章 对象的组合

第五章 基础构建模块

读后感

观点1

读完该书后,受益的核心观点与说明…

观点2

读完该书后,受益的核心观点与说明…

观点3

读完该书后,受益的核心观点与说明…

书摘

  • 该书的金句摘录…
  • 该书的金句摘录…
  • 该书的金句摘录…

    相关资料

    可通过“⌘+K”插入引用链接链接,或使用“本地文件”引入源文件。