synchronized原理

什么是synchronized关键字?

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的。

Java内存的可见性问题

在了解synchronized关键字的底层原理前,需要先简单了解下Java的内存模型,看看synchronized关键字是如何起作用的。

Java内存模型

这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。

这里补充两个问题:

1.为什么要引入这么多内存?

2.为什么要这样拷贝来拷贝去?

1.为什么要引入这么多内存?

这里其实只有主内存才是硬件角度上的真正的内存,而工作内存,则是值CPU的寄存器和缓存器,是一种抽象的叫法.

2.为什么要这样拷贝来拷贝去?

因为CPU访问寄存器的速度远远快于访问内存(快了几千上万倍).

比如在代码在,需要连续读取某个变量的值,如果每次都从内存中读取,那么速度是很慢的.

但如果第一次从内存中读取后就缓冲到寄存器中,后续读取就不用再访问内存,效率就提高了

当线程A要去读取一个变量 X 时,它会先将这个变量拷贝到工作内存中,然后再从工作内存中读取变量,

而当线程A要修改一个共享变量时,也是先修改工作内存的副本,然后再同步到主内存中

而如果,此时线程B也去修改共享变量X,并且把X改为了2,这个时候线程A的工作内存中X的值还是1,但主内存已经修改为2了,此时线程A去获取变量还是从工作内存中获取到的X=1,但此时主内存中X的值为2,到此出现了所谓内存不可见的问题。

一般的当某个线程正在使用对象状态(如例子中的变量X),而另一个线程在同时修改该状态,需要确保当一个线程修改了对象的状态后,其他线程能够立即看到发生的状态变化,这就是内存可见性.

所以为什么会出现可见性错误呢?

因为线程之间的交互是发生在主内存中的,但对于变量的修改又发生在自己的工作内存中,

当读写操作在不同线程在执行时,我们无法确保读操作的线程能够适时地看到其他线程写入的值,

这就会发生可见性错误

该问题Java内存模型是通过synchronized关键字和volatile关键字就可以解决,那么synchronized关键字是如何解决的呢?

其实 **synchronized就是把在加了 synchronized代码块内使用到的变量从线程的本地内存中擦除,这样在 synchronized**块中再次使用到该变量就不能从本地内存中获取了,需要从主内存中获取,解决了内存不可见问题。

synchronized关键字三大特性是什么?

面试时经常拿synchronized关键字和volatile关键字的特性进行对比,synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized

  • 原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。
  • 可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行 lockunlock原子操作,保证可见性。
  • 有序性:程序的执行顺序会按照代码的先后顺序执行。

synchronized关键字可以实现什么类型的锁?

  • 悲观锁synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
  • 非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
  • 可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
  • 独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

synchronized主要有三种使用方式:修饰普通同步方法、修饰静态同步方法、修饰同步方法块,这里就不一一展示。

synchronized关键字的底层原理

在jdk1.6之前,synchronized被称为重量级锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁轻量级锁。下面先介绍jdk1.6之前的synchronized原理。

在 JVM 中,Java对象保存在堆中时,由以下三部分组成:

  • 对象头(object header):包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。
  • 实例数据(Instance Data):主要是存放类的数据信息,父类的信息,对象字段属性信息。
  • 对齐填充(Padding):为了字节对齐,填充的数据,不是必须的。

暂时无法在飞书文档外展示此内容

因为synchronized用的锁是存在对象头里的,这里我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark WordKlass PointLength field 组成,如果对象头非数组类型,对象头则由Mark WordKlass Point组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:

内容 说明 长度 bit
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。 32
Klass Point 即类型指针,储存对象类型的指针 32
Length field 数组长度 32

这里我们需要重点掌握的是Mark Word。

Mark Word

在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:

其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的。

这里我用一个小案例模拟一下,当添加synchronized 关键字后,代码在编译时会发生什么变化

Java 复制代码
public void test(Object obj) {
    synchronized (obj) {
        System.out.println("Hello");
    }
}

在代码运行完后,我们进入Class文件夹,执行javap -p -v -c Test2.class进行反汇编

