下面的代码片段摘自JDK8中System.java:
java
private static volatile Console cons = null;
/**
* Returns the unique {@link java.io.Console Console} object associated
* with the current Java virtual machine, if any.
*
* @return The system console, if any, otherwise <tt>null</tt>.
*
* @since 1.6
*/
public static Console console() {
if (cons == null) { // A
synchronized (System.class) {
cons = sun.misc.SharedSecrets.getJavaIOAccess().console();
}
}
return cons;
}
显然,这个console()方法是创建一个Console单例对象,因为它的实现用到了synchronized关键字,来保护在临界区中创建Console对象,显然这是考虑到了在多线程场景下,要保证创建单例对象的线程安全。
但是,这个实现是有错误的,在多线程环境下是无法保证只能创建唯一的Console对象实例。尽管在创建Console对象时,处于synchronized保护的临界区中,能保证在创建对象时,只能有一个线程能创建对象,但却不能保证只能创建唯一的一个对象实例。
下面看一个多线程执行时的race condition:
假设初始条件是Console单例对象还没有创建,此时cons为null。
在多核环境下,如果有两个线程同时调用console(),它们的执行流同时运行到了A处,检查cons是否等于null,因为它们都能检查到cons等于null,就都进入了if语句块,因为里面使用synchronized锁对创建cons对象进行了保护,只能允许一个线程能够进入临界区去创建对象实例,另一个线程只能等待前一个线程从临界区出来后释放synchronized锁才能进入。
第一个线程创建完对象之后,初始化cons,然后离开临界区释放synchronized锁,接着第二个线程也进入了临界区,再次创建了一个Console对象,并再次对cons进行了赋值操作。可见,在此场景下,创建了两个Console对象,显然不符合单例的要求。
后来在JDK21版本中,发现这个缺陷已经被修改了(应该在大于JDK8和JDL21之间的某个版本就已经修改了),方法实现如下:
java
public static Console console() {
Console c;
if ((c = cons) == null) { // A
synchronized (System.class) {
if ((c = cons) == null) { // B
cons = c = SharedSecrets.getJavaIOAccess().console(); // C
}
}
}
return c;
}
显然这是典型的双检查锁的实现方案,除了在A处第一次检查cons是否为null之外,在synchronized保护的临界区内,又在B处再次检查了cons是否为null,这样就能避免在JDK8版本中实现方案的多次创建单例对象的错误。这是典型的双检查锁实现机制,因为共享变量cons使用volatile进行了修饰,也不会存在臭名昭著的内存读写访问的乱序问题。
不过在这个方法实现中,在返回单例对象时没有直接返回cons,而是通过一个局部变量c,间接的进行了返回。为什么不直接返回cons静态变量呢?即为什么不使用下面的代码实现呢?例如下面的代码实现:
java
public static Console console() {
if (cons == null) {
synchronized (System.class) {
if (cons == null) {
cons = SharedSecrets.getJavaIOAccess().console();
}
}
}
return cons; // C
}
不直接返回cons有意为之的,也体现出了作者的匠心之处。
因为cons是一个volatile修饰的变量,在对它进行读写操作时,比起普通变量的读写操作,可能有额外开销的,也就是读写它可能会比读写普通内存变量性能要差一些。看函数的返回语句,返回的是cons变量,下面是为它生成的字节码:
java
Code:
stack=2, locals=2, args_size=0
0: getstatic #2 // Field cons:Ljava/io/Console;
3: ifnonnull 38
6: ldc #3 // class java/lang/System
8: dup
9: astore_0
。。。// 省略部分字节码
36: aload_1
37: athrow
38: getstatic #2 // Field cons:Ljava/io/Console;
41: areturn
源代码C处的代码,在cons返回时有一个读操作,即对应在偏移量为38的字节码处:
java
getstatic #2 // Field cons:Ljava/io/Console;
该指令从类的静态字段中获取值并压入操作数栈,然后随后的areturn指令返回这个操作数,实际上就是读取了cons静态变量并返回。看着和读取普通的静态变量,在指令形式上并没有区别。下面是静态变量cons在class文件中的定义:
java
private static volatile java.io.Console cons;
descriptor: Ljava/io/Console;
flags: ACC_PRIVATE, ACC_STATIC, ACC_VOLATILE
可见,它的flags带有ACC_VOLATILE,是告诉JVM在对cons变量进行读写访问时,必须要按照volatile语义进行处理。volatile 的语义保证是在 JVM 层面实现的,而不是字节码层面,尽管生成的字节码和读取普通内存变量没有区别,但是当JIT 编译器看到 ACC_VOLATILE 标志后,在读取时会生成带有适当内存屏障的机器码。
在读volatile变量时,按照《The JSR-133 Cookbook for Compiler Writers》的说法,需要在volatile读和后续的普通变量读操作之间要添加LoadLoad和LoadStore内存屏障。该内存屏障在x86、sparc等较强内存序的CPU中,一般不会有额外的指令,但是在ARM、PPC、Alpha等弱序的CPU处理器中要使用额外的屏障指令,可能造成不必要的性能损失。
而使用局部变量c返回单例的console()方法,它生成的字节码是:
java
Code:
stack=2, locals=3, args_size=0
0: getstatic #2 // Field cons:Ljava/io/Console;
3: dup
4: astore_0
5: ifnonnull 44
8: ldc #3 // class java/lang/System
10: dup
11: astore_1
。。。// 省略部分字节码
42: aload_2
43: athrow
44: aload_0
45: areturn
见字节码偏移量44处,此时方法返回的值,使用的指令是aload_0,该指令是加载局部变量到操作数栈,然后随后的areturn指令返回这个操作数,实际上它是对一个普通内存变量的读取操作,JIT编译器在读取时会生成普通内存访问的机器码。
其实除了是否生成内存屏障指令外,重要的是JIT对它们访问操作的优化策略也有所不同,volatile修饰的内存变量是不能被缓存优化的,每次访问时都必须从内存读取,因此在返回volatile变量时可能要访问内存。即使在x86等强序的CPU架构中,读取volatile变量时,尽管不需要额外的内存屏障指令,但也必须从内存加载,或者至少要从L1cache中加载。而普通内存变量,编译器可能会优化处理,比如可以缓存在寄存器中,返回时直接返回寄存器就可以了,显然直接访问寄存器比访问L1缓存甚至内存系统,性能要好的多。这正是没有直接返回volatile修饰的cons变量,而是通过局部变量返回的原因所在。
总之,这里使用了普通变量c来作为返回值,能够节省一次volatile读操作时,不必要的执行内存屏障指令和访问内存系统的开销。不过,在日常编程实践中,如果对性能没有那么敏感的话,可能就直接访问volatil变量了,执行时会多一点额外的开销。不过JDK毕竟是大家都在使用的公共库,对性能肯定要锱铢必较,任何一点性能的提升都是值得去做的。