前言
什么是发布对象呢?简要来说,就是让我们定义的对象能够被其他范围之外的范围使用。
主体概要
- 对象的发布与逸出
- 对象安全发布的四种方法
主体内容
一、对象的发布与逸出
1.概念
发布对象:使一个对象能够被当前范围之外的代码所使用。
对象逸出:一种错误的发布。当一个对象还没有被构造完成时,就使它被其他的线程所见。
2.平时我们会通过一些类的非私有方法返回对象的引用或者公有静态变量发布对象。接下来,看一下非私有方法返回对象的引用的不安全发布的例子。
@Slf4j
public class UnsafePublish {
private String[] states = {"a","b","c"};
//states变量作用域是private而我们在getStates方法中却把它发布了
//这样就称为数组states逸出了它所在的作用域。
public String[] getStates(){ return states; } public static void main(String[] args){ UnsafePublish up = new UnsafePublish(); log.info("states:{}",Arrays.toString(up.getStates())); //对它私有属性的数组进行修改 up.getStates()[0]="d"; log.info("states:{}",Arrays.toString(up.getStates())); } }
这样的结果会是:
23:19:37.949 [main] INFO com.controller.publish.UnsafePublish - states:[a, b, c] 23:19:37.952 [main] INFO com.controller.publish.UnsafePublish - states:[d, b, c]
例子很简单,但是还是要看一下,因为非私有方法(public访问级别),类的外部的线程都可以访问这个域,这样的发布对象其实是不安全的,因为我们无法保证其他线程会不会修改这个域,从而造成类里面的states的错误。
3.接下来,介绍一下对象发布的逸出,演示一个例子。
@Slf4j public class Escape { private int thisCanbeEscape=0;
/** * 内部类的实例里面包含了对封装内容thisCanBeEscape的隐含引用 * 这样在对象没有被正确构造之前,他就会被发布,有可能有不安全的因素在 * 一个导致this引用在构造期间逸出的错误 是在构造的函数过程中启动了一个线程 * 无论是隐式的启动还是显示地启动都会造成this引用的逸出,新线程总是会在对象构造完毕 * 之前就已经看到this引用 所以要再构造函数中使用线程,就不要启动它而应该专有的start或初始化的方法来统一启动线程, * 可以采用工厂方法和私有构造函数来完成对象创建和监听器的注册等 * * * 在对象未完成构造之前 不可以将其发布 */
public Escape(){ new InnerClass(); } //定义一个内部类 private class InnerClass{ public InnerClass(){ log.info("{}",Escape.this.thisCanbeEscape); } } public static void main(String[] args){ new Escape(); } }
结果:
23:35:56.093 [main] INFO com.controller.publish.Escape - 0
看代码中的内部类实例里面包含了对封装实例的引用,这样可能导致Escape对象没有被完全构造完成就会被发布,可能会导致隐患。
具体解释:在Escape的构造函数中相当于是启动了一个线程,这会造成内部类构造方法中的this引用的逸出,新线程总是会在所属对象构造完毕之前就能够看到this的引用了。
this逃逸就是说,在构造函数返回之前,其他线程就已经取得了该对象的引用,由于构造函数还没有完成,所以,对象也可能是残缺的,所以,取得对象引用的线程使用残缺的对象极有可能发生错误的情况。因为这两个线程是异步的,取得对象引用的线程并不一定会等待构造对象的线程完结后在使用引用。
可以参考下大佬的总结:https://www.cnblogs.com/haimishasha/p/11594585.html
如果还是对对象逸出不理解,下面演示一种我个人理解的方式,代码中用log.info()打了标识,方便理解,如有错误,请指出。
(1)让主线程睡个2s
@Slf4j public class Test { private boolean isIt; public Test() throws InterruptedException { log.info("{}",1); new Thread(new Runnable() { public void run() { log.info("{}",2); System.out.println(isIt); log.info("{}",3); } }).start(); log.info("{}",4); Thread.sleep(2000L); log.info("{}",5); isIt = true; log.info("{}",6); } public static void main(String[] args) throws InterruptedException { Test test = new Test(); } }
结果:
16:59:51.472 [main] INFO com.test2.Test - 1 16:59:51.479 [main] INFO com.test2.Test - 4 16:59:51.480 [Thread-0] INFO com.test2.Test - 2 false 16:59:51.480 [Thread-0] INFO com.test2.Test - 3 16:59:53.480 [main] INFO com.test2.Test - 5 16:59:53.480 [main] INFO com.test2.Test - 6
可以看到主线程睡了2s,那么新线程启动直接执行了输出的方法,将isIt输出了,而步骤5下的将isIt赋值为true的操作还没有执行,这就导致初始化参数还没有完成,新线程就将未初始化完成的参数给发布了,这样是不正确的。
二、安全发布对象的四种方法
- 在静态初始化函数中初始化一个对象的引用
- 将对象的引用保存到
volatile
类型域或者AtomicReference
对象中 - 将对象的引用保存到某个正确构造对象的
final
类型域中 - 将对象的引用保存到某个由锁保护的域中
这四种方法该如何理解呢,下面通过四个例子来诠释一下。
1.首先,我们通过一个单例设计模式(懒汉式)的代码看一下其在多线程场景下会出现怎样的问题。
public class Singleton { //私有构造函数 private Singleton() { } //单例对象 private static Singleton instance = null; //静态的工厂方法 public static Singleton getInstance(){ if(instance==null){ instance = new Singleton(); } return instance; } }
观察以上的代码,单线程下没有问题,那么多线程下,当个两个线程同时判断instance==null的条件成立后,都进入了创建对象的过程,那么私有构造函数相当于被调用了两边,如果私有构造函数中是数值加减的操作,这个数值是不是会被加或减了两次,那一定是不正确的。以上的代码是线程不安全的。
2.懒汉单例模式看过了,下面看一下饿汉式的单例模式。
public class Singleton2 { //私有构造函数 private Singleton2() { } //单例对象 private static Singleton2 instance = new Singleton2(); //静态的工厂方法 public static Singleton2 getInstance(){ return instance; } }
无论如何,饿汉式都会直接创建类对象,无论这个对象是否被使用,它都会被创建,这就导致性能问题,造成资源的浪费。因此,我们在使用饿汉式的时候,一定要考虑到它的私有构造函数是否有过多的处理逻辑。
综上两个例子所述,饿汉式虽然是线程安全的,却容易造成性能问题,那么懒汉式能不能改造成线程安全的呢?答案是可以的。接下来看一下如何改造吧!
猜想一下,要是能保证创建对象的方法只能由一个线程访问就不会造成线程不安全了,于是在静态的工厂方法上加上synchronized
//静态的工厂方法 public synchronized static Singleton3 getInstance(){ if(instance==null){ instance = new Singleton3(); } return instance; }
虽然这样保证了线程安全性,但是却造成了性能上的开销,所以这种写法是不推荐的!!!
那我们要是将synchronized下沉到创建对象上呢?
public class Singleton4 { //私有构造函数 private Singleton4() { } //单例对象 private static Singleton4 instance = null; //静态的工厂方法 public static Singleton4 getInstance(){ if(instance==null){ synchronized(Singleton4.class){ if(instance==null){//双重检测机制 instance = new Singleton4(); } } } return instance; } }
这种方法其实又变成了线程不安全了。这里复习一下对象创建的过程,(1)memory=allocate() 分配对象的内存空间 (2)ctorInstance() 初始化对象 (3)instance=memory 设置instance指向刚分配的内存。这里的JVM和CPU发生了指令重排(指令重排请参考并发与高并发(九)),这就导致以上的三步不是按照以上的顺序来执行了,而是变成了(1)(3)(2)这种顺序。这种情况发生的场景需要解释一下:如以下代码所示,在指令重排的前提下,当线程A,线程B各自执行到代码中标识的位置时,A开始执行第(3)步,那么线程B执行判断的时候,发现有值了,便会直接return instance。而实际的线程A第(2)步初始化对象还没做。虽然这种问题出现的概率很小,但是这种方法仍然是线程不安全的。
public class Singleton4 { //私有构造函数 private Singleton4() { } //单例对象 private static Singleton4 instance = null; //静态的工厂方法 public static Singleton4 getInstance(){ if(instance==null){//线程B synchronized(Singleton4.class){ if(instance==null){//双重检测机制 instance = new Singleton4(); //线程A-3 } } } return instance; } }
既然是因为指令重排的原因导致的问题,那么我们就不让JVM和CPU发生指令重排。
想起来之前学过的一个关键字:volatile。在以上代码的基础上,将volatile加到单例对象声明上。
private volatile static Singleton4 instance = null;
这样这个懒汉式单例模式就变成线程安全的了。
这里一定一定记住,因为平时写代码的时候很容易被忽略。这就是volatile的使用场景之一:volatile+双重检测机制=禁止指令重排。
解释一下为什么要做两次null判断:
外面的判断是为了防止不必要地操作mutex,里面的判断是为了防止多次实例化。
外面的判断是出于性能优化(非必须),而里面的判断是出于只实例化一次的保证(必须的)
回顾下代码:
if (p == NULL) { pthread_mutex_lock(&mutex); if (p==NULL) p = new singleton(); pthread_mutex_unlock(&mutex); }
外面的p==NULL在临界区外;这时,如果删掉了里面的p==NULL,在下面的情形下,就会发生内存泄漏:两个mutex中间是临界区,是保证同时最多只有一个线程的。
线程A判断p==NULL为真
线程B判断p==NULL为真
线程A进入临界区
线程A实例化p
线程A退出临界区
线程B进入临界区
线程B实例化p
线程B退出临界区
所以,里面的p==NULL在多线程环境下是必要的7a64e78988e69d8331333361313932。
外面的p==NULL虽然不必要,删掉它依然可以保证只p实例化一次,但他可以在p已经实例化后避免线程再进入临界区,提升性能。
3.另外提到一种线程安全,性能可靠的枚举模式:
public class Singleton5 { private Singleton5(){ } public static Singleton5 getInstance(){ return Singleton.INSTANCE.getInstance(); } private enum Singleton{ INSTANCE; private Singleton5 singleton; //JVM保证这个方法绝对只被调用一次 Singleton(){ singleton = new Singleton5(); } public Singleton5 getInstance(){ return singleton; } } }