【并发编程】安全发布对象与防止对象逸出(缘由与防御方法)

发布对象与对象逸出

首先来明确一下发布与逸出的概念。java

发布对象:使一个对象可以被当前范围以外的代码所使用。数组

对象逸出:是一种错误的发布。当一个对象尚未构造完成时就使它被其余线程所见。安全

在平常开发中,咱们常常须要发布对象,好比经过类的非私有方法返回对象的引用,或者经过公有静态变量发布对象。bash

下面来coding演示一下多线程

首先咱们编写一个不安全发布对象的例子函数

@Slf4j
@NotThreadSafe
public class UnsafePublish {

    private String[] states = {"a", "b", "c"};

    public String[] getStates(){
        return states;
    }

    public static void main(String[] args) {
        UnsafePublish unsafePublish = new UnsafePublish();
        log.info("{}", Arrays.toString(unsafePublish.getStates()));

        unsafePublish.getStates()[0] = "d";
        log.info("{}", Arrays.toString(unsafePublish.getStates()));
    }
}
复制代码

输出结果性能

能够看到statusabc被改为了dbcthis

代码解读spa

这个类经过getStates()这个public方法发布了类的域,在类的任何外部的线程均可以访问这些域,这样的发布对象实际上是不安全的,由于咱们没法保证其余线程会不会修改这个域从而致使这个类状态的错误。能够看到在main方法里经过new发布了一个这个类的实例,而后就能够经过这个类提供的public方法直接获得它的私有域status的引用,获得这个引用以后就能够在其余任何线程里直接去修改这个数组里的值,这样一来当我在其余线程使用这个数组里的值的时候,它的数据就是不彻底肯定的,所以这样发布的对象就是线程不安全的。线程

下面是对象逸出的例子

@Slf4j
public class Escape {

    private int thisCanBeEscape = 0;

    public Escape() {
        new InnerClass();
    }

    private class InnerClass {

        public InnerClass() {
            log.info("{}", Escape.this.thisCanBeEscape);
        }
    }

    public static void main(String[] args) {
        new Escape();
    }
}
复制代码

输出结果

能够看到输出结果就是thisCanBeEscape的值。

代码解读

这个内部类的实例里包含了对封装实例隐含的引用,这样就在对象没有被正确构造完成以前它就会被发布可能有不安全的因素在里面。一个致使this在构造期间逸出的错误,它是在Escape构造函数的过程当中启动了一个线程,不管是隐式仍是显式的启动都会形成this引用的逸出,新线程总会在所属对象构造完毕以前看到这个引用。若是要在构造函数中建立线程,那么应该采用一个专有的start或初始化的方法来统一启动线程,这里这个例子能够采用工厂方法和私有构造函数来完成对象建立和监听器的注册等。

不正确发布可变对象会致使线程看到的被发布对象的引用是最新的,而被发布对象的状态倒是过时的。若是一个对象是可变对象必定要安全发布才能够。

安全发布对象的四种方法

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

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

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

  • 4.将对象的引用保存到一个由锁保护的域中。

懒汉模式

下面经过单例模式的背景来实现发布线程安全的对象

首先咱们来写一个普通的懒汉式单例

/** * className SingletonExample1 * description TODO * 懒汉式单例 * 单例的实例在第一次使用时建立 * * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
public class SingletonExample1 {

    /** * 私有构造函数 */
    private SingletonExample1(){
    }

    /** * 单例对象 */
    private static SingletonExample1 instance = null;

    /** * 静态工厂方法 * @return */
    public static SingletonExample1 getInstance() {
        if (instance == null) {
            instance = new SingletonExample1();
        }
        return instance;
    }

}
复制代码

这段单例是线程不安全的,缘由在于getInstance方法中的if判断,与以前的计数例子相同,如有两个线程同时访问这个方法,都访问到if判断时这个实例都是null,那么两个线程都会去new一个新的实例,这样的话就致使了私有构造函数被调用两次(这里是出错的缘由),这两个线程拿到的实例是不同的。

可能有些读者会疑惑这里的方法只是为了拿到实例,就算初始化了两次也不会有什么影响。其实问题在与实例化的过程当中调用了两次构造函数,在真正实现时构造函数中可能会作不少操做,如对资源的处理、运算等,这时若是运算两次就可能会出现错误。这里只是经过一个简单的示例来讲明它是否运行了两次,运行了两次只能说明是线程不安全,而线程不安全并不能必定致使很差的现象,有时甚至不会有什么影响。这里咱们知道这样写是线程不安全的就能够了。

饿汉模式

/** * className SingletonExample1 * description TODO * 饿汉式单例 * 单例的实例在类装载时建立 * * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
@ThreadSafe
public class SingletonExample2 {

    /** * 私有构造函数 */
    private SingletonExample2(){
    }

    /** * 单例对象 */
    private static SingletonExample2 instance = new SingletonExample2();

    /** * 静态工厂方法 * @return */
    public static SingletonExample2 getInstance() {
        return instance;
    }

}
复制代码

