👏作者简介:大家好,我是若明天不见,BAT的Java高级开发工程师,CSDN博客专家,后端领域优质创作者
📕系列专栏:多线程及高并发系列
📕其他专栏:微服务框架系列、MySQL系列、Redis系列、Leetcode算法系列、GraphQL系列
📜如果感觉博主的文章还不错的话,请👍点赞收藏关注👍支持一下博主哦??
?时间是条环形跑道,万物终将归零,亦得以圆全完美
多线程及高并发系列
volatile 关键字的主要作用是保证被修饰变量的可见性和禁止指令重排序,从而在多线程环境下确保数据的一致性和正确性
volatile 关键字解决的是可见性和指令重排序问题,但不能保证原子性。如果需要实现线程安全的原子操作,可以考虑使用synchronized
关键字或者java.util.concurrent.atomic
包中提供的原子类
volatile 修饰的变量,汇编代码会多执行
lock addl $0x0,(%rsp)
操作,相当于内存屏障。lock指令的底层实现:如果支持缓存行会加缓存锁(MESI);如果不支持缓存锁,会加总线锁
public class TestVolatile {
private static boolean stop = false;
public static void main(String[] args) {
new Thread("Thread A") {
@Override
public void run() {
while (!stop) {
}
System.out.println(Thread.currentThread() + " stopped");
}
}.start();
// Thread-main
try {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread() + " after 1 seconds");
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
}
}
stop变量没有使用volatile修饰,在主线程里的赋值无法被其他线程所“看到”,没能保证线程间的可视性
volatile 保证线程可见性,底层依赖硬件,即CPU缓存一致性协议实现,常见的是MESI协议
标准的单例模式也会使用 volatile 关键字以禁止指令重排序,以免发生线程不安全的问题
public class Singleton {
/**
* volatile保证多个线程不缓存此变量
*/
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
使用 volatile 关键字是为了防止singleton = new Singleton();
这一步被指令重排序
实际上,创建对象这一步分为三个子步骤:
singleton
变量指向分配的内存空间当步骤2和步骤3被重排序,顺序从1-2-3变为1-3-2,在多线程情况下可能会出现返回一个未初始化的对象。因为需要使用 volatile 关键字防止指令重排序
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。详见【多线程及高并发 一】内存模型及理论基础
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
StoreStore
屏障StoreLoad
屏障LoadLoad
屏障LoadStore
屏障在实际执行时,只要不改变 volatile 写/读的内存语义,编译器可以根据具体情况省略不必要的屏障
缓存行(cache line) 是缓存中可以分配的最小存储单位。缓存行是内存和 CPU 缓存之间的数据传输单位,通常为 64 字节(具体大小可能与硬件和架构相关)
著名的Java并发编程大师 Doug lea 在 JDK 7 的并发包里新增一个队列集合类
LinkedTransferQueue
,它在使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能
/** 队列中的头部节点 */
private transient final PaddedAtomicReference<QNode> head;
/** 队列中的尾部节点 */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference T> {
// 使用很多4个字节的引用追加到64个字节
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) { super(r); }
}
public class AtomicReference <V> implements java.io.Serializable {
private volatile V value;
// code
}
Doug lea 使用追加到 64 字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定
Java 中的synchronized
关键字用于实现线程同步,确保在同一时间只有一个线程可以进入被 synchronized 修饰的方法或代码块,从而防止多个线程同时访问共享资源,避免出现数据竞争和并发访问的问题。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
synchronized
关键字在JDK 1.5 前本质上是一把悲观锁。JDK 1.5 之后进行了优化,引入了锁升级的概念。在多线程竞争不激烈的情况下,锁会从无锁状态逐渐升级为偏向锁、轻量级锁,最后升级为重量级锁,以提高性能
值得注意的是,在使用虚拟线程时,synchronized
会导致虚拟线程及其作为载体的平台线程同时被阻塞。详细分析可见 【多线程及高并发 番外篇】虚拟线程怎么被 synchronized 阻塞了?
synchronized
关键字在同步代码块前后加入了monitorenter
和monitorexit
这两个指令。
monitorenter
指令会获取锁对象,如果获取到了锁对象,就将锁计数器加1,未获取到则会阻塞当前线程。monitorexit
指令会释放锁对象,同时将锁计数器减1
该图可以看出,任意线程对Object
的访问,首先要获得Object
的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器
可重入性是指当一个线程已经持有某个锁时,它可以再次获取同一个锁,而不会因为自己已经持有锁而被阻塞。在其他线程看来,这个锁仍然是被持有的状态
synchronized 关键字在 Java 中的可重入性(Reentrant)是基于加解锁原理的计数器累加递减实现的。这种可重入的机制确保了线程在递归调用或者多层嵌套的 synchronized 块中可以反复获取锁而不会造成死锁
public class ReentrantExample {
public synchronized void outer() {
System.out.println("外部方法");
inner();
}
public synchronized void inner() {
System.out.println("内部方法");
}
}
可重入性只适用于同一个线程对同一个对象的锁的重入,不同线程之间的锁重入是不允许的
synchronized
关键字保证可见性的原理,是基于内存模型及happens-before
规则实现的。详细可见【多线程及高并发 一】内存模型及理论基础 中happens-before
的监视器锁规则
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
happens-before
线程B加锁程序顺序规则:2 happens-before 3,4 happens-before 5;监视器规则:3 happens-before 4;传递规则:2 happens-before 5
synchronized 用的锁是存在 Java 对象头里的。 如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode、分代年龄、锁标记位等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
Mark Word 的格式:
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁 | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程 ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录(Lock Record )的指针 | 无作用 | 00 |
重量级锁 | 指向互斥量(堆中的 monitor(监视器)对象)的指针 | 无作用 | 10 |
GC 标记 | 无作用 | 11 |
在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器
在JDK 1.6后,synchronied
同步锁一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程使用自旋会消耗 CPU | 追求响应时间。同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量。同步块执行时间较长 |
Hotspot 的作者经过以往的研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:
如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
-XX:UseBiasedLocking=false
轻量级锁是在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来的线程开销。从而提高并发性能
线程在执行同步块之前,JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word
。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的Displaced Mark Word
里面。
然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁
在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程
重量级锁是通过操作系统的互斥机制来实现的。如果锁已经被其他线程持有,那么该线程会被阻塞,并且操作系统会将其置于等待状态。只有当锁被释放时,操作系统才会唤醒等待的线程,使其能够继续执行
锁升级的目的是在无竞争和低竞争情况下提高性能,减少不必要的线程切换和阻塞。当竞争激烈时,锁会升级为重量级锁,以保证线程安全性
当一个线程进入 synchronized 块时,锁会按照以下升级过程进行优化:
锁升级的过程是自动进行的,并且由JVM进行控制和优化,开发者无需显式干预
参考资料: