在多线程开发中,经常需要多线程公用一个变量、对象或资源,我相信大多数开发者可以使用synchronized、volatile、ConcurrentHashMap等一些线程同步控制方式来开发多线程应用,Doug Lea大师的确为我们开发了很多Java并发控制工具,使得多线程的变得容易,但是如果我们仅仅只会用,而不明白其中原理,往往同步控制,写出的程序性能低下,遇到问题,并发程序难于调试,打击信心,进度拖延。因此,对于原理的理解,才是一个程序员能够立足于快速技术迭代的IT潮流下的资本,是真正的内功。扯远了,话题转回来,在讨论Java并发程序的共享问题之前,我们不得不先来看看Java的内存模型——JMM(Java Memory Module)。
一Java内存模型(JMM)
Java内存模型(JMM)是Java多线程共享变量的控制机制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。这里可能就有疑问了,既然是共享变量,一个线程对于共享变量的更改对于其他线程不应该是立即可见的吗?之所以有这样的疑问,是因为不了解JMM下多线程对于共享变量操作的规则。JMM共享变量操作结构图如下:
在JMM模型中,线程的运行都拥有各自线程的内存存储区域,抽象为线程的本地内存(Local Memory),注意这个本地内存概念是一个抽象概念,并不是真的就是计算机内存,他指的是线程运行状态下,存储变量的所有位置(计算机内存、缓存等)。通过上图可以看到,线程的共享变量是保存在主存中的,线程的本地内存在线程初始化过程中只是保存了共享变量的副本,并在运行过程中操作的是该副本变量,并不是主存中的变量。图中,线程1与线程2的本地内存都存储了共享变量B的副本,但它们却不是同一变量,也就是说两个线程对各自副本变量的操作对于另一线程来说是不可见的,而保证共享变量B对于两个线程的可见性的唯一方法,就是需要将变量副本与主存中的共享变量进行同步操作,而至于何时将副本刷入主存就是由JMM规则控制的。
二volatile线程可见性
在Java中关键字volatile控制了多线程变量可见性,当线程操作对volatile变量进行写操作时,JMM会将该线程的本地内存中的副本变量刷新到主存中去;当线程对volatile变量进行读操作时,线程先将本地内存共享变量设置为无效,线程就会从主存中读取共享变量的值了。JMM下volatile共享变量的操作就变成了下图模型:
从实质来说,共享变量的副本就是主存中变量的缓存,与软件开发系统中的缓存原理相同,在缓存失效时,缓存系统便会从持久存储中读取最新的变量值,当缓存值修改后,缓存系统会在合适时机将新值同步回持久存储中。JMM之所以在线程本地内存中使用副本,也是为了使线程对变量访问更快。从计算机体系的角度来看,线程的本地内存则很多时候是保存在CPU的缓存中的,CPU缓存速度远快于系统内存(因为CPU访问内存需要通过主板的总线系统,而CPU缓存就在CPU的内部,处理器可以直接访问,因此更快),所以说java中volatile的规则的是底层CPU缓存与内存之间进行数据同步的操作来实现的。
这里针对网络中有些对volatile内存语义解释为:“volatile变量会促使线程直接操作主存中的变量”的说法,这里进行辩证说明一下:本作者认为该说法不严密,在处理器与内存之间没有任何缓存介质时,该命题成立。如果处理器直接操作内存,需要获取BUS总线锁,在BUS总线锁被占用的过程中,其他处理器无法对内存进行任何操作,是一种低效的内存控制技术,随着处理器的发展,CPU增加了缓存系统,只有CPU缓存失效或缓存未命中时,处理器才会从内存读取数据,不同的处理器都担保了处理器缓存与内存变量之间的同步的机制,因此这里只可以认为是“等效”于直接操作主存变量,并不一定是直接操作。
对于volatile例子:我们经常通过主线程与子线程之间通过一个布尔变量isRunning来控制子线程退出的时机,在JVM -server运行模式下,如果isRunning没有声明为volatile变量,子线程会一直在私有堆栈中读取isRunning变量,从而导致子线程一直无法退出。在《Effective JAVA》中,该现象被称为“活性失败”。这个例子,也说明了volatile使用的重要性,有些程序员会错误认为,isRunning变量迟早会被同步,的确,两个变量迟早会被同步,认为没有必要用volatile修饰,但是却错误估计了该情况下同步需要的时间有可能与线程的生命周期相同。
有些Java初学者,一直分不清可见性与锁的区别。锁控制的是代码块的同步,控制的是指令的执行顺序,而可见性指的是处理器确保共享变量在不同线程之间的可见,可见性不能保证共享变量操作的原子性,两者解决的问题不同,面对的对象也不同。例如共享volatile变量i++操作,volatile可以确保变量i的值多线程可见,但却不能保证i++是原子操作,因为i++实际上也是由三个原子操作组成:read i、inc、 write i,在多线程环境下,依然可能产生对变量i的脏读,脏写现象。因此只有读者明白JMM可见性产生的原因,JMM共享变量的结构,才能理解并发编程中可见性的作用。
那么对于变量的原子操作是不是必须加锁?不一定,这里建议读者参看AtomicInteger类的实现,其使用无锁CAS操作确保了volatile变量的原子操作。
三为什么CPU需要缓存
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快很多,这样会使CPU花费很长时间等待数据到来或把数据写入内存。缓存大小是CPU的重要指标之一,而且缓存的结构和大小对CPU速度的影响非常大,CPU内缓存的运行频率极高,一般是和处理器同频运作,工作效率远远大于系统内存和硬盘。实际工作时,CPU往往需要重复读取同样的数据块,而缓存容量的增大,可以大幅度提升CPU内部读取数据的命中率,而不用再到内存或者硬盘上寻找,以此提高系统性能。但是从CPU芯片面积和成本的因素来考虑,缓存都很小。
按照数据读取顺序和与CPU结合的紧密程度,CPU缓存又可以分为一级缓存,二级缓存,部分高端CPU还具有三级缓存,每一级缓存中所储存的全部数据都是下一级缓存的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取,由此可见一级缓存是整个CPU缓存架构中最为重要的部分。
既然缓存是好动西,那为什么不把数据都放入缓存?造价问题,CPU缓存造价远高于内存,这和内存造价远高于硬盘一个道理。