【并发编程】安全发布对象与防止对象逸出(原因与防护方法)

发布对象与对象逸出

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

发布对象:使一个对象能够被当前范围之外的代码所使用。

对象逸出:是一种错误的发布。当一个对象还没有构造完成时就使它被其他线程所见。

在日常开发中,我们经常需要发布对象,比如通过类的非私有方法返回对象的引用,或者通过公有静态变量发布对象。

下面来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被改成了dbc

代码解读

这个类通过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

猜你喜欢

转载自juejin.im/post/5d2c9175f265da1b6a34c66b
今日推荐