【并发编程】全面解析volatile和synchronized关键字

举报
MoCrane 发表于 2024/09/11 08:16:30 2024/09/11
【摘要】 volatile可见性问题Java内存模型:在Java内存模型(JMM)中,每个线程有自己的工作内存(这是一个抽象概念,不同于物理内存中的缓存),用于存储从主内存中读取的变量副本。线程对变量的操作(如读取、写入)通常在其工作内存中进行,而不是直接在主内存中操作。当一个线程修改了工作内存中的变量副本后,新的值可能不会立即刷新回主内存。这意味着其他线程在其工作内存中读取该变量时,可能仍然看到旧值...

volatile

可见性问题

  1. Java内存模型

    • 在Java内存模型(JMM)中,每个线程有自己的工作内存(这是一个抽象概念,不同于物理内存中的缓存),用于存储从主内存中读取的变量副本。线程对变量的操作(如读取、写入)通常在其工作内存中进行,而不是直接在主内存中操作。
    • 当一个线程修改了工作内存中的变量副本后,新的值可能不会立即刷新回主内存。这意味着其他线程在其工作内存中读取该变量时,可能仍然看到旧值,而不是最新值。
  2. CPU缓存机制

    • 在物理层面上,现代CPU使用多级缓存(如L1、L2、L3)来加速内存访问。每个处理器核心可能会将主内存中的数据加载到自己的缓存中,并在缓存中进行操作。
    • 当一个线程运行在一个处理器核心上,并修改了缓存中的数据,其他处理器核心的缓存可能不会立即同步这些修改,导致其他线程看到的是旧数据。这种情况在多核处理器系统中尤为常见。
  3. 指令重排序

    • 为了优化性能,编译器和CPU可能会对指令进行重排序。例如,编译器可能会将一些操作的顺序调整,使得对变量的读取和写入操作不按程序的逻辑顺序执行。
    • 这种重排序可能导致一个线程的操作在另一个线程中以错误的顺序被观察到,特别是在没有适当的同步措施时。例如,线程A可能先执行了对变量的写操作,而线程B却在此之前读取了这个变量的旧值。

变量可见性

volatile 关键字可以强制将变量的更新立即写入主内存,并确保其他线程读取时能看到最新的值。如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

指令重排序

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

指令重排序的分类:

  1. JVM重排序:JVM在将字节码转换为机器码时,可能会进行指令的重新排列。
  2. CPU重排序:处理器在执行机器码指令时,也可能会根据当前的硬件状态(如缓存、流水线等)对指令进行重新排列。
  3. 编译器重排序:编译器在编译代码时可能会调整代码的顺序,以生成更高效的字节码。

指令重排序导致的问题

指令重排序在单线程环境中通常不会导致问题,因为编译器和处理器都会确保程序的最终结果与代码的顺序一致。然而,在多线程环境下,指令重排序可能会导致数据可见性和线程安全问题。

  • 可见性问题:由于指令重排序,一个线程可能在另一个线程未完成操作前看到中间状态的变量值,导致读取到不正确的数据。
  • 线程安全问题:当多个线程同时访问和修改共享变量时,如果没有适当的同步机制,指令重排序可能会使得线程间的操作顺序不同于预期,从而引发竞态条件,导致程序行为异常。

synchronized

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

使用方法

1.修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 

synchronized void method() {
//业务代码
}

2.修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
//业务代码
}

3.修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
//业务代码
}

构造方法可以使用synchronized吗?

不能,因为构造方法本来就是线程安全的,不存在同步的构造方法一说。

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥吗?

不互斥。因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例的锁。

加锁方式

synchronized关键字对方法加锁是通过方法的flags标志来实现的,而对同步代码块加锁则是通过monitorentermonitorexit指令来实现的。

  • 同步方法flags里面多了一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法是,会检查是否存在ACC_SYNCHRONIZED标识,如果有设置,则需要先获取监视器锁,方法执行后再释放监视器锁。
  • 同步代码块是由monitorenter获取锁,然后monitorexit释放锁。在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加一。当执行monitorexit指令时,锁的计数器也会减一。当获取锁失败时会被阻塞,一直等待锁被释放。

