Java并发编程实战读书笔记——第三章:对象的共享

第三章:对象的共享

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

3.1 可见性

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

3.1.1失效数据

在缺少同步的情况下,JAVA内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。 此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。

3.1.2非原子的64位操作

当线程没有同步的情况下,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air-safety)。JVM允许将64位的读操作或写操作分解为两个32位的操作。当读一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们或者用锁保护起来。

3.1.3加锁与可见性

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。

这里写图片描述

现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。

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

3.1.4 Volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

访问volatile变量不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量的同步机制。

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一引起重要的程序生命周期事件的发生(例如初始化或关闭)

volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。

Volatile boolean asleep;
... while(!asleep){
​	conutSomeSheep();
}

对于服务器应用程序,无论在开发阶段还是在测试阶段,当启动JVM时一定都要指定–server命令选项。server模式的JVM将比client模式的JVM进行更多的优化,例如将循环中未被修改的变量提升到循环外部。

volatile变量的局限性:它通常用做某个操作完成、发生中断或者状态的标志。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入不依赖当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

3.2 发布与逸出

发布一个对象是指:使对象能够在当前作用域之外的代码中使用。当某个不应该发布的对象被发布时,叫逸出。

发布最简单的方法:将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。

从非私有方法中返回一个引用,那么同样会发布返回的对象。

当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

假定有一个类C,对于C来说,外部方法是指行为并不完全由C来规定的方法,包括其他类中的定义的方法以及类C中可以被改写的方法(既不是私有private方法,也不是final方法)。当把一个对象传递给某个外部方法时,就相当于发布了这个对象。你无法知道哪些代码会执行,也不知道在外部方法中空间会发布这个对象,还是会保留对象的引用并在随后 由另一个线程使用。

封装使得对程序的正确性进行分析变得可能,并使得无意中破坏设计条件变得更难。

最后一种发布对象或其内部状态的机制就是发布一个内部的类实例。内部类的实例包含了对ThisEscape实例的隐含引用。

实例的对象构造过程

不要在构造过程中使this引用逸出。

如果想在构造函数中注册一个事件监听器或者启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程。

线程封闭

实现线程安全性最简单的方式之一:仅在线程内访问的数据。

JDBC的Connection对象不一定是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象还给线程池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理方式在处理请求时隐含地将Connection对象封闭在线程中。

应用程序服务器提供的连接池是线程安全的。连接池通常会由多个线程同时访问,因此非线程安全的连接池是毫无意义的。

机制:局部变量和ThreadLocal类。

3.3.1 Ad-hoc线程封闭

维护线程封装性的职责完全由程序实现来承担。

单线程子系统提供的简便性要用过Ad-hoc线程封闭技术的脆弱性。(同时可以避免死锁)

在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享volatile变量上执行"读取-修改-写入"的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证上还确保了其他线程能看到最新的值。

由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术,例如栈封闭、ThreadLocal类

3.3.2 栈封闭

**在栈封闭中,只能通过局部变量才能访问对象。**局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。

对于基本类型的局部变量,无论如何都不会破坏栈封闭性。

在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。

3.3.3 ThreadLocal类

ThreadLocal类能使线程中的某个值与保存值的对象关联起来。TheadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

当某个线程初次调用TheadLocal.get方法时,就会调用initialValue来获取初始值。这些特定于线程的值保存在Thead对象中,当线程终止后,这些值会作为垃圾回收。

假设将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为TheadLocal对象,可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大的作用。

ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。

3.4 不变性

不可变对象一定是线程安全的。

不可变对象只有一种状态,并且该状态由构造函数来控制。

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

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

3.4.1 Final域

关键字final用于构造不可变对象。final类型的域是不能修改的,但如果final类型引用的对象是可变的,那么这些被引用的对象是可以修改的。然而,在JAVA内存模型中,final域还有着特殊的语义,final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。

即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。

正如"除非需要更高的可见性,否则应将所有的域都声明为私有域"是一个良好的习惯,除非需要某个域是可变的,否则应将其声明为final域,也是一个良好的习惯。

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

使用指向不可变容器对象的volatile类型引用以缓存最新的结果。

与cache相关的操作不会相互干扰,因为对象是不可变的,并且在每条相应的代码路径中只会访问一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可变见,因此也是线程安全的。

3.5 安全发布

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

尚未完全创建的对象没有完整性。由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为"未被正确发布"。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder的引用的值是最新的,但Holder状态的值却是失效的。

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

Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。

一方面,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。另一方面,即使在发布不可变对象的引用是没有使用同步,也你仍然可以安全地访问该对象。

为了维持这种初始化安全性保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。这种保证可以延续到被正确创建对象中所有的final类型的域。在没有额外同步情况下,也可以安全访问final类型的域。然而,如果final类型的域指向的是可变对象,那么在访问这些域所指向的状态时仍然需要同步。

3.5.3 安全发布的常用模式

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

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

在线程安全容器内部的同步意味着,在将对象放入到某个容器中,例如Vector或者synchronizedList时,将满足上述最后一个需求。

线程安全库中容器类提供了以下的安全发布保证:

  1. 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程。
  2. 通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、SynchronizedList或者SynchronizedSet中,可以将元素安全地发布到任何从这些容器访问该元素的线程。
  3. 通过将元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将元素安全地发布到任何从这些队列中访问该元素的线程。

类库中的其他数据传递机制,例如Future和Exchanger同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:publich static Holder holder = new Holder(42); 静态初始化容器由JVM在类的初始化阶段执行。由于JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

3.5.4 事实不可变对象

如果对象在发布后不会被修改,那么对于其它在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的所有安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么就足以确保任何访问都是安全的。

如果对象从技术上看是可变的,但其状态在发布后不会再改变,那么把这种对象称为事实不可变对象(Effectively Immutable Object)。

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

3.5.5 可变对象

如果对象在构造后可以修改,那么安全发布只能确保"发布当时"状态的可见性。对于可变对象,不仅发布时要同步,而且在每次访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,必须安全地发布,并且必须是线程安全的或者由某个锁保护起来。

对象的发布需求取决于它的可变性:

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

3.5.6 安全地共享对象

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

线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。

只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。

线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口进行访问而不需要进一步同步。

保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封闭在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值