Java并发编程实战读书笔记(一)——线程安全性、对象共享

一、线程安全性

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。
当多个线程访问,并且其中有一个执行写入时,必须采用同步机制,Java中主要的同步关键字是 synchronized 独占加锁。但 “同步” 这个术语还包括 volatile 类型的变量,显式锁,原子变量。

1、线程安全的定义

线程安全: 核心正确性,即某个类的行为与其规范完全一致。
线程安全的类: 某个类在主调代码中不需要任何的同步或协调,这个类都表现正确的行为。(在类中封装了必要的同步机制)
无状态对象一定线程安全: 既不包含任何域,也不包含任何对其他类中域的引用。(大多Servlet都是线程安全,除非要保存一些信息)

2、原子性

竞态条件: 由于不恰当的执行时序而出现不正确的结果。当某个计算的正确性取决于多个线程交替执行时序时,就会发生竞态条件。
复合操作: 如 “先检查后执行”(类延时初始化),“读取-修改-写入” (自增)等操作。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

原子性 : 一组语句作为不可分割的单元被执行。

3、加锁

每个Java对象都可以用作一个实现同步的锁,这些锁被称为 内置锁监视器锁,线程只有在进入同步代码块会自动获得锁,退出释放锁。
可重入 : 内置锁是可重入的,即一个线程可以多次进入同步代码块加同一个锁,获取锁的是 线程 不是 调用 。(重入一种实现为每个锁加计数器和关联线程,进入一次加一,退出一次减一,0时候代表已被释放,未加锁。)

对于可能被多个线程同时访问的变量,访问时都需要持同一个锁,称这个状态变量由这个锁保护。每个变量都应只有一个锁保护。
对于包含多个变量的不变性条件,其中涉及的所有变量都需要同一个锁保护。

一种常见加锁约定:将所有可变状态封装对象内部,通过对象内置锁对访问代码路径同步,使对象不会被并发访问。

4、活跃性与性能

过多的同步与锁会带来活跃性(即性能)问题。所以尽可能在正确性前提下缩小锁的粒度。设计时需要在 性能、简单性和安全性 等需求之间权衡。

通常在简单性和性能之间存在着制约因素,一定不能盲目为了性能牺牲简单性。(会给维护带来不变,而且可能破坏安全性)
执行长时间计算或者无法快速完成的操作时(网络I/O、控制台I/O),一定不要持有锁。

二、对象的共享

之前介绍如何同步避免多线程同一时刻访问数据,现在介绍如何 共享和发布 对象。
synchronized 除了实现原子性或者确定 “临界区(Critical Section)”,还有另一个重要方面:内存可见性。

1、可见性

可见性是种复杂的熟悉,因为可见性总会违背我们的直觉。
重排序 : 为了提高执行速度,编译器、处理器以及运行时都会对操作的执行顺序进行调整,符合一定规则的代码不会被重排序,后面详细介绍。
失效数据: 缺乏同步的程序中可能会产生失效数据,并且一个线程可能获得变量的最新值,而另一个线程获得失效值。
最低安全性: 至少失效值是初始化值或者之前线程设置的值,而不是一个随机值。(但有例外,JVM允许64位的读写操作分解为两个32位的操作,所以并不是原子的。所以多线程使用共享可变的long和double等类型必须要保护)

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

2、Volatile变量

提供了一种稍弱的同步机制

  1. 会有内存屏障,不会将改变量操作与其它内存操作重排序。
  2. 不会被缓存在寄存器或者对其它处理器不可见的地方,因此读取时总是最新值。

仅当 volatile 变量能简化代码的实现以及对同步策略验证时,才应该使用。volatile 变量正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象状态的可见性,以及表示一些重要的程序生命周期事件的发生。
加锁机制即可保证可见性又可保证原子性,而volatile变量只能保证可见性。

当且仅当满足下列条件时,才应使用volatile变量:

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

3、发布与逸出

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

  1. 将对象的引用保存在一个公有的静态变量中。
  2. 从非私有方法中返回一个引用,同样会发布返回的对象。(封装能使得对程序的正确性分析边为可行,并使得无意中破坏约束条件变得更难)
  3. 发布一个类内部的类实例,可能会隐式的使 this 引用逸出。
    public class ThisEscape{
        public ThisEscape(EventSource source){
            source.registerListener(new EventListener(){
                public void onEvent(Event e){
                    dosomething(e);
                }
            });
        }
    }

