8.java并发编程之线程安全和单例模式

88 阅读12分钟

线程安全单例

饿汉单例

    // 问题1:为什么加 final
    // 防止被继承重写父类方法
public final class Singleton implements Serializable {
    // 问题3:为什么设置为私有? 是否能防止反射创建新的实例? 
    // 不能防止反射创建新的实例,可以防止调用构造方法创建新实例。
    private Singleton() {}
    // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
    // 能 
    private static final Singleton INSTANCE = new Singleton();
    // 问题5:为什么提供静态方法来获取实例INSTANCE,而不是直接将 INSTANCE设置为public,说出你知道的理由
    // 懒惰初始化 封装性 泛型 初始化细节处理
    public static Singleton getInstance() {
        return INSTANCE;
    }
     // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例 
    //  添加readResolve方法
    public Object readResolve() {
        return INSTANCE;
    }
}

ReadResolve

JAVA对象流序列化时的readObject,writeObject,readResolve是怎么被调用的_supermanL的博客

简而言之就是,当我们通过反序列化readObject()方法获取对象时会去寻找readResolve()方法,

如果readResolve方法不存在则直接返回从文件中反序列化出来的对象。

如果readResolve方法存在则按该方法的内容返回对象。

package 序列化;
​
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.HashSet;
import java.util.Set;
​
public class ReadResolve<T> extends  HashSet<T> {
​
  public Object readResolve() {
        HashSet hashSet = new HashSet();
        hashSet.add("你好");
        return hashSet;
    }
​
    public static void main(String[] args) throws Exception {
        Set<String> set = new ReadResolve<String>();
        set.add("1");
        set.add("2");
        System.out.println( "解析之前:" + set);
​
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\set.obj"))) {
            oos.writeObject(set);
        }
​
        set.clear();
​
​
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\set.obj"))) {
            set = (Set<String>) ois.readObject();
        }
​
        System.out.println( "反序列化以后:" + set);
    }
​
}
​
解析之前:[1, 2]
反序列化以后:[你好]
package 序列化;
​
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.HashSet;
import java.util.Set;
​
public class ReadResolve<T> extends  HashSet<T> {
​
  
    //去掉readResolve
    public static void main(String[] args) throws Exception {
        Set<String> set = new ReadResolve<String>();
        set.add("1");
        set.add("2");
        System.out.println( "解析之前:" + set);
​
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\set.obj"))) {
            oos.writeObject(set);
        }
​
        set.clear();
​
​
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("D:\set.obj"))) {
            set = (Set<String>) ois.readObject();
        }
​
        System.out.println( "反序列化以后:" + set);
    }
​
}
​
解析之前:[1, 2]
反序列化以后:[1, 2]

懒汉单例-不安全版

package com.interview.bintest.singleton;
​
/**
 * 下面是DCL单例懒汉式的demo
 */
public class DCLSingleton {
​
    /**
     * 因为是单例式,所以构造方法肯定是私有化
     * 无法通过new的方式来创建对象
     */
    private DCLSingleton(){
​
    }
​
    /**
     * 创建一个私有化静态成员变量,用于指向创建出来的单例
     */
    private static DCLSingleton dclSingleton;
​
    /**
     * 创建一个  公共静态  方法,提供给外部类调用
     * 方法则返回单例式(方法内创建)
     */
    public static DCLSingleton getInstance(){
        //因为是懒汉式创建,所以我们需要判断一下是否已经被创建了
        //没有则创建一个,有则直接返回该实例
        if(dclSingleton==null){//A
            dclSingleton = new DCLSingleton();//B
        }
        return dclSingleton;
    }
}
上面的代码在遇到多线程的时候就会产生问题,当x线程到达注释A处,判断完毕,条件成立,此时JVM把cpu的资源切换给y线程。

y线程同样到达A处,因为x线程并没有创建实例,所以y执行了注释B处的代码,即完成了单例的创建。之后线程x被重新唤醒。

因为x线程已经判断完了if中的条件,并且成立,于是x线程也执行了注释B处的代码,又创建了一个单例。

这样就产生了线程不安全的问题。这样问题就来了,new出了两个instance,这还能叫单例吗?

懒汉单例-安全版

于是我们通过synchronized同步代码块来解决这个问题,代码如下

package com.interview.bintest.singleton;
​
/**
 * 下面是DCL单例懒汉式的demo
 */
public class DCLSingleton {
​
    /**
     * 因为是单例式,所以构造方法肯定是私有化
     * 无法通过new的方式来创建对象
     */
    private DCLSingleton(){
​
    }
​
    /**
     * 创建一个私有化静态成员变量,用于指向创建出来的单例
     */
    private static DCLSingleton dclSingleton;
​
    /**
     * 创建一个  公共静态  方法,提供给外部类调用
     * 方法则返回单例式(方法内创建)
     */
    public static DCLSingleton getInstance(){
        //因为是懒汉式创建,所以我们需要判断一下是否已经被创建了
        //没有则创建一个,有则直接返回该实例
        synchronized (DCLSingleton.class){
            if(dclSingleton==null){//A
                dclSingleton = new DCLSingleton();//B
            }
        }
        return dclSingleton;
    }
}

此时解决了上面的问题。但是新问题来了。

如果在方法上加上synchronized修饰符,可以保证不会出线程问题了。但是这里有个很大(至少耗时比例上很大)的性能问题。

除了第一次调用时是执行了Singleton的构造函数之外,以后的每一次调用都是直接返回instance对象。

返回对象这个操作耗时是很小的,绝大部分的耗时都用在synchronized修饰符的同步准备上,因此从性能上来说很不划算。

因为synchronized的存在,每个线程在执行注释A的判断之前都会争抢锁,并且每个线程都要锁住了才能判断是否有实例存在。这样就导致了阻塞,因为同一时间下只能有一个线程执行synchronized里的语句,其余的线程都阻塞住。

我们能不能将注释A出的if条件判断提到外面将synchronized代码块包裹住?

问题还是一样的,假设2个线程都通过了判断。

其中一个线程先获得锁进行了创建,后一个线程因为过了判断,所以获得前一个线程释放的锁,又进行一次创建。

为了解决以上的问题,我们就需要进行两次判断,即双重检查锁定。代码如下

DCL懒汉单例

DCL单例模式

public final class Singleton {
        private Singleton() {
        }
        // 问题1:解释为什么要加 volatile ?
        // 防止指令重排序
        private static volatile Singleton INSTANCE = null;
    
        
        public static Singleton getInstance() {
            //问题2:对比懒汉单例, 说出这样做的意义    
            //提高效率:除第一次创建对象之外,其它的线程在访问在第一个if中就返回了,因此不会走到同步块中。
            if (INSTANCE != null) {
                return INSTANCE;
            }  
            synchronized (Singleton.class) {
            // 问题3:为什么还要在这里加非空判断, 之前不是判断过了吗
            // 防止第一次并发创建多个实例化需要。
            if (INSTANCE != null) { // t2
                return INSTANCE;
            }
            INSTANCE = new Singleton();
                return INSTANCE;
            }
        }
}

问题1:解释为什么要加volatile ?

 除了第一次创建对象之外,其它的线程在访问在第一个if中就返回了,因此不会走到同步块中,已经完美了吗?
 如上代码段中的注释:假设线程一执行到instance = new Singleton()这句,这里看起来是一句话,但实际上其被编译后在JVM执行的对应会变代码就发现,这句话被编译成8条汇编指令,大致做了三件事情:
​
  1)给instance实例分配内存;
​
  2)初始化instance的构造器;
​
  3)将instance对象指向分配的内存空间(注意到这步时instance就非null了)
​
  如果指令按照顺序执行倒也无妨,但JVM为了优化指令,提高程序运行效率,允许指令重排序。如此,在程序真正运行时以上指令执行顺序可能是这样的:
​
  a)给instance实例分配内存;
​
  b)将instance对象指向分配的内存空间;
​
  c)初始化instance的构造器;
​
  这时候,当线程一执行b)完毕,在执行c)之前,被切换到线程二上,这时候instance判断为非空,此时线程二直接来到return instance语句,拿走instance然后使用,接着就顺理成章地报错(对象尚未初始化)。
​
  具体来说就是synchronized虽然保证了线程的原子性(即synchronized块中的语句要么全部执行,要么一条也不执行),但单条语句编译后形成的指令并不是一个原子操作(即可能该条语句的部分指令未得到执行,就被切换到另一个线程了)。

根据以上分析可知,解决这个问题的方法是:禁止指令重排序优化,即使用volatile修饰变量。

问题2:为什么要使用2次判断?

说说双重检查加锁单例模式为什么两次if判断?
去掉内层判断:如果去掉内层if判断,就会实例化多次,这是显而易见的,这就违背了单例模式的单例二字。
​
去掉外层判断:
1.整个代码都加上了synchronzed,每次访问get方法都会进入同步代码块。效率太低。
​
2.当线程1走完了内层判断,对象实例化完成后。线程3也调用了getInstace函数,如果没有加外层的判断线程3还是要继续等待线程2的完成,而加上外层判断,就不需要等待了,直接返回了实例化的对象。
​
我的理解:外层的判断是为了提高效率,里层的判断就是防止第一次并发创建多个实例化需要。

静态内部类懒汉单例

内部类简单介绍

内部类分为对象级别和类级别。

类级内部类指的是,有static修饰的成员变量的内部类,静态内部类。