饿汉模式是线程安全的,若是单例类的构造方法中没有包含过多的操做处理饿汉模式仍是能够接受的,不然可能引发性能问题。若是使用饿汉模式而没有实际调用的话会形成资源浪费。

所以使用饿汉模式时必定要考虑两个问题:

  • 1.私有构造函数在实现时没有太多的处理。

  • 2.确保这个类在实际过程当中确定会被使用。

线程安全的懒汉模式

懒汉模式在必定条件下也能够是线程安全的。

只需在以前懒汉模式的实现中的静态工厂方法前加入synchronized修饰便可

public static synchronized SingletonExample3 getInstance() {
    if (instance == null) {
        instance = new SingletonExample3();
    }
    return instance;
}
复制代码

可是这种写法并不推荐,缘由在于这个方法加了`sync`修饰之后,经过同一时间只容许一个线程访问的方式来保证了线程安全,可是却有性能上的开销,而咱们并不但愿产生这种开销。

性能更好的懒汉模式(线程不安全)

接下来咱们继续修改懒汉模式来解决性能上的问题

上个实现中产生性能问题的缘由在于sync修饰了方法,那么咱们就把sync放到方法实现里去

public static SingletonExample4 getInstance() {
    if (instance == null) { //双重检测机制
        synchronized (SingletonExample4.class){ // 同步锁
            if (instance == null){
                instance = new SingletonExample4();
            }
        }
    }
    return instance;
}
复制代码

在第一次判空之后用sync来锁这个类,而后再一次判空,这种方法称为双重检测机制,这种实例的方式也能够称为双重同步锁单例模式。

可是这个类并非线程安全的

你们可能会这样想:第一次判空以后,下面的代码在同一时间内只有一个线程能够访问,而一个线程访问以后若是instance已经被实例化了第二个线程访问时发现第二次判空失败就不会再去实例化了,而后直接返回就能够了。

这样看起来彷佛没有问题,但问题到底出在哪了?

缘由要从CPU的指令开始提及,当咱们执行

instance = new SingletonExample4();
复制代码

时,CPU会执行3步指令

1.分配对象内存空间 memory = allocate()
2.初始化对象 ctorInstance()
3.设置instance指向刚分配的内存 instance = memory
复制代码

在完成了这三步后instance就指向了实际分配的内存地址了,就说咱们说的引用。在单线程状况下上面的方法没有任何问题,但在多线程状况下可能会发生指令重排序,因为23指令没有先后必要的关系,所以在重排序时CPU的指令顺序会变成1-3-2 在这个前提下咱们回来看双重检测机制

假设如今有两个线程AB来调用getInstance方法,这时可能会出现的一种状况:线程A执行到了instance = new SingletonExample4(); 而线程B刚执行到第一次判空的地方,这时按照1-3-2CPU指令执行顺序会出现A正好执行到3的步骤,而线程B在第一次判空的地方会发现这个instance已经指向了一块内存便会直接返回instance,而在A这边实际的初始化对象这一步尚未作,线程B在拿到这个尚未作初始化对象的instance以后一旦调用就会出现问题。虽然这种状况发生的几率不大,但仍是有线程安全风险的。

线程安全且性能更好的懒汉模式

既然出现问题的缘由是发生了指令重排,那么咱们就不让它发生指令重排,这时咱们应该想起以前学的关键字:volatile

/** * 单例对象 */
private volatile static SingletonExample5 instance = null;

/** * 静态工厂方法 * @return */
public static SingletonExample5 getInstance() {
    if (instance == null) { //双重检测机制
        synchronized (SingletonExample5.class){ // 同步锁
            if (instance == null){
                instance = new SingletonExample5();
            }
        }
    }
    return instance;
}
复制代码

这样咱们就禁止了指令重排,这个类又变成线程安全的了。

枚举模式(推荐使用)

/** * className SingletonExample1 * description TODO * 枚举单例 * @author ln * @version 1.0 * @date 2019-07-12 20:52 */
@Slf4j
@ThreadSafe
public class SingletonExample7 {

    /** * 私有构造函数 */
    private SingletonExample7(){
    }

    /** * 静态工厂方法 * @return */
    public static SingletonExample7 getInstance() {
        return Singleton.INSTANCE.getInstance();
    }

    private enum Singleton {
        INSTANCE;

        private SingletonExample7 singleton;

        //JVM保证这个方法绝对只调用一次
        Singleton() {
            singleton = new SingletonExample7();
        }

        public SingletonExample7 getInstance() {
            return singleton;
        }
    }

}
复制代码

咱们经过枚举的值调用枚举类里的getInstance方法时,能够保证这个方法只被实例化一次且是在这个类调用以前初始化的,所以这里很好地完成了线程安全。

枚举方式相比于懒汉模式在安全性方面更容易保证,其次相比于饿汉模式,枚举模式能够在实际调用时才开始作最开始的初始化,在后续使用时也能够直接取到里面的值,不会形成资源浪费。

Written by Autu.

2019.7.15

相关文章
相关标签/搜索