Java并发编程(3)- 如何安全发布对象

时间:2022-07-25
本文章向大家介绍Java并发编程(3)- 如何安全发布对象,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

发布对象与逸出

发布对象:

  • 使一个对象能够被当前范围之外的代码所使用,例如通过方法返回对象的引用,或者通过公有的静态变量发布对象

对象逸出:

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

不正确的发布可变对象导致的两种错误:

  1. 发布线程意外的所有线程都可以看到被发布对象的过期的值
  2. 线程看到的被发布对象的引用是最新的,然而被发布对象的状态却是过期的

不安全的发布示例:

package org.zero.concurrency.demo.example.publish;

import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;

/**
 * @program: concurrency-demo
 * @description: 不安全的对象发布示例
 * @author: 01
 * @create: 2018-10-16 16:21
 **/
@Slf4j
public class UnsafePublish {

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

    /**
     * 过public访问级别发布了类的域,在类的外部,任何线程都可以访问这个域
     * 这样发布的对象是不安全的,因为我们无法得知其他线程是否会修改这个域导致该类里数据的错误
     *
     * @return String[]
     */
    public String[] getStates() {
        return states;
    }

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

        unsafePublish.getStates()[0] = "d";
        // 输出 [d, b, c]
        log.info("{}", Arrays.toString(unsafePublish.getStates()));
    }
}

在这个例子中,我们通过new对象得到了对象实例。获得这个对象后,我们可以调用getStates()方法得到私有属性的引用,这样就可以在其他任何线程中,修改该属性的值。那么这就会导致我们在其他线程中,获取该属性的值时是不确定的,因为并不能得知该属性的值是否已被其他线程所修改过,所以这就是不安全的对象发布。

对象逸出示例:

package org.zero.concurrency.demo.example.publish;

import lombok.extern.slf4j.Slf4j;
import org.zero.concurrency.demo.annotations.NoRecommend;
import org.zero.concurrency.demo.annotations.NotThreadSafe;

/**
 * @program: concurrency-demo
 * @description: 对象逸出示例,在对象构造完成之前,不可以将其发布
 * @author: 01
 * @create: 2018-10-16 16:36
 **/
@Slf4j
@NotThreadSafe
@NoRecommend
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();
    }
}

在以上这个例子中,内部类的构造器里包含了对封装实例的隐含引用,这样在对象没有被正确构造完成之前就会被发布,由此会导致不安全的因素在里面。其中一个就是导致this引用在构造期间逸出的错误,它是在构造函数构造过程中启动了一个线程,无论是显式启动还是隐式启动,都会造成this引用的逸出。新线程总会在所属对象构造完毕之前就已经看到它了,所以如果要在构造函数中创建线程,那么不要启动它,而是应该采用一个专有的start,或是其他初始化的方式统一启动线程。这里其实我们可以使用工厂方法和私有构造函数来完成对象创建和监听器的注册等等来避免不正确的发布。


如何安全发布对象

上一小节中,我们简述了什么是发布对象,以及给出了不安全发布对象的示例和对象逸出的示例和说明。所以本小节我们将看看如何安全的发布对象,想要安全的发布对象主要有四种方法:

  1. 在静态初始化函数中初始化一个对象的引用
  2. 将对象的引用保存到volatile类型域或者AtomicReference对象中
  3. 将对象的引用保存到某个正确构造对象的final类型域中
  4. 将对象的引用保存到一个由锁保护的域中

以上所提到的几种方法都可以应用到单例模式中,所以本文将以单例模式为例,介绍如何安全发布对象,以及单例实现的一些问题。

众所周知,单例模式是最常用的设计模式了。Spring容器中所管理的类的实例默认也是单例的,虽然单例看似简单,但也是有不少需要注意的地方,特别是在多线程环境下。基础的单例模式实现方式就不赘述了,我们来看看为什么采用了双重同步锁的懒汉式单例还是线程不安全的。示例代码如下:

/**
 * 双重同步锁懒汉式单例-线程不安全
 * 实例在第一次使用的时候创建
 *
 * @author 01
 */
public class SingletonExample4 {

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

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

    /**
     * 静态工厂方法-获取实例
     *
     * @return instance
     */
    public static SingletonExample4 getInstance() {
        // 双重检查机制
        if (instance == null) {
            // 同步锁
            synchronized (SingletonExample4.class) {
                if (instance == null) {
                    instance = new SingletonExample4();
                }
            }
        }
        return instance;
    }
}

