💐个人主页:初晴~
📚相关专栏:多线程 / javaEE初阶
一、什么是设计模式
按照设计模式编写代码能让代码更加“死板”一些。代码太过“灵活”也不见得是件好事,反而容易出现难以预料的bug。“死板”一些能一定程度上提高代码的规范性,减少bug的产生
设计模式与框架:
设计模式:针对代码编写过程中的“软性约束”
(不是强制的,但遵守的话能有一定好处)
框架:针对代码编写过程中的“硬性约束”
(针对一些特定场景,大佬们把基本的代码已经写出来了,大部分逻辑也写好了。留出了一些空位,让程序员在空位上填写一些自定义的逻辑)
二、单例模式
单例模式能保证某个类在程序中只存在唯⼀份实例, ⽽不会创建出多个实例.
在开发的一些场景中,我们希望有的类在一个进程中,不应该存在多个实例(对象),此时就可以使用单例模式,限制某个类只能有唯一实例
- 这⼀点在很多场景上都需要. ⽐如 JDBC 中的 DataSource 实例就只需要⼀个.
1、饿汉模式
class Singleton{
private static Singleton instance=new Singleton();
public static Singleton getInstance(){
return instance;
}
}
public class Main {
public static void main(String[] args) {
Singleton s1=Singleton.getInstance();
Singleton s2=Singleton.getInstance();
System.out.println(s1==s2);
}
}
这样s1和s2就会指向同一个实例对象,因此上述代码“s1==s2”的值为true了:
但是不能保证类的使用者会按照规范不去NEW一个新的对象。因此,实现单例模式的核心问题就是 防止 类的使用者不小心 NEW了新对象
这时在类外调用构造方法就会报错了:
注意通过 “反射”或者 “序列化反序列化”等方法都能打破这种单例模式。所以这种单例模式只能避免使用者的 “失误”,不能避免使用者的 “恶意攻击”。不过一般也 不会刻意去规避 恶意攻击,因为代价会比较大。
- 这种单例模式的前提是在一个进程中,如果有多个java进程,那么自然每个进程都能有一个实例了
2、懒汉模式
可不要被名字误解了,在计算机中,“懒”是褒义词,反而意为着效率更高
懒汉模式会推迟创建实例的时机,在第一次使用的时候,才会创建实例
因为很多时候,我们并不需要像“饿汉模式”那样类一加载就创建实例事实上创建实例也是需要开销的,如果一股脑就把一堆类的实例创建了,会浪费很多的资源。
比如打开了一个壁纸网站,这个壁纸网站有着几十个G的图片资源。
- 饿汉模式:一启动,就把所有的资源都加载到内存里,然后再显示在页面上
- 懒汉模式:启动之后,只加载一小部分资源(如当前页面内的图片),在用户进行翻页操作后,再加载其它资源
显然懒汉模式效率是更高的。接下来就让我们来看看懒汉模式该如何用代码实现吧。
(1)单线程实现
class Singletonlazy{
private static Singletonlazy instance=null;
public static Singletonlazy getInstance(){
if(instance==null){
instance=new Singletonlazy();
}
return instance;
}
private Singletonlazy(){};
}
public class Main {
public static void main(String[] args) {
Singletonlazy s1=Singletonlazy.getInstance();
Singletonlazy s2=Singletonlazy.getInstance();
System.out.println(s1==s2);
}
}
这样,类加载时并不会创建实例。在第一次调用 getInstance 方法时,此时“instance==null”的布尔值就为true,就会进入if分支创建出一个实例。之后再调用 getInstance 方法时就不会重复创建实例,而是直接返回第一次创建的实例了。从而实现了单例的效果
(2)多线程实现
事实上我们上面写的程序在多线程运行时会产生线程安全问题。因为在 getInstance 方法中涉及了修改操作。在博主的 深入剖析线程安全问题 一文中曾分析过,当多线程涉及修改操作时,由于线程执行的并发性,就容易出现各种bug。下面我们就来分析一下可能出现的一种bug:
这样就会程序导致创建了两个实例。虽然第二次创建覆盖了 instance 的值,使得第一次创建的实例没有引用指向,很快会被垃圾回收机制回收掉。但事实上这样的代码仍然算是有bug的。
因为实际的构造方法内部处理if语句,还可能会有很多其它的逻辑。一来过多的逻辑并发执行容易导致各种各样的线程安全问题,二来如果创建实例消耗的资源很多,这样重复创建就会导致消耗的资源翻倍,大大降低运行效率。
因此我们应该想办法保证实例创建操作的原子性。最自然的想法就是加锁了:
这样加锁之后确实解决了线程安全问题。但是当已经NEW过对象后,就不会进入if分支,后续执行就只剩读操作了。此时的getInstance方法不加锁也是线程安全的,其实就没必要加锁了。而且加锁操作是会消耗一定资源的,并且会产生阻塞,十分影响效率。如果按上述这种代码的话,每次调用getInstance方法都会加锁,就会消耗很多无意义的资源,严重影响运行效率了。
因此,上述代码还应进行优化。既然只有首次调用才会有线程安全问题,才需要加锁,那我们在加锁前先判断一下是否是首次调用不就可以了。而只有首次调用时 instance 的值才为null,一旦 instance 创建好了,值自然就不为null了,此时也不需要加锁了。因此我们可以通过条件 “instance==null” 来判断是否要加锁:
这里,我们会发现竟然连着写了两个同样的if语句,这在我们以往的单线程编程中是令人匪夷所思的。但现在这是在多线程环境下的,任意两个代码中都可能穿插其它线程的逻辑。synchronized 是一个阻塞操作,即使开始时 instance 的值为null,但在阻塞期间,它的值完全有可能会被其它线程给修改,等再恢复执行时,instance 的值就未必是null了。
事实上,这两句if的作用是完全不同的:
- 外面的if是判段是否要加锁
- 里面的if是为了判段是否要创建对象
只不过正好在这个代码中,完成上述判断逻辑的语句相同罢了
不过上述代码依旧是存在问题的,可能会因为指令重排序而导致线程安全问题。
- 指令重排序
指令重排序也是编译器优化的一种方式。编译器可能会为了方便在不影响运行结果的情况下改变指令的执行顺序。如果是单线程代码,编译器一般都能做出准确的判断。但如果是多线程的话,编译器就可能会误判从而导致线程安全问题了。
比如上面这句代码,在实际运行中会执行三个指令:
(1)分配内存空间
(2)执行构造方法
(3)把内存空间地址赋值给变量
编译器可能会按照123的顺序执行,也可能按照132的顺序执行。对于单线程代码来说这并不会影响执行结果,但对多线程就可能会出现问题了:
这时, s 就会被赋值一块未初始化的内存,此时一旦调用任何 s 中的方法,都会发生错误。从而导致产生许多难以预料的bug。
这时我们就可以用之前小编在 深入剖析线程安全问题 一文中提到的 volatile 来解决这个问题
可以给 instance 变量加上 volatile 修饰:
这样,编译器就会知道 instance 变量是易失的,后续对这个变量的优化就会变得非常克制了。不仅在读取变量上克制,在修改变量上也会变得非常克制。会禁止对 instance 赋值的操作插入到其它操作之间,就不会出现123执行顺序变成132的情况了,也就不会出现上述因指令重排序而造成的bug了
java中volatile有两个功能:
(1)保证内存可见性
(2)禁止指令重排序(针对赋值)
因此饿汉模式的多线程实现的完全体就是这样:
class Singletonlazy{
private volatile static Object locker=new Object();
private static Singletonlazy instance=null;
public static Singletonlazy getInstance(){
// 在这里判断当前是否要加锁
if(instance==null){
synchronized (locker){
if(instance==null){
instance=new Singletonlazy();
}
}
}
return instance;
}
private Singletonlazy(){};
}
那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。作者还是一个萌新,如果有什么讲的不对的地方欢迎在评论区指出,希望能够和你们一起进步✊