程序清单 3-2 中的 MutableInteger 是非线程安全的,因为 get 和 set 都是在没有同步的情况下访问value 的。 与其他问题相比,失效值问题更容易出现:如果某个线程调用了 set,那么另一个正在调用 get 的线程不一定获取到的是最新的值。
程序清单 3-2 非线程安全的可变整数类:
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
【这其实就是一个非常普通的 Bean,但是因为 get 和 set 方法都是非同步方法,所以可能导致线程不安全的情况发生】
在程序清单 3-3 的 SynchronizedInteger 中,通过对 get 和 set 等方法进行同步,可以使 MutableInteger 成为一个线程安全的类, 但是仅对 set 方法进行同步是不够的,调用 get 时 仍然可能看见失效的值。【要保证互斥,就必须都加锁,这样才能保证获取值的时候同时没有别的线程正在修改值】
如果某个线程调用了 set, 那么另一个正在调用 get 的线程可能会看到更新后的 value,也可能无法看到(这就引发了问题)。
程序清单 3-3线程安全的可变整数类:
// 使用内置锁同步 get 和 set 方法。保证 共享变量 value 的 可见性。
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
ThreadLocal 中有 get 和 set 等方法,这些方法为每个使用该变量的线程都独立的保存了一个副本,因此 get 总是返回当前执行线程在调用 set 时设置的最新值。
// ThreadLocal.java 源码 中的 get方法,可以看到 注释中说的很清楚,返回当前线程中 thread-local 值的 copy
// 其实内部就是用一个 Map 来将 线程名称 与 value 进行映射
/**
* Returns the value in the current thread's copy of this
* thread-local variable. If the variable has no value for the
* current thread, it is first initialized to the value returned
* by an invocation of the {@link #initialValue} method.
*
* @return the current thread's value of this thread-local
*/
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
关键字 final 可以视为 C++ 中的 const 机制的一种「受限版本」,用于构造不可变性对象。 final 类型的域是不能修改的,但是如果 final 域所引用的对象是可变的,那么这些被引用的对象是可以「修改」的。 【对象的引用不能修改,但是对象中的字段的值是可以修改的,比如一条狗链,这个狗链本身不能被替换,但是这个狗链所栓的狗是可以修改的。】
在 Java 内存模型中,final 域有着 特殊的语义。 final 域能确保 初始化过程中的「安全性」,从而可以不受限制地访问不可变对象,并在共享这些对象时无须使用同步机制。
即使对象是可变的,通过将对象的某些域声明为 final 类型,仍然可以简化对状态的判断,因此限制对象的可变性,也就相当于限制了该对象的可能状态的集合。仅包含一两个可变状态的"基本不可变" 对象要比包含多个可变状态的对象简单。通过将域声明为 final 也会明确告诉维护人员 这个域是不会发生变化的。
注解①:问题不在于 Holder 类本身,而在于 Holder 类未被正确地发布。然而,如果将 n 声明为 final 类型,那么 Holder 将成为不可变类,从而避免出现非正确发布的问题。
程序清单 3-15 由于未被正确发布,这个类可能出现故障:
// Class at risk of failure if not properly published
// 不安全的发布,在多线程环境下可能会出现异常与状态不一致
// Holder.java
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n) {
throw new AssertionError("This statement is false");
}
}
}
编写正确并发程序的关键在于:访问共享的可变状态时需要进行正确的管理。【也就是需要使用同步来管理对共享可变状态的访问。】第2章 介绍了
如何通过同步避免多个线程在同一时刻访问相同的数据
,本章的主题是介绍如何「共享
」(sharing)和「发布
」(publishing)对象,从而使它们能够「安全」
地被多个线程同时访问。 <----- 【其实就是指的是如何让别的类安全的访问修饰符为 public 的类变量】「
同步代码块
」和「同步方法
」可以确保以原子的方式执行操作
,但是syncrhonized
不仅用于实现原子
性操作或者确定临界区
(Critical Section)<----【也就是被锁保护的代码块】,同步
还有另一个重要的方面
:内存可见性
(Memory Visibility)。 我们不仅希望
防止某个线程正在使用对象的状态时,该字段同时被其他线程修改,而且希望
确保当一个线程修改了对象的状态后,其他线程能看到状态发生了变化
。如果没有
同步
,这种可见性
就无法实现。 你可以通过显式的同步
或者类库中内置的同步
来保证对象
被安全的发布
。3.1 可见性
可见性
是一种复杂的属性,因为可见性中的错误
总会违背我们的直觉。 在「单线程环境」
中,如果向某个变量先写入值,在没有其他操作的情况下读取这个变量,那么总能得到相同的值。这是一件看起来自然而然的事情,但是当
「读操作」
和「写操作」
在「不同的线程」
中执行时,情况却并非如此,我们无法确保
执行「读」
操作的线程能看到其他线程修改的最新的变量状态
,有时甚至是根本无法做到。为了确保
多个线程之间对内存写入操作的可见性
,必须使用「同步机制」
。在
程序清单 3-1
中的NoVisibility
说明了当多个线程在没有同步的情况下「共享」
数据时出现的错误。 在代码中,主线程
和读线程
都将访问共享变量
ready
和number
。「主线程」
启动「读线程」
,然后将number
设置为 42, 并将ready
设置为true
。读线程
一直循环判断
,直到发现ready
的值变为true
,然后输出number
的值。虽然
Novisibility
看起来会输出「42」
,但事实上很可能输出0
,或者根本无法终止循环
【看不到 ready 的值为 true】。这是因为在代码中没有使用足够的
「同步机制」
,因此无法保证主线程
写入的ready
值 和number
值 对于读线程
是可见
的。当读线程无法获取到主线程给
number
与ready
的赋值时,就会发生错误
的情况,而读线程并不一定能看到主线程对变量的赋值,造成这种情形的机制是 重排序(Recordering)【这个机制非常关键】只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作按照程序中指定的顺序来执行。
重排序出现的原因:
JVM
为了充分利用现代多核处理器的强大性能。 【提升JVM 在多核处理器运行环境下的效率】在缺乏同步的情况下 :
Java 内存模型
允许编译器
对操作顺序进行重排序
,并将数值缓存在 寄存器 中。此外它还允许 CPU 对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。更多细节在第16章。
【也就是重排序是为了提高对多核处理器的利用,提高程序执行的效率】
要想避免
NoVisibility
程序中出现的问题,就要保证: 只要数据
在多个线程之间共享
,就使用正确的同步
。3.1.1 失效数据
Novisibility
展示了缺乏同步
程序中可能产生错误的一种情况: 失效数据。例子中的读线程查看
ready
变量的时候,可能得到的是一个已经失效
的值,除非在每次访问变量时都使用同步,否则很可能获得一个失效的变量值。更糟糕的是,失效值可能不会同时出现,一个线程可能获得某个变量最新值,另一个线程获得变量的失效值。【随机产生的错误最可怕】
失效值
可能导致严重的安全错误和活跃性问题。在Novisibility
中失效值可能导致输出错误的值(读线程获取到的number
是 主线程赋值之前的值,也就是0
)或者使程序无法结束(获取到的ready
的值 是false
)。如果是对 对象引用的失效(例如
链表中的指针
) 则情况会更复杂。失效数据
还可能导致令人困惑
的故障 例如:意料之外的异常,被破坏的数据结构、不精确的计算以及无限循环等。程序清单 3-2
中的MutableInteger
是非线程安全的,因为get
和set
都是在没有同步的情况下访问value
的。 与其他问题相比,失效值问题更容易出现:如果某个线程调用了set
,那么另一个正在调用get
的线程不一定获取到的是最新的值。【这其实就是一个非常普通的 Bean,但是因为 get 和 set 方法都是非同步方法,所以可能导致线程不安全的情况发生】
在
程序清单 3-3
的SynchronizedInteger
中,通过对get
和set
等方法进行同步,可以使MutableInteger
成为一个线程安全的类, 但是仅对set
方法进行同步是不够的,调用get
时 仍然可能看见失效的值。【要保证互斥,就必须都加锁,这样才能保证获取值的时候同时没有别的线程正在修改值】如果某个线程调用了
set
, 那么另一个正在调用get
的线程可能会看到更新后的value
,也可能无法看到(这就引发了问题)。3.1.2 非原子的64位操作
当线程在没有同步的情况下读
取变量
时,可能会得到一个 失效值,但至少这个值是由之前 某个线程 设置的值,而不是一个随机值。这种安全性也被称为最低安全性
(out-of-thin-air-safety)。【也就是最低安全性需要保证该值是一个被之前线程设置的值,而不是一个没有逻辑的随机值】最低安全性适用于绝大多数变量,除了 非volatile 的 64位数值变量(double 和 long)
Java 内存模型要求:变量的读取和写入操作都必须是 原子操作。 但对于非
volatile
类型的long
和double
变量, JVM 允许将 64位的读操作或写操作 分解为 两个 32位的操作。当读取一个非volatile
类型的 long变量时
,如果对该变量的 读操作 和 写操作在不同的线程中执行,那么很可能会读取到某个值的高32位 和 另一个值的 低32位。【组合起来就变成了一个莫名其妙的随机数?】因此,即使不考虑数据失效问题,在多线程中使用共享且可变的
long
和double
等类型的变量也是不安全的,除非使用同步机制 —volatile
关键字 或者使用锁syncrhonized
将变量保护起来。R大对于 JVM 对于 原子性的 long 和 double 的规范的回答
3.1.3 加锁与可见性
内置锁 :可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。
如下图所示,当
线程A
执行某个 同步代码块时,线程B
随后进入由同一个锁保护的同步代码块。在这种情况下可以保证,在锁被释放之前, A 看到的变量值 在B 获得锁后同样可以由 B 看到。也就是当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作。
【Happens-Before规则所保证的可见性】
如果没有同步,上述操作无法保证。
现在,我们可以进一步理解为什么在访问某个
共享
且可变
的变量时,要求所有线程在同一个锁
上进行同步,就是为了确保某个线程写入该变量的值,对于其他线程都是可见的。否则,如果一个线程在未持有「正确锁」的情况下读取某个变量,那么可能读取到的是一个「失效值」
3.1.4 Volatile变量
Java
语言提供了一种稍弱的同步机制,volatile
变量,用来确保将变量的更新操作通知到其他线程。当变量被声明为
volatile
类型后,编译器和JVM
都会注意到这个变量是共享的,因此不会将这个变量上的操作与其他内存操作一起进行重排序
。volatile
变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此读取volatile
变量返回的总是其最新的值。【保证了变量的内存可见性】为了更好的理解
volatile
关键字,可以将volatile
成想象程序清单 3-3
中的SynchronizedInteger
的类似行为,将 字段value
用valatile
关键字修饰,就可以获得 类似在其get/set
方法上加锁的效果。**①**
然而因为volatile
没有实际的产生加锁行为,所以不会使执行线程阻塞
,因此volatile
是一种比内置锁synchronized
更轻量级的同步机制。②
volatile 变量对可见性的影响 比 volatile 变量本身更重要。
「线程A」
首先写入一个volatile
变量,并且「线程B」
随后读取该变量时,写入volatile
变量之前对 A 可见的所有变量的值,在 B 读取volatile
变量之后对 B 也是可见的。【这是可见性的传递吗?在看了极客时间的客之后,明白了这确实是 Happens-Before 中的可见性传递
。】volatile
变量相当于进入同步代码块,读取volatile
变量相当于退出同步代码块。但是
volatile
对变量提供的同步性比使用锁的同步代码块更脆弱,同时如果大量滥用
也会造成难以理解的代码的出现。仅当
volatile
变量能简化代码的实现以及对同步策略的验证
时,才应该使用它们,如果验证「正确性」
时需要对可见性
进行复杂的判断,就不适合使用volatile
变量。volatile
正确的使用方式:标识
一些重要程序生命周期事件的发生(例如代表初始化或关闭的boolean
类型标记)程序清单 3-4
给出了volatile
变量的典型用法
:检查某个状态标记以判断是否退出循环。在这个例子中,线程试图通过类似数绵羊的传统方法进入休眠状态。为了使代码能正确执行,asleep
必须是volatile
变量。否则当另一个线程修改asleep
状态时,执行判断的线程可能无法获取asleep
的最新状态③
。 也可以使用 锁 来保证更新操作的可见性,但是使用锁有点大材小用,并使代码更加复杂。【不过说实话,第一遍看,感觉还是有点抽象了,如果能有更加具体的例子就好了】
例如上例中,如果不将
asleep
声明为volatile
类型,则server 模式的 JVM 会将asleep
的判断条件移动到循环体外部,这将导致一个死循环。「JVM优化导致的并发问题」【所以使用 -server 模式最大的价值还是在于开发环境与部署环境的统一,不过现在都用 docker,这种问题少了很多了】
虽然 volatile 变量很方便,但也存在一些
局限性
。 volatile 变量通常用做某个操作完成,发生中断或者状态的标志,例如程序清单 3-4
中的asleep
标志。 尽管volatile
变量可以用于表示其他的状态信息,但在使用时要非常小心
。因为volatile
的语义不足以确保递增操作 count++ 的原子性,除非你能确保
只有一个线程对变量执行写操作
。「加锁机制既能确保可见性又确保原子性,而 volatile 只确保可见性。」
【有得必有失,
synchronized
是一种重型的同步机制,当「只」需要确保可见性的时候,使用volatile
更好。】当且仅当
满足以下 所有 条件
时,才应该使用volatile
变量:变量
不会与其他变量
一起纳入不变性条件中。说实话对以上三点,能有更具体的例子就好了。
3.2发布与逸出
【其实就是本来应该被封装在类的内部的可变性变量,封装被破坏之后怎样保证线程安全性】
发布
(Publish
)一个对象的定义: 使对象能够在当前作用域
之外的代码中使用。例如:
引用
传递到其他类中。【上面的都是破坏封装的情形 ↑】
这里的发布其实就是指 私有字段/方法 经过代理/中转 可以被外部类访问的,其实典型代表就是
bean
的get/set
方法?【我的理解】【之前的这个理解还是狭隘了,指的是类中封装的一切,字段也好,对象也好,暴露给了外界,就是发布】
【也就是某种意义上对封装的一种破坏。】
在许多情况下我们需要确保对象及其
内部状态
不被发布(也就是对封装性的维持),而在某些情况下,我们又需要发布某个对象。但是如果在发布时需要确保线程的安全性,则可能需要使用同步。【多线程环境下保证对被发布状态的访问的安全性】
发布类的内部状态可能会破坏封装性,并使程序难以维持不变性条件。例如如果在对象构造完成之前就发布该对象,就会破坏线程的安全性。
当某个不该发布的对象被发布时,这种情况被称为`逸出(Escape)`。3.5 节 介绍了如何安全发布对象的一些方法。
发布对象的最简单的方法是:将对象的引用保存到一个 公有静态变量中,以便任何类和线程 都能看见该对象。如
程序清单 3-5
所示。 在initilaize
方法中实例化一个新的HashSet
对象,并将对象的引用保存到knowSecrets
中以「发布」 该对象。当「发布」某个对象时,可能会间接发布其他对象。如果将一个
Secret
对象添加到集合knowSecrets
中,那么同样这个Secret
会被间接发布,因为任何代码都可以遍历这个集合,并获得对这个新Secret
对象的引用。 同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。程序清单 3-6
中的UnsafeStates
发布了本应是私有状态的数组。如果按照上面例子中的方式来 「发布」
states
, 就会出现问题。因为任何调用者都能修改这个数组的内容。 在这个示例中,数组states
已经「逸出」了它所在的作用域,因为这个本应私有的变量已经被发布
了。当发布一个对象时,该对象中的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达「其他对象」,那么这些对象也会被发布。
假设有一个
类C
,对于C
来说,外部(Alien)方法 是指:行为并不完全由 C 来规定的方法,包括其他类中定义的方法以及类
C 中可以被override
的方法(既不是private
也不是final
的方法)。当把一个对象传递给某个外部方法时,就相当于发布了这个这个对象。你无法知道哪些代码会执行,也无法知道在
外部方法
中究竟会发布这个对象,还是保留对象的引用并在随后由另一个线程使用。无论其他线程会对已发布的引用执行哪种操作,其实都不重要,因为误用引用的风险会始终存在。
【就像你的密码被泄漏,无论账号是否被人使用,都已经不安全了】这正是需要使用
封装
的最主要的原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。最后一种发布对象或其内部状态的机制就是发布一个内部的类的实例,如
程序清单 3-7
的ThisEscape
所示。当ThisEscape
发布EventListener
时,也隐含地发布了一个ThisEscape
实例本身,因为在这个内部类的实例中包含了对ThisEscape
实例的引用(Java 中的内部类持有外部类的隐式指针)安全对象的构造过程
在
ThisEscape
中给出了一个特殊的逸出示例:this
引用在构造函数中逸出。当内部的EventListener
实例发布时,在外部封装的ThisEscape
实例也逸出了。并且仅当对象构造函数返回时,对象才处于可预测和一致的状态。因此当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this
引用在构造过程中逸出,那么这种对象就被认为是不正确构造。①在构造过程中使
this
引用逸出的一个常见错误是,在构造函数中启动一个线程。【为啥要在构造函数中启动一个线程?】当对象在其构造函数中创建一个线程时,无论是
显示创建
(通过将它传给构造函数)还是隐式创建
(由于Thread
或Runnable
是该对象的一个内部类),this
引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。【也就是对象的可见性与构造函数是否完成无关】
在构造函数中创建线程并没有错误,但是最好不要立即启动它,而是通过一个
start
或initialize
方法来启动(详情在第7章 更多服务生命周期的内容)。在构造函数中调用一个可改写的实例方法时(也就是非
private
和final
的方法,可以被子类覆写的方法),同样会导致 this 引用在构造过程中逸出。疑问:
【构造函数中调用 非
private
和final
方法是怎样导致this
引用在构造过程中逸出的?】如果想在构造函数中
注册一个事件监听器
或者启动线程,那么可以使用 私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程。与上面例子中的区别:
构造函数
中创建匿名内部类的行为改成了 在类中声明匿名内部类类型的引用,然后在构造函数中对引用赋值,这样就不会使this
逸出,导致可以访问到这个类。保证构造函数完整执行
,如果仅仅使用公共的构造函数,就可能会造成对象未被构建完成就发布了,导致不可预知的后果。3.3 线程封闭
需要使用
同步
的情景:多线程访问
的 「可变」 「共享」数据于是我们有了一个不需要使用同步也能让线程安全的方法:不共享数据。
如果仅在
单线程
内访问数据,就不需要同步,这句技术被称为 「线程封闭」(Thread Confinement),它是实现线程安全的最简单的方式之一。<---【也就是将变量的访问区域确定在单个线程中,使变量无法同时被多个线程同时访问】当某个对象被封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
使用线程封闭的两个典型的例子:
Swing
JDBC
中的Connection
对象Connection
必须是线程安全的。 在典型的服务器应用程序中,线程从连接池中获得了一个Connection
对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。Servlet
请求 或EJB
调用等)都是由「单个线程采用同步的方式」来处理。所以在Connection
对象返回之前,连接池不会再将它分配给其他线程,因此这种连接管理模式在处理请求时隐含地将Connection
对象封闭在线程中。Java
语言中并没有强制规定某个变量必须由锁来保护。 同样 Java 语言也无法强制将对象封闭在某个线程中。↑【也就是线程封闭是一种从设计上来保证线程安全的手段,而不是 Java 语言的一种强行的安全机制,所以只能依靠程序员自己来实现并保证。】
线程封闭是「程序设计」中的一个考虑因素,必须在程序中实现。
Java
语言及其核心库提供了一些机制来帮助维持线程的封闭性例如:局部变量<---【将变量封闭在方法栈上】 和ThreadLocal
类。但是即便如此,线程封闭仍然需要程序员来小心地实践与确定其封闭对象不会逸出。
3.3.1 Ad-hoc 线程封闭
Ad-hoc 线程封闭:维护线程封闭性的职责
完全由程序实现
来承担。【也就是完全靠程序员对类的设计,没有任何强制的手段和机制】Ad-hoc 线程封闭是 非常`脆弱`的,因为没有任何一种语言特性来强制保证对变量的封闭,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。 事实上,对线程封闭对象的引用(例如 GUI 应用程序中的可视化组件或数据模型等)通常保存在公有变量中。
当决定使用
线程封闭
技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc
线程封闭技术的 脆弱性。【使用单线程子系统
的另一个原因是为了避免 死锁,这也是大多数 GUI 都是单线程的原因。「第9章」将 进一步介绍 「单线程子系统」。】volatile
变量上存在一种特殊的线程封闭: 只要你能确保只有 单个线程 对共享的 volatile 变量执行写入操作,那么就可以安全地在这些共享的volatile
变量上执行 "读取 — 修改 — 写入
" 的操作。<---【volatile 保证了变量的可见性,再加上只有单个线程对变量访问,这两个条件结合在一起确保了对变量操作的原子性】在这种情况下,相当于将修改操作 封闭在 单个线程中以防止发生竞态条件,并且
volatile
变量的 可见性保证还确保了其他线程可以看到最新的值。总结:
Ad-hoc
线程封闭技术「太脆弱」,因此在程序中应该尽量少用,在可能的情况下使用更强的 栈封闭 或者ThreadLocal
类 来实现 线程封闭。3.3.2 栈封闭
栈封闭是线程封闭的一种特例,在
栈封闭
中,只能通过局部变量
才能访问对象。【其实也就是使用方法内的局部变量来替代类变量,因为方法中的变量都存在于虚拟机栈中,是线程的私有变量,所以不存在线程安全问题。】
在「栈封闭」中,只能通过「局部变量」才能访问对象。正如 「封装」 能使得代码更容易维持「不变性条件」那样,「同步变量」也能使对象更易于封闭在线程中。
局部变
量的固有属性之一就是封闭
在执行线程
中。它们位于执行线程的
栈
中,其他线程无法访问这个栈。 <---【因为 Java内存模型中方法栈是线程私有的部分,每个线程都有自己的方法栈。】「栈封闭」 也被称为
线程内部使用
或者线程局部使用
,比 Ad-hoc 更容易维护,也更加健壮。对于 基本类型的 局部变量,例如 3-9 中的
loadTheArk
方法的numParis
无论如何使用,都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此 Java 语言的这种语义
就确保了基本类型的局部变量始终封闭在线程内。书上只截取了
loadTheArk
方法中的代码。在
维持
对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用对象不会被逸出。在loadTheArk
中实例化了一个TreSet
对象,并将指向该对象的一个引用保存到animals
中。此时,只有一个
引用
指向集合animals
,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而如果发布了对集合
animals
(或者该对象中的任何内部数据的引用),封闭性将被破坏,并导致 对象animals
「逸出」。【疑问:】
【这两者之间,后者就将 animals 封闭在了方法中吗? 为什么?我个人认为
没有区别
】如果在线程内部(Within-Thread)上下文中使用
非线程安全
的对象,那么该对象仍然是线程安全的。【线程封闭可以的特性可以让非线程安全的对象变为线程安全的】然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要备封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,后续的维护中很可能错误地将对象逸出。【也就是如果没有明确的文档,指出需要维护线程封闭性,单靠编程很容易破坏这种封闭】
3.3.3 ThreadLocal 类
维持线程封闭性的一种更规范的方法是使用
ThreadLocal
,这个类能使线程中的某个值与保存值
的对象
关联起来。ThreadLocal
中有get
和set
等方法,这些方法为每个使用该变量的线程都独立的保存了一个副本
,因此get
总是返回当前执行线程在调用 set 时设置的最新值。ThreadLocal
对象通常用于防止对可变的单例变量(Singleton) 或 全局变量进行共享。具体应用:
例如,在单线程应用程序中程序可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都需要传递一个
Connection
对象。 由于
JDBC
的连接对象不一定是 线程安全的,因此当多线程应用程序在 没有协同 的情况下使用全局变量时,就不是线程安全的。 通过将JDBC
的连接保存到ThreadLocal
对象中,每个线程就拥有了属于自己的连接。当某个频繁执行的操作需要一个临时对象:例如一个缓冲区,而又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。
当某个线程初次调用
ThreadLocal.get
() 方法时,就会调用initialValue
来获取初始值。从概念上看,可以将
ThreadLocal<T>
视为包含了Map<Thread,T>
的对象,其中保存了特定于该现成的 值,但ThreadLocal
的实现并非如此,这些特定于线程的值保存在Thread
对象中,当线程终止后,这些值会作为垃圾回收。在
Java5.0
之前,Integer.toString()
方法使用ThreadLocal
对象来保存一个 12个字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(因为这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。①①:除非这个操作执行频率非常高,或者分配操作的开销非常高,否则这项技术不可能带来性能提升。在 Java 5.0 中,这项技术被一种更直接的方式替代—— 每次调用时分配一个新的缓冲区,对于像临时缓冲区这种简单的对象,该技术没有什么性能优势
ThreadLocal的应用场景:
假设你需要将一个单线程应用移植到多线程环境中,通过将 共享的全局变量 转换为 ThreadLocal 对象(如果全局变量的语义允许),就可以维持线程的安全性。
然而,如果将应用程序范围内的 缓存 转换为 线程局部的缓存,就不会有太大作用。
【疑问:】
【为什么 将应用程序范围内的 缓存 转换为 线程局部的缓存,就不会有太大作用。】
在实现应用程序框架时,大量使用了 ThreadLocal。
例如在
EJB
调用期间,J2EE
容器需要将一个事务上下文(Transaction Context) 与某个执行中的 线程关联起来。 通过将事务上下文保存在一个 静态的ThreadLocal
对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪个事务时,只需要从这个ThreadLocal
中读取事务上下文(通过get
获取)。这种机制很方便,因为它避免了在调用每个方法时都传递上下文信息,然而这也将使用该机制的代码与框架代码 耦合 在了一起。【缺点】
开发人员经常性的 滥用 ThreadLocal,例如将所有全局变量都作为 ThreadLocal 对象,或者作为一种"隐藏" 方法参数的手段。 ThreadLocal 变量类似于全局变量,它能降低代码的可重用性,并在类之间引入 隐含的耦合性,因此在使用时要格外小心。
3.4 不变性
满足「同步」需求的另一种方法是使用
不可变对象
(Immutable Object)目前为止介绍了许多与 「
原子性
」 和 「可见性
」 相关的问题:失效
数据。如果对象的状态不会被改变,那么这些问题与复杂性也就消失了。如果某个对象在被
创建后
就不能被修改
,那么这个对象就被称为 「不可变对象」。「线程安全性
」 是 不可变对象的 固有属性之一,它们的不变性条件是由 「构造函数」 创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。不可变对象很
简单
,因为它们只有一种状态
,并且在创建的时候由构造函数
来控制。在程序设计中,一个最困难的地方就是判断「复杂对象」的可能状态
。但是「不可变对象」将这种困难消除了。同样,「不可变对象」也更加
安全
。如果将一个「可变对象」传递给不可信的代码,或者将该对象发布到了不可信代码可以访问到的地方,那么就很危险——不可信代码会改变可变对象的状态。更糟糕的是,在代码中将保留一个对该对象的引用,并稍后在其他线程中修改对象的状态。另一方面,
不可变对象
不会被「不可信代码」或者「恶意代码」破坏状态
,因此可以安全地「共享
」和「发布
」这些对象,而无须创建保护性的副本。如果将一个 可变对象 传递给 不可信的代码 或者将该对象发布到不可信代码可以访问到的地方,那么就很危险,因为不可信代码会修改可变对象的状态,更糟糕的是代码中将保留一个对该对象的引用并稍后在其他线程中修改可变对象的状态。
「
Java 语言规范
」和 「内存模型
」 中都没有给出对 「不可变性」 的正式定义,但是不可变性并不等于将对象中的所有域都声明为final
类型,即使对象中的所有域
都是final
类型的,这个对象也仍然是可变的
,因为在final
类型的域中可以保存对「可变对象
」 的 引用。当满足以下条件,
对象
才是不可变
的:final
类型的①。确创建
的(在对象创建期间,this
引用没有「逸出
」)在 「不可变对象」的内部仍然可以使用「可变对象」来管理它们的
状态
,如程序清单 3-11
中的ThreeStooges
所示。 尽管保存姓名的Set
是「可变」的,但从ThreeStooges
的设计中可以看到,在Set
对象构造完成后无法对其进行「修改
」。stooges
是一个final
类型的「引用变量」,因此所有其所有对象的状态都通过一个final
域 来访问
。对象不可变的最后一个要求是「
正确地构造
」,这很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。由于程序的「状态」总在不断地变化,你可能会认为需要使用「不可变对象」的地方不多,但实际情况并非如此。
在 「不可变对象」与「不可变对象引用」 之间存在着
差异
。保存在不可变对象中的程序状态仍然可以更新——通过将一个保存新状态的实例来替换原有的不可变对象。下一节将给出使用这项技术的实例。①3.4.1 Final 域
关键字
final
可以视为C++
中的const
机制的一种「受限版本」,用于构造不可变性对象。final
类型的域是不能修改的,但是如果final
域所引用的对象是可变的,那么这些被引用的对象是可以「修改」的。 【对象的引用不能修改,但是对象中的字段的值是可以修改的,比如一条狗链,这个狗链本身不能被替换,但是这个狗链所栓的狗是可以修改的。】在
Java 内存模型
中,final
域有着 特殊的语义。final
域能确保 初始化过程中的「安全性
」,从而可以不受限制地访问不可变对象,并在共享这些对象时无须使用同步机制。即使对象是可变的,通过将对象的某些
域
声明为final
类型,仍然可以简化对状态的判断,因此限制对象的可变性,也就相当于限制
了该对象的可能状态的集合。仅包含一两个可变状态的"基本不可变" 对象要比包含多个可变状态的对象简单。通过将域声明为final
也会明确告诉维护人员 这个域是不会发生变化的。3.4.2 示例:使用 Volatile 类型来发布不可变对象
第2章中的
UnsafeCachingFactorizer
中尝试使用了两个AtomicReference
来保存最新的数值以及因式分解结果,但是这种方式并非线程安全
。因为无法以原子
的方式来同时 更新或读取 这两个相关的值。同样, 用volatile
修饰的变量来保存这2个值也不是线程安全的。然而在某些情况下,不可变对象
能提供弱形式的原子性。因式分解
Servlet
需要执行2个原子操作:每当需要对一组相关数据以原子方式执行某个
操作
时,就可以考虑创建一个不可变的类来包含这些数据。例如程序清单 3-12 中的OneValueCache
①对于在访问和更新多个相关变量时出现的「竞态条件」问题,可以通过将这些变量全部保存在一个「不可变对象」中消除。 如果是一个「可变对象」,那么就必须使用
锁
来确保原子性
。如果是一个「不可变对象」,那么当线程
获得了该对象的引用后,不必担心「另一个线程
」会修改对象的状态。 如果要更新
这些变量,那么可以创建一个新的「容器」对象,但其他使用原有对象的「线程
」仍然会看到对象处于一致状态。程序清单 3-13
中的VolatileCachedFactorizer.java
使用了3-12
中构建的OneValueCache
来缓存数值以及因数分解的结果。当一个线程将 volatile 类型的 cache 设置为引用一个新的 OneValueCache 时,其他线程会立即看到新缓存的数据。【volatile
保证了内存可见性】当一个线程将
volatile
类型的cache
设置为引用一个新的OneValueCache
时,其他线程会立即看到新的缓存数据【 volatile 的内存可见性 特性】与
cache
相关的操作不会互相干扰,因为OneValueCache
是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持
「不变性条件」,并使用volatile
类型的引用来确保可见性。使得VolatileCachedFactorizer
在没有显示的使用锁
的情况下就保证了线程的安全性。3.5 安全发布
目前为止,讨论的重点是如何确保对象不被发布,例如让对象
封闭在线程
或另一个对象的内部。在某些情况下我们希望在多个线程之间共享
对象,此时必须确保安全
地进行共享
。然而,如果只是像程序清单 3-14
那样将对象引用保存到 「公有域
」 中,那么还不足以保证安全地发布这个对象:这个看似没有问题的示例为什么会失败?在
单线程
中这个类毫无疑问是可用的,但是在多线程环境
下,由于存在「可见性」问题,其他线程看到的Holder
对象可能处于不一致的状态,即便在该对象的构造函数
中已经正确地构建了 「不变性条件」。 这种不正确的发布导致其他线程
看到尚未创建完成的对象。3.5.1 不正确的发布:正确的对象被破坏
一个尚未完全创建的对象并不拥有「完整性」。 某个观察该对象的
线程
将看到对象处于不一致状态,然后看到对象的状态
突然发生变化,即使线程在对象「发布
」后没有修改过它。 事实上,如果程序清单 3-15
中的 Holder 使用清单 3-14
中的不安全发布方式,那么另一个线程在调用assertSanity
时 将抛出AssertionError
①。由于没有使用同步机制来保证
Holder
对象,因此将Holder
称为 「未被正确发布」的对象。 在这种对象中存在两个问题:线程
外,其他线程看到的Holder
域 是一个失效的值。因此将看到一个空引用
或之前的旧值
。线程
看到的Holder
引用的值是最新的, 但Holder
状态的值确实失效②的。情况更加不可预测的是:某个线程
在第一次读取域
的时得到的是失效值,而再次读取
这个域时会得到一个更新的值,这也是assertSanity
可能会抛出AssertionError
的原因。如果没有足够的同步,在多个线程之间共享数据会发生一些非常奇怪的事情。
3.5.2 不可变对象与初始化安全性
不可变对象是一种非常重要的对象。 「
Java内存模型
」为「不可变对象」提供了特殊的初始化安全性保证。我们之前已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态 对于使用该对象的线程来说一定是可见的。【缓存带来了可见性问题】为了确保「对象状态」能呈现出「一致的视图」,就必须使用同步。另一方面,即使在「发布」不可变对象的引用时没有使用
同步
,也可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态
不可修改final
类型。Holder
对象是不可变的,那么即使没有正确发布,也不会抛出AssertionError
异常。这种保证还将
延伸
到被正确创建对象中所有final
类型的域。 在没有额外同步的情况下,也可以安全地访问final
类型的域。然而,如果final
类型的域指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步
。3.5.3 安全发布的常用模式
「可变对象」必须通过 「安全的方式」来发布,这意味着 「发布」和「使用」 该对象的
线程
都必须使用「同步」。下面是如何确保使用对象的线程能够看到该对象处于已发布状态,并稍后介绍如何在发布后对其可见性进行
修改
:要安全地发布一个对象,「对象的
引用
」 以及 「对象的状态
」 必须 同时 对其他线程可见,一个正确构造的对象可以通过以下方式来安全地发布:volatile
类型的域 或者AtomicReference
对象中 <---【通过volatile
保证可见性,或者通过原子类 来保证对象安全发布】final
类型的域中。 <---【通过不可变对象安全发布】在「线程安全容器」内部进行同步意味着,将对象放入到某个容器 如
Vector
或synchronizedList
时,将满足上述最后一条需求。如果线程A
将对象X
放入一个线程安全的容器,随后线程B
读取
这个对象,那么可以确保线程B
看到线程A
设置的 X的状态,即便这段 读/写 X 的应用程序代码中没有显示的包含显式的同步。尽管 Javadoc 在这个主题上没有给出很清晰的说明,但是线程安全库中的
容器类
提供了以下的安全发布保证
:Hashtable
、synchronizedMap
或者ConcurrentMap
中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)Vector
、CopyOnWriteArrayList
、ConpyOnWriteArraySet
、synchronizedList
或synchronizedSet
中可以将该元素安全地发布到任何从这些容器中访问该元素的线程BlockingQueue
或者ConcurrentLinkedQueue
中,可以将元素安全地发布到任何从这些「队列」中访问该线程的元素上面就是三类线程安全的容器。
类库中的其他数据传递机制 例如
Future
和Exchanger
同样能实现安全发布,在介绍这些机制时将要讨论它们的安全发布功能。通常,要
发布
一个 静态构造对象, 最简单和安全的方式是使用静态的初始化器
:「静态的初始化器」由 JVM 在类的「初始化」阶段执行(
static
的特性)。由于 在「JVM 内部」存在着「同步机制」,因此通过这种方式初始化的任何对象都可以被安全地发布[JLS 12.4.2]。【↑也就是利用虚拟机的同步机制来安全发布对象。】
3.5.4 事实不可变对象(Effectively Immutable Object)
【这里的事实不可变跟 《OnJava8》中讲
函数式编程
中的闭包特性的事实不可变是一致的概念:虽然这个变量是非final
修饰的,但是在这个作用域内它确实没有改变 就是事实不可变对象】如果对象在发布后没有被修改,那么对于其他没有在额外同步的情况下安全地访问这些对象的
线程
来说,「安全发布」是足够的。所有的「安全发布机制」都能确保,当「对象的引用」对所有访问该对象的线程
可见时,对象发布时的状态对于所有线程
也将是可见的。并且如果对象状态不会再改变,那么就足以确保任何线程访问都是安全的。如果对象从技术上来说是可变的,但是事实上其状态在发布后
不会再改变
,那么将这种对象称为事实不可变对象(Effectively Immutable Object)。通过使用「事实不可变对象」可以保证安全性,简化开发过程,同时不会因为使用同步而减少性能。例如
Date
本身是可变的①,但是如果将它作为不可变对象来使用,那么在多个线程之间共享Date
对象时就省去了对锁
的使用。假设需要维护一个
Map
对象,其中保存了每位用户最近的登录时间:如果
Date
值被放入Map
中之后就不会再改变,那么synchronizedMap
中的同步机制足以使Date
被安全地发布,并且在访问这些
Date
时不需要额外的同步。3.5.5 可变对象
如果对象在构造后可以修改,那么「安全发布」只能确保 "发布当时" 状态的可见性。 对于「可变对象」,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用
同步
来确保后续操作的「可见性」。要安全地共享
「可变对象」,这些对象就必须被安全地发布,并且必须是线程安全的,或者由某个锁
保护起来。对象的发布需求取决于它的可变性:
线程安全
的或者由某个锁
保护起来。3.5.6 安全地共享对象
当获取一个对象的引用时,你首先需要知道在这个引用上可以执行哪些操作。在使用这个引用之前是否需要获得
锁
?是否可以修改它的状态,或者只能读取它。许多并发错误都是没有理解共享对象的这些 "既定规则" 而导致的。当发布一个对象时,必须明确地说明对象的访问方式。【也就是需要有文档性的说明来指导怎样使用对象的引用】
在并发程序中
使用
和共享
对象时,可以使用一些实用的策略:任何线程都不能修改
它。共享的只读对象包括 不可变对象 和 事实不可变对象。锁
进行访问。保护对象包括封装在其他线程安全对象中的对象,以及已经发布的并且由某个特定锁保护的对象。思维导图