谈谈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对象,比如在UsingSecret 类中 向 knowSecrets添加元素 或者删除元素。因此,也就发布了knowSecrets 这个对象。

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变量规则:对该变量的访问也不能开始。这样就保证了安全发布。这也是为什么DCL双重检查锁中定义的static变量 用volatile修饰就能安全发布的原因。

4. 总结

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

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

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

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

5. 参考资料

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

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

谈谈JAVA中的安全发布的更多相关文章

  1. 谈谈java中静态变量与静态方法在有继承关系的两个类中调用

    谈谈java中静态变量与静态方法在有继承关系的两个类中调用 学习的中如果遇到不明白或者不清楚的的时候,就是自己做些测试,自己去试试,这次我就做一个关于静态变量和静态方法在有继承关系的两个类中的问题测试 ...

  2. 谈谈java中成员变量与成员方法继承的问题

    谈谈java中成员变量与成员方法继承的问题 关于成员变量和成员方法的的继承问题,我也可以做一个小测试,来看看结果. 首先我们先创建一个父类:

  3. 浅拷贝和深拷贝(谈谈java中的clone)

    clone顾名思义就是复制, 在Java语言中, clone方法被对象调用,所以会复制对象.所谓的复制对象,首先要分配一个和源对象同样大小的空间,在这个空间中创建一个新的对象.那么在java语言中,有 ...

  4. java中使用axis发布和调用webService及dom4j解析xml字符串

    工作中需要调用webService服务,这里记录一下如何在java中发布和调用webService. 需要的jar包: webService服务端: import javax.jws.WebMetho ...

  5. 谈谈java中的WeakReference

    Java语言中为对象的引用分为了四个级别,分别为 强引用 .软引用.弱引用.虚引用. 本文只针对java中的弱引用进行一些分析,如有出入还请多指正. 在分析弱引用之前,先阐述一个概念:什么是对象可到达 ...

  6. 谈谈java中遍历Map的几种方法

    java中的map遍历有多种方法,从最早的Iterator,到java5支持的foreach,再到java8 Lambda,让我们一起来看下具体的用法以及各自的优缺点 先初始化一个map public ...

  7. java基础(五):谈谈java中的多线程

    1.多线程 1.1.多线程介绍 学习多线程之前,我们先要了解几个关于多线程有关的概念. 进程:正在运行的程序.确切的来说,当一个程序进入内存运行,即变成一个进程,进程是处于运行过程中的程序,并且具有一 ...

  8. java基础(四):谈谈java中的IO流

    1.字节流 1.1.字节输出流output 1.1.1.数据写入文件中 通过api查找output.找到很多,其中java.io.OutputStream,OutputStream: 输出字节流的超类 ...

  9. 谈谈Java中的代理模式

    首先来看一下代理模式的定义:为其他对象提供一种代理以控制对这个对象的访问.在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介的作用, 其特征是代理类与 ...

随机推荐

  1. 数据库原理剖析 - 序列1 - B+树

    本文节选自<软件架构设计:大型网站技术架构与业务架构融合之道>第6.3章节. 作者微信公众号: 架构之道与术.进入后,可以加入书友群,与作者和其他读者进行深入讨论.也可以在京东.天猫上购买 ...

  2. C# 当前 .NET SDK 不支持将 .NET Core 2.1 设置为目标。请将 .NET Core 2.0 或更低版本设置为目标,或使用支持 .NET Core 2.1 的 .NET SDK 版本。

    报错信息: 严重性 代码 说明 项目 文件 行 禁止显示状态 错误 NETSDK1045 当前 .NET SDK 不支持将 .NET Core 2.2 设置为目标.请将 .NET Core 2.1 或 ...

  3. [idea] SpringBoot整合swagger2实现CRUD

    一:创建SpringBoot ,在pom.xml文件中加入jar包 <dependency> <groupId>io.springfox</groupId> < ...

  4. SSIS中xml的输入输出

    输出为XML的两种方法 1.用数据流, 将平面文件作为DES输出 在SQL里将要输出的数据查询成为单列的字符串: SELECT (SELECT * FROM A FOR XML ROOT('A'),E ...

  5. Nginx作为HTTP服务器--Nginx配置图片服务器

      首先安装nginx安装环境 nginx是C语言开发,建议在linux上运行,本教程使用Centos6.5作为安装环境. --> gcc 安装nginx需要先将官网下载的源码进行编译,编译依赖 ...

  6. MYSQL内置MYSQL数据库中你可以得到的信息

    1:help_topic  可以查看函数帮助,例如:SELECT * from help_topic WHERE name='concat' 可以查看concat函数. 2:SLOW_LOG 慢查询日 ...

  7. LINUX配置过程记录(二) 工具安装

    安装谷歌游览 sudo apt-get update sudo apt-get install google-chrome-stable Ubuntu 16.04下源码安装Catkin https:/ ...

  8. “百度杯”CTF比赛 十月场 Hash 复现

    进入题后老套路得到两个关键: 1.$hash=md5($sign.$key);the length of $sign is 8 2.key=123&hash=f9109d5f83921a551 ...

  9. 第二章 Python基本图形绘制

    2.1 深入理解Python语言 Python语言是通用语言 Python语言是脚本语言 Python语言是开源语言 Python语言是跨平台语言 Python语言是多模型语言 Python的特点与优 ...

  10. selenium跳过webdriver检测并爬取淘宝我已购买的宝贝数据

    简介 上一个博文已经讲述了如何使用selenium跳过webdriver检测并爬取天猫商品数据,所以在此不再详细讲,有需要思路的可以查看另外一篇博文. 源代码 # -*- coding: utf-8 ...