草庐IT

Java单例模式的最佳实践?

程语有云 2023-03-28 原文

“读过书,……我便考你一考。茴香豆的茴字,怎样写的?”——鲁迅《孔乙己》

0x00 大纲

0x01 前言

最近在重温设计模式(in Java)的相关知识,然后在单例模式的实现上面进行了一些较深入的探究,有了一些以前不曾注意到的发现,遂将其整理成文,以作后用。

单例模式最初的定义出现于《设计模式》(艾迪生维斯理, 1994):“保证一个类仅有一个实例,并提供一个访问它的全局访问点。”

其应用场景可以说是十分广泛,尤其是在涉及到资源管理方面的代码,像应用配置(实例)、部分工具类或工厂类、JDK里的Runtime等,都有出现单例模式的身影。

0x02 单例的正确性

探讨单例模式有多少种实现方式的意义不是很大,因为单例模式的实现方式比茴字的写法还多,但是正确的实现却不多,我们不妨将重点放在如何保证单例的正确性上,从而寻求最佳实践方案。

单例模式的关键在于如何保证“一个类仅有一个实例”。首先思考一下创建实例的方式有哪些?在Java语言里面,有这几种方式:new关键字、clone方法克隆、反序列化、反射。

new关键字

public class Main {
    public static void main(String[] args) {
        Singleton instance = new Singleton();
    }
}

如果要保证一个类是单例,则必须阻止用户通过new关键字来随意创建对象,最简单粗暴的方法就是将构造方法私有化,然后提供一个静态方法来进行实例的外部访问:

public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() { }

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

此时就不能在类的外部通过new来创建对象了。

clone方法克隆

clone方法是原型模式中创建复杂对象的方法,在Java中,clone方法是Object基类的方法,因此所有的类都会继承该方法,但只有实现了Cloneable接口的类才能正常调用clone方法克隆对象实例,否则会抛出类型为CloneNotSupportedException的异常,单例的类要防止用户通过clone方法克隆就不能实现Cloneable接口。

反序列化

在Java里面,实现了Serializable接口的类可以通过ObjectOutputStream将其实例序列化,然后再通过ObjectInputStream进行反序列化,而在默认情况下,反序列之后得到的是一个新的实例,这就违背了单例的法则了。幸好JDK的开发人员也想到了这点,再Serializable接口的文档中有这样一段描述:

Classes that need to designate a replacement when an instance of it is read from the stream should implement this special method with the exact signature.

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

意思就是在反序列化时可以通过在类里面定义readResolve方法来指定反序列化时返回的对象,例如:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();

    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

反射

聪明的你也许注意到了,上面的readResolve方法是private的。那么它是怎么被调用的呢?答案就是通过反射,想了解更详细的调用过程可以去看看ObjectInputStream类源码中的readOrdinaryObject方法。

通过反射可以无视private修饰符的限制调用类里面的各种方法,也就是说用户可以利用反射来调用我们的私有构造方法,像这样:

public class Main {
    public static void main(String[] args) throws Exception {
        // 这句代码无法执行,因为我们的构造方法是private的
        // Singleton singleton = new Singleton();
        // 通过反射来创建实例
        java.lang.reflect.Constructor<Singleton> constructor;
        constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton = constructor.newInstance();
        // 两个实例不一样,单例完蛋
        if(singleton != Singleton.getInstance()) {
            System.out.println("哦嚯,完蛋");
        }
    }
}

解决方法是在构造方法里面判断类的实例是否已经被创建过,如果已经创建过的,抛出异常从而阻止反射调用。把单例类的代码修改如下:

