单例模式,顾名思义就是只有一个实例,并且它自己负责创建自己的对象,这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。单例模式的写法就只有3个步骤:
- 构造器私有化。
- 定义一个私有的类的静态实例。
- 提供一个公有的获取实例的静态方法。
单例模式虽然看似简单,但是要想写好一个完美的单例模式,其中也有非常多的细节。
懒汉式
写法一
普通写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public class Singleton { private static Singleton singleton;
private Singleton(){ System.out.println("我被new出来了"); }
public static Singleton getInstance(){ if(singleton==null){ singleton = new Singleton(); } return singleton; } }
|
懒汉式的单例模式只在需要对象的时候再创建,所以被称为懒汉式,但是这种写法有缺点:多线程的情况下还是会创建出多个实例对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Test public void SingletonTest() { final ExecutorService service = Executors.newFixedThreadPool(100); for (int i = 0; i < 100; i++) { Runnable runnable = new Runnable() { @Override public void run() { final Singleton instance = Singleton.getInstance(); log.info(instance.toString()); } }; service.execute(runnable); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| 我被new出来了 我被new出来了 我被new出来了 我被new出来了 2022-01-05 16:15:14.206 INFO 15180 --- [pool-2-thread-1] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@6a043a46 2022-01-05 16:15:14.206 INFO 15180 --- [pool-2-thread-5] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@55aa1a18 我被new出来了 我被new出来了 我被new出来了 我被new出来了 我被new出来了 我被new出来了 我被new出来了
|
可以从日志输出看出有多个线程创建了实例对象,问题就出在创建实例对象的这个方法,因为这个方法没有加同步机制,所以会导致多线程下可能会有多个线程singleton==null
为true
,就会创建多个对象。
1 2 3 4 5 6
| public static Singleton getInstance(){ if(singleton==null){ singleton = new Singleton(); } return singleton; }
|
写法二
同步方法
我们可以在创建实例对象方法上面加上synchronized
,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
public static synchronized Singleton getInstance(){ if(singleton==null){ singleton = new Singleton(); } return singleton; }
|
多次在多线程环境下测试只有一个实例对象。
1 2 3 4 5
| 2022-01-05 09:52:11.909 INFO 22356 --- [pool-2-thread-5] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@74b5f573 2022-01-05 09:52:11.909 INFO 22356 --- [pool-2-thread-7] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@74b5f573 2022-01-05 09:52:11.909 INFO 22356 --- [pool-2-thread-3] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@74b5f573 2022-01-05 09:52:11.909 INFO 22356 --- [pool-2-thread-6] com.guyou.test.TestApplicationTests : com.guyou.test.test.Singleton@74b5f573
|
这种方式虽然实现了线程安全,但是synchronized
是加在方法上的,导致锁粒度太大了,我们可以加在代码块里面,锁粒度就会小一下,前提不是用this
当成锁对象,因为用了this
其实就和加在方法上没什么区别了。
写法三
同步代码块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public static Singleton getInstance(){ synchronized(obj){ if(singleton==null){ singleton = new Singleton(); } } return singleton; }
|
此种写法锁粒度会小一些,也可以使用其他的对象作为锁。
写法四
双重判断写法
在写法三的基础上再进行优化,加入双重判断机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
public static Singleton getInstance(){ if(singleton==null){ synchronized(obj){ if(singleton==null){ singleton = new Singleton(); } } } return singleton; }
|
为什么要使用双重判断,第二个判断可能容易理解,第二个判断就是加锁会判断对象是否为null
,为null
才创建对象,第一个判断是为了提高效率的,给后面的线程直接返回对象的机会,如果有线程获取到锁并创建了对象,那么后面的线程在第一个判断就直接可以返回创建好了的对象,无需再获取锁,直接返回对象。因为获取锁是消耗资源,消耗性能的。
写法五
双重判断加禁止指令重排序
一般单例模式能写到写法四那种情况就差不多了,但是还有一种情况需要考虑,就是指令重排序,下面简单的说一下指令重排序。
1 2 3 4
| int a = 1; int b = 2; a = a + 1; b = b + 2;
|
上面这段代码经过指令重排后可能会是下面这样
1 2 3 4
| int a = 1; a = a + 1; int b = 2; b = b + 2;
|
这就是对指令重排比较简单、直观的解释。
现在来分析一下写法四中的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| public class Singleton {
private static volatile Singleton singleton; private static final Object obj = new Object();
private Singleton(){ System.out.println("我被new出来了"); }
public static Singleton getInstance(){ if(singleton==null){ synchronized(obj){ if(singleton==null){ singleton = new Singleton(); } } } return singleton; } }
|
假如有AB两个线程当A线程执行到代码1处的时候.这个语句不是一个原子操作.它实际上分为三个步骤
- 分配内存
- 初始化对象
- 设置变量指向刚分配的地址
假如因为指令重排导致执行的顺序变为了1、3、2
那么假如A线程中执行完1、3之后,B线程到达代码2处,执行判断语句。发现singleton指向的是一段地址,因此直接不进入判断语句而是直接返回了一个没有初始化的空的对象。
为了避免这种情况我们可以在加上volatile
来避免指令重排序,volatile
关键字有两个作用:
- 禁止指令重排序。
- 保证内存可见性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| public class Singleton {
private static volatile Singleton singleton; private static final Object obj = new Object();
private Singleton(){ System.out.println("我被new出来了"); }
public static Singleton getInstance(){ if(singleton==null){ synchronized(obj){ if(singleton==null){ singleton = new Singleton(); } } } return singleton; }
}
|
饿汉式
饿汉式,从名字上也很好理解,就是“比较勤”,实例在初始化的时候就已经建好了,不管你有没有用到,都先建好了再说。好处是没有线程安全的问题,坏处是浪费内存空间。
写法一
静态常量法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class Singleton {
private static final Singleton singleton = new Singleton();
private Singleton(){ System.out.println("我被new出来了"); }
public static Singleton getInstance(){ return singleton; } }
|
特点:多线程下可以保证单例,但是会造成资源的浪费(不管我们需不需要这个唯一单例,它都会创建出来这个单例。如果我们根本不需要单例时,就会造成资源浪费。
方法二
静态代码块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public class Singleton {
private static Singleton singleton;
static { singleton = new Singleton(); }
private Singleton(){ System.out.println("我被new出来了"); }
public static Singleton getInstance(){ return singleton; }
}
|
将类的实例化放在静态代码块中,与上述的静态常量一致,都是在类装载时创建单例,因此优缺点一致。
方法三
静态内部类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public class Singleton {
private Singleton(){ System.out.println("我被new出来了"); }
public static class SingletonInstance{ private static final Singleton singleton = new Singleton(); }
public static Singleton getInstance() { return SingletonInstance.singleton; } }
|
Singleton在加载的时候不会被实例化,而是在需要实例化时(调用getInstance()),才会装载静态内部类,从而完成Singleton的实例化,多线程下可以实现单例。
枚举式
枚举式最安全的单例模式,就算是懒汉式的双重检测加volatile
的写法也可以利用反射创建出多个实例对象。
1 2 3 4 5 6 7
| public enum Singleton { INSTANCE;
public Singleton getInstance(){ return INSTANCE; } }
|