Java并发机制的底层实现原理
Java代码代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的的并发机制依赖于JVM的实现和CPU的指令
一、 volatile的应用
volatile是轻量级的synchronize,它保证了多线程开发中保证了共享变量的可见性。
可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
volatile的定义与实现原理
Java语言规范第3版中对volatile的定义如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
在多处理器下,为提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后在进行操作,但操作完不知道何时会再写回内存。
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存,写回的同时其它处理器缓存的值还是旧的,执行后续操作就会有问题。所以在多处理器下,需要实现 缓存一致性协议。
每个处理器通过嗅探在总线上传播的数据检查自己缓存的数据是否过期,一旦发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候会重新从系统内存中读取到缓存里。(总线嗅探机制)
volatile的两个实现原则:
- Lock前缀指令会引起处理器缓存数据写回系统内存
- 一个处理器的缓存回写会导致其他处理器的缓存失效
二、synchronized的实现原理与应用(JDK6之后)
synchronized实现同步的基础:Java中的每一个对象都可以作为锁,主要由以下三种表现形式
- 对于普通同步方法,锁是当前实例对象 this
- 对于静态同步方法,锁是当前类的Class对象
- 对于同步方法快,锁是synchronized括号内的对象
要了解 synchronized 实现同步的原理,需要先理解两个预备知识。
1. Java对象头
锁的类型和状态和对象头的Mark Word息息相关
对象存储在堆中,主要分为对象头、对象实例数据和对齐填充(数组对象多一个区域:记录数组长度)
对象头:
分为
长 度 | 内 容 | 说 明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或GC标志灯信息 |
32/64bit | Class Metadata Address | 存储指向对象所属类(元数据)的指针,JVM通过这个确定对象属于哪个类 |
32/32bit | Array length | 数组的长度 |
对象实例数据:
如上图所示,勒种的成员变量就属于对象实例数据
对齐填充:
JVM要求对象占用的空间必须是8的倍数,方便内存分配(以字节为最小分配单位),因此这部分就是用于填满不够的空间凑数的。
Java对象头的存储结构:
锁状态 | 25bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标志位 |
无锁状态 | 对象的hashcode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位变化。(以32位VM为例)
锁状态 | 25bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标志位 |
轻量级锁 | 指向栈中所记录的指针 | 00 | ||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | ||
GC标记 | 空 | 11 | ||
偏向锁 | 线程ID(23bit) Epoch(2bit) | 对象分代年龄 | 1 | 01 |
2. Monitor
JVM基于使用 monitorenter 和 monitorexit 指令进入和退出Monitor对象来实现方法同步和代码块同步。
monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 指令是插入到方法结束处或者异常处。
JVM要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。
线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象锁。
3. 锁的升级与对比
JKD1.6引入“偏向锁”和“轻量级锁”,共4种状态:无锁、偏向锁、轻量级锁和重量级锁。
- 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后在该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
(1)无锁 -> 偏向锁
- 首先A线程访问同步代码块,使用CAS操作将Thread ID 存储到 Mark Word
- 如果CAS成功,此时A线程获取锁
- 如果线程CAS失败,证明有别的线程持有锁,例如上图的线程B来CAS就失败,此时启动偏向锁撤销(revoke bias)
- 锁撤销流程:需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的所记录,栈中的所记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
(2)关闭偏向锁
偏向锁在Java6和7里时默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟(-XX:BiasedLockingStartupDelay=0)
如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁(-XX:-UseBiasedLocking = false) 程序默认会进入轻量级锁状态。
- 轻量级锁
(1)加锁
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储所记录的空间, 并将对象头中的 Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程自旋。
(2)解锁
解锁时,会使用原子的CAS操作把 Mark Word 替换回对象头,如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋,一旦锁升级成重量级锁,其他线程试图获取锁时,都会被阻塞,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会争取锁。 - 锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的情况 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间 同步块执行速度快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量 同步块执行 |
偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的Thread ID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
当第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。
当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
二、Java如何实现原子操作
(1)在Java中通过 锁 和 循环CAS 实现原子操作
package com.jsh.erp.exception;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<Thread>();
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
//等待所有线程执行完毕
for (Thread t : ts) {
try {
t.join();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 线程安全计数器
*/
private void safeCount() {
for (;;) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
i++;
}
}
运行结果:
98902
100000
14
从Java1.5开始,JDK并发包里提供了一些类来支持原子操作,如 AtomicBoolean(用原子方式更新boolean值)、AtomicInteger和AtomicLong,这些原子包装类还提供了有用的工具方法,比如原子的方式将当前值自增1和自减1。
(2)CAS实现原子操作的三个问题
1)ABA问题
因为CAS需要在操作值的时候检查值是否发生变化,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS检查时会发现值没有变化,但实际上却变化了。ABA问题的即决思路就是使用版本号。
在变量前追加版本号,每次更新版本号➕1,那么A -> B -> A 就会变成 1A -> 2B -> 3A。
从Java 1.5开始,JDK的Atomic包里提供了一个类 AtomicStampedReference 来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子的方式将该引用和该标志设置为给定的更新值。
2)循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来很大的执行开销。
如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间为0;第二,他可以避免在推出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3)只能保证一个共享变量的原子操作
当对一个共享变量进行原子操作时,可以使用循环CAS的方式来保证原子操作,但是对多个共享变量进行操作时,循环CAS就无法保证操作的源自行,这个时候就可以用锁。还有一个办法,就是把锁哥共享变量合并成一个共享变量进行操作。比如,有两个共享变量 i = 2, j = a,合并一下 ij = 2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicR俄飞人册类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里进行CAS操作。
(3)使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁/轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程进入同步快的时候使用CAS的方式来获取锁,当它推出同步快的时候使用CAS来释放锁。