以上代码中在执行 instance = new SingletonExample4(); 语句的时候,底层实际进行了以下三步操作:

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

在代码逻辑上,看似不会出现线程不安全的问题。但是在JVM里,这几步可能会被乱序执行,即便是乱序执行,在单线程下也不会有什么问题,但是在多线程下就不一样了。经过JVM和CPU的优化,指令可能会重排成下面的顺序:

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

假设按照这个指令顺序执行的话,那么当线程A执行完1和3时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if (instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象。如下图所示:

那么要如何避免这一情况呢?我们需要给instance对象增加一个volatile关键字进行修饰,这样就不会出现指令重排的情况了。若对volatile不是很清楚的话,可以参考我另一篇文章中对volatile关键字的介绍:

修改后线程安全的懒汉式单例代码如下:

public class SingletonExample5 {
    /**
     * 单例对象,使用 volatile 关键字禁止指令重排
     */
    private volatile static SingletonExample5 instance = null;

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

    /**
     * 静态工厂方法-获取实例
     *
     * @return instance
     */
    public static SingletonExample5 getInstance() {
        // 双重检查机制
        if (instance == null) {
            // 同步锁
            synchronized (SingletonExample5.class) {
                if (instance == null) {
                    instance = new SingletonExample5();
                }
            }
        }
        return instance;
    }
}

经过volatile的修饰,当线程A执行instance = new Singleton的时候,JVM执行顺序是什么样?始终保证是下面的顺序:

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

如此在线程B看来,instance对象的引用要么指向null,要么指向一个初始化完毕的Instance,而不会出现某个中间态,保证了安全。


实现单例模式的方式有很多种,除了以上所提到的,我们还可以使用静态内部类来实现单例,这样更简单,不需要判空也不需要加 volatile 关键字去防止指令重排的问题。示例代码如下:

/**
 * 使用静态内部类实现的单例模式-线程安全
 * 实例在第一次使用的时候创建
 *
 * @author 01
 */
public class SingletonExample8 {
    /**
     * 私有构造函数
     */
    private SingletonExample8() {
    }

    /**
     * 静态工厂方法-获取实例
     *
     * @return instance
     */
    public static SingletonExample8 getInstance() {
        return LazyHolder.INSTANCE;
    }

    /**
     * 用静态内部类创建单例对象
     */
    private static class LazyHolder {
        private static final SingletonExample8 INSTANCE = new SingletonExample8();
    }
}

这里有几个需要注意的点:

  1. 从外部无法访问静态内部类LazyHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象INSTANCE。
  2. INSTANCE对象初始化的时机并不是在单例类Singleton被加载的时候,而是在调用getInstance方法,使得静态内部类LazyHolder被加载的时候。因此这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。

以上所提到的单例实现方式并不能算是完全安全的,这里的安全不仅指线程安全还有发布对象的安全。因为以上例子所实现的单例模式,我们都可以通过反射机制去获取私有构造器更改其访问级别从而实例化多个不同的对象,虽然一般不会这么干,但也难免会有这种情况。那么如何防止利用反射构建对象呢?这时我们就需要使用到内部枚举类了,因为JVM可以阻止反射获取枚举类的私有构造方法。示例代码如下:

/**
 * 使用枚举类实现的单例模式-线程最为安全
 * 实例在第一次使用的时候创建
 *
 * @author 01
 */
public class SingletonExample7 {
    /**
     * 私有构造函数
     */
    private SingletonExample7() {
    }

    /**
     * 静态工厂方法-获取实例
     *
     * @return instance
     */
    public static SingletonExample7 getInstance() {
        return Singleton.INSTANCE.getInstance();
    }

    /**
     * 由枚举类创建单例对象
     */
    @Getter
    private enum Singleton {
        INSTANCE;

        /**
         * 单例对象
         */
        private SingletonExample7 instance;

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

使用枚举实现的单例模式,是最为推荐的一种写法,因为这种实现方式不但可以防止利用反射强行构建单例对象,而且可以保证线程安全,并且可以在枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象。这里之所以使用内部枚举类的原因是为了让这个单例对象可以懒加载,相当于是结合了静态内部类的实现思想。若不使用内部枚举类的话,单例对象就会在枚举类被加载的时候被构建。


单例模式实现总结: