谈谈JAVA中的安全发布

谈谈JAVA中的安全发布

昨天看到一篇文章关于技术类资料的"等级"的,看完之后很有共鸣。再加上最近在工作中越发觉得线程安全性的重要性和难以捉摸,又掏出了《Java并发编程实战》研读一番,这本书应该是属于为“JAVA 多线程作注解”的一本书,那我就为书中关于对象安全发布的内容作一些注解,作为今年的第一篇博文。

我读的是中文版,确实感觉书中有些地方的描述晦涩难懂,也没有去拿英文原文来对照,就按中文版描述,再配上一些示例代码记录我的一些理解吧。

1. 安全发布的定义

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

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

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

/**
 * @author psj
 * @date 2019/03/10
 */
public class PublishExample {
    public static Set<Secret> knowSecrets;

    public void initialize() {
        knowSecrets = new HashSet<>();
    }
}

public修饰引用knowSecrets,导致 在其他类中也能访问到这个HashSet对象,比如向HashSet添加元素或者删除元素。因此,也就发布了这个对象。

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

另外,值得注意的是:添加到HashSet集合中的Secret对象也被发布了。

2. 不安全的发布

因为对象一般是在构造函数里面初始化的(不讨论反射),当 new 一个对象时,会为这个对象的属性赋值,当前时刻对象各个属性拥有的 称为对象的状态。

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")改变了对象的状态。

创建对象的目的是使用它,而要用它,就要把它发布出去。同时,也引出了一个重要问题,我们是在哪些地方用到这个对象呢?比如:只在一个线程里面访问这个对象,还是有可能多个线程并发访问该对象?

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

2.1 this引用逸出

先看一个不安全发布的示例----this引用逸出。参考《Java并发编程实战》第3章程序清单3-7

当我第一次看到"this引用逸出"时,是懵逼的。后来在理解了“发生在先”原则、“初始化过程安全性”、"volatile关键字的作用"之后才慢慢理解了。这些东西后面再说。

外部类ThisEscape和它的内部类EventListener

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;
    }

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

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

看下面示例:我故意让EventSource对象持有EventListener对象的引用,也意味着:隐式地持有ThisEscape对象的引用了,这就是this引用逸出。

public  class EventSource {
    ThisEscape.EventListener listener;//EventSource对象 持有外部类ThisEscape的 内部类EventListener 的引用
    public ThisEscape.EventListener getListener() {
        return listener;
    }
    public void registerListener(ThisEscape.EventListener listener) {
        this.listener = listener;
    }
}
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关系,用来判断程序执行顺序的问题。这个概念还是太抽象,下面会用具体的示例说明。在我写代码的过程中,发现有四个规则对判断多线程下程序执行顺序非常有帮助:

  • 程序顺序规则:

    如果程序中操作A在操作B之前(即:写的代码语句的顺序),那么在单个线程执行中A操作将在B操作之前执行。

  • 监视器规则:

    这个规则是关于锁的,定义是:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前。咋一看,没啥用。我这里扩展一下,如下图:

    在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。(这就是程序顺序规则)

    由于A释放了锁,而B获得了锁,因此A中所有在释放锁之前的操作 位于 B中请求锁之后的所有操作之前。这句话:它的意思就是:在线程A解锁M之前的所有操作,对于线程B加锁M之后的所有操作都是可见的。这样,在线程B中就能看到:线程A对 变量x 、变量y的所写入的值了。

    再扩展一下:为了在线程之间传递数据,我们经常用到BlockingQueue,一个线程调用put方法添加元素,另一个线程调用take方法获取元素,这些操作都满足发生在先关系。线程B不仅仅是拿到了一个元素,而且还能看到线程A修改的一些对象的状态(这就是可见性

    总结一下:

    同步操作,比如锁的释放和获取、volatile变量的读写,不仅满足发生在先关系(偏序),而且还满足全序关系。总之:要想保证执行操作B的线程看到操作A的结果(不管操作A、操作B 是否在同一个线程中执行),操作A、操作B 之间必须满足发生在先关系

  • volatile变量规则:对volatile变量的写入操作必须在该变量的读取操作之前执行。这条规则帮助理解:为什么在声明类的实例变量时用了volatile修饰,作者的意图是什么?

  • 传递性:如果操作A在操作B之前执行,操作B在操作C之前执行,那么操作A必须在操作C之前执行。在你看到一大段代码,这个线程里面调用了synchronized修饰的方法、那个线程又向阻塞队列put了一个元素、另一个线程又读取了一个volatile修饰的变量…从这些发生在先规则里面 使用 传递性 就能大致推断整个代码的执行流程了。

扯了这么多,看一个不安全发布的示例。

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource();//不安全的发布
        }
        return resource
    }
}

这段代码没有应用到前面提到的任何一个发生在先规则,代码在执行过程中发生的指令重排导致了不安全的发布。

在创建对象、发布对象时,隐藏了很多操作的。new Resource对象时需要给Resource对象的各个属性赋值,赋值完了之后,在堆中对象的地址要赋值给 静态变量resource。在整个过程中就有可能存在指令重排,看图:

类似地,双重检查加锁也会导致不安全的发布。

