有的对象是线程之间共享的,那么就需要想办法解决共享下线程安全的问题。
JMM 下的可见性问题
Java 内存模型(JMM) 中可以概括为三种对象:线程、线程的局部变量池、主内存。线程从主内存读取数据后, 会将其放到线程的局部变量池中(如寄存器等), 之后直接更改, 更改后地数据可能不会再放到主内存中。因此两个线程之间存在同一个数据不同值的可能性。
失效数据
例子 1: 在程序清单 3-1 (P27)中的 NoVisibility.java 说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中, 主线程和读线程都将访问共享变量 ready 和 number。主线程启动读线程, 然后将 number 设为 42, 并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true, 然后输出 number 的值。虽然 NoVisibility 看起来会输出 42, 但事实上很可能输出 0, 或者根本无法终止。这是因为在代码中没有使用足够的同步机制, 因此无法保证主线程写入的 ready 值和 number 值对于读线程来说是可见的。
例子 2: 程序清单 3-2 中的 MutableInteger.java 不是线程安全的, 因为 get 和 set 都是在没有同步的情况下访问 value 的。与其他问题相比, 失效值问题更容易出现:如果某个线程调用了 set, 那么另一个正在调用 get 的线程可能会看到更新后的 value 值, 也可能看不到。
非原子的 64 位操作
当线程在没有同步的情况下读取变量时, 可能会得到一个失效值, 但至少这个值是由之前某个线程设置的值, 而不是一个随机值。这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量, 但是存在一个例外:非 volatile 类型的 64 位数值变量(double 和 long, 请参见 3.1.4 节)。
Java 内存模型要求, 变量的读取操作和写入操作都必须是原子操作, 但对于非 volatile 类型的 long 和 double 变量, JVM 允许将 64 位的读操作或写操作分解为两个 32 位的操作。当读取一个非 volatile 类型的 long 变量时, 如果对该变量的读操作和写操作在不同的线程中执行, 那么很可能会读取到某个值的高 32 位和另一个值的低 32 位。
解决方法一:加锁
加锁的含义不仅仅局限于互斥行为, 还包括内存可见性。为了确保所有线程都能看到共享变量的最新值, 所有执行读操作或者写操作的线程都必须在同一个锁上同步。
解决方法二:volatile 修饰符
Java 语言提供了一种稍弱的同步机制, 即 volatile 变量, 用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile 类型后, 编译器与运行时都会注意到这个变量是共享的, 因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方, 因此在读取 volatile 类型的变量时总会返回最新写入的值。
在访问 volatile 变量时不会执行加锁操作, 因此也就不会使执行线程阻塞, 因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。
然而, 我们并不建议过度依赖 volatile 变量提供的可见性。如果在代码中依赖 volatile 变量来控制状态的可见性, 通常比使用锁的代码更脆弱, 也更难以理解。仅当 volatile 变量能简化代码的实现以及对同步策略的验证时, 才应该使用它们。
volatile 只能保证变量的可见性, 不能保证变量修改时的原子性。加锁机制即可以保证可见性又可以保证原子性。
volatile 变量通常用做某个操作完成、发生中断或者状态的标志。
volatile 变量的正确使用方式包括:(1)确保它们自身状态的可见性, (2)确保它们所引用对象的状态的可见性, (3)以及标识一些重要的程序生命周期事件的发生(例如, 初始化或关闭)。
当且仅当满足以下所有条件时, 才应该使用 volatile 变量:
- 对变量的写入操作不依赖变量的当前值, 或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
调试小提示:对于服务器应用程序, 无论在开发阶段还是在测试阶段, 当启动 JVM 时一定都要指定-server 命令行选项。server 模式的 JVM 将比 client 模式的 JVM 进行更多的优化, 例如将循环中未被修改的变量提升到循环外部, 因此在开发环境(client 模式的 JVM)中能正确运行的代码, 可能会在部署环境(server 模式的 JVM)中运行失败。
如何共享对象
发布和逸出
如果在对象构造完成之前就发布该对象, 就会破坏线程安全性。当某个不应该发布的对象被发布时, 这种情况就被称为逸出(Escape)。
单例模式增加 volatile 修饰的原因也是这样,避免 JVM 指令重排导致先更新了对象的引用地址,导致发布了该对象。
发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中, 以便任何类和线程都能看见该对象。
发布某个对象可能会间接发布其他对象。 发布一个 HashMap 对象,间接发布了其中的 K-V 对象。
构造过程中,要防止 this 引用逸出。
问题 1:构造器中直接或间接启动了一个新线程。新线程可以使用 this 引用,从而使用了未初始化完全的对象。
解决 1:(1)可以创建,但是尽量不要启动 (2)单独的方法进行创建与启动。
问题 2:调用一个可改写的实例方法(既不是私有方法, 也不是终结方法)。内部类、匿名内部类都可以访问外部类的对象的域,为什么会这样,实际上是因为内部类构造的时候,会把外部类的对象 this 隐式的作为一个参数传递给内部类的构造方法,这个工作是编译器做的,他会给你内部类所有的构造方法添加这个参数,所以你例子里的匿名内部类在你构造 ThisEscape 时就把 ThisEscape 创建的对象隐式的传给匿名内部类了。
解决 2:使用工厂方法来创建,私有的构造函数+公共的工厂方法。工厂方法中先根据构造函数创建对象,再进行后续一系列绑定、启动线程等处理。
不正确的发布
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.");
}
}
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}由于 n 没有使用 final 修饰,因此可能导致线程 A 调用 initialize 函数,线程 B 直接调用 holder 对象时,显示 holder 对象已经创建,但是 n 其实还没来得及赋值 42,仅保留 0 初始值。这是由于创建对象时,先调用 Object 的构造方法,将所有位置置为 0;然后调用对象本身的构造方法,将 n 进行赋值。
对象的引用地址和调用对象的构造方法,正常是先执行哪个呢?(之后系统看 JVM 的时候再进行了解,相关链接:深入理解 Java 对象的创建过程:类的初始化与实例化)
疑问 1:这里是否需要 volatile 进行修饰?否则可能线程 A 初始化完成后,线程 B 不一定可见最新的对象。好吧,下面解释了。
疑问 2:这里可不可以只使用 volatile 修饰 StuffIntoPublic 的成员变量 holder 呢?
安全的发布
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布, 并且必须是线程安全的或者由某个锁保护起来。
要安全地发布一个对象, 对象的引用以及对象的状态必须同时对其他线程可见。
一个正确构造的对象一定可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到 volatile 类型的域或者 AtomicReferance 对象中。
- 将对象的引用保存到某个正确构造对象的 final 类型域中。
- 将对象的引用保存到一个由锁保护的域中。
有锁保护的域中:
- 通过将一个键或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中, 可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或 synchronizedSet 中, 可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
- 通过将某个元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中, 可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
要发布一个静态构造的对象, 最简单和最安全的方式是使用静态的初始化器:public static Holder holder = new Holder(42);。静态初始化器由 JVM 在类的初始化阶段执行。由于在 JVM 内部存在着同步机制, 因此通过这种方式初始化的任何对象都可以被安全地发布。
对象的分类与发布
对象可以分为三种,不可变对象、可变对象、事实不可变对象。
不可变对象
不可变对象一定是线程安全的。
当满足以下条件时, 对象才是不可变的:
- 对象创建以后其状态就不能修改。
- 对象的所有域都是 final 类型。
- 对象是正确创建的(在对象的创建期间, this 引用没有逸出)。
使用 final 修饰对象,保证对象引用不可变,但不保证对象不可变,
被 final 修饰的对象并不代表不可变对象,仅仅是该对象的引用不可变而已。该对象内部如果存在可变量,对象还是可能改变的。final 域能确保初始化过程的安全性, 从而可以不受限制地访问不可变对象, 并在共享这些对象时无须同步。
良好的编程习惯:
- 除非需要更高的可见性, 否则应将所有的域都声明为私有域;除非需要某个域是可变的, 否则应将其声明为 final 域。
将需要原子执行的操作封装为一个不可变类,然后发布时使用 volatile 修饰。
每当需要对一组相关数据以原子方式执行某个操作时, 就可以考虑创建一个不可变的类来包含这些数据, 例如程序清单 3-12 (OneValueCache.java)中的 OneValueCache.
程序清单 3-13 中的 VolatileCachedFactorizer(VolatileCachedFactorizer.java) 使用了 OneValueCache 来保存缓存的数值及其因数。当一个线程将 volatile 类型的 cache 设置为引用一个新的 OneValueCache 时, 其他线程就会立即看到新缓存的数据。
因为 OneValueCache 是不可变的, 并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件, 并使用一个 volatile 类型的引用来确保可见性, 使得 volatile CachedFactorizer 在没有显式地使用锁的情况下仍然是线程安全的。
事实不可变对象
如果对象从技术上来看是可变的, 但其状态在发布后不会再改变, 那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。
**在没有额外的同步的情况下, 任何线程都可以安全地使用被安全发布的事实不可变对象。
如何不共享对象
不共享对象,即线程封闭。
Ad-hoc 线程封闭
Ad-hoc 线程封闭是指, 维护线程封闭性的职责完全由程序实现来承担。Ad-hoc 线程封闭是非常脆弱的, 因为没有任何一种语言特性, 例如可见性修饰符或局部变量, 能将对象封闭到目标线程上。
在 volatile 变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的 volatile 变量执行写入操作, 那么就可以安全地在这些共享的 volatile 变量上执行“读取-修改-写入”的操作。在这种情况下, 相当于将修改操作封闭在单个线程中以防止发生竞态条件, 并且 volatile 变量的可见性保证还确保了其他线程能看到最新的值。
由于 Ad-hoc 线程封闭技术的脆弱性, 因此在程序中尽量少用它, 在可能的情况下, 应该使用更强的线程封闭技术(例如, 栈封闭或 ThreadLocal 类)。
栈封闭
栈封闭下,只有通过局部遍历才能访问对象。将可变与不可变对象当作局部变量,并且保证引用的对象不会逸出。
ThreadLocal 类
ThreadLocal 简介
每一个线程都有自己的专属本地变量。如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
ThreadLocal 原理
每一个 Thread 下面有一个 ThreadLocal.ThreadLocalMap 对象。ThreadLocalMap 下有个 Entry 数组,每个 Entry 代表一个对象。Entry 构造函数包含两个数据,一个是当前的 ThreadLocal 对象,一个是 Object 对象。存对象时,是向当前线程的 ThreadLocalMap 中存储数据;读对象时,也是根据当前 ThreadLocal 对象来获取对应的结果。从而线程隔离。
从 Thread 类源代码入手。
public class Thread implements Runnable {
//......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
//......
}从上面 Thread 类源代码可以看出 Thread 类中有一个 threadLocals 和一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set 或 get 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get()、set() 方法。
ThreadLocal 类的 set() 方法
public void set(T value) {
//获取当前请求的线程
Thread t = Thread.currentThread();
//取出 Thread 类内部的 threadLocals 变量(哈希表结构)
ThreadLocalMap map = getMap(t);
if (map != null)
// 将需要存储的值放入到这个哈希表中
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。
每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//......
}
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。
ThreadLocal 数据结构如下图所示:

