你真的会写单例模式吗——Java实现

饿汉法

顾名思义,饿汉法就是在第一次引用该类的时候就创建对象实例,而不管实际是否需要创建。代码如下:

public class Singleton {  
    private static Singleton = new Singleton();
    private Singleton() {}
    public static getSignleton(){
        return singleton;
    }
}

这样做的好处是编写简单,但是无法做到延迟创建对象。但是我们很多时候都希望对象可以尽可能地延迟加载,从而减小负载,所以就需要下面的懒汉法:

单线程写法

这种写法是最简单的,由私有构造器和一个公有静态工厂方法构成,在工厂方法中对singleton进行null判断,如果是null就new一个出来,最后返回singleton对象。这种方法可以实现延时加载,但是有一个致命弱点:线程不安全。如果有两条线程同时调用getSingleton()方法,就有很大可能导致重复创建对象。

public class Singleton {
    private static Singleton singleton = null;
    private Singleton(){}
    public static Singleton getSingleton() {
        if(singleton == null) singleton = new Singleton();
        return singleton;
    }
}

考虑线程安全的写法

这种写法考虑了线程安全,将对singleton的null判断以及new的部分使用synchronized进行加锁。同时,对singleton对象使用volatile关键字进行限制,保证其对所有线程的可见性,并且禁止对其进行指令重排序优化。如此即可从语义上保证这种单例模式写法是线程安全的。注意,这里说的是语义上,实际使用中还是存在小坑的,会在后文写到。

public class Singleton {
    private static volatile Singleton singleton = null;
 
    private Singleton(){}
 
    public static Singleton getSingleton(){
        synchronized (Singleton.class){
            if(singleton == null){
                singleton = new Singleton();
            }
        }
        return singleton;
    }   
}

兼顾线程安全和效率的写法

虽然上面这种写法是可以正确运行的,但是其效率低下,还是无法实际应用。因为每次调用getSingleton()方法,都必须在synchronized这里进行排队,而真正遇到需要new的情况是非常少的。所以,就诞生了第三种写法:

public class Singleton {
    private static volatile Singleton singleton = null;
 
    private Singleton(){}
 
    public static Singleton getSingleton(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }   
}

这种写法被称为“双重检查锁”,顾名思义,就是在getSingleton()方法中,进行两次null检查。看似多此一举,但实际上却极大提升了并发度,进而提升了性能。为什么可以提高并发度呢?就像上文说的,在单例中new的情况非常少,绝大多数都是可以并行的读操作。因此在加锁前多进行一次null检查就可以减少绝大多数的加锁操作,执行效率提高的目的也就达到了。

那么,这种写法是不是绝对安全呢?前面说了,从语义角度来看,并没有什么问题。但是其实还是有坑。说这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。volatile关键字就可以从语义上解决这个问题。

例如,考虑下面的事件序列:

  1. 线程A发现变量没有被初始化, 然后它获取锁并开始变量的初始化。
  2. 由于某些编程语言的语义,编译器生成的代码允许在线程A执行完变量的初始化之前,更新变量并将其指向部分初始化的对象。
  3. 线程B发现共享变量已经被初始化,并返回变量。由于线程B确信变量已被初始化,它没有获取锁。如果在A完成初始化之前共享变量对B可见(这是由于A没有完成初始化或者因为一些初始化的值还没有穿过B使用的内存(缓存一致性)),程序很可能会崩溃。

Symantec JIT 编译 singletons[i].reference = new Singleton(); 这段代码时,如果不加volatile关键词,会生成如下字节码:

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; allocate space for
                                                 ; Singleton, return result in eax
02061074   mov         dword ptr [ebp],eax       ; EBP is &singletons[i].reference 
                                                ; store the unconstructed object here.
02061077   mov         ecx,dword ptr [eax]       ; dereference the handle to
                                                 ; get the raw pointer
02061079   mov         dword ptr [ecx],100h      ; Next 4 lines are
0206107F   mov         dword ptr [ecx+4],200h    ; Singleton's inlined constructor
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

可以看到,在执行Singleton的构造函数之前,Singleton的新实例就被赋值给了singletons[i].reference,这在Java内存模型中是完全合法的。

注意,前面反复提到“从语义上讲是没有问题的”,但是很不幸,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。

静态内部类法

那么,有没有一种延时加载,并且能保证线程安全的简单写法呢?我们可以把Singleton实例放到一个静态内部类中,这样就避免了静态实例在Singleton类加载的时候就创建对象,并且由于静态内部类只会被加载一次,所以这种写法也是线程安全的:

public class Singleton {
    private static class Holder {
        private static Singleton singleton = new Singleton();
    }
 
    private Singleton(){}
 
    public static Singleton getSingleton(){
        return Holder.singleton;
    }
}

但是,上面提到的所有实现方式都有两个共同的缺点:

  • 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。
  • 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

枚举写法

当然,还有一种更加优雅的方法来实现单例模式,那就是枚举写法:

public enum Singleton {
    INSTANCE;
    private String name;
    public String getName(){
        return name;
    }
    public void setName(String name){
        this.name = name;
    }
}

使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,Effective Java推荐尽可能地使用枚举来实现单例。

总结

代码没有一劳永逸的写法,只有在特定条件下最合适的写法。在不同的平台、不同的开发环境(尤其是jdk版本)下,自然有不同的最优解(或者说较优解)。
比如枚举,虽然Effective Java中推荐使用,但是在Android平台上却是不被推荐的。在这篇Android Training中明确指出:

Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

再比如双重检查锁法,不能在jdk1.5之前使用,而在Android平台上使用就比较放心了(一般Android都是jdk1.6以上了,不仅修正了volatile的语义问题,还加入了不少锁优化,使得多线程同步的开销降低不少)。

最后,不管采取何种方案,请时刻牢记单例的三大要点:

  • 线程安全
  • 延迟加载
  • 序列化与反序列化安全

参考资料

《Effective Java(第二版)》
《深入理解Java虚拟机——JVM高级特性与最佳实践(第二版)》
双重检查锁定模式
The “Double-Checked Locking is Broken” Declaration

为什么不能在for循环中remove掉ArrayList中的对象

先看一段错误的代码,执行的时候不会报错,但是结果和编码人的预期却是不一样的:

List<String> a = new ArrayList<>();
a.add("1");
a.add("2");
for (String temp : a) {
    System.out.println(temp);
    if("1".equals(temp)){
        a.remove(temp);
    }
}

再看一段错误的代码,执行的时候会直接抛出java.util.ConcurrentModificationException:

List<String> a = new ArrayList<>();
a.add("1");
a.add("2");
for (String temp : a) {
    System.out.println(temp);
    if("2".equals(temp)){
        a.remove(temp);
    }
}

奇怪了,为什么第一段代码不会抛出异常,而是执行一次循环就结束了呢,使用javap查看编译好的class文件:

26: aload_1
27: invokeinterface #7,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
32: astore_2
33: aload_2
34: invokeinterface #8,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
39: ifeq          72
42: aload_2
43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
48: checkcast     #10                 // class java/lang/String
51: astore_3

在执行for循环时,其实是先调用了ArrayList中的iterator方法得到一个Iterator,然后每次循环时,判断Iterator.hasNext,如果hasNext(根据cursor != size判断),则调用Iterator.next,此时就会判断List是否被意外修改(checkForComodification),if (modCount != expectedModCount)则抛出ConcurrentModificationException。如果remove的cursor刚好为size-2,即倒数第二个对象,下次判断hasNext时,cursor == size了,就返回false,认为没有后面的元素了,不会再调用Iterator.next,因此也不会抛出异常,但是也不会把最后一个没有遍历过的元素拿出来进行业务处理。

查看Iterator.remove()的源码,处理基本和ArrayList.remove一致,比较明显的区别就是,Iterator.remove完成后,会调整当前的游标值,继续遍历时就不会导致遍历数据错误,而ArrayList.remove不会调整游标值,如果继续遍历,就会漏掉数据而出错,因此需要抛出ConcurrentModificationException避免被错误使用。

 

解决php-fpm启动了端口监听但是不能连接的问题

CentOS7里面重启了一下php-fpm,然后发现服务不能用了,查看php-fpm是否正常监听:

netstat -lpn|grep php-fpm

