并发编程需要处理两个关键问题:线程之间如何 通信 以及线程之间如何 同步 。
通信是指线程之间以何种机制来交换信息。线程之间的通信机制有两种: 共享内存和消息传递。
共享内存模型中,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信。多条线程共享一片内存,发送者将消息写入内存,接收者从内存中读取消息,从而实现了消息的传递。
消息传递模型中,线程之间通过发送消息来进行显式通信。
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存模型中,需要进行显式的同步,程序员必须显式指定某段代码需要在线程之间互斥执行;在消息传递模型中,消息发送必须在消息接收之前,因此同步是隐式进行的。
Java采用的是共享内存模型。
在 Java 中,所有实例域、静态域和数组元素存放在堆内存,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享。
Java 线程之间的通信由 Java 内存模型控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
当线程A与线程B之间要通信的话,首先线程A将本地内存中更新过的共享变量刷新到主内存;然后线程B到主内存去读取线程A之前已经更新过的共享变量。
Java 内存模型和硬件的内存架构不一致,是交叉关系。无论是堆还是栈,大部分数据都会存储到内存中,一部分栈和堆的数据也有可能存到CPU寄存器中。Java内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
Java 内存模型的三大特性:原子性、可见性和顺序性
原子性就是指一个操作中要么全部执行成功,否则失败。Java内存模型允许虚拟机将没有被volatile修饰的64位数据(long,double)的读写操作划分为两次32位操作进行。
i++这样的操作,其实是分为获取i,i自增以及赋值给i三步的,如果要实现这样的原子操作就需要使用原子类实现,或者也可以使用synchronized互斥锁来保证操作的原子性。
CAS 也就是 CompareAndSet, 在Java中可以通过循环CAS来实现原子操作。在JVM内部,除了偏向锁,JVM实现锁的方式都是用了CAS,也就是当一个线程想进入同步块的时候使用CAS获取锁,退出时使用CAS释放锁。
可见性指的是当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。
重排序可能导致多线程程序出现内存可见性问题。JMM 通过插入特定类型的内存屏障指令来禁止特定类型的处理器重排序,确保了不同的编译器和处理器平台上,能提供一致的内存可见性保证。
如果两个操作访问同一个变量,且这两个操作中有一个是写操作,这两个操作之间就存在数据依赖性。在重排序时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序,也就是不会重排序。但是,这是针对单个处理器或单个线程而言的,多线程或多处理器之间的数据依赖性不被考虑在内。
不管怎么重排序,单线程程序的执行结果不能被改变。as-if-serial 语义使得单线程程序员无需担心重排序的干扰。
重排序可能会改变多线程程序的执行结果,如下图所示
JMM 一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能放松。
JMM 对不同性质的重排序,采取了不同的策略:
JSR-133 中对 happens-before 关系定义如下:
happens-before 与 as-if-serial 相比,后者保证了单线程内程序的执行结果不被改变;前者保证正确同步的多线程程序的执行结果不被改变。
JSR-133中定义了如下的 happens-before 规则:
可见性有三种实现方式:
在一个线程中写一个变量,在另一个线程中读一个变量,而且写和读没有通过同步来排序。
在理想化的顺序一致性内存模型中,有两大特性:
JMM 的实现方针为:在不改变正确同步的程序执行结果的前提下,尽可能为优化提供方便。因此,JMM 与上述理想化的顺序一致性内存模型有如下差异:
Java中可以使用volatile关键字来保证顺序性,还可以用synchronized和lock来保证。
volatile 关键字解决的是内存可见性的问题,会使得所有对 volatile 变量的读写都会直接刷新到主存,保证了变量的可见性。
要注意的是,使用 volatile 关键字仅能实现对原始变量操作的原子性(boolean,int,long等),不能保证符合操作的原子性(如i++)。
一个 volatile 变量的单个读/写操作,和使用同一个锁对普通变量的读/写操作进行同步,执行的效果是相同的。锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总能看到对这个变量最后的写入,从而实现了可见性。需要注意的是,对任意单个 volatile 变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性。
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到内存。 当读一个 volatile 变量时,JMM 会把线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
具体来说,线程A写一个 volatile 变量,实质上是线程A向接下来将要读这个 volatile 变量的线程发出了它修改的信息;线程B读一个 volatile 变量,实质上是线程B接收了之前某个线程发出的修改信息。
JVM 是通过进入和退出对象监视器来实现同步的。Java 中的每一个对象都可以作为锁。
JDK 1.6 中对 synchronized 进行了优化,为了减少获取和释放锁带来的消耗引入了偏向所和轻量锁。也就是说锁一共有四种状态,级别从低到高分别是:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。锁可以升级但是不能降级。
synchronized 使用的锁是存放在 Java 对象头中的。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。
Java 头中包含了Mark Word,用来存储对象的 hashCode 或者锁信息,在运行期间其中存储的数据会随着锁的标志位的变化而变化。
大多数情况下,锁不仅不存在多线程竞争,而且总是由统一线程多次获得,为了让线程获取锁的代价更低而引入了偏向锁。
它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。
释放锁:当有另外一个线程获取这个锁时,持有偏向锁的线程就会释放锁,释放时会等待全局安全点(这一时刻没有字节码运行),接着会暂停拥有偏向锁的线程,根据锁对象目前是否被锁来判定将对象头中的 Mark Word 设置为无锁或者是轻量锁状态。
加锁:当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record)区域,同时将锁对象的对象头中 Mark Word 拷贝到锁记录中,再尝试使用 CAS 将 Mark Word 更新为指向锁记录的指针。如果更新成功,当前线程就获得了锁。如果更新失败 JVM 会先检查锁对象的 Mark Word 是否指向当前线程的锁记录。如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。不是则说明有其他线程抢占了锁,尝试使用自旋锁来获取锁。
**解锁:**轻量锁的解锁过程也是利用 CAS 来实现的,会尝试锁记录替换回锁对象的 Mark Word 。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量锁)
锁类型 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU | 追求响应时间,锁占用时间很短 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,锁占用时间较长 |
对于 final 域,遵循两个重排序规则:
public class FinalExample{ int i; final int j; static FinalExample obj; public FinalExample(){ i=1; j=2; } public static void writer(){ obj=new FinalExample(); } public static void reader(){ FinalExample object=obj; int a=object.i; int b=object.j; } } 复制代码
假设线程A执行 writer() 方法,线程B执行 reader() 方法。
写final域的重排序规则写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。从而确保了在对象引用被任意线程可见之前,对象的final域已经被正确的初始化过了。在上述的代码中,线程B获得的对象,final域一定被正确初始化,普通域i却不一定。
读final域的重排序规则在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序该操作。从而确保在读一个对象的final域之前,一定会先读包含这个final域的对象的引用
final域为引用类型在构造函数内对一个final引用的对象的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,不能重排序。
但是,要得到上述的效果,需要保证在构造函数内部,不能让这个被构造对象的引用被其他线程所见,也就是不能有this逸出。