【多线程及高并发 三】volatile & synchorized 详解

发布时间:2023年12月28日

👏作者简介:大家好,我是若明天不见,BAT的Java高级开发工程师,CSDN博客专家,后端领域优质创作者
📕系列专栏:多线程及高并发系列
📕其他专栏:微服务框架系列MySQL系列Redis系列Leetcode算法系列GraphQL系列
📜如果感觉博主的文章还不错的话,请👍点赞收藏关注👍支持一下博主哦??
?时间是条环形跑道,万物终将归零,亦得以圆全完美


多线程及高并发系列


volatile

volatile 关键字的主要作用是保证被修饰变量的可见性禁止指令重排序,从而在多线程环境下确保数据的一致性和正确性

  1. 保证线程可见性volatile 关键字保证了被修饰变量的可见性。当一个线程修改了被 volatile 修饰的变量的值时,该值会立即被写入主内存,并且其他线程在读取该变量时会从主内存中获取最新值,而不是使用自己线程的缓存值。这样可以确保不同线程之间对该变量的修改是可见的,避免了由于缓存不一致导致的数据不一致性问题。
  2. 禁止指令重排序volatile 关键字还具有禁止指令重排序的效果。编译器和处理器在对指令进行优化时,可能会对指令的执行顺序进行一定的调整,但是对于被 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();这一步被指令重排序

实际上,创建对象这一步分为三个子步骤:

  1. 分配对象的内存空间
  2. 初始化对象
  3. singleton变量指向分配的内存空间

当步骤2和步骤3被重排序,顺序从1-2-3变为1-3-2,在多线程情况下可能会出现返回一个未初始化的对象。因为需要使用 volatile 关键字防止指令重排序

内存屏障

为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。详见【多线程及高并发 一】内存模型及理论基础

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  1. 在每个 volatile 写操作的前面插入一个StoreStore屏障
  2. 在每个 volatile 写操作的后面插入一个StoreLoad屏障
  3. 在每个 volatile 读操作的后面插入一个LoadLoad屏障
  4. 在每个 volatile 读操作的后面插入一个LoadStore屏障

在实际执行时,只要不改变 volatile 写/读的内存语义,编译器可以根据具体情况省略不必要的屏障

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 字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定

synchronized

Java 中的synchronized关键字用于实现线程同步,确保在同一时间只有一个线程可以进入被 synchronized 修饰的方法或代码块,从而防止多个线程同时访问共享资源,避免出现数据竞争和并发访问的问题。

  1. 修饰实例方法,为当前实例加锁,进入同步方法前要获得当前实例的锁
  2. 修饰静态方法,为当前类对象加锁,进入同步方法前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
// 关键字在实例方法上,锁为当前实例
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关键字在同步代码块前后加入了monitorentermonitorexit这两个指令。

  • 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
}

  • 黑色的是程序顺序规则
  • 红色的是监视器锁规则:线程A释放锁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/64bitMark Word存储对象的 hashCode、分代年龄、锁标记位等
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度(如果是数组)

Mark Word 的格式:

锁状态29 bit 或 61 bit1 bit 是否是偏向锁2 bit 锁标志位
无锁001
偏向锁线程 ID101
轻量级锁指向栈中锁记录(Lock Record)的指针无作用00
重量级锁指向互斥量(堆中的 monitor(监视器)对象)的指针无作用10
GC 标记无作用11

在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器

锁状态

在JDK 1.6后,synchronied同步锁一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程使用自旋会消耗 CPU追求响应时间。同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗 CPU线程阻塞,响应时间缓慢追求吞吐量。同步块执行时间较长
偏向锁

Hotspot 的作者经过以往的研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

偏向锁撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

-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 块时,锁会按照以下升级过程进行优化:

  1. 偏向锁:如果只有一个线程访问同步块,那么锁会升级为偏向锁。这个线程可以直接获取锁,无需竞争,提高性能。
  2. 轻量级锁:当多个线程竞争同一个锁时,偏向锁会撤销,升级为轻量级锁。线程会使用乐观锁策略尝试获取锁,避免使用系统调用,提高性能。
  3. 重量级锁:如果轻量级锁获取失败,说明有其他线程竞争锁,锁会膨胀为重量级锁。这时会使用操作系统的互斥锁机制,确保线程的互斥访问,但性能较差。

锁升级

锁升级的过程是自动进行的,并且由JVM进行控制和优化,开发者无需显式干预


参考资料:

  1. Java 并发编程实战
  2. 【多线程及高并发 一】内存模型及理论基础
  3. 关键字: synchronized详解
  4. Java并发控制机制
  5. ReentrantLock与synchronized
文章来源:https://blog.csdn.net/why_still_confused/article/details/135026479
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。