1.什么是单例模式
单例模式是设计模式中的一种,其实设计模式就好好比是一个棋谱,我们在日常下棋的时候会有一些经典的套路。那么在设计模式中也有这样的经典套路。这些经典的套路都是有大佬前辈们实现的。我们在写代码的时候,有很多经典的场景,在经典场景中有一些经典的应对套路。大佬们把这些常见的应对手段给整理起来,就起来个名字–设计模式。有了设计模式,无论是新手程序员还是资深的老程序员,都会有一个代码编程规范。以便让初出茅庐的新手,代码不至于写的很糟糕。
2. 单例模式的组成
单例模式分为:饿汉模式和懒汉模式。单例模式之所以被称为单例模式,是因为我们在创建单例模式类的时候,就把该类的构造方法使用private进行修饰,以便在该类外,不能直接创建出一个实例。
3.饿汉模式实例
饿汉模式:指的是在单例模式中,在对单例进行初始化的时候,直接赋予单例实例,直接new出一个对象。
饿汉模式也可以这样理解:我们平时在自己家里的时候,都洗过碗吧。就比如说,中午这顿饭使用了4个碗,在吃完饭后,我们立即就把4个碗给刷了。这里之所以被称为饿汉模式是因为,在饿汉模式中,创建实例的时候比较着急。在初始化的时候,直接创建实例。
饿汉模式代码案例:
class Singleton{
public static Singleton instance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}
public class TestDemo2 {
public static void main(String[] args) {
Singleton single = Singleton.getInstance();
}
}
我们尝试在main类中,自己创建出一个SingleTon的实例。
3.1在饿汉模式中为什么在创建实例的时候使用static修饰?
因为 static 修饰的成员更准确的说是类成员,类属性、类方法,不加 static 修饰的成员准确的来说,就是实例方法,实例成员,实例属性。
在一个java程序中,一个类方法只会存在一份(JVM保证的) 这也就是为什么要使用static对实例进行修饰的原因。进一步的就保证了在类的static 成员也只会存在一份。
在这里我们在深究一个static 关键字
其实在我们使用的编程语言java中,static表示的意思和这个单词的字面意思完全不同,static 的意思大家知道 是静态的。这其实是一个历史遗留问题。
在C语言中的static有3个作用:
-
修饰局部变量,把局部变量的生命周期变长。,修饰一个全局变量,把这个全局变量的作用域限制到整个.c文件。 -
修饰一个函数,把这个函数的作用域限制到整个.c文件。 我们在这里也可以看出在c语言中static 关键字的英语本意和在c语言中的使用效果,也是对不上号的。 其实在上古时期,那时候的static是表示把变量放到静态内存区中,于是引入了static关键字,但是随着计算机的发展,这个东西就逐渐的没落了。但是static 关键字有被赋予了新的功能。 在C++中 static关键字除了上述C语言的static 功能之外还有新的用法,修饰一个类的成员变量和成员函数,此处static 修饰的成员就表示为类成员。 Java语言就是把C++中static 的功能继承过来了而已。
既然static 关键字的本意和它的对应效果对不上号,那么为什么不使用其他的词呢?
在一个编程语言中,要想新增一个关键字,是一件非常有风险的事情。因为不能还在程序中的单词重合。
SingleTon.class 类对象,就是.class文件被JVM加载到内存中,表现出来的模样。类对象就有着.class文件的所有信息。就像类名,属性等都可以有SingleTon.class中找到。这样也就实现了反射
3.2 判断该实例是否是线程安全的
饿汉模式是线程安全的
那么为什么饿汉模式样式的单例模式是线程安全的呢?我们在程序的哪里判断该单例模式是线程安全的?
线程安不安全,具体是在多线程环境之下,并发调用的getInstance()方法是否会产生bug?
在博主的上一篇文章中,介绍了产生线程不安全的案例。
造成线程不安全的案例有5种。
- 线程抢占式执行,线程间的调度充满了随机性。
- 多线程对一个变量进行修改。
- 针对变量操作不是原子的
- 内存可见性问题
- 指令重排序问题。
我们现在回顾在饿汉模式中的getInstance()方法,在该方法中只有一个return操作,就是对一个变量进行了读取,符合针对变量操作的原子性。所以是线程安全的
4.懒汉模式实例
懒汉模式创建懒汉模式的单例模式的时候,我们不着急创建出实例。
还是那洗碗举例,我们中午吃饭的时候,使用了4个碗,吃完饭后,我们不着急洗碗,到了晚上吃饭的时候,需要使用的几个碗,那么我们现在就洗几个碗。假如说我们晚上要使用2个碗,那么就洗两个,剩下的两个碗不管。
在我们平时生活中,饿汉模式比懒汉模式好,因为你试一试中午吃完饭不洗碗,如果不洗碗肯定会被挨骂。但是在我们的计算机中可未必,懒汉模式要比饿汉模式要好一些。
那么为什么在计算机中懒汉模式要比饿汉模式要好一些呢?
就比如说,现在有一个1G的图片文件,如果按照饿汉模式,那么计算机就会在内存中一下把这1G大的图片文件全部加载出来,这不就耗费CUP资源,并且如果计算机用户在浏览图片文件的时候,就看了一点没有全部把图片文件浏览完。那么这样的饿汉模式不就是费力不讨好好吗?
反而看看我们的懒汉模式,之所以懒,是因为你赶它一些它走一下。在懒汉模式中,我们一次不会再内存中把所有的图片加载完,而是把计算机用户的一个计算机屏幕中的图片加载出来。用户滑动一下滚动条,加载一下。这样就进行了优化。
其实在计算机中懒汉模式是褒义词,但是在现实世界中就算了吧😂
class Singleton2{
public static Singleton2 instance = null;
private Singleton2(){}
public static Singleton2 getInstance(){
if(instance == null){
instance = new Singleton2();
}
return instance;
}
}
public class TestDemo3 {
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.getInstance();
}
}
4.1 判断该实例是否是线程安全的,如果不是线程安全的,那么怎样修改可以成为线程安全的实例
首先上述的所谓的懒汉模式的单例模式不是线程安全的
那么为什么它不是线程安全的呢?
因为在多线程中,我们调用懒汉模式中的getInstance()方法的时候,针对变量的操作不是原子的,那么有从哪可以看出不是原子的呢?
如图:
那么针对变量操作不是原子性的,它的解决办法就是进行加锁,使用synchronized关键字进行加锁!!!
修改之后的代码:
class Singleton2{
public static Singleton2 instance = null;
private Singleton2(){}
public static Singleton2 getInstance(){
synchronized(Singleton2.class) {
if (instance == null) {
instance = new Singleton2();
}
}
return instance;
}
}
public class TestDemo3 {
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.getInstance();
}
}
我们知道如果遇到可针对变量操作不是原子的,要使用synchronized关键字进行加锁,但是也不是说,代码中有了synchroniezd关键字就一定不会线程安全,我们要把synchronized关键字加对地方。synchronized加的位置正确,不能随便写。
synchronized(SingleTon2.class){
}
但是我们加锁之后,又带来的新的问题!!!
对于刚才的这个懒汉模式的代码而言,线程不安全发生在instance没有被初始化之前,未被初始化的时候,多线程调用getInstance()方法时,会存在线程安全问题,因为涉及到读和修改。但是在instance初始化之后,instance一定不是null,if条件一定不成立,getInstance()就只剩下两个读操作,也就是说instance初始化之后,线程就是安全的了。
并且按照上述的加锁操作,无论是代码中的instance初始化之前,还是初始化之后。每次调用getInstance()方法的时候,都会对其进行加锁。也就意味着即使初始化之后(已经线程安全了),但是仍然存在大量的锁竞争。
既然这里的instance已经被初始化过了,即使这里的条件在不能被满足了,但是仍然会调用getInstance()方法,都需要进行加锁,也就可能会产生锁竞争,但是我们知道这里的锁竞争其实是没有必要的。
我们知道加锁确实能让线程安全,但是同时也付出了代价,一旦在一个线程中加了锁之后,那么就和运行高效无关了。(程序的速度就变慢了)因为加锁之后,线程之间是串行执行的。代码的运行效率就变慢了。
博主以前说过开发效率要比运行效率更重要,一切都要从程序员的利益出发,但是运行效率也不是说不重要!如果说运行效率不重要的话,那么我们在前面学习那么多的数据结构干啥,不都是使用一个较好的数据结构,来组织数据,让代码变得有效嘛
改进方案:
在instance初始化之前,才进行加锁,在初始化之后,不进行加锁。在加锁这里在加一个条件判断即可
代码如下:
class Singleton2{
public static Singleton2 instance = null;
private Singleton2(){}
public static Singleton2 getInstance(){
if(instance == null) {
synchronized (Singleton2.class) {
if (instance == null) {
instance = new Singleton2();
}
}
}
return instance;
}
}
public class TestDemo3 {
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.getInstance();
}
}
我们在上述的代码中,可以看到在getInstance()方法中,使用了两个条件判断语句,都是判断instance==null ,但是这两个条件判断语句的实际含义是千差万别。这两个添加判断长得一样,纯属是一个美丽的错误。
上面的条件判断是是否需要加锁。也就是说现在的instance是否已经被初始化过了
下面的条件判断是是否需要创建实例。
我们如果去掉了里层的条件判断语句那么就会变成:
class Singleton2{
public static Singleton2 instance = null;
private Singleton2(){}
public static Singleton2 getInstance(){
if(instance == null) {
synchronized (Singleton2.class) {
instance = new Singleton2();
}
}
return instance;
}
}
public class TestDemo3 {
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.getInstance();
}
}
如果直接对getInstance()方法进行加锁,那么就是一个无脑加锁
但是博主告诉大家在上述加了if条件判断的代码中,还有一处问题。
我们可以想想,在多线程执行中,多个线程同时调用单例模式中的getInstance()方法,就会有大量的读instance内存的操作。
可能会让编译器把这个读内存操作优化成读寄存器操作。
一旦这里触发了优化,后续如果第一个线程已经完成了对instance的修改,那么紧接着后面的线程都感知不到这个修改,仍然把instance当成null.
内存可见性问题,可能会引起第一个if判定失败,但是对于第二个if判定影响不大,因为synchronized本身就能保证内存可见性。只是引起第一层的误判,也就是导致,不该加锁的时候加锁了,但是不会影响第二层if的判断(不至于说创建多个实例)
解决内存可见性问题,给instance加上volatile关键字
class Singleton2{
public static volatile Singleton2 instance = null;
private Singleton2(){}
public static Singleton2 getInstance(){
if(instance == null) {
synchronized (Singleton2.class) {
instance = new Singleton2();
}
}
return instance;
}
}
public class TestDemo3 {
public static void main(String[] args) {
Singleton2 singleton2 = Singleton2.getInstance();
}
}
总结一下:
实现一个线程安全的单例模式—针对懒汉模式
- 在正确的位置加锁
- 双重if判定
- volatile关键字
|