3. 对象的共享

1.可见性

指线程之间的可见性。当一个线程修改了对象的某个状态后,对其他线程是可见的。也就是说其他线程能立即看到这个修改后的结果。

Java指令重排序

在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速所带来的CPU空置的影响。虚拟机会将不满足happen-before规则的部分代码的执行顺序打乱——即写在后面的代码在时间顺序上可能先执行,而写在前面的代码可能会后执行,以尽可能充分地利用CPU。

happen-before规则

  • 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是代码顺序,因为要考虑分支、循环等结构。

  • 监视器锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个对象锁的lock操作。这里强调的是同一个锁,而“后面”指的是时间上的先后顺序,如发生在其他线程中的lock操作。

  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”也指的是时间上的先后顺序。

  • 线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):线程中的每个操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。

  • 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否已中断。

  • 对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

失效数据

在多线程访问某个变量时,如果没有使用同步,很可能获取到该变量的一个失效值。

下面这个例子用于演示变量的可见性,从理论上可能会输出0,也有可能不会输出,但是几率极低。

public class NoVisibility {
    private static boolean ready;
    private static int num;

    public static void main(String[] args) {
        new ReaderThread().start();
        num = 42;
        ready = true;
    }

    static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready)
                Thread.yield();
            System.out.println(num);
        }
    }
}

失效数据可能会导致输出错误的值,或者使程序无法结束,还有一些令人困惑的bug,比如意料之外的异常、被破坏的数据结构、不精确的计算以及无限循环。

最低安全性(out-of-thin-air-safety)

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证被称为最低安全性。

非原子的64位操作

Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但是对于非volatile类型的longdouble变量,JVM允许将64位的读操作和写操作分解为两个32位操作。在多线程程序中使用共享且可变的longdouble变量时考虑使用volatile关键字来声明或者使用锁进行保护。

使用内置锁来保证可见性

在共享变量上使用同一个内置锁来进行读操作和写操作的同步,那么可以保证所有使用该锁进行同步的线程都能看到共享变量的最新值。

使用volatile关键字来保证可见性

当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不将该变量上的操作与其他内存操作一起重排序。volatile变量不会缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型变量时总会返回最新写入的值。

仅当volatile变量能够简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。

当且仅当满足以下所有条件时,才应该使用volatile变量:

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

需要注意:

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。


2.发布与逸出

发布(Publish)对象

使对象能够在当前作用域之外的代码中使用。

例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。

逸出(Escape)

当某个不应该发布的对象被发布时,这种情况被称为逸出。

例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。即使没有线程在去使用这个未完全构造,但是误用该引用的风险始终存在。

发布对象的几种情况

  1. 最简单的方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。

    public class Params {
        public static Map<String, Object> params;
    
        public void init() {
            params = new HashMap<String, Object>();
        }
    }
  2. 从非私有方法中返回一个引用。

    public class UnsafeStates {
        private String[] states = new String[] {"AK", "AL"};
    
        public String[] getStates() {
            return states;
        }
    }
  3. 发布一个内部的类实例,这同时会隐含的发布外部类的实例。

    public class ThisEscape {
        public ThisEscape(EventSource source) {
            source.registerListener(
                new EventListener() {
                    public void onEvent(Event e) {
                        doSomething(e);
                    }
                }
            );
        }
    }

安全的对象构造过程

不要在构造过程中使this引用逸出。

当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。
在构成过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程。


3.线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。

Ad-hoc 线程封闭

维护线程封闭性的职责完全由程序实现来承担。

Ad-hoc 线程封闭技术很脆弱,尽量在程序中少使用它。

栈封闭

将局部变量封闭在执行线程的栈中。栈封闭也被称为线程内部使用或者线程局部使用。

在Java语言中,保证了基本类型的局部变量始终被封闭在线程中。而引用类型的局部变量则需要小心不要将这些对象错误地逸出。

ThreadLocal 类

维持线程封闭性的一种更为规范的方法是使用ThreadLocalThreadLocal一般称为线程本地变量,它是一种特殊的线程绑定机制,将变量与线程绑定在一起,为每一个线程维护一个独立的变量副本。通过ThreadLocal可以将对象的可见范围限制在同一个线程内。
使用ThreadLocal类来为每个线程获取一个专属的SimpleDateFormat对象。

private static final ThreadLocal<DateFormat> DATE_FORMATS = new ThreadLocal<DateFormat>() {
    @Override
    protected DateFormat initialValue() {
        return new SimpleDateFormat();
    }
};

4.不变性

不可变对象(Immutable Object)

如果某个对象在被创建后期状态不能被修改,那么这个对象就被称为不可变对象。不可变对象一定是线程安全的。

当满足以下条件时,对象才是不可变的:

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

final 域

final域能够确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。

除非需要某个域是可变的,否则应将其声明为final域。


5.安全的发布对象

不可变对象与初始化安全性

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步情况下,也可以安全地访问final类型的域。但是,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍需要同步。

安全发布的常用模式

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

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

下面举例说明:

  • 在静态初始化函数中初始化一个对象引用。

    public static Number number = new Number(1);

    使用静态的初始化器是最简单和最安全的方式。静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。

  • 将对象的引用保存到某个正确构造对象的final类型域中

    // 构造一个缓存类,保存计算结果。
    public class OneValueCache {
        private final BigInteger lastNumber;
        private final BigInteger[] lastFactors;
    
        public OneValueCache(BigIneger i, BigInteger[] factors) {
            this.lastNUmber = i;
            this.lastFactors = Arrays.copyOf(factors, factors.length);
        }
    
        public BigInteger[] getFactors(BigInteger i) {
            if (lastNumber == null || !lastNumber.equals(i))
                return null;
    
            return Arrays.copyOf(lastFactors, lastFactors.length);
        }
    }
    
    // 构造一个因式分解的类
    public class Factorizer {
        private volatile OneValueCache cache = new OneValueCache(null, null);
    
        public BigInteger[] calculate(BigInteger i) {
            BigInteger[] factors = cache.getFactors(i);
            if(factors == null) {
                factors = factor(i);
                cache = new OneValueCache(i, factors);
            }
    
            return factors;
        }
    
        private BigInteger[] factor(BigInteger i) {
            // 没有实现
        }
    }

    cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性,并使用一个volatile类型的引用来确保可见性,Factorizer在没有显示使用锁的情况下仍旧是线程安全的。

  • 将对象的引用保存到一个由锁保护的域中。
    如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的代码中没有包含显式的同步。
    在Java线程安全库中的容器类提供了以下的安全发布保证:

    • 通过将一个键或者值放入HashTablesynchronizedMapConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
    • 通过将某个元素放入VectorCopyOnWriteArrayListCopyOnWriteArraySetsynchronizedListsynchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
    • 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

事实不可变对象(Effectively Immutable Object)

如果对象从技术上来看是可变的,但其状态在发布后不会再改吧,那么把这种对象称为事实不可变对象。在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

可变对象

如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。

对象的发布需求

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

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

安全地共享对象

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

  • 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
  • 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改他。共享的只读对象包括不可变对象和事实不可变对象。
  • 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  • 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值