在当我们谈论线程安全时, 我们在谈论什么中, 我们讨论了怎样通过 Java 的 synchronize 机制去避免几个线程同时访问一个变量时发生问题. 忧国忧民的 Brian Goetz 大神在多年的开发过程中, 也悟到了人性的懒惰, 他深知许多程序员不会在设计阶段就考虑到线程安全, 只是假设自己的代码能按照自己的想法很好地运转. 然而当程序上线, 线程安全问题真的发生时, 要花费多于前期设计数倍的时间和精力去进行排查, 解决, 甚至重新设计. 于是, 他在字里行间一直秉持一种 "凡事皆可发生" 的小心翼翼的哲学, 并以这种哲学努力影响读者. 或许我们在设计一个类时, 对类中的各个域的访问修饰符并不会过多地进行思考, 对于构造函数也只是按照 IDE 的提示顺手一填了事; 当我们设计一个 boolean 类型作为线程是否应该睡眠的 flag 时, 或许也不会立马想到可能需要把它设置为 volatile.Brian Goetz 大神花了整整十四页密密麻麻的英文告诉你, 设计时懒得花时间思考的问题, 总有一天代码会逼你想得更加透彻.
本章目录
复习
可见性
内置锁与可见性
volatile 与可见性
线程封闭
纯靠自觉的线程封闭
栈封闭
ThreadLocal 类
不变对象
安全发布
发布(publication)
安全发布的常用模式
总结: 任意对象的安全发布
总结: 如何安全地共享对象
复习
上一章讲到, 当多个线程同时访问同一个变量时, 由于线程调度的顺序不定, 或线程之间的执行刚好互相穿插, 最终的结果可能不同, 这种情况叫做竞态条件. 避免竞态条件的方法大致分为三种:
取消多线程对变量的共享
把变量设为不可变
设置同步机制去控制对变量的访问
上一章中, 我们讲了第三点的一个方式 -- 内在锁, 也就是 synchronized 关键字. 这里我们要提出第二种方法, 即 volatile 关键字, 并讨论它跟 synchronized 有什么不同. 另外, 我们要详细讨论前两点如何实现.
此外, 心系天下的 Brian Goetz 大神还要提出一个 "安全发布" 的概念, 一个绝大多数编程菜鸟 (比如我) 从未想过的问题.
可见性
要知道, synchronized 关键字不止保证原子性而已, 它还能保证可见性, 即一个线程对某变量做出的修改可以被另一个线程马上看到. 要明白可见性的重要, 需要研究一个例子.
- public class NoVisibility{
- private static boolean ready;
- private static int number;
- private static class ReaderThread extends Thread{
- public void run(){
- while(!ready){Thread.yield();}
- System.out.println(number);
- }
- }
- public static void main(String[] args){
- new ReaderThread().start();
- number = 42;
- ready = true;
- }
- }
例子中有两个线程, 其中一个会不停地查询 ready 这个标志位, 当它为 true 时停止循环, 打印 number 的值. 另外一个线程先把 number 置为 42, 然后把 ready 写为 true.
写这段代码的程序员的本意是希望 ReaderThread 停止后打印 42. 然而出于某些原因, 结果并不一定如他所料. 其实这段代码的执行结果有三种可能:
ReaderThread 停止后打印 42.
ReaderThread 停止后打印 0.
ReaderThread 永不停止.
后两种 "错误" 的结果反映了影响可见性的两个行为:
ReaderThread 停止后打印 0. -- 指令重排序, 即 number = 42; 和 ready = true; 的执行顺序被编译器改变. 于是 ReaderThread 看到 ready 为 true 时, number 还是默认的初始值 0.
ReaderThread 永不停止. --ReaderThread 读到的 ready 始终为线程栈上的缓存值. 主线程执行的 ready = true 并未写到内存中, 或写到了内存中, 但 ReaderThread 线程并未看到.
保证可见性有两种方法: 给变量访问加锁, 或者用 volatile 关键字修饰变量.
内置锁与可见性
如果线程 A 和线程 B 先后获取了某对象的内置锁 M, 那么线程 B 拿到锁之后, 可以看到线程 A 释放锁之前的所有操作结果. 即线程 A 释放锁之前的操作 happens-before 线程 B 拿到锁之后的操作.
(happens-before 并不是说事情发生的先后顺序, 而是说其他线程能看到某线程在某个时间点之前做过的事情.)
所以总结起来, 加锁可以同时保证原子性和可见性.
volatile 与可见性
volatile 关键字会促使 Java 编译器做如下两件事情:
禁止指令重排序. 对 volatile 变量的读写操作之前和之后的指令可以被分别重排序, 但前, 后的指令不能发生混杂.
- nonVolatile1 = 123;
- nonVolatile2 = 456;
- nonVolatile3 = 789; // 以上三条可以内部重排序, 但必须发生在 * 前
- volatileVariable = 666; (*) //volatile 变量操作
- someValue1 = nonVolatile4;
- someValue2 = nonVolatile5;
- someValue3 = nonVolatile6; // 以上三条可以内部重排序, 但必须发生在 * 后
读写操作会直接从内存读, 向内存写, 而非暂存在其他处理器看不到的 register 或 cache 中. 实际上, volatile 也提供与内置锁相似的 happens-before 性质, 即若线程 A 对某 volatile 变量进行写操作, 而线程 B 随后对该变量进行读操作, 则 A 写操作之前的所有操作对 B 读操作之后的所有操作可见. 它的内部机理是:A写这个 volatile 变量时, 在此之前 A 修改过的所有变量都会被 flush 到主存; 而 B 读这个 volatile 变量时会将其他变量一起从主存读出.
volatile 的典型用法是做标志位来标示一个生命周期事件的发生, 如初始化或关闭.
- volatile boolean asleep;
- while(!asleep)
- countSomeSheep();
需要额外注意的是, volatile 并不保证操作的原子性. 所以牵涉复合操作的变量用 volatile 修饰并不能保证线程安全, 除非只有一个线程对该变量进行写操作.
说到原子性还有一件事需要注意, 就是 Java 基本类型中的 long 和 double. 因为它们是 64 位的, 而 JVM 的基本寻址单位是 32 位, 所以 long 和 double 的读写并不一定是原子的(与 JVM implementation 有关). 也就是说, 当两个线程同时对一个 long 变量进行写操作时, 有可能的结果是这个变量的高字节是线程 A 写的, 而低字节是线程 B 写的. 虽然我们刚说完 volatile 不保证原子性, 但涉及到 long 和 double 时, JLS 规定 https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7 volatile 的 long 和 double 读写操作始终是原子的. 也就是说, 代码中共享可变的 long 或 double 变量需要加 volatile 或加锁保护.
线程封闭
如果让某个变量只能被单个线程访问, 那么即使这个变量对象本身不是线程安全的, 也不会出现安全问题. 这种技术叫做线程封闭. 它的典型例子有两个:
Swing 中的可视化组件和数据模型对象都不是线程安全的. Swing 专门设置了一个事件分发线程 (EDT, the Event Dispatching Thread) 去管理这些对象, 只有这个线程有权做 UI 更新等操作. 如果其他线程也想修改 UI, 可以通过 invokeLater()机制提交修改, 这些修改事件会存放在 message queue 中等待 EDT 逐一处理.
JDBC (Java Database Connectivity) 池提供的 Connection 对象也不是线程安全的(JDBC Spec 未要求它们线程安全). 这是因为通常每处理一个请求时, 会有一个线程去池中拿到一个 Connection, 而这个 Connection 在该请求的处理结束前并不会被分配给其他的线程, 也就是达到了线程封闭.
线程封闭的实现方式大致有三种, 以下逐一介绍.
纯靠自觉的线程封闭
开发者们商量好某些对象是线程封闭的, 然后依靠代码实现去实现线程封闭. 通常会决定把一个子系统 (如 GUI) 做成线程封闭的.
这种方法一般非常脆弱, 因为没有硬性的语法规范, 很容易被新来的不懂事的程序员或者因为没写文档所以过几个月就忘了自己当初怎么想的老开发打破. 不过, 它带来的简洁性在一定程度上可以弥补脆弱性.
另外, 如上文所说的, 对于 volatile 变量来说, 存在一种特殊的线程封闭, 即写封闭. 即使有多个线程会去读取 volatile 变量, 只要保证只有一个线程在写, 那么这个变量还是线程安全的, 即使写操作是一个复合操作也没关系. 因为这种情况相当于把修改操作封闭在单个线程中而防止了竞态条件, 而 volatile 又能保证其他线程看到最新的值.
栈封闭
通过在方法中定义局部变量来保证线程封闭. 由于局部变量在线程栈上, 所以无法被其他线程拿到.
如果局部变量是基本类型的, 那么就算程序员想, 也没办法传给别的线程.
如果局部变量是个对象就要注意了, 不要犯傻传给其它的线程. 最好把这一点注释好, 免得新来的程序员不知道我们是这么设计的.
ThreadLocal 类
ThreadLocal 类实际上是一种机制, 通过程序员自定义的方式去给每一个线程都分配某个类的实例. 这样可以防止线程之间共享一个对象.
比如, SimpleDataFormat 是线程不安全的. 如果两个线程同时调用同一个 SimpleDataFormat 实例的. format()方法, 可能会造成数据的破坏. 因此, 我们可以通过以下代码, 给每个线程都分配一个 SimpleDataFormat 实例, 这样, 某个线程想使用 SimpleDataFormat 时, 只要调用 Foo.format(...)就可以了.
- 1 public class Foo{
- 2 private static final ThreadLocal<SimpleDataFormat> threadLocalFormatter = new ThreadLocal<SimpleDataFormat>(){
- 3 @Override
- 4 protected SimpleDataFormat initialValue(){
- 5 return new SimpleDataFormat("yyyy MMdd HHmm");
- 6 }
- 7 }
- public String format(Date date){
- return threadLocalFormatter.get().format(date);
- }
- 8 }
当某个线程初次调用 ThreadLocal.get()方法时, 会调用 initialValue()来获取初始值. 每个线程中会有一个 ThreadLocalMap, 它会以 ThreadLocal<T > 对象为 key, 保存 T 的实例. 在以上的例子中, 每个调用过 ThreadLocal.get()方法的线程的 ThreadLocalMap 都会存有一个 Entry, 它的 key 是 threadLocalFormatter,value 是 new SimpleDataFormat("yyyy MMdd HHmm")所创建的对象.
在 Java 5.0 之前, Integer.toString()是通过 ThreadLocal 来为每个线程分配缓冲区, 用来对结果进行格式化的, 因为使用共享的静态缓冲区需要持锁访问, 而用这种方法又不用每次分配一个新的缓冲区. 不过 Java 5.0 中改成了每次分配新的缓冲区, 因为 ThreadLocal 只在分配的频率 / 开销非常高时才会带来明显的性能提升. 而对于缓冲区这种简单的对象来说, ThreadLocal 没有太多性能优势.
作者认为, 使用 ThreadLocal 会降低代码的可重用性, 并在类之间引入隐含的耦合性, 所以要小心使用. 虽然我并不理解他为什么这么讲, 但显然 ThreadLocal 的应用场景还是有限的, 它的真实用途很容易被误解.
不变对象
为了跟 invariant(不变性)区分开, 这里将 immutability 翻成了不变对象, 即创建之后状态不可改变的对象. 不变对象永远是线程安全的.
并不是设为 final 就是不变对象, 因为 final 对象的域还可能指向可变对象. 满足以下条件的对象才是不变对象:
对象创建之后状态不可更改.
所有的域都是 final 类型的.(其实 String 中的 hash 域并不是 final 的, 但因为 hash 只与 value[]有关而 value[]是不可变的, 所以 hash 理论上也是不可变的, 不把 hash 设为 final 是为了将 hash 的计算推迟到第一次调用 hashCode()时进行. 不过作者并不建议程序员尝试这种方法, 可能是因为会难以维护.)
对象是正确创建的(创建期间 this 引用没有逸出).
以下类满足了这三个要求:
- @Immutable
- public final class ThreeStooges{
- private final Set<String> stooges = new HashSet<String>();
- public ThreeStooges(){
- stooges.add("Moe");
- stooges.add("Larry");
- stooges.add("Curly");
- }
- public boolean isStooge(String name){
- return stooges.contains(name);
- }
- }
虽然看起来 stooges 会指向一些可变对象, 但是 ThreeStooges 类并没有提供可以改变这些对象的接口, 所以 ThreeStooges 是不可变的.
作者建议开发者多考虑不可变对象的使用. 它们看起来不怎么实用(因为不可变), 但其实我们对对象的引用是可变的, 所以需要时只要创建一个新的不可变对象就可以. 它的好处是不用加锁, 而且会降低对 generational garbage collection 的影响(作者说的). 而且内存分配的开销通常比我们想象的要低.
就算对象是可变的, 也应该把尽可能多的域设为 final, 这和把尽可能多的域设为 private 一样是一种好习惯, 因为这样减少了对象可能的状态的数量, 便于分析问题和维护.
以下阐释了把 UnsafeCachingFactorizer 改成用一个 volatile 的不变对象进行缓存的线程安全代码的方法.
- @NotThreadSafe
- public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
- private final AtomicReference<BigInteger> lastNumber
- = new AtomicReference<BigInteger>();
- private final AtomicReference<BigInteger[]> lastFactors
- = new AtomicReference<BigInteger[]>();
- public void service(ServletRequest req, ServletResponse resp) {
- BigInteger i = extractFromRequest(req);
- if (i.equals(lastNumber.get()))
- encodeIntoResponse(resp, lastFactors.get()); //! 没维护不变性
- else {
- BigInteger[] factors = factor(i);
- lastNumber.set(i);
- lastFactors.set(factors); //! 没维护不变性
- encodeIntoResponse(resp, factors);
- }
- }19 }
- @Immutable
- public class OneValueCache {
- private final BigInteger lastNumber;
- private final BigInteger[] lastFactors;
- public OneValueCache(BigInteger i,
- BigInteger[] factors) {
- lastNumber = i;
- lastFactors = Arrays.copyOf(factors, factors.length);
- }
- public BigInteger[] getFactors(BigInteger i) {
- if (lastNumber == null || !lastNumber.equals(i))
- return null;
- else
- return Arrays.copyOf(lastFactors, lastFactors.length); // 用 copyOf()保证 lastFactors 不可变
- }
- }
- @ThreadSafe
- public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
- private volatile OneValueCache cache = new OneValueCache(null, null);
- public void service(ServletRequest req, ServletResponse resp) {
- BigInteger i = extractFromRequest(req);
- BigInteger[] factors = cache.getFactors(i);
- if (factors == null) {
- factors = factor(i);
- cache = new OneValueCache(i, factors);
- }
- encodeIntoResponse(resp, factors);
- }
- }
安全发布
这个话题对于我这种菜鸟来说是一个全新领域, 或许只有提高经验值才能慢慢明白作者在此处的煞费苦心.
发布(Publication)
发布一个对象指把这个对象暴露给当前作用域之外的代码使用. 以下是四种发布对象的方式:
直接把它存成一个 public static 域, 所有其他类和线程都可以访问.
从一个非 private 的方法中返回对象引用.
把对象引用传给一个陌生方法(包括其他类的方法; 自己类中可以被继承的, 即非 private 且非 final 的方法).
在构造函数中隐式发布 this 引用.(<- 作者极力禁止)
禁止 4 的原因是在构造函数中发布 this 时, this 对象可能还没初始化好. 而陌生代码可能对 this 作任意操作而引起问题. 如:
- public class ThisEscape {
- public ThisEscape(EventSource source) {
- source.registerListener(new EventListener() {
- public void onEvent(Event e) {
- doSomething(e);
- }
- });
- }
- }
这里的 this 对象就隐式逸出了. 作者建议按照 private 构造函数 + public 工厂方法的方式去修改:
- public class SafeListener {
- private final EventListener listener;
- private SafeListener() {
- listener = new EventListener() {
- public void onEvent(Event e) {
- doSomething(e);
- }
- };
- }
- public static SafeListener newInstance(EventSource source) {
- SafeListener safe = new SafeListener();
- source.registerListener(safe.listener);
- return safe;
- }
- }
此外, 作者还建议不要在构造函数中启动线程, 因为线程启动时可能用到对象中还没构造好的域. 但是在构造函数中创建线程是可以的. 我个人表示怀疑.(在构造函数中创建线程也有可能导致线程使用对象中未创建好的域进行初始化).
安全发布的常用模式
安全发布: 对象发布后, 对象的引用本身和对象中的域的最新值对其他线程都可见.
由于缓冲区的存在, 一个构造完成的对象在其他线程的眼中可能处于各种状态.
可通过以下方式安全地发布一个对象:
用 static initializer 初始化.
用 volatile 或 AtomicReference 存储要发布的对象.
用 final 存储对象.
用锁保护对象.
1 能保证安全发布, 是因为 static initializers 是 JVM 在类初始化的时期执行的, 而 JVM 的类初始化是 synchronized 的, 即获得锁才能进行初始化. 所以 static initialization 后的对象及其状态对其他线程可见.
3 能保证安全发布, 是因为 Java 内存模型 https://www.ibm.com/developerworks/library/j-jtp03304/index.html 保证了对象构造后 (只要 this 不在构造时溢出) 它的所有 final 域和 final 域指向的对象的最新值马上对其它线程可见.
总结: 任意对象的安全发布
对象的发布策略取决于它是否可变.
不可变对象可以被任意发布.
effectively 不可变的对象 (程序中可以保证不对其进行修改的对象) 须被安全发布, 但发布后不必加锁使用.
可变对象必须安全发布. 且如果不是线程安全对象, 使用时需加锁保护.
总结: 如何安全地共享对象
Thread-confined: 只有一个线程可以修改.
Shared read-only: 不可变和 effective 不可变对象
Shared thread-safe: 对象内部是 synchronized
Guarded: 放在线程安全容器中使用, 或使用时加锁保护.
来源: https://www.cnblogs.com/mozi-song/p/8723650.html