3. 安全的发布

public class EagerInitialization {
    private static Resource resource = new Resource();

    public static Resource getResource() {
        return resource;
    }
}

在声明静态变量时同时初始化,由JVM来保证初始化过程的安全性。static修饰说明是类变量,因而符合单例模式。

3.1 初始化安全性

初始化安全性是一种保证:正确构造的对象在没有同步的情况下也能安全地在多个线程之间共享,而不管它是如何被发布的。换句话说:对于被正确构造的对象,所有线程都能看到由构造函数为对象各个final域设置的正确值。

再换句话说:对于含有final域的对象,初始化安全性可以防止对象的初始引用被重排序到构造过程之前。这句话已经点破了关键了。看上一幅图,线程A在赋值到半路,太累了,休息了一下,抽了一根烟。然后继续开始了它的赋值,这些赋值操作,就是对象的构造过程。而在赋值的中间,存在着一个指令重排---将尚未构造完成的对象的堆地址写入到初始引用中去了,而如果这个时候恰好有其他线程拿着这个初始引用去访问对象(比如访问该对象的某个属性),但这个对象还未初始化完成啊,就会导致bug。

哈哈哈哈……是不是还是看不懂、很抽象?这就是 。经书级别的经,难念的经。咱用代码来说明一下:

public class Resource {
    private int x;//没有用final修饰
    private String y;//没有用final修饰

    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

而如果,这两个属性都用final修饰的话,那么就满足初始化安全的保证,就没有指令重排了。

这就是final关键字所起的作用。

另外,你是不是注意到,如果用final修饰实例变量时,IDEA会提示你尚未给final修饰的实例变量赋初始值?哈哈……

总结一下:

构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。

所以:如果Resouce是一个不可变对象,那么UnsafeLazyInitialization就是安全的了。

//不可变
public class Resource {
    private final int x;
    private final String y;
    public Resource(){x=10;y="hello"}
    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

//UnsafeLazyInitialization 不仅是安全的发布,而且在多线程访问中也是线程安全的。
//因为Resource的属性x、y 都是不可变的。
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource();//安全的发布!
        }
        return resource;
    }
}

关于初始化安全性,只能保证 final 域修饰的属性在构造过程完成时的可见性。如果,构造的对象存在非final域修饰的属性,或者在构造完成后,在程序中其他地方能够修改属性的值,那么必须采用同步来保证可见性(必须采用同步保证线程安全),示例如下:

import java.util.HashMap;
import java.util.Map;
/**
 * @author psj
 * @date 2019/03/10
 */
public class UnSafeStates {
    /**
     * UnSafeStates 唯一的一个属性是由final修饰的,初始化安全性还是存在的
     * 即:其他线程能看到一个正确且 **构造完成** 的UnSafeStates对象
     */
    private final Map<String,String> states;

    public UnSafeStates() {
        states = new HashMap<>();
        states.put("hello", "he");
        states.put("world", "wo");
    }

    public String getAbbreviation(String s) {
        return states.get(s);
    }

    /**
     * 这个方法能够修改 states 属性的值, UnSafeStates 不再是一个线程安全的类了
     * 如果多线程并发调用 setAbbreviation 方法, 就存在线程安全性问题. HashMap的循环引用了解一下?哈哈……
     * @param key
     * @param value
     */
    public void setAbbreviation(String key, String value) {
        states.put(key, value);
    }
}

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

这个和final关键字中讨论的初始化安全性类似。只不过,volatile修饰的属性是满足发生在先关系的。

套用volatile变量规则:在volatile变量的写入操作必须在对该变量的读取操作之前执行,那volatile也能避免前面提到的指令重排了。因为,初始化到一半,然后好累,要休息一下,说明初始化过程尚未完成,也即:变量的写入操作尚未彻底完成。那根据volatile变量规则:对该变量的访问也不能开始。这样就保证了安全发布。

4. 总结

在写代码过程中,有时不太刻意地去关注安全发布,在声明一个类的属性时,有时就顺手给实例变量用一个final修饰。抑或是在考虑多线程访问到一个状态变量时,给它用个volatile修饰,并没有真正地去思考总结final到底起作用在哪里了?

所以总结起来就是:final关键字在初始化过程中防止了指令重排,保证了初始化完成后对象的安全发布。volatile则是通过JMM定义的发生在先关系,保证了变量的内存可见性。

最近在看ES源码过程中,看别人写的代码,就好奇,哎,为什么这里这个属性要用个final呢?为什么那个属性加了volatile修饰呢?其实只有明白背后原理,才能更好地去理解别人的代码吧。

当然,上面写的全是自己的理解,有可能出错,因为我并没有将源代码编译成字节码、甚至是从机器指令角度去分析 上面示例的执行流程,因为我看不懂那些汇编指令,哈哈哈哈哈哈……

5. 参考资料

《Java并发编程实战》第3章、第16章

这篇文章前前后后加起来居然写了6个小时,没时间打球了…^:(^ ^:(^

原文:https://www.cnblogs.com/hapjin/p/10505337.html

猜你喜欢

转载自www.cnblogs.com/hapjin/p/10505337.html