设计模式之单例模式

        设计模式并不是使用了多么高大上的技术,大家不用望而却步。它是前人总结出来的经验套路。以Java语言为例,大部分设计模式都只是用了oop的最简单思想:封装、多态等来实现的,上手比较简单(这也是设计模式最可贵的一点)。针对某种特定需求情况下,我用了这种设计模式,会使代码的复用性,健壮性、可维护性等得到提升。使代码朝着低耦合、高内聚的方向前进。

        当然,并不是说我使用了一种设计模式,就不能使用其他设计模式,设计模式之间可以搭配着使用。例如单例模式可以和工厂模式组合成为单例工厂模式(咱们所熟悉的mvc模式就是用了多种设计模式构建出来的,感兴趣的可以自行了解一下)。而且,设计模式并不是一条条定律,不是说我就一定要完全按着这条设计模式所规定的那样去实现,改变一点都不行。设计模式可以按着实际需要进行灵活变动,这往往需要使用者进行多方面的决策考量。例如代码复杂度,灵活性,封装性、复用性等等。不同人针对某种需求所使用的设计模式也不尽相同。

        下面将以我在实际项目开发中所使用到的单例模式为例,简单介绍一下。

        “确保一个类只有一个实例,并提供全局访问点。”

        单例模式应该算是23种设计模式中比较简单的一种(可以从后文看到,如果往深了探究,单例模式反而是最复杂的设计模式之一)。但是在现今项目开发集成框架的情况下,我们感觉似乎用不到单例模式了,其实不然。以集成spring框架的注解方式为例,通过spring的依赖注入特性我们可以拿到一个实例,我们只需要声明一个变量并用autowired注解注释一下就可以了。但其实spring内部的依赖注入也是单例注入进去的(默认为单例,可以改成多例),我们只是没有感受到而已。在我现在进行参与开发的解析器模块中,解析程序都是没有集成框架的java程序。这就更加需要单例的存在。


1 懒汉式

        单例模式有多种实现方式,最简单的是懒汉式。这也是我们写得最多的单例实现方式。不过需要注意的是一定要显示地覆写一个私有的构造器。如果不写的话,会默认生成一个公有的无参构造器。这样外界还是能通过new的方式创建一个实例对象。私有构造器可以确保只有在该类的内部才能new出该对象,外界无法new出该对象,外界创建该对象的接口只有getInstance方法。

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

        同样的思想也可用在工具类方法的使用上。一般建议在工具类上显示覆写一个私有的构造器。因为工具类的方法一般都做成static静态的,调用工具方法可以通过类名.方法名的方式调用,也可以通过创建一个工具类对象,再调用该对象的方法来调用。显然后者不是一个好的实现:创建这个对象只是为了调用它的方法,那这个对象的创建则完全没有意义。这样的话我每次调用工具方法都需要new出一个对象,这样的代码质量就不高,会生成很多无用的对象。通过显示覆写私有构造器,外界调用工具方法只能通过类名.方法名的方式调用,通过new对象的方式会报编译时错误。这样可以强制你使用类名.方法名的方式调用工具类方法。

        但是懒汉式本身是非线程安全的,需要特别注意。解决办法有多种:

        第一种方式是加同步锁synchronized:

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

        但简单加同步锁的方式会影响并发性能。在日常的开发中,我们在使用synchronized时也要尽量确保锁住的范围足够小。因为锁住的范围内的代码块都会变成同步去执行,其他的线程会等待。

        同时,同步锁synchronized式单例在序列化反序列化后,得出的对象不是不是单例的,这点需要特别注意。解决办法是写一个readResolve方法,详情请看下述静态内部类的序列化反序列化解决方案。

        还有一点需要说明,同步锁synchronized式单例无法避免反射攻击,所以如果需要绝对的安全,请避免使用该方式。