YAML 复制代码
0: aload_1             // 加载对象 obj 到操作数栈
1: dup                 // 复制栈顶值(obj 的引用)
2: astore_2            // 存储到局部变量表(保存锁对象)
3: monitorenter        // 进入监视器(获取锁)
4: getstatic #2        // 获取 System.out 静态字段
7: ldc #3              // 加载字符串 "Hello"
9: invokevirtual #4    // 调用 println 方法
12: aload_2
13: monitorexit         // 正常退出时释放锁
14: goto 22
17: astore_3           // 异常处理块
18: aload_2
19: monitorexit         // 异常退出时释放锁
20: aload_3
21: athrow
22: return

修饰synchronized方法时

Java 复制代码
public class SynchronizedMethodTest {
   
    static int cnt = 0;
    public synchronized void cntUpdate() {
   
        cnt++;
        System.out.println("修改cnt后的数值是:" + cnt);
    }
}

一样,我们反编译后可以看到 ACC_SYNCHRONIZED标识

C++ 复制代码
public synchronized void cntUpdate();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED         // 重点是这个!!ACC_SYNCHRONIZED
Code:
  stack=3, locals=1, args_size=1
     0: getstatic     #2                  // Field cnt:I
     3: iconst_1
     4: iadd
...

写到这里,你是否会好奇,monitorenter和monitorexit是来干嘛的?

Monitor

在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的,首先我们先下载Hotspot的源码,源码下载链接:hg.openjdk.java.net/jdk8/jdk8/h...,找到ObjectMonitor.hpp文件,路径是src/share/vm/runtime/objectMonitor.hpp,这里只是简单介绍下其数据结构

C++ 复制代码
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
    _waiters      = 0, //等待线程数
    _recursions   = 0; //锁的重入次数
    _object       = NULL; 
    _owner        = NULL; //指向持有ObjectMonitor对象的线程地址
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //阻塞在EntryList上的单向线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    }

其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图

  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
  2. 当获取锁的线程调用wait()方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。
  • 总而言之,monitorenter/monitorexit 指令主要做了如下事情:
    • monitorenter:尝试获取 Monitor,成功则成为 Owner。
    • monitorexit:释放 Monitor,唤醒 EntryList 中的线程。
  • 异常处理:即使抛出异常,monitorexit仍会被执行以保证锁释放。

而同步方法中**ACC_SYNCHRONIZED,**原理也是一样的JVM 会在方法调用前隐式调用 monitorenter,在方法返回(包括正常返回和异常返回)时隐式调用 monitorexit

JDK1.6 之后

锁升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种.

无锁 → (单线程访问) → 偏向锁 → (竞争发生) → 轻量级锁 → (自旋失败) → 重量级锁

无锁

场景:对象刚被创建,未有任何线程竞争。

特点:所有线程均可直接访问同步块,无锁竞争

偏向锁

核心优化 :消除无竞争时的同步开销。同一个线程进入同步代码块时,直接检查线程ID,发现MarkWord中的线程ID和当前一致。无需任何同步操作(如CAS、操作系统互拆),直接执行代码。

触发条件 :当一个线程进入synchronized同步代码块时,JVM检查当前对象处于 无锁状态(锁标志为001)。

流程

  1. 检查对象是否可偏向:JVM检查对象头MarkWord,锁标志为是否为001?是否已偏向过?如果已经偏向过,会走偏向锁的撤销流程,升级为轻量级锁。(不要急,下面会细说偏向锁的撤销)
  2. cas设置偏向锁:如果对象从未被偏向,JVM执行CAS操作,讲当前线程ID 写入MarkWord并将锁标志从001改为偏向模式101。如果cas失败,说明有别的线程竞争,升级为轻量级锁。
  • 撤销条件:检测到竞争时升级为轻量级锁。

一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。

偏向锁的延迟启用

JVM 默认在程序启动后 4秒(可通过 -XX:BiasedLockingStartupDelay 设置)才启用偏向锁。 目的:避免启动阶段因类加载、初始化等操作导致的频繁偏向锁撤销。

JDK 15 后默认禁用偏向锁(-XX:-UseBiasedLocking)

被移除的原因:JEP 374: Deprecate and Disable Biased Locking

简单来说就是:偏向锁为整个「同步子系统」引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件。

轻量级锁

偏向锁升级为轻量级锁,又称为偏向锁的撤销(Revoke)。