public class Singleton implements java.io.Serializable {
    private static final long serialVersionUID = 1L;
    private static Singleton instance = new Singleton();
    private Singleton() {
        if(instance != null) {
            throw new RuntimeException("Not Allowed.");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }

    /**
     * 显式指定反序列化时返回的单例对象
     * @return
     * @throws java.io.ObjectStreamException
     */
    private Object readResolve() throws java.io.ObjectStreamException {
        return getInstance();
    }
}

再次通过反射进行对象创建时,就会抛出类型为RuntimeException的异常,从而阻止新实例的创建。

0x03 最佳实践方案

可以看到,我们为了实现单例模式,加入了一大堆胶水代码,用于保证其正确性,这一点都不简洁。那么有没有更简单更有效的方式呢?有,而且已经有人帮我们验证过了。

Joshua Bloch在《Effective Java》一书中写道:

使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

我们直接上代码看看:

public enum EnumSingleton {
    INSTANCE;
    public void doSomething() {
        System.out.println("do something.");
    }
}

就是这么简单,再看看调用它的代码:

public class Main {
    public static void main(String[] args) {
        EnumSingleton.INSTANCE.doSomething();
    }
}

使用枚举实现单例模式,不仅代码简洁,而且可以轻松阻止用户通过new关键字、clone方法克隆、反序列化、反射等方式创建重复实例,还保证线程安全,这一切由JVM替你操办,不需要添加额外代码。

0x04 验证测试

枚举实现单例模式能不能保证上面的提到的各种属性呢?我们用代码逐一验证一下:

public class Main {
    public static void main(String[] args) throws Exception {
        // TEST-1: 验证是否单一实例
        EnumSingleton s1 = EnumSingleton.INSTANCE;
        EnumSingleton s2 = EnumSingleton.INSTANCE;
        if (s1.hashCode() != s2.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-1 PASSED.");
        }
        // TEST-2: 验证反射创建
        java.lang.reflect.Constructor<EnumSingleton> constructor;
        // 注意这里用的是枚举的父构造器,因为我们没有定义构造方法
        constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        boolean passed = false;
        try {
            EnumSingleton s3 = constructor.newInstance("NEW_INSTANCE", 2);
        } catch (Exception ex) {
            // 报错说明反射不能创建
            passed = true;
        }
        if (!passed) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-2 PASSED.");
        }
        // TEST-3: 验证反序列化
        EnumSingleton s4 = EnumSingleton.INSTANCE;
        EnumSingleton s5;
        try (java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(new java.io.FileOutputStream("EnumObject"))) {
            oos.writeObject(s4);
        }
        try (java.io.ObjectInputStream ois = new java.io.ObjectInputStream(new java.io.FileInputStream("EnumObject"))) {
            s5 = (EnumSingleton) ois.readObject();
        }
        if (s4.hashCode() != s5.hashCode()) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-3 PASSED.");
        }
        // TEST-4: 多线程测试
        java.util.concurrent.CountDownLatch begin = new java.util.concurrent.CountDownLatch(10);
        java.util.concurrent.CountDownLatch end = new java.util.concurrent.CountDownLatch(10);
        java.util.Set<EnumSingleton> set = new java.util.HashSet<>(1024);
        java.util.stream.IntStream.range(0, 20).forEach(
                i -> {
                    new Thread(() -> {
                        try {
                            begin.await();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        set.add(EnumSingleton.INSTANCE);
                        System.out.println(System.currentTimeMillis() + ":" + Thread.currentThread().getName() + "->" + EnumSingleton.INSTANCE.hashCode());
                        end.countDown();
                    }).start();
                    begin.countDown();
                }
        );
        end.await();
        if(set.size() != 1) {
            System.out.println("哦嚯,完蛋");
        } else {
            System.out.println("TEST-4 PASSED.");
        }
    }
}

测试结果:

TEST-1 PASSED.
TEST-2 PASSED.
TEST-3 PASSED.
...
TEST-4 PASSED.

0x05 真的是最佳实践吗

在 Java Language Specification 枚举类型这一章节中,具体阐述了若干点对于枚举类型的强制和隐性约束:

An enum declaration specifies a new enum type, a special kind of class type.

It is a compile-time error if an enum declaration has the modifier abstract or final.

An enum declaration is implicitly final unless it contains at least one enum constant that has a class body (§8.9.1).

A nested enum type is implicitly static. It is permitted for the declaration of a nested enum type to redundantly specify the static modifier.

This implies that it is impossible to declare an enum type in the body of an inner class (§8.1.3), because an inner class cannot have static members except for constant variables.

It is a compile-time error if the same keyword appears more than once as a modifier for an enum declaration.