2 饿汉式

        第二种方式是饿汉式。饿汉式能保证线程安全,但是在没使用该实例的时候也会生成一个实例,即饿汉式在类创建的同时就实例化一个静态对象出来,不管之后会不会使用这个单例,都会占据一定的内存。如果该实例不确定是否一定会被使用,且创建实例的过程会很慢,则用饿汉式实现单例的方式显然不太理想。

        但是,饿汉式单例在序列化反序列化后,得出的对象不是不是单例的,这点需要特别注意。解决办法是写一个readResolve方法,详情请看下述静态内部类的序列化反序列化解决方案。

public class Singleton {

    private static Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

        同样,使用静态代码块来实现也是可以的,示例如下:

public class MyObject {

    private static MyObject instance = null;

    private MyObject() {
        if (instance != null) {
            throw new RuntimeException("单例构造器禁止反射调用!");
        }
    }

    static {
        instance = new MyObject();
    }

    public static MyObject getInstance() {
        return instance;
    }
}

3 双重检查加锁

        第三种方式是双重检查加锁。该方式是对第一种加同步锁方式影响性能的缺点所做出的优化。只有第一次创建实例的时候会进同步锁,之后都不会再进。这种方式需要注意的是一定要用volatile修饰实例变量(我在网上查博客资料的时候发现有人在写单例的双重检查加锁方式时并没有加volatile关键字,这显然不对。这也意味着读者在网上查资料的时候需要多去进行比对,博客的性质意味着所写内容不一定完全正确)。volatile关键字在这里的作用是防止编译器对代码进行优化,具体可以查看相关内容。但是需要说明的一点是volatile关键字在jdk1.5以前的版本使用的话可能会出现意想不到的结果,在jdk1.5以后完善了该关键字的功能。

        但是,双重检查加锁式单例在序列化反序列化后,得出的对象不是不是单例的,这点需要特别注意。解决办法是写一个readResolve方法,详情请看下述静态内部类的序列化反序列化解决方案。

        还有一点需要说明,双重检查加锁式单例无法避免反射攻击,所以如果需要绝对的安全,请避免使用该方式。

public class Singleton {

    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

4 静态内部类

        第四种方式是静态内部类。利用了classloader的机制来保证初始化instance时只有一个线程,所以也是线程安全的,同时没有性能损耗。这种方式也是我现在正在使用的单例实现方式。内部类的创建并不会随着外部类的创建而创建,而是调用内部类的成员时才会创建。该方式没有性能问题,同时也比双重检查加锁方式的实现简单。

public class Singleton {

    private static class LazyHolder {

        private static final Singleton INSTANCE = new Singleton();
    }

    private Singleton() {
    }

    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

        但是如果遇到序列化对象时,使用默认的方式运行得到的结果还是多例的。如下所示:

import lombok.SneakyThrows;

import java.io.*;

public class MyObject implements Serializable {

    private static final long serialVersionUID = 5457572679511073588L;

    private static class InnerClassHolder {

        private static final MyObject INSTANCE = new MyObject();
    }

    private MyObject() {
        if (InnerClassHolder.INSTANCE != null) {
            throw new UnsupportedOperationException("单例构造器禁止反射调用!");
        }
    }

    public static MyObject getInstance() {
        return InnerClassHolder.INSTANCE;
    }

//    private Object readResolve() {
//        return InnerClassHolder.INSTANCE;
//    }

    @SneakyThrows
    public static void main(String[] args) {
        MyObject myObject1 = MyObject.getInstance();
        FileOutputStream fosRef = new FileOutputStream(new File("myObjectFile.txt"));
        ObjectOutputStream oosRef = new ObjectOutputStream(fosRef);
        oosRef.writeObject(myObject1);
        oosRef.close();
        fosRef.close();
        System.out.println(myObject1.hashCode());

        FileInputStream fisRef = new FileInputStream(new File("myObjectFile.txt"));
        ObjectInputStream iosRef = new ObjectInputStream(fisRef);
        MyObject myObject2 = (MyObject) iosRef.readObject();
        iosRef.close();
        fisRef.close();
        System.out.println(myObject2.hashCode());
    }
}

        在我的机器上运行结果如下:

1550089733
295530567

