在我的博客和公众号中,我发表了很多关于并发编程的文章。在上一篇文章中,我们介绍了Java并发编程中比较重要的两个关键字:synchronized和volatile。相关内容简要回顾:1、为了解决并发编程中的原子性、可见性和顺序性问题,Java语言提供了一系列与并发处理相关的关键字,如synchronized、volatile、final、concurrentpackages,ETC。。2、通过加锁,当需要原子性、可见性和顺序这三个特性时,synchronized可以作为解决方案之一,看起来“通用”。事实上,大多数并发控制操作都可以使用synchronized来完成。3.volatile通过在volatile变量操作前后插入内存屏障,保证并发场景下变量的可见性和顺序。4、volatile关键字不能保证原子性,但是synchronized可以通过monitorenter和monitorexit两条指令,保证synchronized修改的代码一次只能被一个线程访问,可以保证不会出现CPU时间片。可以通过线程之间的切换来保证原子性。好了,我们知道关键字synchronized和volatile是Java并发编程中经常用到的两个关键字,而且,通过前面的回顾,我们知道synchronized可以保证不会出现原子性、可见性和顺序性的问题,而volatile可以只保证可见性和有序性,那么,如果有synchronized,怎么会诞生volatile呢?接下来,本文将讨论为什么Java既有synchronized关键字,又提供volatile关键字。1.synchronized的问题我们都知道synchronized其实是一种锁机制。既然是锁,自然有以下缺点:1.有性能损耗。虽然在JDK1.6中对synchronized做了很多优化,比如适配性自旋、锁消除、锁粗化、轻量级锁和偏向锁等(深入理解多线程(五)——Java虚拟机的锁优化技术),但毕竟还是一种锁。以上优化都是为了避免给Monitor加锁(深入理解多线程(四)——Monitor的实现原理),但并不是所有情况都能优化,而且即使优化之后,优化过程也是耗时的.因此,无论是使用同步方法还是同步代码块,在同步操作前还是需要加锁,同步操作后需要解锁。这个加锁和解锁的过程需要性能损失。关于两者的性能对比,由于虚拟机在锁上做了很多剔除和优化,我们很难量化两者的性能差距,但我们可以确定的一个基本原则是:read的性能对volatile变量的操作与小型普通变量几乎没有区别,但是由于需要插入内存屏障,写操作会比较慢。即便如此,在大多数场景下,volatile的开销低于锁。2.阻塞我们在深入理解多线程(一)中介绍了同步的实现原理——Synchronized的实现原理。无论是同步方式还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter,monitorexit都是基于Monitor的。.基于Monitor对象,当多个线程同时访问一段同步代码时,会先进入EntrySet。一个线程获取到对象的锁后,就可以进入TheOwner区,其他线程会继续在EntrySet中等待。而当一个线程调用wait方法时,就会释放锁,进入WaitSet等待。所以synchronize实现的锁本质上是阻塞锁,也就是说多个线程访问同一个共享对象需要排队。而volatile是Java虚拟机提供的一种轻量级的同步机制,是基于内存屏障实现的。毕竟他不是锁,不会有synchronized带来的阻塞和性能损失问题。2.volatile的附加功能除了前面我们提到的volatile比synchronized的性能要好之外,volatile其实还有一个非常好的附加功能,就是禁止指令重排。我们先举个例子看看如果只用synchronized而不用volatile会怎么样。下面就拿我们比较熟悉的单例模式来说吧。我们通过doublechecklock的方式实现单例,这里没有使用volatile关键字:(singleton==null){singleton=newSingleton();}}}returnssingleton;}}在上面的代码中,我们使用synchronized对Singleton.class进行加锁,保证只有一个线程能够执行到synchronized代码块中的内容。同时,也就是说操作singleton=newSingleton()只会执行一次,也就是实现一个单例。但是,当我们在代码中使用上面的单例对象时,可能会出现空指针异常。这是一个相当奇怪的情况。我们假设当Thread1和Thread2两个线程同时请求Singleton.getSingleton方法时:Step1,Thread1执行到第8行开始初始化对象。Step2,Thread2执行到第5行,判断singleton==null。Step3,Thread2判断后发现singleton!=null,于是执行第12行,返回singleton。Step4,Thread2得到单例对象后,开始执行后续操作,比如调用singleton.call()。上面的过程看似没有问题,但实际上在Step4中,Thread2调用singleton.call()时,有可能抛出空指针异常。之所以会抛出NPE,是因为在Step3中,Thread2获取到的单例对象并不是一个完整的对象。什么是不完整的对象,你怎么理解这个?这里先看看,singleton=newSingleton();这行代码是干什么的,大体流程如下:1.当虚拟机遇到新的指令时,会去常量池中定位到这个类的符号引用。2.检查符号引用所代表的类是否已经被加载、解析和初始化。3、虚拟机为对象分配内存。4.虚拟机将分配的内存空间初始化为零。5、虚拟机对对象进行必要的设置。6.执行方法并初始化成员变量。7.将对象的引用指向这块内存区域。让我们将这个过程简化为三个步骤:JVM为对象分配一块内存Mb,在内存M上初始化对象,c。将内存M的地址复制到单例变量中如下图:因为内存地址给单例变量赋值是最后一步,所以在Thread1执行这一步之前,Thread2在判断单例时一直为真==null,那么就会阻塞,直到Thread1完成这一步。但是,问题是上面的过程不是原子操作,编译器可能会重新排序,如果把上面的步骤重新排列成:JVM为对象分配了一块内存Mc,并将内存的地址复制给单例变量b。如下图初始化内存M上的对象:这种情况下Thread1会先进行内存分配,然后进行变量赋值,最后进行对象初始化。那么,也就是在Thread1还没有初始化对象的时候,Thread2进来判断singleton==null可能会提前得到一个false,会因为还没有完成初始化操作而返回一个不完整的sigleton对象。一旦出现这种情况,我们得到的是一个不完整的单例对象,尝试使用这个对象时极有可能会出现NPE异常。那么,如何解决这个问题呢?因为指令重排导致了这个问题,所以避免指令重排就足够了。所以volatile就派上用场了,因为volatile可以避免指令重排。只需将代码更改为以下代码即可解决此问题:newSingleton();}}}returnssingleton;}}对singleton使用volatile约束,保证其初始化过程不会被指令重排。这样就可以保证Thread2要么拿不到对象,要么拿到一个完整的对象。3、synchronized的顺序保证呢?这里可能有朋友会问。归根结底,上述问题是指令重排导致的。其实还是顺序的问题。并不是说synchronized就可以保证顺序。那么,为什么它在这里不起作用?首先明确一点,synchronized不能禁止指令重排和处理器优化。那么他是如何保证有序性的呢?这是为了扩大秩序的概念。Java程序中的自然顺序可以用一句话来概括:如果在这个线程中观察,所有的操作都是自然有序的。如果一个线程观察另一个线程,则所有操作都是无序的。上面这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明没有详细解释。这里简单展开一下,其实和as-if-serial语义有关。as-if-serial语义的意思是:不管怎么重新排序,单线程程序的执行结果是无法改变的。编译器和处理器无论如何优化都必须遵守似串行语义。这里不详细描述as-if-serial语义。简单来说,as-if-serial语义保证在单线程中,无论指令如何重新排列,最终的执行结果都不会改变。那么,让我们回到刚才的双重检查锁的例子。从单线程的角度来看,也就是如果我们只看Thread1,因为编译器会遵守as-if-serial语义,所以这个优化是没有问题的。不会对本线程的执行结果产生任何影响。但是Thread1内部的指令重排对Thread2有影响。那么,我们可以说,synchronized保证的顺序是多线程间的顺序,即锁定的内容必须被多个线程依次执行。但是内部同步代码还是会重新排序,但是因为编译器和处理器都遵循as-if-serial语义,我们可以认为这些重新排序在单线程内部可以忽略不计。4.总结本文从两个方面讨论了volatile的重要性和不可替代性:一方面因为synchronized是锁机制,存在阻塞问题和性能问题,而volatile不是锁,所以没有阻塞和性能问题问题。另一方面,因为volatile使用了内存屏障来帮助它解决可见性和排序问题,而内存屏障的使用也给它带来了一个附属的禁止指令重排的功能,所以在某些场景下是可以避免的。出现顺序重排问题。所以,以后需要做并发控制的时候,如果不涉及原子性问题,可以优先使用volatile关键字。【本文为专栏作家霍利斯原创文章,作者微信公众号Hollis(ID:hollishuang)】点此阅读更多本作者好文
