首先,为什么需要有内存模型?
因为我们的程序(不管是什么程序)在运行的时候,数据和代码都是放在内存中的,在一个程序的执行过程中,都需要读取和写入数据的。在java中,可以是对本地变量的读写、对对象后属性的读写、或者是对类变量的读写等等。这些读写操作都是需要与内存进行交互的(这一点是由计算机的体系结构决定的),那么在与内存进行交互的过程中,对一个语言来说,哪些交互是合法的?语言本身又对这些读写操作提供什么样的保证?这些问题就需要有一个明确的规范来说明。只要编译器或者运行时环境的厂商根据这个说明来实现语言的编译器或者运行时环境,那么使用同一种语言编写的程序就能够在这些由不同厂商提供的编译器或者运行是环境中都能够正确运行。
Java的内存模型也是起到了这个作用。它的主要目的是用来说明在多线程环境下,什么样的值对于读操作来说是合法的。
已经有很多文章和书籍在谈论java的内存模型,这些文章和书籍基本都会介绍什么是java内存模型以及在多线程环境下我们应该怎么利用java语言提供的支持来保证程序运行的正确性。本文不打算重复这些内容,而是想从另外一角度来尝试说明在多线程环境下,java的内存模型能够给程序员带来哪些最基本的数据同步方面的保证,这里说的最基本是指不使用synchronize或者任何其他java提供的同步构件。
[b]1. Java中的原子操作[/b]
在java中,对除了long和double类型之外的基本数据类型,以及引用的付直和读取操作都是原子的,java的运行时环境会保证不会读取和写入不完整的值。但是对于long和double来说,在多线程环境下,一个线程第一次访问一个long或者double的时候,只看到了写入的前4个字节,在后续的访问中,才会看到后面也写入的4个字节。
上面的描述告诉我们,对于非long或者double的数据类型来说,我们在写付值语句的时候是不需要任何特殊同步构件的。但是这只是让程序员不用担心会读到不完整的数据,与可见性是没有任何关系的。也就是说,对于一个可以被多个线程贡献的变量来说,虽然对这个变量的付值或者读取都不用担心数据完整性的问题,但是不同线程仍然可以看到不同版本的值,也就是数据一致性的问题仍然是存在的。这是由于现代计算机的CPU为了减少对内存的访问次数,提高执行效率,往往会采取寄存器缓存的策略,将程序中用到的一些数据线缓存到寄存器中,读取的时候直接访问寄存器。这样就有可能出现内存中的数据已经修改,但是程序执行过程中用到的仍然是寄存器中缓存的过期值的情况。这样的现象在多核CPU架构下尤为严重。
那么,在java中,我们有没有办法让程序在读取一个数据的时候始终从内存加载而不是读取放在寄存器中的值呢?答案是肯定的,我们可以使用java提供的volatile关键字来声明一个共享变量。这样java程序在执行的过程中,碰到被声明为volatile的数据时,都会直接从内存读取该变量的最新值。另外,volatile关键字还有另外一个附加的作用,就是保证对long或者double类型数据在读取和写入时的完整性。
稍微总结一下,如果共享变量的声明没有加上volatile关键字,那么java的运行时环境只能保证除了long和double之外的数据类型的读写完整性,而如果共享变量的声明加上了volatile关键字,那么java能够保证所有数据类型的读写完整性以及他们的一致性(这里说称是可见性可能更加准确一点)。
[b] 2. 正确同步的程序[/b]
在不使用java提供的任何同步构件时(volatile关键字除外),java的运行时环境能够在一致性和完整性上提供给程序员的帮助也就仅限于第一节所给出的描述了。由此可见,java的内存模型给程序员提供的帮助实在是少的可怜,为了保证程序的在多线程环境下的正确执行,需要程序员付出很多的努力。虽然java本身也在sdk中提供了很多的同步构件,但是程序员在编写复杂的并发程序时仍然会碰到很多挑战,需要花费很多的时间去进行调试,保证程序访问数据的一致性和完整性,避免程序出现死锁。但是,即便是这样,也很难保证并发程序的正确性,有些bug是极其不容易发现的。目前的一些专门为并发程序设计的语言,比如scala,在给程序员减负方面做的应该会更好一点(我也没有看过,只是猜测)。
那么java或者与其相似的一些程序语言来说,怎样才算是一个正确同步的程序呢?首先来看几个概念。
线程间操作:线程间操作是指这样的一些操作,这些操作被一个线程执行之后,会被其他线程察觉到或者影响到其他线程的执行。在java中,对共享变量的读写、对volatile变量的读写、进出synchronized方法或者块、显示的加锁和解锁、线程生命周期中的第一个以及最后一个操作、启动线程的指令、监听线程结束的指令以及对外部系统执行的操作(比如远程方法调用、网络IO、磁盘IO等)等,都是线程间操作;
编程顺序:编程顺序是指所有线程间操作的一个全局顺序。在程序执行期间不同的线程可能会执行不同的线程间操作,编程顺序就是不同线程所执行的线程间操作的一个全局顺序。这个顺序与单线程环境下,线程间操作被执行的顺序一致;
顺序一致性:顺序一致性是指,如果所有线程执行线程间操作的顺序满足编程顺序,并且每一次读都能看到上一次对同一变量执行的写操作的结果,那么整个程序就是顺序一致的(读一定发生在写之后,且读之前只有一次写入操作);
数据竞争:数据竞争是指两个对同一个变量进行访问的线程,至少有一个执行的是写作;
并发程序中碰到的各种错误,一般都是因为程序中共享变量或者程序状态的最新值没有被正在执行的多个线程及时获取到,导致执行失败或者出错。而能够操作共享数据或者状态的操作都是线程间操作,不同线程执行的线程间操作往往既包含读操作也包含写操作,这就会引起数据竞争。所以,归根结底数据竞争是并发编程中的错误根源所在。
要编写能够正确运行的并发程序,我们就需要解决数据竞争问题。从概念上来说,一个程序会被周而复始反复地执行,如果每一次执行都能够满足顺序一致性,并且相互之间不会引起数据竞争,那么这个程序就能够正确地执行。
下面举一个简单的例子,图1中给出了一个简单程序的执行流程图,我们要关注的就是n=n+1这个步骤。在下面的例子中,我们只考虑数据竞争的问题,假设n能够被所有线程看到(比如为n的声明加上了volatile关键字)。
[img]http://dl.iteye.com/upload/attachment/0069/8722/d1d70c62-6d3d-3beb-8aa9-14fe7b37a9c9.png[/img]
我们知道,n=n+1这个语句在真正执行的时候至少要分为三条语句执行,首先要读取n的最新值,然后执行n+1操作,然后将n+1的值写回到n中。我们假设,n=n+1就是执行了这个操作,拆解开来就是:
k = n;
k = k + 1;
n = k;
上面三条指令中,由于k时局部变量,所以对k的操作是不会影响到其他线程的,而1和3分别是读取n和为n赋值,这两个操作都是线程间操作,因此是会给其他线程带来影响的。
在并发环境下,我们假设,每个线程都能完成对整个程序的一次执行,这样从每个线程的角度来看它们的执行过程是满足顺序一致性的,但是由于每个线程对n都即进行了读又进行了写,因此就产生了数据竞争。所以,在不采取任何措施的情况下,这个程序是无法正确运行的。
为了让程序能够正确地执行,我们就要想办法消除数据竞争,最简单的办法就是将n=n+1放入到同步块中。这样,每次就只有一个线程能够执行n=n+1这个语句,因此也就消除了数据竞争。
当然,上面这个例子过于简单,实际的情况要复杂很多。我们在编写并发程序的时候,要考虑的主要问题就是如何消除数据竞争,并且保证程序中的所有线程间操作在不同的执行过程中,都是保持相同顺序被执行的,这样我们就可以保证程序的正确性。当然,说起来似乎是很简单的,但是实践起来往往非常困难,需要不断地练习和经验的积累。