导读
单例模式看起来很好理解,表面上看就是单纯的构造了一个静态变量,然后给外部暴露一个可以访问的接口。那么,这个单例模式应该怎么从JVM中解释呢?
一切都得从宇宙大爆炸开始说起……
这可扯太远了吧!
既然要从JVM
原理来看的话,那就是说必然得了解一下JVM
咯?
类的装载
简要概括,类的生命周期是由这几个部分构成[参考1]:
- 加载
- 连接
- 初始化
- 使用
- 卸载
其中 1 1 1到 3 3 3是类的装载过程,装载后就可以进入使用阶段,使用结束就可以卸载掉,从内存中抹除。
加载
为了加载类,JVM需要做到这些内容[参考2]:
- 通过一个类的全限定名来获取其定义的二进制字节流;
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在Java堆中生成一个代表这个类的
java.lang.Class
对象,作为对方法区中这些数据的访问入口
昂?发现了关键字?
不急,继续。
连接
连接过程分为三个部分。
- 验证:会采用多种验证手段保证类的正确性
验证阶段和运行过程中的报错所进行的验证是不一样的,还需要多加注意。
参考3中说到,报错实际上就是程序运行过程中因为一些原因,使得程序无法运行下去。
打一个奇怪的比方:你想让你家的猫娘帮你做点饭,猫娘会为了保证没有听错而开启验证阶段,但猫娘自己打翻了油壶就是在做饭的过程中出现了异常情况,没办法再继续了。当然,这个时候你就会像
JVM
一样开启错误处理机制。这都是后话了。
- 准备:为类的静态变量分配内存,并将其初始化为默认值
出现了关键词了。
在
JVM
中,静态变量是直接属于类本身的。至于为什么,还有待考究。目前就先这么记住吧。总之这个部分将对定义的静态变量赋予初始值。
- 解析:把类中的符号引用转换为直接引用。
如果对
C++
有一点基础,大概会理解引用和指针的区别。由于Java
中删除了指针,并使用堆内存与栈内存中的映射处理指针的指向问题,所以在处理过程中就分为符号引用和直接引用。符号引用讲起来有点抽象,参照参考4的说法,符号引用说白了就是随便用一个方便却又能尽可能准确地表示所描述一群对象的符号去描述某一个对象。
好像也没说那么白还是拿你的猫娘举例。这次呢,你正在修带有毛垫的凳子,因为被你家的小猫娘抓坏了。突然呢,你需要一把螺丝刀拧一下,然后就喊你的猫娘:给我一把改锥。大猫娘和你生活了一阵子,她了解改锥这个符号代表的就是那种拧螺丝的螺丝刀,她通过回忆想起了这个符号所对应的物品在家里的哪个柜子里。在她的心里,符号引用就是改锥在哪个柜子里,这个过程也是符号引用转变为直接引用的过程。而小猫娘刚从老家过来,但老家那边说的是起子,于是小猫娘就一脸奇怪地看着你。这也说明,不同的厂商在符号引用的设计上多多少少会有一点偏差,虽然东西都是一样的,但是符号引用不一样的话就会造成由于对接不上带来的诸多问题。
而直接引用就好懂多了,就是指针,这一点与
C\C++
的指针是完全相同的概念。在
JVM
中,栈内存将保存指针,也就是各位经常能够看到的类似0x98
之类的输出,表示当前访问内容的真正的位置。而堆内存就那个真正的位置。
到这里,JVM
全自动执行的部分就全部结束了。剩下的就得靠程序员手动触发了。
初始化
到这里理解起来就很简单了。就和迎接客人之前需要打扫一样,在使用之前是需要对部分内容进行一些基本的操作。
说是初始化,实际上在刚刚的准备阶段已经完成了程序中静态变量的初始化,所以实际上这部分应该叫做完善,这部分将完善类中的非静态成员,按照顺序JVM
将依次执行:
- 构造函数执行
- 初始化父类
在这里需要强调,既然父类的构造函数是在执行子类构造函数的过程中执行的,为什么还会有些人将父子构造函数分开讲解?
这是因为,如果子类的构造函数中没有显式地调用父类构造函数,那么子类构造函数将会覆盖父类构造函数,于是父类构造函数和子类构造函数会变得相同。具体原因可以查看参考5。
而在JVM
中,初始化只有被主动调用的时候才会执行初始化操作。
这里面有人罗列了很多种情况,总接下来就是3种情况:
- 主动调用了该类的静态或非静态成员、变量或方法等,包含:创建类的实例化对象、执行被标记为项目入口的类、调用静态方法等等
- 主动调用了子类的构造函数,过程中间接主动地调用了该类的构造函数
- 主动调用了反射,过程中间接主动地调用了该类的构造函数,包含:访问字节码对象等
卸载
最后就是垃圾回收了。这还真是个大坑,就以后在填补吧。
单例模式
好了,既然回顾了JVM
构造对象的过程,接下来就是切入正题了:怎么理解单例模式。
既然这样我们就不得不看一看单例模式的代码了。
package day01;
public class SingletonDemo {
public static void main(String[] args) {
// 给出两个Lazy对象
Lazy lazy1 = Lazy.getInstance();
Lazy lazy2 = Lazy.getInstance();
// 给出两个Hungry对象
Hungry hungry1 = Hungry.getInstance();
Hungry hungry2 = Hungry.getInstance();
// 对比地址,发现输出为true, true
System.out.println(String.format("%b", lazy1 == lazy2) + ", " + String.format("%b", hungry1 == hungry2));
}
}
// 懒汉式
class Lazy {
private Lazy () {}
private static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
lazy = new Lazy();
}
}
}
// 饿汉式
class Hungry {
private Hungry () {}
private static Hungry hungry = new Hungry();
public static Hungry getInstance() {
return hungry;
}
}
众所周知,单例模式有两种实现方法,分别是饿汉式和懒汉式。
还是拿你们家的猫娘举例。饿汉式就类似于,你在包里始终放着一袋子猫粮,猫饿了就立马从包里掏出来味你们家的小猫娘。而懒汉式就相当于,猫娘饿了再去买一袋子猫粮,再喂给她吃。
从静态成员的角度分析单例模式
好了,既然JVM
和单例模式都复习到了,那么我们再来综合考虑一下。
无论是饿汉式还是懒汉式,类内都采用了静态成员变量的方式构造了一个私有变量。
我们先不考虑这个私有变量的访问权限。于是这个静态成员变量就在JVM
中在堆内存里分配了一个内存空间,而且在栈内存中还有一个指向它的指针。
即使这个类再被创建,这个静态变量既然已经存在了,那就不会再产生新的副本了,因为静态变量是公共访问的,只能有一个。
那么,就表明该程序内最终也就只有唯一一个这样的变量。
这也达到了单例模式的目的。
最后,类内再放一些可以修改该静态成员变量的方法就可以实现唯一对象的操作了。
本方法应用起来比较灵活,对于唯一的主角可以使用单例模式来实现,这样即使下一个接手该程序的程序员重新创建一个主角类的实例化对象也不会产生新的主角对象了。剩下的就只需要考虑多个敌人线程对主角造成的伤害需要避免脏数据的读取与修改,也就是多线程的同步问题了。