首页 > 代码库 > static实现单例的隐患
static实现单例的隐患
1. 前言
Java的单例有多种实现方式:单线程下的简单版本、无法在指令重排序下正常工作的Double-Check、static、内部类+static、枚举……。这篇文章要讨论的,是在使用static实现饿汉模式的单例时,会有隐患存在。
2. Static单例的隐患
2.1 传统写法
static实现单例的代码如下:
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
私有的类变量、私有构造函数,再配合一个工厂方法返回实例。
2.2 隐患
乍看之下没有问题,但请考虑这样一种情况:当构造函数的执行依赖于静态变量时。代码如下:
public class Singleton {
private static Singleton instance = new Singleton();
private static int i = 1; //1
public static Singleton getInstance(){
return instance;
}
private int count;
private Singleton(){
count = i; //2
}
public int getCount(){
return count;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
这段代码中,成员变量count
的初始值是在构造函数中赋予的,与静态变量i
有关。稍微有Java基础的同学都知道,静态变量是在类加载的过程中被初始化,而成员变量是在类被实例化的时候才初始化。所以,Singleton.getInstance().getCount()
得到的应该是1
,这样才符合逻辑。但是,事实往往是反逻辑的:运行结果是0。
2.3 问题所在
这是因为类构造函数clinit
导致的。类加载过程分为多个阶段(后面会有介绍),其中有一个叫初始化阶段
。在这个阶段内,会执行程序员定义好的一系列操作,这些操作都会被放入clinit
方法。这些操作包括:static变量的赋值(有例外,后面会介绍)、static块的代码。它们在clinit中的组织顺序就是在源码中出现的顺序。这怎么理解呢?看例子:
public class Test{
private static int i = 1; //1
static{ //2
i = 2;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 1
- 2
- 3
- 4
- 5
- 6
分析字节码,可以看到clinit
方法的执行顺序是:
如果1、2互换的话:
public class Test {
static{
i = 2;
}
private static int i = 1;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 1
- 2
- 3
- 4
- 5
- 6
字节码变为:
回到我们讨论的问题。当我们查看Singleton
的clinit
时,发现:
在给i
赋值之前,就调用了Singleton
的构造方法,而在Singleton
的构造方法中:
将i
赋值给了count
,但此时i还没有被赋值!
2.4 解决方案
针对这个例子,有个很简单的解决方案:将i
加final修饰,变成常量。这样一来,i
的赋值就从初始化阶段提前到了准备阶段(后面会有介绍)。
但这种解决方案很有局限性。如果类加载阶段不仅仅是给i赋值呢?比如用static块做一些更为复杂的操作。此时final就无能为力了。我们要保证在这些操作执行结束前,Singleton不能被实例化,否则就可能产生意想不到的结果。
所以,我的建议是:
- 将所有的static赋值语句与逻辑操作,均放入到一个static块中,即使是static final(后面会看到,static final也不能保证一定会在准备阶段赋值)。
- 在static块中,
instance
的赋值语句要放在最后。
代码如下:
public class Singleton {
private static Singleton instance;
private static int i; //1
static { //所有对静态变量的逻辑操作都放在一个static块中
i=1;
instance = new Singleton(); //instance的实例化要放在最后
}
public static Singleton getInstance(){
return instance;
}
private int count;
private Singleton(){
count = i; //2
}
public int getCount(){
return count;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
3. 有趣的问题
这一节,我们介绍一个有趣的问题:即便是static final
修饰的常量,也不能保证一定在构造函数前被赋值。
要理解这个问题,首先要介绍一下JVM加载类的过程。
3.1 JVM类加载过程
图中蓝色标注部分,是与变量的值相关的阶段:
- 在准备阶段,静态变量会在方法区获得内存空间。此外,那些
常量
的赋值语句将被执行。 - 在初始化阶段,会执行类构造方法
clinit
。 - 在使用阶段的对象实例化过程中,会执行构造函数
init
。这也就是我们常说的实例化
了。
知道了类加载的过程,再回头看第2节的问题,就很明显了:在初始化阶段尚未结束时,执行了使用阶段的对象实例化的代码。
3.2 有趣的问题
在第2节提到过,如果给i
加final修饰,就可以解决问题。实际上,这是将i变为常量,使i=1
的执行,从初始化阶段提前到了准备阶段。但是这样会有问题,下面来看这样一个问题:static final 修饰的变量一定在准备阶段被赋值吗?我们来看一个例子:
public class Singleton {
private static final int i = initI();
private static int initI(){
return 1;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 1
- 2
- 3
- 4
- 5
- 6
- 7
本例中,i
被static final
所修饰,其初始化代码应该在准备阶段
被执行?来看类的clinit
方法:
i是在clinit方法中被赋值的,并不在准备阶段。实际上,不止这一种情况下不行,当对static final 变量用new
赋值时,也不会在准备阶段
执行。因为准备阶段只会执行:static final修饰的、且赋值是字面量的赋值语句。这体现在字节码中,就是变量的字段属性表中存在ConstantValue,看下面的代码:
public class Singleton {
private static final int i1 = 1;
private static final String str1 = "1";
private static final int i2 = initI();
private static final String str2 = new String("1");
private static int initI(){
return 1;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
查看字节码中每个变量的属性表:
明显看到,虽然都是static final
修饰,但i1
与str1
因为赋值的是字面量,所以有ConstantValue
域,会在准备阶段被赋值;而i2
与str2
一个是方法的返回值,一个是对象实例化,所以必须在clinit
方法中执行:
4. 总结
- static实现单例会有隐患,所以写法上要保证:所有初始化在static块中完成;instance的初始化最后完成。
- 并非所有
static final
修饰的变量都会在准备阶段被赋值,这与所赋的值是否为字面量有关。准确的说,只有编译后属性表中有ConstantValue域的变量才会在准备阶段被赋值。
static实现单例的隐患