一、单例模式简介
一个类只能有一个实例,提供该实例的全局访问点;
二、单例模式实现步骤
使用一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。
私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。
三、单例模式的两种方式
1.懒汉模式
懒汉模式,通俗来讲就是只有饿的时候,才会去找饭吃。通常只有对象被需要的时候才会去创建。最显而易见的优点就是,节省资源。如果没有地方用到这个类,这个类将不会进行实例化。
1.1 简易版懒汉模式
public class LzaySingleton {
/**
* 成员变量
*/
private static LzaySingleton lzaySingleton;
/**
* 构造方法私有化
*/
private LzaySingleton(){
}
/**
* 获取单例对象
* @return
*/
public static LzaySingleton getInstance(){
if(lzaySingleton == null){
System.out.println("创建实例");
lzaySingleton = new LzaySingleton();
return lzaySingleton;
}
System.out.println("实例对象已存在,无需再创建");
return lzaySingleton;
}
}
测试类
public static void main(String[] args) {
// 先创建一个对象,看是否有输出
LzaySingleton lzaySingleton = LzaySingleton.getInstance();
LzaySingleton lzaySingleton1 = LzaySingleton.getInstance();
}
结果:
简易版的单例模式存在的问题就是:在多线程的情况下是不安全的,会打破单例的定义。
例如:有2个线程,线程A,线程B;同时成员变量lzaySingleton
为null;线程A,线程B,同时走到if(lzaySingleton == null)
,那将会执行两次lzaySingleton = new LzaySingleton();
就会实例化两次对象,从而打破单例模式的设定。
怎么解决呢?接下来就是我们另外一种懒汉单例模式登场了。
1.2线程安全的单例模式
怎么解决线程安全?那就很简单了,加锁就可以了。
只需要再getInstance()
方法上加 synchronized
就行,这样保证同一个时间点,只会有一个线程进入到这个方法,从而解决多次创建实例的问题。
public class LzaySingleton {
/**
* 成员变量
*/
private static LzaySingleton lzaySingleton;
/**
* 构造方法私有化
*/
private LzaySingleton(){
}
/**
* 获取单例对象
* @return
*/
public static synchronized LzaySingleton getInstance(){
if(lzaySingleton == null){
System.out.println("创建实例");
lzaySingleton = new LzaySingleton();
return lzaySingleton;
}
System.out.println("实例对象已存在,无需再创建");
return lzaySingleton;
}
}
以上的方法虽然可以解决多线程的问题,但是往往单例对象的内容逻辑是非常复杂的,使用synchronized
修饰方法,当其他线程进入该方法的时候,就会进入等待,对性能还是有一定影响的。
解决这个问题,可以灵活的使用synchronized
。
1.3 线程安全的单例模式V2.0版
为了解决synchronized
修饰方法带来的系统开销。我们可以通过灵活运用synchronized
来解决此问题。众所周知synchronized
加锁是有多种方式的。我们使用代码块的方式,只有再创建对象的时候使用 synchronized
。
public class LzaySingleton {
/**
* 成员变量
*/
private static LzaySingleton lzaySingleton;
/**
* 构造方法私有化
*/
private LzaySingleton(){
}
/**
* 获取单例对象
* @return
*/
public static LzaySingleton getInstance(){
if(lzaySingleton == null){
// synchronized 代码块
synchronized (LzaySingleton.class){
System.out.println("创建实例");
lzaySingleton = new LzaySingleton();
}
return lzaySingleton;
}
System.out.println("实例对象已存在,无需再创建");
return lzaySingleton;
}
}
这种方式虽然解决了,锁粒度问题带来的性能开销问题,但是又有一个致命问题,我们又回到解放前了。
同样的多线程问题,如果线程A,线程B,同时又到了这一步:
线程A和B拿到的对象都是null,然后线程A侥幸拿到了锁,线程B就只能再外面等待线程A。同样的问题就会再现,线程A执行完lzaySingleton = new LzaySingleton();
线程B就会拿到锁,然后再执行一次lzaySingleton = new LzaySingleton();
,所有使用synchronized
代码块的方式加锁,还不够完善。
1.3 线程安全的单例模式V2.1版-双重校验锁
因为上面使用了synchronized
代码块的方式加锁,减少了系统的开销,但是也带来了新的问题,因此我们多增加一个判断,如下:
public class LzaySingleton {
/**
* 成员变量
*/
private static LzaySingleton lzaySingleton;
/**
* 构造方法私有化
*/
private LzaySingleton(){
}
/**
* 获取单例对象
* @return
*/
public static LzaySingleton getInstance(){
if(lzaySingleton == null){
synchronized (LzaySingleton.class){
if(lzaySingleton == null){
System.out.println("创建实例");
lzaySingleton = new LzaySingleton();
}
}
return lzaySingleton;
}
System.out.println("实例对象已存在,无需再创建");
return lzaySingleton;
}
}
这样,即使线程A和线程B同时都到了这一步:
即使A拿到了锁,执行完lzaySingleton = new LzaySingleton();
以后,到B执行时也会被这个校验给拦住
至此高性能加锁的单例模式完成,但是他还不是最终版本,依旧存在一些小问题。
1.3 线程安全的单例模式V3.0版-双重校验锁终极版本
目前代码层面已经解决问题,但是深究底层,时 lzaySingleton = new LzaySingleton();
这个操作并不是原子性的,因为底层在编译运行代码的时候,会对当前代码进行优化,会存在指令重排序情况。而 new LzaySingleton()
时至少需要3步才能完成。
1.分配内存空间;
2.实例化对象;
3.将对象指向分配的空间地址;
如果编译的时候进行了指令重排序,本来正常操作时 1 -> 2 -> 3这样,重排序后则可能会出现 1-> 3 -> 2 这个时候,单线程肯定没问题,但是在多线程的情况下,因为对象还没创建完成,其他线程执行到这里的时候,认为对象不为空,已经实例化成功了,就直接获取对象使用了。其实拿到的对象并不是最终的对象,只是一个半成品的,所以使用的过程中,就会出现意想不到的问题。
这个时候就需要使用 JVM的关键字 volatile
来解决指令重排序的问题了。
简单介绍一下 volatile
1.volatile
有3个特性:可见性、有序性、原子性;
可见性是当多个线程同时访问一个变量的时候,其中一个线程修改了变量的值,其他线程能立刻看到修改的变量值。
有序性是禁止了指令重排序,执行程序代码时,按照顺序来执行。
原子性是一个操作是不能中断的,要不全部都执行,要不都不执行。
2. volatile
是用来修饰变量的,无法修饰代码块和方法。
3. volatile
的使用:只要修饰一个 可能被多线程同时访问的变量上就行。
详细情况可自行查询相关资料。
最终代码如下: 对成员变量lzaySingleton 进行了volatile 修饰,防止了创建时的指令重排序。
public class LzaySingleton {
/**
* 成员变量
*/
private static volatile LzaySingleton lzaySingleton;
/**
* 构造方法私有化
*/
private LzaySingleton(){
}
/**
* 获取单例对象
* @return
*/
public static LzaySingleton getInstance(){
if(lzaySingleton == null){
synchronized (LzaySingleton.class){
if(lzaySingleton == null){
System.out.println("创建实例");
lzaySingleton = new LzaySingleton();
}
}
return lzaySingleton;
}
System.out.println("实例对象已存在,无需再创建");
return lzaySingleton;
}
}
至此单例模式的懒汉模式最终版完成。
2.饿汉模式
饿汉模式相对来说,比较简单,通俗来说就是,一上来就先去找吃的和懒汉相反。系统加载的时候就初始化对象。优点就是简单,不存在什么多线程问题。缺点就是占用内存。
实现如下:
public class HungrySingleton {
// 一开始就初始化对象
private static HungrySingleton hungrySingleton = new HungrySingleton();
// 私有化构造方法
private HungrySingleton(){}
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}
四、扩展实现单例
1.使用枚举的方式实现单例模式
使用枚举的方式实现单例模式,是《Effective java》一书中提到的
上面的几种方式已经实现了单例模式,但是如果碰到特殊的情况,比如反射的时候,通过 setAccessible()
方法还是可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象的。使用枚举就天然的解决反射问题。
直接在枚举类里面写功能方法,代码如下:
public enum SingletonEnum {
INSTANCE
;
public void test(){
System.out.println("1111");
}
}
测试类
public static void main(String[] args) {
SingletonEnum.INSTANCE.test();
}
结果
2.使用内部类的方式实现单例模式
内部类的方式实现单例模式,加载Singleton
的时候静态内部类 SingletonHolder
不会被加载。 只有调用 getInstance()
方法的时候才会去初始化对象。这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。
以下是代码实现:
/**
* 静态内部类方式实现单例
*/
public class Singleton {
// 私有化构造方法
private Singleton(){}
public void test(){
System.out.println("2222");
}
/**
* 静态内部类
*/
private static class SingletonHolder{
// 初始化对象
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance(){
return SingletonHolder.INSTANCE;
}
}
测试类
public static void main(String[] args) {
Singleton.getInstance().test();
}
结果
FAQ
1.为什么要私有化构造方法?
单例模式主要特点就是保证对象只被实例化一次,所以构造方法的私有化,才能保证不能随便的去new()
对象,从而保证对象只能初始化一次。
2.为什么成员变量要用 static 修饰?
程序调用类中方法只有两种方式,①创建类的一个对象,用该对象去调用类中方法;②使用类名直接调用类中方法,格式“类名.方法名()”;
现在没有办法new 对象
了,所以只能使用第二种方式。
java中静态方法没有办法调用非静态的类或者变量,所以成员变量也需要使用static来修饰。
3.单例模式的应用场景?
- 数据库连接池:数据库连接池是一个重要的资源,单例模式可以确保应用程序中只有一个数据库连接池实例,避免资源浪费。
- 配置文件管理器:应用程序通常需要一个配置文件管理器来管理配置文件,单例模式可以确保在整个应用程序中只有一个这样的实例。
- 缓存系统:缓存系统是提高应用程序性能的重要组件,单例模式可以确保只有一个缓存实例。
4.单例模式使用的注意情况
单例模式主要分为 懒汉 和饿汉,我们通常再使用的时候要综合评估两种方式的优缺点,决定使用,比如:对于一些占用内存小的类我们使用饿汉模式,占用内存较大的类我们就使用懒汉模式。一开始就需要加载的并且会被频繁使用的就用饿汉模式。
5.JDK中的单例
java.lang.Runtime类使用的就是单例模式(饿汉),这个类是运行时的类,很多信息需要获取所以使用的是饿汉单例模式,如下:
java.awt.Desktop类使用的是懒汉单例模式: