何为安全发布,又何为安全初始化?

Posted createmyself

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了何为安全发布,又何为安全初始化?相关的知识,希望对你有一定的参考价值。

前言

很多时候我们需要跨线程共享对象,若存在并发我们必须以线程安全的方式共享对象,此时将涉及到我们如何安全初始化对象从而进行安全发布,本节我们将来讨论安全初始化、安全发布,文中若有错误之处,还望批评指正。

安全发布

按照正常叙述逻辑来讲,我们应该首先讨论如何安全初始化,然后再进行安全发布分析,在这里呢,我们采取倒叙的方式,先通过非安全发布的方式讨论所出现的问题,然后最后给出如何进行安全初始化,如下,我们以单例模式为例。

public class SynchronizedCLFactory {
    private Singleton instance;

    public Singleton get() {
        synchronized (this) {
            if (instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    }
}

public class Singleton {

}

如上提供了用于获取Singleton实例的公共方法,我们通过同步关键字保持线程安全,无论有多少个线程在请求一个Singleton,也不管当前状态如何,所有线程都将获得相同的Singleton实例,Singleton初始化在第一次请求Singleton时发生,而不是在初始化Singleton类时发生,至于是否惰性初始化并不是我们关注的重点,同时将对代码块加锁,使得Singleton状态的开销尽量保持最小。为了更加严谨而使得单例必须具备唯一实例,我们进行DCL(Double Check Lock),我们可能会进行如下改造:

public class SynchronizedCLFactory {
    private Singleton instance;

    public Singleton get() {
        if (instance == null) {
            synchronized (this) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

public class Singleton {

}

或许我们认为成功完成进行第一步判断之后,就可以正确初始化Singleton实例,然后可以将其返回,其实这是错误的理解,因为Singleton实例仅对构造线程完全可见,而无法保证在其他线程中能够正确看到Singleton实例,因为正在与初始化Singleton实例线程存在竞争,再者,即使最终已获得非空实例,也并不意味着我们能正确观察其内部状态,从JMM角度来看,在Singleton构造函数中的初始化存储与读取Singleton字段之间没有发生任何事情,我们也可以看到,在第一步判断和最后一步返回并没有进行任何同步的读取,Java内存模型的目的之一是允许对普通读取进行重排序(reordering),否则性能开销将可想而知,在规范方面,读取操作可以通过竞争观察无序写入,这是针对每个读取动作决定的,而与其他什么动作已经读取同一位置没有任何关系,在如上示例中,这意味着即使通过第一步判断可以读取非空实例,但代码随后继续返回它,然后又读取了一个原始值,并且可以读取将返回的空的实例。安全发布与常规发布在一个关键点上有所不同:安全发布使发布之前编写的所有值对观察发布对象的所有线程可见,它大大简化了关于动作,命令等JMM约定规则。所以接下来我们来讲讲安全发布之前的动作即安全初始化。

安全初始化

在初始化共享对象时,该对象必须只能由构造它的线程访问,但是,一旦初始化完成,就可以安全的发布对象即使该对象对其他线程可见,Java内存模型(JMM)允许多个线程在初始化开始后但结束之前观察对象,因此,我们写程序时必须防止发布部分初始化的对象,该规则禁止在初始化结束之前发布对部分初始化的成员对象实例的引用,特别适用于多线程代码中的安全性,在对象构造期间不要让this引用转义,以防止当前对象的this引用转义其构造函数。如下代码示例在Foo类的initialize方法中构造一个Holder对象,Holder对象的字段由其构造函数初始化。

public class Foo {
    private Holder holder;

    public Holder getHolder() {
        return holder;
    }

    public void initialize() {
        holder = new Holder(42);
    }
}

public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }
}

如果线程在执行initialize方法之前使用getHolder方法访问Holder类,则该线程将观察到未初始化的holder程序字段,接下来如果一个线程调用initialize方法,而另一个调用getHolder方法,则第二个线程可以观察这几种情况之一:holder的引用为空、完全实例化的Holder对象中的n为42,具有未初始化n的部分初始化的Holder对象,其中包含字段的n默认值0,其主要原因在于,JMM允许编译器在初始化新的Holder对象之前为新的Holder对象分配内存,并将对该内存的引用分配给holder字段,换句话说,编译器可以对holder实例字段的写入和初始化Holder对象的写入(即this.n = n)进行重排序,以至于使前者优先出现,这将出现一个竞争,在此期间其他线程可以观察到部分初始化的Holder对象实例。在对象构造函数完成其初始化之前,不应发布对对象实例的引用,这会在对象构造期间造成this引用逸出。我们继续往下看如何正确的进行安全初始化。

同步机制

我们使用方法同步可以防止发布对部分初始化的对象的引用,代码如下:

public class Foo {
    private Holder holder;

    public synchronized Holder getHolder() {
        return holder;
    }

    public synchronized void initialize() {
        holder = new Holder(42);
    }
}

我们将上述初始化和获取实例化的变量holder这两种方法进行同步,可确保它们不能同时执行,如果一个线程恰好在getHolder方法的线程之前调用initialize方法,则同步的initialize方法将始终首先完成,这是因为synchornized关键字在两个线程之间建立了事前发生(happens-before)的关系,因此调用getHolder方法的线程将看到完全初始化的Holder对象或缺少的Holder对象,也就是说,holder将包含空引用,这种方法保证了对不可变成员和可变成员的完全正确发布。

final关键字

JMM保证将声明为final的字段的完全初始化的值安全发布到每个线程,这些线程在不早于对象构造函数结尾的某个时间点读取这些值,如下代码示例:

public class Foo {
    
    private final Holder holder;
    
    public  Foo(){
        holder = new Holder(42);
    }

    public Holder getHolder() {
        return holder;
    }
    
}

但是,此解决方案需要将新的Holder实例holder分配在Foo的构造函数中进行,根据Java语言规范,在构造期间读取final字段:构造该对象的线程中对象的final字段的读取是根据通常的事前发生(happens-before)规则对构造函数中该字段的初始化进行排序的,如果在构造函数中设置了字段之后才进行读取,则它将看到为final字段分配的值,否则,它将看到的是默认值,因此,在Foo类的构造函数完成之前,对Holder实例的引用应保持未发布状态。

final关键字和线程安全组合

我们知道在java中有一些集合类提供对包含元素的线程安全访问,当我们将Holder对象插入到这样的集合中时,可以确保在将其引用变为可见之前对其进行完全初始化,比如如下Vector集合。

public class Foo {

    private final Vector<Holder> holders;

    public Foo() {
        holders = new Vector<>();
    }

    public Holder getHolder() {
        if (holders.isEmpty()) {
            initialize();
        }
        return  holders.elementAt(0);
    }

    public synchronized void initialize() {
        if (holders.isEmpty()) {
            holders.add(new Holder(42));
        }
    }
}

将holder字段声明为final,以确保在进行任何访问之前始终创建对象Holder的集合Vector,可以通过调用同步的initialize方法安全地对其进行初始化,以确保仅将一个Holder对象添加到Vector中,如果在initialize方法之前调用,那么getHolder方法通过有条件地调用initialize方法来避免空指针从而取消引用的可能性,尽管getHolder方法中的isEmpty方法调用的是从不同步的上下文(允许多个线程决定必须调用初始化)进行的,但是仍然可能导致竞争条件,而这种竞争条件可能导致向Vector集合中添加第二个对象,同步的initialize方法还在添加新的Holder对象之前检查holder是否为空,并且最多一个线程可以随时执行initialize方法,因此,只有第一个执行initialize方法的线程才能看到一个空的Vector集合,而getHolder方法可以安全地忽略其自身的任何同步。

静态初始化

我们将holder字段静态初始化,以确保该字段引用的对象在其引用变为可见之前已完全初始化,如下:

class Foo {

    private static final Holder holder = new Holder(42);

    public static Holder getHolder() {
        return holder;
    }
}

我们需要将holder字段声明为final,以记录该类的不变性,根据Java语言规范,静态final字段:类初始化的规则确保任何读取静态字段的线程将与该类的静态初始化同步,这是可以设置静态final字段的唯一位置,因此,对于静态final字段,JMM中不需要特殊规则。

不可变对象(final关键字、volatile引用)

JMM保证在发布的对象变得可见之前,对象的所有final字段都将完全初始化,通过声明final使得Holder类变得不可变, 另外将holder字段声明为volatile以确保对不可变对象的共享引用的可见性,只有在完全初始化Holder之后,才能确保对调用getHolder方法的任何线程可见Holder的引用。

class Foo {

    private volatile Holder holder;

    public Holder getHolder() {
        return holder;
    }

    public void initialize() {
        holder = new Holder(42);
    }
}

final class Holder {
    private final int n;

    public Holder(int n) {
        this.n = n;
    }
}

如上将holder声明为volatile且Holder类是不可变的,如果辅助字段不是可变的,则将违反确保对不可变对象的共享引用的可见性,推荐提供公共静态工厂方法来返回Holder的新实例,这种方法允许在私有构造函数中创建Holder实例。不可变对象永远线程安全,volatile确保其可见性使得共享对象能够完全正确安全发布。

可变对象(线程安全和volatile引用)

当Holder虽可变但线程安全时,可以通过在Holder类的volatile中声明holder字段来安全地发布它:

class Foo {

    private volatile Holder holder;

    public Holder getHolder() {
        return holder;
    }

    public void initialize() {
        holder = new Holder(42);
    }
}

final class Holder {
    private volatile int n;

    private final Object lock = new Object();

    public Holder(int n) {
        this.n = n;
    }

    public void setN(int n) {
        synchronized (lock) {
            this.n = n;
        }
    }
}

需要进行同步以确保在初始发布之后可变成员的可见性,因为Holder对象可以在其构造后更改状态,同步setN方法以确保n字段的可见性,如果Holder类的同步不正确,则在Foo类中声明volatile的holder将只能保证Holder初始发布的可见性,可见性保证将排除后续状态更改的可见性,因此,仅可变引用不足以发布不是线程安全的对象,如果Foo类中的holder字段未声明为volatile,则必须将n字段声明为volatile,以在n的初始化与将Holder写入holder字段之间建立事先发生(happens-before)的关系,仅当无法信任调用方(类Foo)时将Holder类声明为volatile时才需要这样做,因为Holder类被声明为公共类,所以它使用私有锁来进行同步,使用私有的final锁定对象来同步可能与不受信任的代码。

 

那么问题来了, 声明对象的volatile能与声明基本类型的volatile提供同样的保证吗?如果有可变或线程安全的对象我们是否有十分充足的理由声明为volatile呢?声明对象的volatile不能提供与声明基本类型的volatile提供相同的保证,对于可变的对象应禁止声明为volatile,而是设置同步,因为同步化主要强调的是原子性,其次才是可见性,但volatile主要保证的是可见性,所以可变对象和volatile一同使用时会出现陷阱(仅当共享对象已完全构造或不可变时,才可以使用volatile安全发布),所以当通过正确性推断出可见性时,应该避免使用volatile变量,volatile主要是用来确保它所引用对象的可见性或用于标识重要的生命周期事件(比如初始化或关闭)的发生。

 

如果一个对象不是不可变的,那么它必须要被安全发布,如何确保其他线程能够看到对象发布时的状态,必须解决对象发布后其他线程对其修改的可见性问题,为了安全发布对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确创建的对象可通过:通过静态初始化器初始化对象的引用(因为JMM可确保共享对象已完全构造)、将对象引用存储到volatile或AtomicReference、将对象引用存储到正确创建的对象的final域中、将对象引用通过锁机制保护。

总结

Java允许我们始终可以以安全发布的方式声明对象即为我们提供了安全初始化的机会,安全初始化使观察该对象的所有读者都可以看到构造函数中初始化的所有值,而不管对象是否被安全发布,如果对象中的所有字段都是final,并且构造函数中未初始化的对象没有逸出,那么Java内存模型(JMM)将更加对此提供强有力保障,我们应时刻谨记共享对象在进行安全发布之前必须避免被部分初始化即局部创建对象。

以上是关于何为安全发布,又何为安全初始化?的主要内容,如果未能解决你的问题,请参考以下文章

如何为 Spring Security 创建类型安全的用户角色?

如何为 Android 应用设置 Firebase 数据库安全规则

如何为实时数据库设置安全规则,但允许注册新用户并创建哈希图?

如何为特定 url 启用 spring 安全会话管理

如何为 HTML 属性设置内容安全策略

如何为用户创建无痛的安全春季社交注册/登录