如果没有static修饰的成员变量的内部类被称为对象级内部类,非静态内部类。

类级内部类相当于其外部类的static成员,它的对象与外部类对象间不存在依赖关系,相互独立,因此可直接创建。

而对象级内部类的实例,是必须绑定在外部对象实例上的。

类级内部类只有在第一次被使用的时候才被会装载。

要想很简单地实现线程安全,可以采用静态初始化器的方式,它可以由JVM来保证线程的安全性,如恶汉式单例,这种实现方式,会在类装载的时候就初始化对象,有可能浪费一定的内存(假设你不需要的话),有一种方法能够让类装载的时候不去初始化对象,就是采用类级内部类,在这个类级内部类里面去创建对象实例。

 有上面我们进行的测试可以得出结论,静态内部类和非静态内部类一样,都不会因为外部内的加载而加载(所以是懒汉),同时静态内部类的加载不需要依附外部类,在使用时才加载,不过在加载静态内部类的过程中也会加载外部类

代码如下:

public final class Singleton {
private Singleton() { }
    // 问题1:属于懒汉式还是饿汉式
    // 懒汉
    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }
    // 问题2:在创建时是否有并发问题
    // no
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

Cas实现单例模式

public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE
                                        = new AtomicReference<Singleton>();
    private Singleton() {
        System.out.println("我被初始化了");
        CasSingletonTest.objectcount.getAndIncrement();
    }
    public static Singleton getInstance() {
        for (;;) {
            Singleton singleton = INSTANCE.get();
            if (null != singleton) {
                return singleton;
            }
            singleton = new Singleton();
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}

这是网上一位大牛的实现,他的这种非锁 CAS 实现的单例,挺好的。但是平时可能没有人使用,比用锁稍微复杂了一点,这也是为什么没有被列入单例模式的 7 大写法之中了。我在他的基础上,也就是他的构造方法里添加了两行代码。

我主要是想看看它到底是实例化了几次。加上这两行代码,可以方便我观察控制台,和统计实例化的总次数。

然后,我的测试代码如下:

package com.xttblog.canal.test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * CasSingletonTest
 * @author www.xttblog.com
 * @date 2019/2/27 下午2:39
 */
public class CasSingletonTest {
    public static AtomicInteger objectcount = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch begin = new CountDownLatch(1);
        final CountDownLatch last = new CountDownLatch(1000);
        for(int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //1.所有的线程都会阻塞在这
                        begin.await();
                        System.out.println
                            (Thread.currentThread().getName()+":begin...");
                        //3.阻塞的1000个线程并发执行
                        Singleton sba = Singleton.getInstance();
                        System.out.println(Thread.currentThread().getName()+":OK");
                        //4.释放门栓
                        last.countDown();
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
                }
            }).start();
        }
        //2.释放门栓
        begin.countDown();
        //5.等待1000个线程执行完毕 
        last.await();
        System.out.println("new objects: "+objectcount.get());
    }
}

关于 CountDownLatch 有不会的,可以看《CountDownLatch 压测教程》一文。

我这里主要是想压测一下,非锁 CAS 单例模式是否会创建多次对象。

运行上面的 main 方法,我截图了一下最终结果。

image.jpg

结论:CAS 以原子方式更新内存中相应的值,从而保证了多线程环境下共享变量更新操作的同步。的确,这种方式可以保证每次调用getInstance() 方法得到的一定是同一个实例。因此,从功能实现的角度来看,这种做法达到了预期的目的。

但是,经过分析和测试,却发现这种方式有一些预期之外的弊病:可能会创建不止一个对象。

CAS 本身的操作的确是原子方式,但是包装 CAS 指令的方法并非是全程同步的,当然,在包含 CAS 指令的方法开始调用之前,参数计算过程中更不是互斥执行的!当一个线程测试 instance.get() == null 得到 true 之后,往下它就一定会调用 new Singleton()。因为,这并不是 CAS 方法的一部分,而是它的参数。在调用一个方法之前,需要先将其参数压入栈,当然,需要先计算参数表达式,因此,产生如上结果也就不难预料了。

CAS 与锁的区别在于,它是非阻塞的,也就是说,它不会去等待一个条件,而是一定会去执行,结果要么成功,要么失败。它的操作时间是可预期的。如果我们的目的是一定要成功执行 CAS,那就需要不断循环执行直至成功,同时,建立在成功预期之上大量的准备工作是值得的,但是,如果我们不希望操作一定成功,那为成功操作而做的准备工作就浪费掉了。

枚举单例

// 问题1:枚举单例是如何限制实例个数的
// 问题2:枚举单例在创建时是否有并发问题
// 问题3:枚举单例能否被反射破坏单例
// 问题4:枚举单例能否被反序列化破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton {
INSTANCE;
}