内存泄漏问题
需要注意的是,Extry 对象继承了弱引用,存在内存泄漏的问题。即,ThreadLocal 被垃圾回收后,ThreadLocalMap 属于 Thread 的成员变量,与 Thread 的生命周期相同,因此出现 key 不存在,value 仍存在的情况。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后最好手动调用 remove() 方法。
class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
....
}
class ThreadLocal<T> {
static class ThreadLocalMap {
private Entry[] table;
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry getEntry(ThreadLocal<?> key) {... }
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { ... }
private void set(ThreadLocal<?> key, Object value) { .... }
private void remove(ThreadLocal<?> key) { ... }
}
}维持线程封闭性的一种更规范方法是使用 ThreadLocal, 这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 与 set 等访问接口或方法, 这些方法为每个使用该变量的线程都存有一份独立的副本, 因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。
ThreadLocal 对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。
由于 JDBC 的连接对象不一定是线程安全的, 因此, 当多线程应用程序在没有协同的情况下使用全局变量时, 就不是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中, 每个线程都会拥有属于自己的连接
当某个线程初次调用 ThreadLocal.get 方法时, 就会调用 initialValue 来获取初始值。从概念上看, 你可以将 ThreadLocal < T > 视为包含了 Map < Thread, T > 对象, 其中保存了特定于该线程的值, 但 ThreadLocal 的实现并非如此。这些特定于线程的值保存在 Thread 对象中, 当线程终止后, 这些值会作为垃圾回收。
假设你需要将一个单线程应用程序移植到多线程环境中, 通过将共享的全局变量转换为 ThreadLocal 对象(如果全局变量的语义允许), 可以维持线程安全性。然而, 如果将应用程序范围内的缓存转换为线程局部的缓存, 就不会有太大作用。
总结
许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时, 必须明确地说明对象的访问方式。 也就是文章后面提到的写文档。
在并发程序中使用和共享对象时, 可以使用一些实用的策略, 包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有, 对象被封闭在该线程中, 并且只能由这个线程修改。
- 只读共享。在没有额外同步的情况下, 共享的只读对象可以由多个线程并发访问, 但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享。线程安全的对象在其内部实现同步, 因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象, 以及已发布的并且由某个特定锁保护的对象。