本文主要介绍单例创建的集中方式和反射给单例造成的影响。
单例的定义
单例模式:保证一个类仅有一个实例对象,并且提供一个全局访问点。
单例的特点
- 单例类只能有一个实例对象
- 单例类必须自己创建自己的唯一实例
- 单例类必须对外提供一个访问该实例的方法
使用场景及优点
优:
- 提供了对唯一实例的受控访问
- 保证了内存中只有唯一实例,减少内存开销,比如需要多次创建和销毁实例的场景
- 避免对资源的多重占用,比如文件的写操作
缺:
- 没有抽象层,接口,不能继承,扩展困难,违反了开闭原则
- 单例类一般写在同一个类中,职责过重,违背了单一职责原则
应用场景:
文件系统;数据库连接池的设计;日志系统等 IO/生成唯一序列号/身份证/对象需要共享的情况,比如web中配置对象
实现单例
三步:
- 构造函数私有化
- 在类内部创建实例
- 提供本类实例的唯一全局访问点,即唯一实例的方法
饿汉式:
public class Hungry {
// 构造器私有,静止外部new
private Hungry(){}
// 在类的内部创建自己的实例
private static Hungry hungry = new Hungry();
// 获取本类实例的唯一全局访问点
public static Hungry getHungry(){
return hungry;
}
}
懒汉式:
public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
}
// 定义即可,不真正创建
private static Lazy1 lazy1 = null;
// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
lazy1 = new Lazy1();
}
return lazy1;
}
public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy1.getLazy1();
}).start();
}
}
}
单线程环境下是没有问题的,但是多线程的情况下就会出现问题
DCL 懒汉式:
方法上直接加锁:
public static synchronized Lazy1 getLazy1(){
if (lazy1 == null) {
lazy1 = new Lazy1();
}
return lazy1;
}
缩小锁范围:
public static Lazy1 getLazy1(){
if (lazy1 == null) {
synchronized(Lazy1.class){
lazy1 = new Lazy1();
}
}
return lazy1;
}
双重锁定:
// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}
指令重排序: 指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。
首先要知道 lazy1 = new Lazy1(); 这一步并不是一个原子性操作,也就是说这个操作会分成很多步
① 分配对象的内存空间 ② 执行构造函数,初始化对象 ③ 指向对象到刚分配的内存空间
但是 JVM 为了效率对这个步骤进行了重排序,例如这样:
① 分配对象的内存空间 ③ 指向对象到刚分配的内存空间,对象还没被初始化 ② 执行构造函数,初始化对象
解决的方法很简单——在定义时增加 volatile 关键字,避免指令重排
最终代码:
public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
}
// 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null;
// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}
public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy1.getLazy1();
}).start();
}
}
}
静态内部类懒汉式单例:
双重锁定算是一种可行不错的方式,而静态内部类就是一种更加好的方法,不仅速度较快,还保证了线程安全,先看代码:
public class Lazy2 {
// 构造器私有,静止外部new
private Lazy2(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
}
// 用来获取对象
public static Lazy2 getLazy2(){
return InnerClass.lazy2;
}
// 创建内部类
public static class InnerClass {
// 创建单例对象
private static Lazy2 lazy2 = new Lazy2();
}
public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy2.getLazy2();
}).start();
}
}
}
上面的代码,首先 InnerClass 是一个内部类,其在初始化时是不会被加载的,当用户执行了 getLazy2() 方法才会加载,同时创建单例对象,所以他也是懒汉式的方法,因为 InnerClass 是一个静态内部类,所以只会被实例化一次,从而达到线程安全,因为并没有加锁,所以性能上也会很快。
枚举创建单例:
public enum EnumSingle {
IDEAL;
}
代码就这样,简直不要太简单,访问通过 EnumSingle.IDEAL 就可以访问了
反射破坏单例模式
单例是如何被破坏的:
这是我们原来的写法,new 两个实例出来,输出一下
public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
}
// 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null;
// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}
public static void main(String[] args) {
Lazy1 lazy1 = getLazy1();
Lazy1 lazy2 = getLazy1();
System.out.println(lazy1);
System.out.println(lazy2);
}
}
运行结果: main 访问到了 cn.ideal.single.Lazy1@1b6d3586 cn.ideal.single.Lazy1@1b6d3586
可以看到,结果是单例没有问题
一个普通实例化,一个反射实例化:
public static void main(String[] args) throws Exception {
Lazy1 lazy1 = getLazy1();
// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy2 = declaredConstructor.newInstance();
System.out.println(lazy1);
System.out.println(lazy2);
}
运行结果:
main 访问到了 main 访问到了 cn.ideal.single.Lazy1@1b6d3586 cn.ideal.single.Lazy1@4554617c
可以看到,单例被破坏了
如何解决:因为我们反射走的其无参构造,所以在无参构造中再次进行非null判断,加上原来的双重锁定,现在也就有三次判断了。
解决方案:增加一个标识位,例如下文通过增加一个布尔类型的 ideal 标识,保证只会执行一次,更安全的做法,可以进行加密处理,保证其安全性。
这样就没问题了吗,并不是,一旦别人通过一些手段得到了这个标识内容,那么他就可以通过修改这个标识继续破坏单例,代码如下(这个把代码贴全一点,前面都是节选关键的,都可以参考这个)
public class Lazy1 {
private static boolean ideal = false;
// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if (ideal == false){
ideal = true;
} else {
throw new RuntimeException("反射破坏单例异常");
}
}
System.out.println(Thread.currentThread().getName() + " 访问到了");
}
// 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null;
// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}
public static void main(String[] args) throws Exception {
Field ideal = Lazy1.class.getDeclaredField("ideal");
ideal.setAccessible(true);
// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy1 = declaredConstructor.newInstance();
ideal.set(lazy1,false);
Lazy1 lazy2 = declaredConstructor.newInstance();
System.out.println(lazy1);
System.out.println(lazy2);
}
}
运行结果: main 访问到了 main 访问到了 cn.ideal.single.Lazy1@4554617c cn.ideal.single.Lazy1@74a14482 实例化 lazy1 后,其执行了修改 ideal 这个布尔值为 false,从而绕过了判断,再次破坏了单例 所以,可以得出,这几种方式都是不安全的,都有着被反射破坏的风险。