java内存模型和多线程

单个处理器的频率愈来愈难以提高,所以人们转而面向多处理器,这么多年来致力于提升程序的运行效率,然而面向多核处理器的并发编程却不是那么的轻松,java在语言级别提供的多线程并发能力为咱们编写并发的程序提供了很多便利。可是本文并不打算讲述如何编写多线程并发程序,而是尝试从另外一个角度理解一下java并发和多线程的基础,理解其中的内容可以帮助咱们更好的使用java的并发库。前端

本文所涉及的有些内容可能和咱们以前的认知有些许不一样,但这正是JMM存在的意义。上面咱们提到一个词JMM,也即java memory model。这个词在JSL中出现过,可是对其更详细的解释是在JSR133中。java

一、什么是内存模型?程序员

在多处理器系统中,每一个处理器一般都有一层或多层缓存,缓存的做用自没必要多说,可是缓存在提升性能的同时却带来了另一个问题。假如两个处理器同时操做同一块内存,因为缓存的存在,那么在什么条件下两个处理器能够看到一致的值?编程

在处理器级别,内存模型定义了什么时候一个处理器可以看到另外一个处理器写入的值或者一个处理器写入的值什么时候可以被其余的处理器看到。一些系统中可以表现出很强的一致性模型,在任意时刻全部的处理器看到的数据都是同样的。可是在另一些系统中却表现出另一种相对弱的一致性模型,也就是说有一些被称为内存屏障的特殊指令来控制内存的可见性,在高级语言这个层面,这些指令一般伴随着锁来生效,一般咱们不须要关心。一般大多数处理器架构实现的都是弱一致性模型,这是由于它可以带来更好的扩展性和性能。因为java跨平台的特性,所以java尝试提供一个在不一样平台上统一的内存模型,,至于为何提供这种模型,前面已经提到过,是为了定义多线程中共享变量的可见性。后端

JSL中提到过JVM中“存在”一个主内存,全部的线程共享主内存,而每一个线程有本身独立的工做内存,工做内存相互不可见,所以java中线程之间的通讯实际上使用的是共享内存,另一种经常使用的通讯方式是消息,好比在scala中提供了基于actor的并发系统。下面是一张从网上找来的图片,描述了内存模型的概况:api


jvm对变量的操做是在本身的工做内存中,以后再刷新到主内存,这样其余线程才有机会看到前面线程的操做。基于这种事实,在并发中会出现一些难以预测的行为,尤为是当碰上指令重排序,则状况更加复杂。缓存

2、指令重排序安全

因为编译器、runtime、硬件指令重排序的存在,使得多线程中内存的可见性变得更加难以理解。在不改变程序语义的前提下,编译器可能会为了提升程序的执行效率而进行指令重排序。具体来讲一个对内存的写入指令可能会被“提早”执行,这种指令重排序在编译器、运行时和硬件上都有可能发生,只要是内存模型容许的指令重排序都是合法的,可是有一个“as-if-serial”的原则,也就是无论如何重排序,程序串行的执行结果是不会改变。关于指令重排序能够用下面一个简单的例子来解释多线程

/**
 * 指令重排序
 * @author Administrator
 *
 */
public class Reordering {
	private int a,b;
	public void write()
	{
		a = 1;
		b = 2;
	}
	public void read()
	{
		int r1 = b;
		int r2 = a;
	}
}

咱们假设上面的代码在两个线程之间并发执行,因为这里涉及到对类的成员变量并发读写,所以这不是一个被正确同步过的代码,而代码的执行顺序和结果也不固定,有可能获得如下几种执行路径:架构

                    

以上三种状况都是咱们能够预料到的,可是因为指令重排序的存在还可能出现一种看起来有违常理的结果,也就是r1=2r2=0,若是r1=2则说明b=2已经执行,按常理讲a=1也已经执行,不管如何r2也不可能为0,可是从单线程角度看,因为ba两个数据不存在依赖关系,所以将b=2操做排在a=1前面执行也是合理的,所以可能会出现下面一种执行路径:


的确上面的结果有些出乎意料,可是未正确同步的代码确实可能出现这种诡异的现象,这对程序员来讲有点儿不能接受。而解决上面问题的方式就是正确的使用同步,具体来讲就是后面要涉及到的内容。

3synchronized

同步大概会涉及到几个方面,最容易理解的是互斥,在同一时间只能有一个线程持有锁,值得一提的是在jvm层面synchronized关键字是利用monitor来实现的,同一个线程能够屡次进入monitor。然而同步不只仅包括互斥,一样重要的还有可见性,同步块可以保证被以前线程写过的内存对后面进入同步块的线程可见。当退出同步块的时候伴随着将缓存刷新到主内存的动做,所以此线程的写入能够被后面的线程看见。

前面咱们讨论重排序都是基于多处理器或者多线程的场景,可是实际上在单处理器或者单线程上也存在重排序,所以java为了保证可以让正确的同步不会被重排序所影响,描述了一个被称为“happens-before”的原则,若是一个操做happens-before另一个操做,则JMM能够保证第一个操做对第二个操做可见,这些规则大体有如下几种:

· a.某个线程中的每一个动做都happens-before 该线程中该动做后面的动做。

· b.某个管程上的unlock 动做happens-before 同一个管程上后续的lock 动做。

