操作系统实现线程之间通信主要有两种方式:共享内存和消息传递。共享内存将线程共享状态存储在公共存储区域,比如内存,各个线程共同读写公共存储区域实现多个线程之间状态共享。消息传递模型则没有共享存储区域,线程之间通过显式的发送消息进行通信。Java实现线程之间通信采用的是共享内存模型。
现代处理器模型
我们知道现代计算机基本都是多核处理器,我们可以把多核处理器想象为多个CPU,每个CPU都有一个高速缓存(寄存器)用来缓存CPU当前处理的数据。为了提高处理速度,处理器不直接和内存进行通信,而是将内存中的数据load进高速缓存进行操作,但操作完不知道何时会写回内存。
Java内存模型抽象结构
Java内存模型(JMM)就是基于现代处理器模型进行设计,从抽象的角度来看JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存,每个线程都有一个线程本地内存,线程本地内存中存储了线程读写变量的副本。
线程A如果要与线程B进行通信需要经过两个步骤:
这里我们所说的线程A与线程B通信的过程在没有其他同步措施的情况下是在非常理想的,然而实际情况下并非总是如此。
缓存不一致问题
在没有其他任何并发控制的条件下,线程A和线程B并发执行i++操作。下面这样的情况是可能会发生:
线程A在线程本地缓存中看到的i=0,执行i=i+1, 此时线程A的本地缓存i=1,但线程A并没有立即将i=1刷新至主内存。
这时候线程B看到本地缓存中仍然是i=0,线程B同样执行i=i+1,线程B的本地缓存i=1,同时将i=1刷新到主内存中。
在线程B刷新主内存后,这时线程A也执行了i=1回写主内存,这时就出现了丢失更新的情况。A、B两个线程分别执行一次+1后,内存中的结果应该是2。由于线程A和B本地缓存中数据不一致,导致其中一次+1操作丢失。
缓存不一致导致一次+1操作丢失
缓存一致性协议
为了解决缓存不一致问题,出现了缓存一致性协议:每个处理器会嗅探总线(内存通过总线与CPU进行数据传递)上的数据检查自己缓存的数据是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器缓存置无效,当处理器对这个数据进行修改时,会重新从内存中将数据load进处理器缓存。简单来说缓存一致性协议实现以下两点:
原子性
原子操作是指不可被中断的一个或一系列操作,满足原子性的操作我们称为原子操作。在多线程环境下如果操作不满足原子性很可能出现不一致问题。我们仍以i++操作举例:
简单的i++就是将执行一个i=i+1的操作,它是否是原子操作呢?
答案是否定的,i++其实包含三个操作:从缓存中读取i的值、执行i=i+1、将i写回缓存和内存。
类似于这种读改写不满足原子性的操作,就可能出现共享变量被多个处理器同时操作后共享变量值和期望不一致的情况。就如同在说明缓存不一致问题时i最终结果为2的情况。
Java中如何实现原子操作呢?
Java中可以通过锁和CAS的方式实现原子操作。
Java中包含两种加锁方式分别synchronized和ReentrantLock,这两种方式都可以对一段代码加锁来实现操作的原子性,我们平时用的都比较多,这里就不做展开了。
CAS实现原子操作时我们平时使用较少的,我们一起来了解下它是如何实现原子操作的。
循环CAS实现原子操作
CAS(Compare And Swap)操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
JVM中的CAS操作利用了处理器提供的原子指令CMP进行实现。循环CAS实现的基本思路是循环进行CAS操作直到成功为止。Java1.5后提供了一些原子操作类如AtomicInteger、AtomicBoolean和AtomicLong等就是利用CAS实现的。例如AtomicInteger的addAndGet(int delta)方法:
/** * Atomically adds the given value to the current value. * * @param delta the value to add * @return the updated value */ public final int addAndGet(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta) + delta; } /** * Unsafe类是java实现CAS操作的辅助类,所有的CAS操作都最终都依赖Unsafe类中的方法 * var1是原子操作类的实例,var2是旧址在内存中的地址,var4是增加的值 * * while循环会一直尝试执行CAS操作,直到成功为止 */ public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); //var5是旧值 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; } /** * 最终执行CAS操作的方法是一个Java本地方法 */ public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
总的来说当某个线程执行addAndGet时是这样一个流程:先把旧值取出(预期旧值),新值=旧值+增加值,将内存中旧值与预期旧值比较,如果不一致,循环进行CAS操作,直到成功为止。
可见性
可见性是指当一个线程修改了共享变量后,修改后的值对其他线程立马可见。我们仍然以缓存不一致小节中i++中的例子来说明。
线程A执行i=1+1后,线程A本地缓存的i=1;可见性确保i=1的状态值立刻被刷新到主内存,同时线程B会即刻得知共享变量i被修改了,重新从主内存中将最新的i值load进线程B的本地缓存。
我们可以将可见性抽象为:线程A修改后共享变量后向线程B发送了状态变更的消息,不过这个通信过程必须经过主内存。Java内存模型通过控制主内存与每个线程本地缓存之间的交互来为Java程序提供内存可见性。
指令重排序
在程序执行时,为了提高性能,编译器和处理器通常会对指令做重排序。指令重排序分为三种:
从Java源代码到最终实际执行序列,会分别经历下面三种重排序:
上述重排序类型中1属于编译器重排序,2和3属于处理器重排序,这些重排序可能会导致多线程程序出现可见性问题。因此JMM的编译器重排序规则会对特定类型的编译器重排序禁止(不是所有的编译器重排序都禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)来禁止特定类型的处理器重排序。
当一个共享变量声明为volatile后,volatile变量具备以下特性:
对于volatile具备可见性这一点,有了前面我们关于Java内存模型和可见性含义的铺垫应该很好理解了。
volatile变量写的内存语义:当写一个volatile变量时,JMM会把该线程本地缓存中的共享变量刷新到主内存。
volatile变量读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地缓存置无效,线程接下来将从主内存中读取共享变量。
理解volatile特性的一个好方法就是把对单个volatile变量的读写看成对这些单个变量做了读/写同步。
public class VolatileExample { private volatile int i = 0; //对单个volatile变量的写 public void set(){ i = 1; } //对单个volatile变量的读 public int get(){ return i; } }
以上代码其实等价于对普通变量的加锁读写
public class VolatileExample { private int i = 0; //普通变量的加锁写 等价于 对单个volatile变量的写 public synchronized void set(){ i = 1; } //普通变量的加锁读 等价于 对单个volatile变量的读 public synchronized int get(){ return i; } }
正如volatile第二点中所提到的,我们应该清楚volatile并没有提供原子性,类似于i++这种复合操作在多线程并发操作的环境下是无法保证结果的正确性的。除非只有一个线程执行i++,其余线程仅仅是读取i的值,在这种情况下使用volatile来代替synchronized或者ReetrantLock就非常合适。
在指令重排序小节中我们介绍了编译器和处理器为了优化程序执行速度,在不改变单线程执行语义的前提下会对指令进行重排序。对于volatile变量JMM会根据volatile变量重排序规则对包含volatile变量的代码块进行重排序干预,禁止不符合规则的重排序。举例来说:
当第二条语句是volatile变量写时,不管第一条语句是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
int a = 5; //语句1 int b = 3; //语句2 volatile boolean flag = true; //语句3
虽然在单线程环境下语句1、语句2、语句3重排序不会影响单线程执行语义,但根据volatile变量的禁止排序规则语句3不能和语句1、语句2进行重排序。
语句1和语句2是普通变量写,它们两个之间可以进行重排序。
实际上JMM遵从的volatile变量重排序规则比我们举的例子要复杂些,有兴趣的同学可以研究下。
synchronized是Java并发编程中元老级的角色,是最早用于实现Java多线程同步的策略。Java中的每个对象都可以作为锁,synchronized实现线程同步主要有三种形式:
线程在访问同步代码块时首先要获取锁,退出同步代码块或者抛出异常时必须释放锁。那么synchronized关键字实现的锁到底存在哪里呢?
Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,JVM基于Monitor对象来实现方法同步和代码块同步。当一个线程进入同步代码块时会执行monitorenter指令,表示monitor锁被当前线程锁持有,直到当前线程释放monitor锁之前其他线程都不能进入同步代码块。与monitorenter相匹配,每个线程在退出同步块或者抛出异常都会执行monitorexit指令,使当前线程释放monitor锁。这就是synchronized锁实现的原理。
众所周知锁能够实现临界区互斥执行,但锁的内存语义常常被忽视。
public class MonitorExample { private int i = 0; public synchronized void set(){ i = 1; } public synchronized int get(){ return i; } }
当线程A执行完set方法释放锁后,会立即将i=1刷新到主内存。同样线程B获取锁执行get方法时会将本地缓存置无效,从主存中刷新最新的i值。
线程A释放锁随后线程B获取锁,这个过程实质上是线程A向线程B发送消息。
对比volatile的的内存语义,释放锁与volatile写有相同的内存语义;获取锁与volatile读有相同的内存语义。
本篇文章主要介绍了一下几点: