Java-安全发布

一、安全发布的定义

 发布是一个动词,是去发布对象。而对象,通俗的理解是:JAVA里面通过 new 关键字 创建一个对象。

 发布一个对象的意思是:使对象在当前作用域之外的代码中使用。比如下面knowSecrets指向的HashSet类型的对象,由static修饰,是一个类变量。当前作用域为PublishExample类。 Demo-1:

import java.util.HashSet;
import java.util.Set;

public class PublishExample {
    public static Set<Secret> knowSecrets;
    
    public void initialize() {
        knowSecrets = new HashSet<>();
    }

 public修饰引用knowSecrets,导致在其他类中也能访问到这个HashSet对象,比如向HashSet添加元素或者删除元素。因此,也就发布了这个对象。Demo-2代码段相当于使用了被发布的

Demo-2:

public class UsingSecret {
    public static void main(String[] args) {
        PublishExample.knowSecrets.add(new Secret());
        PublishExample.knowSecrets.remove(new Secret());
    }
}

 另一方面,值得注意的是:被发布对象中所存的引用类对象也会被发布,比如Demo-2代码段中添加的到HashSet集合中的Secret对象也被发布了。


二、 不安全的发布

 因为对象一般是在构造函数里面初始化的(不讨论反射),当构造一个对象时,会为这个对象的属性赋值,当前时刻对象各个属性拥有的值称为对象的状态。给出一个常见的单线程代码例子:Demo-3 Demo-3:

public class Secret {
    private String password;
    private int length;
    public Secret(){}

    public Secret(String password, int length) {
        this.password = password;
        this.length = length;
    }