显示端口监听正常,但是网站就是不能访问,telnet端口显示连接被拒绝,查看php-fpm的运行日志:

tail -100f /var/log/php-fpm/error.log

看不到任何异常,php-fpm默认是不打开子进程的日志输出的,手动打开:

vim /etc/php-fpm.d/xx.conf

修改内容: 

catch_workers_output = yes

重启服务: systemctl restart php-fpm

错误日志终于出来了:

WARNING: [pool www] child 24297 said into stderr: "ERROR: Connection disallowed: IP address '202.x.x.x' has been dropped."

原来是IP不在允许访问列表内的原因,再次修改配置文件:

vim /etc/php-fpm.d/xx.conf

listen.allowed_clients = 127.0.0.1,202.x.x.x

 
重启服务: systemctl restart php-fpm

一切正常了

 

 

解决OpenVPN “Waiting for TUN/TAP interface to come up”的问题

公司的VPN忽然连不上了,拨号成功后,OpenVPN 的日志中隔几秒就出现:

Route: Waiting for TUN/TAP interface to come up...

一段时间后直接出现失败的提示,虽然OpenVPN会变成绿色,并且鼠标移上去会显示出10开头的内网IP,但是其实是不能访问公司内网的,VPN并没有成功拨号。

在网上搜了一下,最终解决方案如下:

使用管理员权限打开cmd窗口,然后输入:

netsh winsock reset catalog
netsh int ipv4 reset reset.log

重启电脑即可解决。

 

解决 jps/jconsole NullPointerException 的问题

前阵子windows设置环境变量的时候卡死了,强制结束系统进程,结果导致用户环境变量全丢了,当时也没在意,后来使用jconsole的时候,问题来了,打开就报NPE空指针异常,无法调出选择连接的那个面板。网上搜了一下,发现有人使用JPS的时候,也报一样的错:

Exception in thread "main" java.lang.NullPointerException
at sun.jvmstat.perfdata.monitor.protocol.local.LocalVmManager.activeVms(LocalVmManager.java:148)
at sun.jvmstat.perfdata.monitor.protocol.local.MonitoredHostProvider.activeVms(MonitoredHostProvider.java:150)
at sun.tools.jps.Jps.main(Jps.java:62)

再搜了一下,有人给Java提交过一个bug,显示已经解决了

Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
        at sun.jvmstat.perfdata.monitor.protocol.local.LocalVmManager.activeVms(LocalVmManager.java:127)
        at sun.jvmstat.perfdata.monitor.protocol.local.MonitoredHostProvider.activeVms(MonitoredHostProvider.java:133)
        at sun.tools.jconsole.ConnectDialog$ManagedVmTableModel.getManagedVirtualMachines(ConnectDialog.java:528)
        at sun.tools.jconsole.ConnectDialog$ManagedVmTableModel.refresh(ConnectDialog.java:511)
        at sun.tools.jconsole.ConnectDialog$ManagedVmTableModel.<init>(ConnectDialog.java:502)
        at sun.tools.jconsole.ConnectDialog.<init>(ConnectDialog.java:139)
        at sun.tools.jconsole.JConsole.showConnectDialog(JConsole.java:571)
        at sun.tools.jconsole.JConsole.access$100(JConsole.java:34)
        at sun.tools.jconsole.JConsole$4.run(JConsole.java:702)
        at java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:209)
        at java.awt.EventQueue.dispatchEvent(EventQueue.java:461)
        at java.awt.EventDispatchThread.pumpOneEventForHierarchy(EventDispatchThread.java:242)
        at java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:163)
        at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:157)
        at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:149)
        at java.awt.EventDispatchThread.run(EventDispatchThread.java:110)

这个bug详情很有用,看了这个大概知道是由于临时文件夹的问题引起的,echo %TMP%,发现是有路径的,start %TMP%,提示没权限。于是用管理员身份运行jconsole,一切正常了。然后修改用户环境变量,指向有权限的文件夹:C:\Users\xxx\AppData\Local\Temp,然后jconsole和jps终于可以正常使用了。

看来这个bug在后来的某个版本又被谁改回去了,应该再去Oracle把这个bug reopen一下。