        读出来的对象和之前写进去的对象的hash值不同,说明不是同一对象。解决办法是将上面注释掉的readResolve方法解开,再次运行程序结果如下:

1550089733
1550089733

        反序列化时会看是否实现了readResolve方法,如果实现了,会进行反射调用。


5 枚举

        第五种方式是使用枚举。枚举enum和之前的静态代码块的特性相似,在使用枚举类时,构造方法会被自动调用,也可以应用其这个特性来实现单例。

public enum MyEnumSingleton {

    INSTANCE;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(MyEnumSingleton.INSTANCE.hashCode());
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(MyEnumSingleton.INSTANCE.hashCode());
            }
        });
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(MyEnumSingleton.INSTANCE.hashCode());
            }
        });
        t1.start();
        t2.start();
        t3.start();
    }
}

        枚举方式的单例既可以防御序列化反序列化攻击,又可以防御反射攻击,是一种比较理想的的单例实现方案。该方式也是《Effective Java》这本书的作者所推荐的方式。


6 警惕循环体中的字符串拼接问题

        使用单例模式的意义更多的在于优化。不使用单例往往程序不会出现bug,但是你可能会new出一大堆无用对象出来。明明用一个对象能解决的问题结果我却用了多个对象。这样会加重gc的回收压力,同时在大数量数据的情况下会使程序执行变慢。应该形成的一种思想是当我们写代码的时候new出了一个对象,就应该反射神经似的想new出的这个对象是否一定要new出来、是否放在了循环体里,能否可以放在循环体之外、new出的对象可不可以做成单例,等等。

        有些情况下即使你没有显示地new出对象,仍然会生成大量对象。如下所示:

public class Test {
 
    private static int TIME = 200000;
 
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        method1();
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
 
    public static String method1() {
        String result = "";
        for (int i = 0; i < TIME; i++) {
            result += i;
        }
        return result;
    }
}

        method1方法实现将String对象循环遍历添加的操作。这里为了能显著看出差异,循环次数选择了二十万次。经过我在自己电脑上的实测,运行时间大概在41995毫秒左右,即41秒钟上下。

public class Test {
 
    private static int TIME = 200000;
 
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        method2();
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
 
    public static String method2() {
        StringBuilder result = new StringBuilder(TIME);
        for (int i = 0; i < TIME; i++) {
            result.append(i);
        }
        return result.toString();
    }
}

        如上图所示,改用StringBuilder字符串变量循环遍历二十万次,运行时间大概在10毫秒左右,高下立判。

        那为什么会有这么大的区别呢?众所周知,String常量的值是放在常量池中的,即如果你修改了String的值,是会在常量池中重新生成新的你修改过的值,而不是会把之前的值进行修改(如果修改后的值在常量池中已存在,则不会生成新值,确保常量池中同样的值只会有一份)。所以我循环了二十万次,会在常量池中生成二十万个常量(如果常量池中这些数值之前不存在的话),我其实只是要最后一次循环生成的值而已。

        但是,编译器会优化这段代码,会将字符串拼接优化成StringBuilder的append操作。如下图所示:

        通过反编译可知,编译器是将StringBuilder对象的new操作放在了循环体里面,即一共生成了二十万个StringBuilder对象。new出来的对象都是放在堆里,回收是靠垃圾回收器gc回收的。回收时间不确定,所以执行速度会变得这么慢。再来看一下method2的class文件,如下:

        由上可见StringBuilder对象的new操作是在循环体之外。这也提示我们当进行循环操作的时候,尽量要将new操作放在循环体外面,提高执行效率。这样的话,只会生成一个StringBuilder对象,拿它去进行遍历操作。

        毕竟我们写代码的意义并不只是为了能实现某种功能而已,而是要更高效的实现它。如果碍于开发时间紧张的因素等,当时开发并没有考虑代码的高效性,等以后空闲下来也一定要多去做code review,自己也要多回炉之前写过的代码。并不是说只有遇见bug了我才去看之前写过的代码,不断回炉,不断学习,才能成长得更快。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值