触发条件:JVM 检测到当前锁已偏向其他线程,触发 偏向锁撤销,并升级为轻量级锁。

核心优化:通过CAS自旋避免线程阻塞。

流程:

  1. 暂停原持有线程:JVM出发安全点(Safe Point),暂停原持有线程(STW,stop the world,STW 会导致短暂停顿,高并发场景下频繁撤销偏向锁可能降低性能。),确保原持有线程的状态稳定。防止在撤销的过程中修改对象头或执行同步代码块。
  2. 检查原持有线程锁状态是否已经退出同步代码块: a. 原持有线程退出同步代码块:将对象头恢复为 无锁状态,允许其他线程重新竞争;其他线程可以参试CAS直接获取偏向锁。 b. 原持有线程仍然在同步代码中:
    1. 创建Lock Record在原持有线程栈帧中分配一个Lock Record,;CAS更新对象头,将原对象头的Mark Word复制到Lock Record中(备份原状态);,并将锁标志位改为 00;最后唤醒原持有线程,继续执行同步代码块。其他线程,通过 自旋(Spin) 尝试获取轻量级锁(CAS 替换对象头,对象头指向自己的 Lock Record)。

    2. 暂时无法在飞书文档外展示此内容

自旋锁

Java锁的几种状态并不包括自旋锁,当轻量级锁的竞争就是采用的自旋锁机制。

什么是自旋锁:当线程A已经获得锁时,线程B再来竞争锁,线程B不会直接被阻塞,而是在原地循环 等待,当线程A释放锁后,线程B可以马上获得锁。

引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。

自旋锁的缺点:自旋等待虽然可以避免线程切花的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,自旋锁的效果就比较好,如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数-XX:PreBlockSpin调整,一般默认为10。

自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。

重量级锁

触发条件:线程自旋等待超过阈值,多个线程尝试获取锁,自旋等待期见锁未被释放。

核心机制:依赖操作系统互斥量(mutex)实现阻塞与唤醒。

流程:

  1. 自旋失败:自旋次数超过动态阈值,或新线程加入竞争。
  2. 创建重量级线锁:JVM为对象分配一个Monitor对象,
  3. 修改对象头:Mark Word指向这个Monitor对象;锁标志位从轻量级锁的 00 改为重量级锁的 10
  4. 阻塞竞争线程:所有未获取锁的线程进入_EntryList队列,由操作系统调度为 阻塞状态。线程从用户态切换到内核态,依赖操作系统的互斥锁(Mutex)实现阻塞。

整体流程如下图:

总结

在JDK1.6之前,synchronized是通过MarkWord(对象头)和Monitor实现线程同步的,具体实现呢则是通过进出Monitor对象实现的代码块同步和方法同步的

其中代码块通过Monitorenter和Monitorexit的指令来实现,而方法同步则是去识别一个Access 标识来实现,其实也是通过Monitor实现.

1.6之后对锁进行了升级,即无锁(001)->偏向锁(101)->(出现竞争)轻量级锁(00)->(CAS失败)重量级锁(10)

相关推荐
柏油31 分钟前
可视化 MySQL binlog 监听方案
数据库·后端·mysql
舒一笑44 分钟前
Started TttttApplication in 0.257 seconds (没有 Web 依赖导致 JVM 正常退出)
jvm·spring boot·后端
M1A11 小时前
Java Enum 类:优雅的常量定义与管理方式(深度解析)
后端
AAA修煤气灶刘哥2 小时前
别再懵了!Spring、Spring Boot、Spring MVC 的区别,一篇讲透
后端·面试
柏油2 小时前
MySQL 字符集 utf8 与 utf8mb4
数据库·后端·mysql
程序猿阿越2 小时前
Kafka源码(三)发送消息-客户端
java·后端·源码阅读
javadaydayup2 小时前
Apollo 凭什么能 “干掉” 本地配置?
spring boot·后端·spring
似水流年流不尽思念2 小时前
Spring MVC 中的 DTO 对象的字段被 transient 修饰,可以被序列化吗?
后端·面试
武子康2 小时前
大数据-70 Kafka 日志清理:删除、压缩及混合模式最佳实践
大数据·后端·kafka