The direct superclass of an enum type E is Enum (§8.1.4).

An enum type has no instances other than those defined by its enum constants. It is a compile-time error to attempt to explicitly instantiate an enum type (§15.9.1).

其中最为突出和有影响是以下两点:

不能显式继承

和常规类一样,枚举可以实现接口,并提供公共实现或每个枚举值的单独实现,但不能继承,因为所有的枚举默认隐式继承了Enum<E>类型,不能继承也就意味着丧失了一部分的抽象能力(不能定义abstract方法),虽然可以通过组合的方式变通实现,但这无疑牺牲了扩展性和灵活性。

无法延迟加载

因为枚举实例化的特殊性,所有的构造器属性都必须在枚举创建时指定,无法在运行时通过代码动态传递和构造。

0x06 小结

非枚举的单例实现除开少数极端场景,在大多数时候下也都够用了,且保留了OOP的灵活特性,方便日后业务扩展,基于枚举的单例实现有序列化和线程安全的保证,而且只要几行代码就能实现,不失为一种有效的方案,但并不无敌。具体的实现方案还是要根据业务背景和实际情况来进行选择,毕竟,软件工程没有银弹。

有关Java单例模式的最佳实践?的更多相关文章

  1. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  2. ruby-on-rails - Rails - 子类化模型的设计模式是什么? - 2

    我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co

  3. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  4. ruby - 如何在续集中重新加载表模式? - 2

    鉴于我有以下迁移:Sequel.migrationdoupdoalter_table:usersdoadd_column:is_admin,:default=>falseend#SequelrunsaDESCRIBEtablestatement,whenthemodelisloaded.#Atthispoint,itdoesnotknowthatusershaveais_adminflag.#Soitfails.@user=User.find(:email=>"admin@fancy-startup.example")@user.is_admin=true@user.save!ende

  5. java - 等价于 Java 中的 Ruby Hash - 2

    我真的很习惯使用Ruby编写以下代码:my_hash={}my_hash['test']=1Java中对应的数据结构是什么? 最佳答案 HashMapmap=newHashMap();map.put("test",1);我假设? 关于java-等价于Java中的RubyHash,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/22737685/

  6. java - 从 JRuby 调用 Java 类的问题 - 2

    我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www

  7. ruby - 是否有用于序列化和反序列化各种格式的对象层次结构的模式? - 2

    给定一个复杂的对象层次结构,幸运的是它不包含循环引用,我如何实现支持各种格式的序列化?我不是来讨论实际实现的。相反,我正在寻找可能会派上用场的设计模式提示。更准确地说:我正在使用Ruby,我想解析XML和JSON数据以构建复杂的对象层次结构。此外,应该可以将该层次结构序列化为JSON、XML和可能的HTML。我可以为此使用Builder模式吗?在任何提到的情况下,我都有某种结构化数据-无论是在内存中还是文本中-我想用它来构建其他东西。我认为将序列化逻辑与实际业务逻辑分开会很好,这样我以后就可以轻松支持多种XML格式。 最佳答案 我最

  8. java - 我的模型类或其他类中应该有逻辑吗 - 2

    我只想对我一直在思考的这个问题有其他意见,例如我有classuser_controller和classuserclassUserattr_accessor:name,:usernameendclassUserController//dosomethingaboutanythingaboutusersend问题是我的User类中是否应该有逻辑user=User.newuser.do_something(user1)oritshouldbeuser_controller=UserController.newuser_controller.do_something(user1,user2)我

  9. java - 什么相当于 ruby​​ 的 rack 或 python 的 Java wsgi? - 2

    什么是ruby​​的rack或python的Java的wsgi?还有一个路由库。 最佳答案 来自Python标准PEP333:Bycontrast,althoughJavahasjustasmanywebapplicationframeworksavailable,Java's"servlet"APImakesitpossibleforapplicationswrittenwithanyJavawebapplicationframeworktoruninanywebserverthatsupportstheservletAPI.ht

  10. 叮咚买菜基于 Apache Doris 统一 OLAP 引擎的应用实践 - 2

    导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵

随机推荐