对象结构分析

要深入理解 synchronized 的实现原理,必须先了解 Java 对象在内存中的布局,尤其是对象头中的 Mark Word

对象内存结构

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

  • 对象头包括两部分信息:

    1. 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。synchronized就是依靠Mark Word字段来进行锁升级、获取锁等操作的。
    2. 类型指针(Klass Word):对象指向它的类元数据的指针,JVM可以通过该指针来确定这个对象是哪个类的实例。
  • 实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

  • 对齐填充不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

Mark Word

Mark Word 不仅存储了对象的基本信息,还承担着锁状态的管理职责,包括锁的获取、升级、释放等操作。 正是通过对 Mark Word 的精细控制,Java 虚拟机(JVM)才能高效地实现线程同步机制,从而保障并发环境下的数据一致性和程序的正确性。

在Java中,锁的状态分为四种,分别是无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

Mark Word的低两位用于表示锁的状态,分别为"01"(无锁状态)、“01”(偏向锁状态)、“00”(轻量级锁状态)和"10"(重量级锁状态)。但是由于无锁状态和偏向锁都是”01",所以在低三位引入偏向锁标记位用"0"表示无锁,"1"表示偏向。

锁升级过程

对于synchronized关键字,一共有四种状态:无锁状态偏向锁状态轻量级锁状态重量级锁状态(级别从低到高)。

偏向锁

偏向锁提升性能的经验依据是:**对于绝大部分锁,在整个同步周期内不仅不存在竞争,而且总由同一线程多次获得。**偏向锁会偏向第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程不需要再进行同步。这使得线程获取锁的代价更低。

触发条件: 如果JVM启动参数没有禁用偏向锁,那么首次进入synchronized块时会自动开启。

偏向锁的获取过程:

  • 初始状态: 当一个对象刚创建时,它处于无锁状态。此时,Mark Word 中包含对象的哈希码和其他一些标记信息。
  • 线程尝试加锁: 当线程第一次访问这个对象并进入 synchronized 块时,JVM 会将 Mark Word 中的锁标志位设置为偏向锁,并将该线程的 ID 记录在 Mark Word 中。这样,锁就“偏向”于这个线程。
  • 偏向锁加锁成功: 如果同一线程再次访问该对象的 synchronized 块,JVM 检查 Mark Word 中的线程 ID,如果与当前线程的 ID 匹配,则认为该线程已经持有了锁,无需执行任何额外操作,直接进入同步块。这避免了加锁和解锁的开销。

偏向锁的释放过程:

  • 解锁时: 当持有偏向锁的线程退出 synchronized 块时,锁不会立即释放,Mark Word 中的线程 ID 和偏向标记位保持不变,仍然表示锁偏向于该线程。
  • 锁重入: 如果偏向锁的线程再次进入 synchronized 块,可以直接重用这个锁而无需加锁操作。

  • 锁撤销(偏向锁失效): 如果另一线程尝试访问同一个对象的synchronized块,JVM 发现Mark Word 中的线程 ID 与当前线程不符,偏向锁就会被撤销。偏向锁撤销的过程包括:

    • 偏向锁升级为轻量级锁:JVM 会暂停持有偏向锁的线程,撤销偏向锁,将 Mark Word 更新为指向轻量级锁的状态,并将锁记录在新的线程的栈中。
    • 新线程继续尝试获取锁:如果当前没有其他线程竞争锁,新的线程将获取轻量级锁;否则,锁将进一步升级为重量级锁。

轻量级锁

轻量级锁是相对基于OS的互斥量实现的重量级锁而言的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用OS的互斥量而带来的性能消耗。