内部 EventListener 实例发布时,外部封装的 ThisEscape 也逸出了。
在对象构建时,构造函数中启动一个线程,会使得 this 引用被新线程共享。在构造函数中调用一个可改写的实例方法时,同样会使得 this 引用在构造过程中逸出。

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;
        }
    }

4、线程封闭

一种避免使用同步的方式就是不共享数据(如果仅在单线程中访问数据就不需要同步),这种技术被称为线程封闭,它是实现线程安全性的最简单方式之一。当某个对象被封闭在一个线程中时,这种用法将自动实现线程安全性。

(1)Ad-hoc线程封闭

维护线程封闭性的职责完全由程序实现来承担,这种线程封闭技术是脆弱的,应该尽少使用。

(2)栈封闭

栈封闭是线程封闭的特例,栈封闭中,只能通过局部变量访问对象。同步变量能够使对象更容易被封闭在线程中,局部对象的固有属性之一就是封闭在执行线程中。

(3)ThreadLocal 类

维持线程封闭一种更规范方法是使用 ThreadLocal 类,这个类能使线程中某个值与保存值对象关联起来,提供了get 与 set 等访问接口或方法,这些方法为每个使用改变量的线程都存一份独立的副本,因此 get 总是返回 set 设置的最新值。
ThreadLocal 通常用来防止对可变的单实例变量或全局变量共享。

    private static ThreadLocal<Connection> connectionHolder 
            = new ThreadLocal<Connection>(){
        public Connection initialValue(){
            return DriverManager.getConnection(DB_URL);
        }
    };
    public static Connection getConnection(){
        return connectionHolder.get();
    }

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免每次执行都重新分配该临时对象,就可以使用这项技术。
但ThreadLocal 变量类似于全局变量,它能降低代码可重用性并在类间引入隐含的耦合性,因此使用时要小心。

5、不变性

不可变的对象一定是线程安全的。即使对象的所有域都是final类型,对象也不一定是不可变的,因为 final 类型的域中可以保存对可变对象的引用。
满足下列条件,对象次啊是不可变的。

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

Final 域

通过对域声明为final,可以告诉其它人这个域是不可变的。

除非需要更高的可见性,否则应将所有的域都声名为私有域。除非需要某个域是可变的,否则应将其声明为 final 域。

6、安全发布

我们希望在多个线程之间共享对象,此时必须保证安全地进行共享。

(1)不正确的发布

//没有在足够同步的情况下发布对象
public Holder holder;
public void initialize(){
	holder = new Holder(42);
}

除了发布对象的线程外,其它线程可能看到的 Holder 域是一个失效值,因此将看到一个空引用或之前的旧值。如果没有足够的同步,那么在多个线程间共享对象会发生一些奇怪的事。

(2)不可变性与初始化安全性

Java内存模型为不可变对象的共享提供了一种特殊的初始化保证其安全性。
任何线程都可以不需要额外同步情况下安全地访问不可变对象,即使发布时对象没有使用同步。

(3)安全发布常用模式

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

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到 volatile 类型的域或 AtomicReferance 中。
  • 将对象的引用保存到某个正确构造对象的f inal 域中。
  • 将对象的引用保存一个由锁保护的域中。
  • 一些线程安全的容器也可以安全发布对象。(Hashtable、synchronizedMap、ConcurrentMap;Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList、synchronizedSet;BlockingQueue、ConcurrentLinkedQueue;Future、Exchanger 等)
public static Holder holer = new Holer(42);

使用静态初始化器发布一个静态构造对象。JVM在类初始化阶段执行静态初始化器,在JVM内部存在同步机制,因此这种方式初始化的任何对象都可以正确被发布。

(4)事实不可变对象、可变对象

  1. 任何线程都可以不需要额外同步安全使用被安全发布的事实不可变对象。
  2. 可变对象不仅在发布时需要同步,每次对象访问也需要同步来确保可见性。

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

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

(5)实用策略

  • 线程封闭。 线程封闭对象只能由一个线程拥有,对象被封闭在线程中,并只能由这个线程修改。
  • 只读共享。 共享的只读对象(包括不可变对象和事实不可变对象)可以不需要额外同步由多个线程访问。
  • 线程安全共享。 线程安全的对象在其内部实现同步,多个线程可以对共有方法直接使用。
  • 保护对象。 被保护的对象只能通过持有锁来访问。保护对象包括封装在其它线程安全中的对象,以及已发布并由某个特定锁保护的对象。

版权声明:本文为kongzyu原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。