· c.对某个 volatile 字段的写操做happens-before 每一个后续对该volatile 字段的读

操做。

· d.在某个线程对象上调用start()方法happens-before 该启动了的线程中的任意

动做。

· e.某个线程中的全部动做happens-before 任意其它线程成功从该线程对象上的

join()中返回。

· f.若是某个动做a happens-before 动做b,且 b happens-before 动做c,则有a happens-before c

这些都是JMM为咱们提供的一种保证,所以针对以上场景的代码编写不须要显示的进行同步,好比对一个线程的start,而后在run方法中执行一些操做,JMM保证执行run方法的时候线程确定已经启动了,happens-before不会受到任何级别的指令重排序影响,也就是说针对以上或者可以用以上原则推导出来的happens-before关系,JVM经过插入正确的内存屏障指令能够保证程序的正确语义。因为synchronized关键字是经过对monitorlockunlock实现的所以上面的原则也包含了synchronized,值得一提的是final关键字的语义则稍有不一样。

4final

语法层面上咱们都知道被final修饰的变量都是不可变的,这也意味着一旦final字段被第一次初始化,后面都不会再出现对它的写操做,所以被final修饰的字段自然是线程安全的。在JSR133以前final语义是不完备的,甚至和普通的字段并无区别,这致使某些场景下final所表现出的行为违背了本来所规定的不可变性质。好比下面一段简单的代码:

public class FinalFieldExample {
	final int a;
	static FinalFieldExample obj;
	public FinalFieldExample()
	{
		a= 4;
	}
	
	public static void writer()//线程A执行
	{
		obj = new FinalFieldExample();
	}
	
	public static void reader()//线程B执行
	{
		if(null != obj)
		{
			int r1 = obj.a;
		}
	}
}

在多线程的场景下final字段a可能为0也可能为4,为何会出现如此奇怪的现象?这须要将上面的代码拆开来看,writer方法中的obj = new FinalFieldExample();这段代码其实是由不少指令组成的,从逻辑上讲若是按照粗粒度来划分至少也有对象初始化和引用赋值两步,假设对象引用的赋值操做先行发生,那么对于线程B来讲看到的是一个不完整的对象,这里的不完整也就是说对象的属性还未彻底初始化好,由于对象的初始化并非一个原子的操做,所以a可能为0,这种不肯定性并非final关键字想要的结果。在JSR-133出现以前若是想保证前面的代码执行正确,须要对读写方法加锁。可是从另一个角度去想,final表示的是只读,那就不会产生并发的问题,也就不该该用锁,因而JSR-133final的语义作了加强,所以上面的代码在JSR-133以后不会有任何歧义产生,final的值在任什么时候刻都是4。一句题外话final关键字对于JVM的优化是很友好的,这有助于编译器(前端、后端)对代码进行内联,内联的好处没必要多讲,所以可以使用final修饰的尽量使用final上面的代码还能够引出一个很是有名的问题“Double-Checked Locking,记得以前咱们写懒加载一般会这么写:

class Foo { 
	  private Helper helper = null;
	  public Helper getHelper() {
	    if (helper == null) 
	      synchronized(this) {
	        if (helper == null) 
	          helper = new Helper();
	      }    
	    return helper;
	   }
	  // other functions and members...
}

实际上上面的代码相似前面讲到的内容,会有Helper未彻底初始化的问题,所以有人建议将helper字段设置为volatile,可是在JSR-133以前加volatile也是没有用的,后面JSR-133加强了volatile的语义,使得加volatile的写法是可行的,至于为何以前不行,以后又可行了,须要去了解volatile语义先后的差异,其实主要是重排序规则的变化,后面的volatile语义更接近同步块。实际上《java并发编程实践》一书中给出了一个更优雅的实现方式:

public class ResourceFactory
{
	private static class ResourceHolder
	{
		public static Resource resource = new Resource();
	}
	
	public static Resource getResource()
	{
		return ResourceHolder.resource;
	}
}

利用jvm自身的初始化加锁机制很好的解决了懒加载的问题。

五、volatile

volatile保证了内存的可见性,可是正如上一节所讲的双检查锁的问题,在JSR-133以前volatile的语义容许volatile变量和非volatile变量之间的重排序,这就致使了一个问题,假设线程A进入同步块,而且构造Helper,此时对Helper的赋值操做很肯能会和Helper实例变量的初始化(此处假设Helper有成员变量,这很合理)操做重排序,这会致使线程B看到一个未被彻底初始化的Helper对象。JSR-133加强了volatile的语义,使得volatile变量和普通的变量的操做也不容许重排序出现,这就使得线程B只会看到一个彻底或者压根没有初始化的对象,不会产生歧义。

程序每次读取到的volatile变量都是其余线程写入的最新值,每次对volatile变量的写入也都会触发缓存刷新的动做。不依赖当前状态的变量一般可使用volatile来避免锁的竞争,好比标记某次初始化是否进行过的变量。

private volatile boolean isInitialized;

至于volatile的实现原理,鉴于篇幅的缘由,再也不详细介绍,有兴趣的可以自行google

本文主要介绍了JMM模型以及happens-before原则,并对java中几个常见的并发api作了简单的介绍,若是对并发方面的内容感兴趣能够看下官方对JMM的介绍,而且强烈推荐《java并发编程实践》以及Doug Lea的《Concurrent Programming in Java》两本书。