硬件的效率与一致性
在正式讲解Java虚拟机并发相关的知识之前,我们先花费一点时间去了解一下物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来顺理成章,实际上它们之间的关系并 没有想象中的那么简单,其中一个重要的复杂性来源是绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个MO操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的髙速缓存( Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后冉从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性( Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存( Main Memory)如图12-1所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为∫解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI( Illinois protocol)、MoSl、 Synapse、 Firefly及 DragonProtocol等。在本章中将会多次提到的“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且这里介绍的内存访问操作与硬件的缓存访问操作具有很高的可比性。
Java内存模型
Java虚拟机规范中试图定义一种Java内存模型( Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到-一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台E内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。
主内存和工作内存
Java内存模型规定了所有的变量都在储在主内存 Main Memory中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的部分)。每条线程还有自己的工作内存( Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝°,线程对变量的所有操作(读取、赋值等)都必须在作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图12-2所示。工作内存是JMM的一个抽象概念,并不真实存在。
内存之间的交互操作
关于主内存与1作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步冋主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于作内存的变量,它把read操作从主内存中得到的变量值放入I作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把作内存中一个变量的值传送到主内存中,以便随后的 write操作使用。
- wite(写入):作用于主内存的变量,它把sore操作从工作内存中得到的变量的值放人主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store和 write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、 store与 write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现顺序是 read a、 read b、 load b、 load a。
除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 1.不允许read和load、 store和 write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
- 2.不允许一个线程丢弃它的最近的 assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 3.不允许一个线程无原因地(没有发生过任何 assign操作)把数据从线程的工作内同步回主内存中。
- 4.一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说,就是对一个变量实施use、 store操作之前,必须先执行过了 assign和load操作。
- 5.一个变量在同一个时刻只允诈一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的 unlock操作,变量才会被解锁。
- 6.如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或 assign操作初始化变量的值。
- 7.如果一个变量事先没有被lock操作锁定,那就不允许对它执行 unlock操作,也不允许去 unlock一个被其他线程锁定住的变量。
- 8.对一个变量执行 unlock操作之前,必须先把此变量同步回主内存中(执行 store、wrie操作)
Java内存模型与java多线程之间的问题 (重点)
java的多线程并发问题最终都会反映在java的内存模型上,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改。总结java的内存模型,要解决两个主要的问题:可见性和有序性。
何为可见性:
多个线程的工作内存之间是不能互相传递数据通信的,它们之间的沟通只能通过主内存的共享变量来进行,因为主内存是多线程共享的,线程的工作内存只储存了某些对象变量的副本。当工作内存操作主内存的变量时执行过程如下:
- (1) 从主存复制变量到当前工作内存 (read and load)
- (2) 执行代码,改变共享变量值 (use and assign)
- (3) 用工作内存数据刷新主存相关数据 (store and write)
当一个共享变量同时在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改后的值,这就是多线程的可见性问题。再java中volatile关键字可以解决可见性的问题。
volatile关键字
volatile是java提供的一种同步手段,只不过它是轻量级的同步,为什么这么说,因为volatile只能保证多线程的内存可见性。在多线程环境下,某个共享变量如果被其中一个线程给修改了,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。那么volatile如何保证变量多线程间可见性的呢?答案是 在工作内存中的变量写回到主内存的时候会利用缓存一致性协议来保证其他处理器中的缓存数据的一致性。
缓存一致性协议:
刚才说volatile 关键字“如果一个共享变量被一个线程修改了之后,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取”,实际上是这样的:
线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况,一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。
举个例子:
public class Test2{
public static boolean b=false;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new Runnable() {
public void run() {
while(!b) {}
System.out.println("初始化完成");
}
}).start();
Thread.sleep(1000);
Thread t2=new Thread(new Runnable() {
public void run() {
System.out.println("初始化");
b=true;
}
}).start();
}
}
这个程序会首先打印“初始化”却无法打印“初始化完成”,由于线程t1首先执行,它会将变量b加载到线程t1的工作内存中,这时t1的工作内存中的b=false。然后第二个线程t2再执行,再t2执行完成后主内存的b值已经改成了true,但是此时t1是无法感知到的。所以t1将会无限循环下去。但是如果给b变量加volatile 关键字如下public static volatile boolean b=false;,再次执行程序将会打印“初始化 初始化完成”,程序正常结束。原因就是volatile关键字的可见性,由于缓存一致性协议会导致工作内存中的b值无效,从而再向主内存取值,此时主内存中的b值已经变成了true。
使用volatile关键字的的第二个语义是禁止指令重排序优化。
例如下列两行代码:
Int a=0;
Int b=1;
对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。如果变量使用了volatile关键字虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。
何为有序性:
通过上面的叙述可以知道volatile关键字可以解决多线程之间的可见性,但是却不能保证多线程之间运行代码的有序性。即使是只有一行代码,但是再java底层的运行中却分为了多个步骤执行,而且这些步骤都是原子操作。
例如:x=x+1; x初始为10
它的执行过程如下:
- 1.从主存中读取变量x副本到工作内存
- 2.给x加1
- 3.将x加1后的值写回主存
但是在多线程的情况下,多个线程之间执行代码的循序是不确定的,例如有两个线程a,b则执行过程可能会出现:
- 1:线程a从主存读取x副本到工作内存,工作内存中x值为10
- 2:线程b从主存读取x副本到工作内存,工作内存中x值为10
- 3:线程a将工作内存中x加1,工作内存中x值为11
- 4:线程a将x提交主存中,主存中x为11
- 5:线程b将工作内存中x值减1,工作内存中x值为9
- 6:线程b将x提交到中主存中,主存中x为9
或还有可能为其他的组合循序。在这种情况下即使volatile关键字能保证多线程之间的可见性。但是依然会存在线程安全问题。
举个例子:
public class Test2{
public static volatile int num=0;
public static void add() {
num++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] ts=new Thread[10];
for(int i=0;i<ts.length;i++) {
ts[i]=new Thread(new Runnable() {
public void run() {
for(int i=0;i<3000;i++) {
add();
}
}
});
ts[i].start();
}
for(int i=0;i<ts.length;i++)
ts[i].join();
System.out.println(num);
}
}
主函数中启动了10个线程,每个线程都执行3000此循环,如果没有对java内存模型有很好的理解的话,大部分程序员都会认为执行结果为30000。其实不然,即使num变量加了volatile关键字,但是执行结果肯定会小于等于30000,大部分情况下是小于30000。
造成这一现象的原因就是多线程之间执行程序的有序性是不确定的。Volatile能解决的是在执行use操作时如果发现缓存变量失效,则重新重主内存中读取。但是有可能发生的情况是,同样的缓存变量在一个线程执行了use操作后wite操作之前,另一个线程也执行了use操作,这样的话实际变量num只增加了1,但是却执行了两个自增操作。所以最终的num会小于等于预期的值。
那么怎么解决这一问题呢。答案就是java中的锁机制,例如给add方法加一把锁
public synchronized static void add() {
num++;
}
这样num的值会保证为30000。
下篇文章我将会重点介绍一下java中的各种锁,以及实现的原理。