轻量级锁提升性能的经验依据是:对于绝大部分锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁就可以使用 CAS 操作避免互斥量的开销,从而提升效率。

触发条件: 当有另一个线程尝试获取已被偏向的锁时,偏向锁会升级为轻量级锁。

轻量级锁的加锁过程:

  • 创建锁记录(Lock Record): 当线程进入同步代码块时,JVM 会在当前线程的栈帧中创建一个锁记录(Lock Record)。这个锁记录用于存储对象当前的 Mark Word 的副本,称为 Displaced Mark Word。此时,锁记录中的 _owner 指针会指向对象的 Mark Word

  • CAS 尝试加锁: JVM 使用 CAS(Compare-And-Swap)操作尝试将对象头中的 Mark Word 更新为指向线程栈中 Lock Record 的指针。如果 CAS 操作成功,则表明当前线程获取了轻量级锁,锁的标志位(lock state bits)将更新为 00,表示轻量级锁。

  • 加锁成功: 若 CAS 操作成功,表示该线程已经拥有了该对象的锁,可以安全地进入同步代码块执行。

  • 加锁失败(锁竞争): 如果 CAS 操作失败,JVM 会检查对象的Mark Word 是否指向当前线程的栈帧中的锁记录:

    • 当前线程已拥有锁: 如果 Mark Word 已经指向当前线程的锁记录,说明该线程已拥有锁,可以直接进入同步代码块继续执行。
    • 锁被其他线程持有: 如果 Mark Word 指向的是其他线程的锁记录,说明锁被其他线程占用。此时,当前线程会尝试通过 自旋 一定次数获取锁。如果在多次自旋后 CAS 仍然失败,轻量级锁会升级为 重量级锁(标志位更新为 10),并将 Mark Word 指向重量级锁的指针。之后,未获得锁的线程将进入阻塞状态,等待锁的释放。

轻量级锁的解锁过程:

  • CAS 释放锁: 当线程退出同步代码块时,JVM 会使用 CAS 操作,将线程中锁记录的 Displaced Mark Word 恢复到对象的 Mark Word,即通过 CAS 将对象头的 Mark Word 替换为最初的值。

  • 解锁成功: 如果 CAS 操作成功,意味着锁成功释放,整个同步过程完成。

  • 解锁失败(锁竞争): 如果 CAS 操作失败,说明有其他线程正在尝试获取该锁,或者锁已经升级为重量级锁。在这种情况下,JVM 会在释放锁的同时,唤醒等待该锁的阻塞线程。

重量级锁

监视器锁实现

synchronized 关键字用于实现线程同步,而它的底层是通过**监视器锁(Monitor)**来实现的。无论是对方法加锁还是对同步代码块加锁,JVM 都是依赖 monitor 机制来保证同步。在进入同步代码块之前,线程必须先获取监视器锁。成功获取锁后,锁的计数器增加 1;执行完同步代码后,计数器减少 1。如果线程无法获取锁,它会进入阻塞状态,直到锁被释放。

在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),其跟同步相关的数据结构如下:

ObjectMonitor() {
_count        = 0; //用来记录该对象被线程获取锁的次数
_waiters      = 0;
_recursions   = 0; //锁的重入次数
_owner        = NULL; //指向持有ObjectMonitor对象的线程 
_WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock  = 0 ;
_EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_owner区域并把 monitor 中的_owner变量设置为当前线程,同时 monitor 中的计数器count加一,即获得对象锁。

若持有 monitor 的线程调用wait()方法,将释放当前持有的 monitor,_owner变量恢复为null_count自减一,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。

加解锁

监视器锁monitor本质上是依赖操作系统的 Mutex Lock 互斥量 来实现的,我们一般称之为重量级锁。因为 OS 实现线程间的切换需要从用户态转换到核心态,这个转换过程成本较高,耗时相对较长,因此synchronized效率会比较低。

重量级锁的锁标志位为’10’,指针指向的是monitor对象的起始地址。

只有在使用重量级锁时,才会涉及到 monitor 及线程状态的控制。线程的生命周期分为五个状态,分别是初始状态(new)、运行状态(runnable)、等待状态(waiting)、阻塞状态(blocking)和终止状态(terminated)。

触发条件: 当轻量级锁的CAS操作失败,轻量级锁升级为重量级锁。

重量级锁的加锁过程:

  • 线程进入阻塞队列: 如果锁已被其他线程持有,后续尝试获取该锁的线程会进入 ObjectMonitor 对象的 _EntryList 队列,处于阻塞状态等待锁。
  • 线程获取锁进入运行状态: 成功获取锁的线程会从 _EntryList 中移出,进入运行状态。此时,ObjectMonitor  _owner 字段指向该线程,_count 加一。如果线程重入锁,_count 递增。
  • 调用 wait() 方法进入等待状态: 线程调用 wait() 时,释放 monitor 锁,进入等待状态_owner 置为 null_count 减一。线程进入 _WaitSet 队列,等待 notify()  notifyAll() 唤醒。
  • 线程被唤醒后重新竞争锁: 被唤醒的线程从 _WaitSet 移至 _EntryList 队列,重新竞争锁。获取锁后,进入运行状态_owner 指向该线程。

重量级锁的解锁过程:

  • 线程正常退出同步代码块: 线程退出同步代码块时,_count 减一。若 _count 为零,表示锁完全释放。
  • 释放锁和唤醒其他线程: 如果 _count 为零,_owner 置为 null。如果 _EntryList 队列中有其他线程,JVM 会唤醒其中一个,让其尝试获取锁。
  • 线程退出或等待结束: 线程执行完同步代码或被唤醒后,若锁可用,获取锁并继续执行,否则重新进入阻塞队列。锁释放时,_owner 置为 null,其他线程有机会获取锁。

锁优化

自旋锁

  • 获取轻量级锁时: 即当一个线程尝试获取一个被其他线程持有的轻量级锁时,会自旋等待锁的持有者释放锁。

    • OpenJDK 8中,轻量级锁的自旋默认是开启的,最多自旋15次,每次自旋的时间逐渐延长。如果15次自旋后仍然没有获取到锁,就会升级为重量级锁。
  • 获取重量级锁时: 即当一个线程尝试获取一个被其他线程持有的重量级锁时,它会自旋等待锁的持有者释放锁。

    • OpenJDK 8中,默认情况下不会开启重量级锁自旋。如果线程在尝试获取重量级锁时,发现该锁已经被其他线程占用,那么线程会直接阻塞,等待锁被释放。如果锁被持有时间很短,可以考虑开启重量级锁自旋,避免线程挂起和恢复带来的性能损失。
  • 自适应自旋: JDK6之后的版本中,JVM引入了自适应的自旋机制。该机制通过监控轻量级锁自旋等待的情况,动态调整自旋等待的时间。

    • 如果自旋等待的时间很短,说明锁的竞争不激烈,当前线程可以自旋等待一段时间,避免线程挂起和恢复带来的性能损失。如果自旋等待的时间较长,说明锁的竞争比较激烈,当前线程应该及时释放CPU资源,让其他线程有机会执行。

锁消除

锁消除是JVM在编译或即时编译(JIT)过程中通过逃逸分析判断锁对象是否只在单个线程中使用。如果确定该锁对象不会逃逸到其他线程,即它只在当前线程中被使用,JVM会自动移除这些不必要的同步锁,从而减少锁的开销。

锁粗化

锁粗化是指将多个连续的、临近的小范围锁操作合并为一个更大的锁操作,以减少加锁和解锁的频率,从而提高性能。锁粗化的主要目的是避免在短时间内频繁进行锁的获取和释放操作,因为每次加锁和解锁都带有一定的开销。

两者比较

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。