[JCIP笔记] 如何设计一个线程安全的对象

Posted mskitten

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[JCIP笔记] 如何设计一个线程安全的对象相关的知识,希望对你有一定的参考价值。

当我们谈论线程安全时,我们在谈论什么中,我们讨论了怎样通过Java的synchronize机制去避免几个线程同时访问一个变量时发生问题。忧国忧民的Brian Goetz大神在多年的开发过程中,也悟到了人性的懒惰,他深知许多程序员不会在设计阶段就考虑到线程安全,只是假设自己的代码能按照自己的想法很好地运转。然而当程序上线、线程安全问题真的发生时,要花费多于前期设计数倍的时间和精力去进行排查、解决,甚至重新设计。于是,他在字里行间一直秉持一种“凡事皆可发生”的小心翼翼的哲学,并以这种哲学努力影响读者。或许我们在设计一个类时,对类中的各个域的访问修饰符并不会过多地进行思考,对于构造函数也只是按照IDE的提示顺手一填了事;当我们设计一个boolean类型作为线程是否应该睡眠的flag时,或许也不会立马想到可能需要把它设置为volatile。Brian Goetz大神花了整整十四页密密麻麻的英文告诉你,设计时懒得花时间思考的问题,总有一天代码会逼你想得更加透彻。

复习

上一章讲到,当多个线程同时访问同一个变量时,由于线程调度的顺序不定,或线程之间的执行刚好互相穿插,最终的结果可能不同,这种情况叫做竞态条件。避免竞态条件的方法大致分为三种:

  • 取消多线程对变量的共享
  • 把变量设为不可变
  • 设置同步机制去控制对变量的访问

上一章中,我们讲了第三点的一个方式——内在锁,也就是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编译器做如下两件事情:

  1. 禁止指令重排序。对volatile变量的读写操作之前和之后的指令可以被分别重排序,但前、后的指令不能发生混杂。
    1 nonVolatile1 = 123;
    2 nonVolatile2 = 456;
    3 nonVolatile3 = 789; //以上三条可以内部重排序,但必须发生在*前
    4 
    5 volatileVariable = 666; (*) //volatile变量操作
    6 
    7 someValue1 = nonVolatile4;
    8 someValue2 = nonVolatile5;
    9 someValue3 = nonVolatile6; //以上三条可以内部重排序,但必须发生在*后
  2. 读写操作会直接从内存读、向内存写,而非暂存在其他处理器看不到的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规定volatile的long和double读写操作始终是原子的。也就是说,代码中共享可变的long或double变量需要加volatile或加锁保护。

线程封闭

如果让某个变量只能被单个线程访问,那么即使这个变量对象本身不是线程安全的,也不会出现安全问题。这种技术叫做线程封闭。它的典型例子有两个:

  1. Swing中的可视化组件和数据模型对象都不是线程安全的。Swing专门设置了一个事件分发线程(EDT, the Event Dispatching Thread)去管理这些对象,只有这个线程有权做UI更新等操作。如果其他线程也想修改UI,可以通过invokeLater()机制提交修改,这些修改事件会存放在message queue中等待EDT逐一处理。
  2. 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对象的域还可能指向可变对象。满足以下条件的对象才是不变对象:

  1. 对象创建之后状态不可更改。
  2. 所有的域都是final类型的。(其实String中的hash域并不是final的,但因为hash只与value[]有关而value[]是不可变的,所以hash理论上也是不可变的,不把hash设为final是为了将hash的计算推迟到第一次调用hashCode()时进行。不过作者并不建议程序员尝试这种方法,可能是因为会难以维护。
  3. 对象是正确创建的(创建期间this引用没有逸出)。

以下类满足了这三个要求:

 1 @Immutable
 2 public final class ThreeStooges{
 3       private final Set<String> stooges = new HashSet<String>();
 4 
 5       public ThreeStooges(){
 6           stooges.add("Moe");
 7           stooges.add("Larry");
 8            stooges.add("Curly");
 9       }
10 
11       public boolean isStooge(String name){
12           return stooges.contains(name);
13       }
14 }  

虽然看起来stooges会指向一些可变对象,但是ThreeStooges类并没有提供可以改变这些对象的接口,所以ThreeStooges是不可变的。

作者建议开发者多考虑不可变对象的使用。它们看起来不怎么实用(因为不可变),但其实我们对对象的引用是可变的,所以需要时只要创建一个新的不可变对象就可以。它的好处是不用加锁,而且会降低对generational garbage collection的影响(作者说的)。而且内存分配的开销通常比我们想象的要低。

就算对象是可变的,也应该把尽可能多的域设为final,这和把尽可能多的域设为private一样是一种好习惯,因为这样减少了对象可能的状态的数量,便于分析问题和维护。

以下阐释了把UnsafeCachingFactorizer改成用一个volatile的不变对象进行缓存的线程安全代码的方法。

 1 @NotThreadSafe
 2 public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
 3     private final AtomicReference<BigInteger> lastNumber
 4             = new AtomicReference<BigInteger>();
 5     private final AtomicReference<BigInteger[]> lastFactors
 6             = new AtomicReference<BigInteger[]>();
 7 
 8     public void service(ServletRequest req, ServletResponse resp) {
 9         BigInteger i = extractFromRequest(req);
10         if (i.equals(lastNumber.get()))
11             encodeIntoResponse(resp, lastFactors.get()); //!没维护不变性
12         else {
13             BigInteger[] factors = factor(i);
14             lastNumber.set(i);
15             lastFactors.set(factors);  //!没维护不变性
16             encodeIntoResponse(resp, factors);
17         }
18     }19 } 
 1 @Immutable
 2 public class OneValueCache {
 3     private final BigInteger lastNumber;
 4     private final BigInteger[] lastFactors;
 5 
 6     public OneValueCache(BigInteger i,
 7                          BigInteger[] factors) {
 8         lastNumber = i;
 9         lastFactors = Arrays.copyOf(factors, factors.length);
10     }
11 
12     public BigInteger[] getFactors(BigInteger i) {
13         if (lastNumber == null || !lastNumber.equals(i))
14             return null;
15         else
16             return Arrays.copyOf(lastFactors, lastFactors.length); //用copyOf()保证lastFactors不可变
17     }
18 }
19 
20 @ThreadSafe
21 public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
22     private volatile OneValueCache cache = new OneValueCache(null, null);
23 
24     public void service(ServletRequest req, ServletResponse resp) {
25         BigInteger i = extractFromRequest(req);
26         BigInteger[] factors = cache.getFactors(i);
27         if (factors == null) {
28             factors = factor(i);
29             cache = new OneValueCache(i, factors); 
30         }
31         encodeIntoResponse(resp, factors);
32     }
33 }

安全发布

这个话题对于我这种菜鸟来说是一个全新领域,或许只有提高经验值才能慢慢明白作者在此处的煞费苦心。

发布(Publication)

发布一个对象指把这个对象暴露给当前作用域之外的代码使用。以下是四种发布对象的方式:

  1. 直接把它存成一个public static域,所有其他类和线程都可以访问。
  2. 从一个非private的方法中返回对象引用。
  3. 把对象引用传给一个陌生方法(包括其他类的方法;自己类中可以被继承的,即非private且非final的方法)。
  4. 在构造函数中隐式发布this引用。(<-作者极力禁止)

禁止4的原因是在构造函数中发布this时,this对象可能还没初始化好。而陌生代码可能对this作任意操作而引起问题。如:

1 public class ThisEscape {
2     public ThisEscape(EventSource source) {
3         source.registerListener(new EventListener() {
4             public void onEvent(Event e) {
5                 doSomething(e);
6             }
7         });
8     }
9 }

这里的this对象就隐式逸出了。作者建议按照private构造函数 + public工厂方法的方式去修改:

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

此外,作者还建议不要在构造函数中启动线程,因为线程启动时可能用到对象中还没构造好的域。但是在构造函数中创建线程是可以的。我个人表示怀疑。(在构造函数中创建线程也有可能导致线程使用对象中未创建好的域进行初始化)。

安全发布的常用模式

安全发布:对象发布后,对象的引用本身和对象中的域的最新值对其他线程都可见。

由于缓冲区的存在,一个构造完成的对象在其他线程的眼中可能处于各种状态。

可通过以下方式安全地发布一个对象:

  1. 用static initializer初始化。
  2. 用volatile或AtomicReference存储要发布的对象。
  3. 用final存储对象。
  4. 用锁保护对象。

1能保证安全发布,是因为static initializers是JVM在类初始化的时期执行的,而JVM的类初始化是synchronized的,即获得锁才能进行初始化。所以static initialization后的对象及其状态对其他线程可见。

3能保证安全发布,是因为Java内存模型保证了对象构造后(只要this不在构造时溢出)它的所有final域和final域指向的对象的最新值马上对其它线程可见。

总结:任意对象的安全发布

对象的发布策略取决于它是否可变。

  1. 不可变对象可以被任意发布。
  2. effectively不可变的对象(程序中可以保证不对其进行修改的对象)须被安全发布,但发布后不必加锁使用。
  3. 可变对象必须安全发布。且如果不是线程安全对象,使用时需加锁保护。

总结:如何安全地共享对象

  • Thread-confined:只有一个线程可以修改。
  • Shared read-only:不可变和effective不可变对象
  • Shared thread-safe:对象内部是synchronized
  • Guarded:放在线程安全容器中使用,或使用时加锁保护。

以上是关于[JCIP笔记] 如何设计一个线程安全的对象的主要内容,如果未能解决你的问题,请参考以下文章

在这个类的对象上调用start()是否安全? Java Concurrency实践中的一个例子

笔记-线程安全的生命期管理

线程安全加锁的代码块的实现

《java并发编程实战》读书笔记3--对象的组合

《Java并发编程实战》学习笔记

ThreadLocal学习笔记