    public static void main(String[] args) {
        //"current state" 5 组成了secObjCurrentState对象的当前状态
        Secret secObjCurrentState = new Secret("current state", 5);

        //改变 secObjCurrentState 对象的状态
        secObjCurrentState.setPassword("state changed");
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

 Secret对象有两个状态(属性):password和length,利用构造方法进行状态的初始化。  利用secObjCurrentState.setPassword("state changed")进行password状态的修改。 创建对象的目的是使用它,而要用它,就要把它发布出去。同时,也引出了一个重要问题,我们是在哪些地方用到这个对象呢?比如:只在一个线程里面访问这个对象,还是有可能多个线程并发访问该对象?然而在Demo-3代码段中显然没有考虑多线程的安全性问题。

 对象被发布后,是无法知道其他线程对已发布的对象执行何种操作的,这也是导致线程安全问题的原因。

2.1 this引用逸出

 先看一个不安全发布的示例----this引用逸出。参考《Java并发编程实战》第3章程序清单3-7。 this逸出主要指我们仍未构造好对象(某些状态并未初始化,或并未按照构造函数指定初始化状态),却将对象发布出去了。在其中可能显式地用到了this关键字,也有可能隐式地发布了this。下面利用Dome-4来说明这个问题。 Demo-4:

public class ThisEscape {
    private int intState;//外部类的属性,当构造一个外部类对象时,这些属性值就是外部类状态的一部分
    private String stringState;

    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener(){
            @Override
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
//执行到这里时,new 的EventListener就已经把ThisEscape对象隐式发布了,而ThisEscape对象尚未初始化完成
        
        intState=10;//ThisEscape对象继续初始化....
        stringState = "hello";//ThisEscape对象继续初始化....
        
        //执行到这里时, ThisEscape对象才算初始化完成...
    }

    /**
     * EventListener 是 ThisEscape的 非静态 内部类
     */
    public abstract class EventListener {
        public abstract void onEvent(Event e);
    }

    private void doSomething(Event e) {}

    public int getIntState() {
        return intState;
    }

    public void setIntState(int intState) {
        this.intState = intState;
    }

    public String getStringState() {
        return stringState;
    }

    public void setStringState(String stringState) {
        this.stringState = stringState;
    }

 EventListener是ThisEscape类的内部类。  **创建内部类必须得要有外部类创建的实例。**现在要创建一个ThisEscape对象,于是执行ThisEscape的构造方法,构造方法里面有 new EventListener对象,于是EventListener对象就隐式地持有外部类ThisEscape对象的引用。

 如果能在其他地方访问到EventListner对象,就意味着"隐式"地发布了ThisEscape对象,而此时ThisEscape对象可能还尚未初始化完成,因此ThisEscape对象就是一个尚未构造完成的对象,这就导致只能看到ThisEscape对象的部分状态!

 看下面示例(Demo-5):其中让EventSource对象持有EventListener对象的引用,也意味着:隐式地持有ThisEscape对象的引用了,这就是this引用逸出。

Demo-5:

public  class EventSource {
    ThisEscape.EventListener listener;//EventSource对象 持有外部类ThisEscape的 内部类EventListener 的引用
    public ThisEscape.EventListener getListener() {
        return listener;
    }
    public void registerListener(ThisEscape.EventListener listener) {
        this.listener = listener;
    }
}

 内部类对象隐式持有外部类对象,可能会发生内存泄漏问题。持有内部类的实例,其对应的外部类对象不一定能够安全地释放相关资源。用Demo6代码段来进行说明。 Demo-6:

public class ThisEscapeTest {
    public static void main(String[] args) {
        EventSource eventSource = new EventSource();
        ThisEscape thisEscape = new ThisEscape(eventSource);
        ThisEscape.EventListener listener = eventSource.getListener();//this引用逸出
        thisEscape.setStringState("change thisEscape state...");
        
        //--------演示一下内存泄漏---------//
        thisEscape = null;//希望触发 GC 回收 thisEscape
        consistentHold(listener);//但是在其他代码中长期持有listener引用
    }
}

2.2 不安全的延迟初始化

 Happens Before 发生在先关系

 深刻理解这个关系,对判断代码中是否存在线程安全性问题很有帮助。扯一下发生在先关系的来龙去脉。

 为了加速代码的执行,底层硬件有寄存器、CPU本地缓存、CPU也有多个核支持多个线程并发执行、还有所谓的指令重排…那如何保证代码的正确运行?因此Java语言规范要求JVM:

 JVM在线程中维护一种类似于串行的语义:只要程序的最终执行结果与在严格串行环境中执行的结果相同,那么寄存器、本地缓存、指令重排都是允许的,从而既保证了计算性能又保证了程序运行的正确性。

 在多线程环境中,为了维护这种串行语义,比如说:操作A发生了,执行操作B的线程如何看到操作A的结果?

 Java内存模型(JMM)定义了Happens-Before关系,用来判断程序执行顺序的问题。这个概念还是太抽象,下面会用具体的示例说明。

 有四个规则对判断多线程下程序执行顺序非常有帮助

  1. 程序顺序规则: 如果程序中操作A在操作B之前(即:写的代码语句的顺序),那么在单个线程执行中A操作将在B操作之前执行。
  2. 监视器规则(确保了某个线程对于某个锁的独占): 这个规则是关于锁的,定义是:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前(如果把监视器大致看作线程,那么这个用线程的知识来把握就比较容易理解了)。  在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。(这就是程序顺序规则)  由于A释放了锁,而B获得了锁,因此A中所有在释放锁之前的操作位于B中请求锁之后的所有操作之前。这句话:它的意思就是:在线程A解锁M之前的所有操作,对于线程B加锁M之后的所有操作都是可见的。这样,在线程B中就能看到:线程A对 变量x 、变量y的所写入的值了。 可见性:为了在线程之间传递数据,我们经常用到BlockingQueue(阻塞队列),一个线程调用set/put方法添加元素,另一个线程调用get/take方法获取元素,而这些操作都必须满足发生在先关系。线程B不仅仅是拿到了一个元素,而且还能看到线程A修改的一些对象的状态。
  1. volatile变量规则:对volatile变量的写入操作必须在该变量的读取操作之前执行。
  2. 操作顺序的传递性:如果操作A在操作B之前执行,操作B在操作C之前执行,那么操作A必须在操作C之前执行。在你看到一大段代码,这个线程里面调用了synchronized修饰的方法、那个线程又向阻塞队列put了一个元素、另一个线程又读取了一个volatile修饰的变量…从这些发生在先规则里面 使用 传递性 就能大致推断整个代码的执行流程了。

3.安全发布

安全发布由4种常见模式,接下来单独介绍各自线程安全的原因(模式1和模式3)。

注意事项:安全发布并不能保证在对象发布后的相关操作是线程安全的。

3.1安全发布的常用模式

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

3.2在静态初始化函数中初始化一个对象的引用

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器。使用静态的工厂方法返回此实例 Demo-7

public class SecurityPublish {
    public static void main(String[] args) {
        Holder holder=Holder.holder;//静态的方法属于类,无需调用构造器
    }
}

class Holder{
    private int n;

    private Holder(int n){//私有化构造函数的目的是为了单例模式下的线程安全性
        this.n =n;
    }
    public static Holder holder = new Holder(42);
}

 线程安全的实现途径:为了确保对象引用的安全性,如果不适用锁机制,还有另外一个方法就是使用静态初始化器(单例模式)。这是由于JVM的一个特性:静态初始化器由JVM在类的初始化阶段执行,JVM依靠其自身的同步机制,可以使初始化的任何对象都可以被安全地发布。私有化构造函数的目的是为了单例模式下的线程安全性,如果用public修饰构造方法,那么确保不了一个尚未完全创建的对象拥有完整性,如下面的代码: Demo-8

public class SecurityPublish {
    public static void main(String[] args) {
        Holder holder=new Holder(42);
    }
}

class Holder{
    private int n;

    public Holder(int n){//私有化构造函数的目的是为了单例模式下的线程安全性
        this.n =n;
    }
}

3.2 volatile 修饰的属性的安全发布问题

 在volatile变量的写入操作必须在对该变量的读取操作之前执行,变量的写入操作尚未彻底完成。那根据volatile变量规则:对该变量的访问也不能开始,这样就保证了安全发布。

3.3 将对象的引用保存到某个正确构造对象的final类型域中(避免this逸出)

 对于含有final域的对象,JVM必须保证对对象的初始引用在构造函数之后执行,不能乱序执行(out of order),也就是可以保证一旦你得到了引用,final域的值都是完成了初始化的工作。所以其能够进行对象的正确发布。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券