您当前的位置: 首页 > 

庄小焱

暂无认证

  • 3浏览

    0关注

    805博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

并发编程——synchronized原理

庄小焱 发布时间:2022-05-15 15:41:21 ,浏览量:3

摘要

在JUC并发编程中synchronized关键字具有非常重要的作用,同时JDK中大量的应用。synchronized,即俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。本博文将详细介绍synchronized原理和底层实现。

一、synchronized的作用

为了避免临界区的竞态条件发生,有多种手段可以达到目的

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

synchronized的三个作用

  • 原子性:确保线程互斥的访问同步代码
  • 可见性:保证共享变量的修改能够及时可见
  • 有序性:有效解决重排序问题
二、synchronized的语法
class Test1{
    public synchronized void test() {
    }
}
 
//等价于
class Test1{
    public void test() {
        //锁的是当前对象
        synchronized(this) {
        }
    }
 
}
class Test2{
    public synchronized static void test() {
    }
}
 
//等价于
class Test2{
    public static void test() {
        //锁的是类对象,类对象只有一个
        synchronized(Test2.class) {
        }
    }
}
三、Monitor原理 3.1 Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:

  1. 实例数据:存放类的属性数据信息,包括父类的属性信息;
  2. 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
  3. 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Synchronized用的锁就是存在Java对象头里的,那么什么是Java对象头呢?

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。 Java对象头具体结构描述如下:

Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

 对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:

 在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

3.2 Monitor结构原理

Monitor 被翻译为监视器或管程,每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word中就被设置指向 Monitor 对象的指针。

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。
  • 注意:不加 synchronized 的对象不会关联监视器
四、synchronized的原理

通过对Java代码进行反编译可知,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因?

答案:从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高。锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

偏向锁:当一个线程第一次获取到锁之后,再次申请就可以直接取到锁

轻量级锁:没有多线程竞争,但有多个线程交替执行

重量级锁:有多线程竞争,线程获取不到锁进入阻塞状态

4.1 锁的基本介绍 4.1.1 偏向锁

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

调用了对象的 hashCode,但偏向锁的对象MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销

  • 轻量级锁会在锁记录中记录 hashCode
  • 重量级锁会在 Monitor 中记录 hashCode

引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

那么偏向锁是如何来减少不必要的CAS操作呢?首先我们看下无竞争下锁存在什么问题:

现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。

CAS为什么会引入本地延迟?

这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:

SMP(对称多处理器)架构:其意思是 所有的CPU会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”。

而CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

例如:Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。

而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

4.1.2 轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是 synchronized。

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

4.1.3 锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

4.1.4 重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的(上面已经提到相关原理)。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。

4.1.5 自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

所以引入自旋锁,何谓自旋锁?所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

4.1.6 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但我们将StringBuffer作为一个局部变量使用,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

4.1.7 锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

4.2 synchronized锁的编译原理

synchronized不论是修饰静态方法、实例方法或者是代码块,最后锁住的要么是实例化后的对象,要么是一个类。对于修饰一个(静态/实例)方法时,JVM会在字节码层面给该方法打上一个ACC_SYNCHRONIZE标识,当有线程访问这个方法时,都会尝试去获取对象的objectMonitor对象锁,得到锁的线程才能继续访问该方法。修饰代码块时,JVM会在字节码层面给方法块入口处加monitorenter,出口处添加monitorexit标识,一般出口有两个,正常出口和异常出口,所以一般1个monitorenter对应2个monitorexit。线程执行到monitorenter处就需要尝试获取objectMonitor对象锁,获取不到就会一直阻塞,获取到了才能继续运行。

 ObjectMonitor() {
 	_header       = NULL;
     _count        = 0; // 记录个数
     _waiters      = 0,
     _recursions   = 0;
     _object       = NULL;
     _owner        = NULL;
     _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
     _WaitSetLock  = 0 ;
     _Responsible  = NULL ;
     _succ         = NULL ;
     _cxq          = NULL ;
     FreeNext      = NULL ;
     _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
     _SpinFreq     = 0 ;
     _SpinClock    = 0 ;
     OwnerIsThread = 0 ;
 }
4.3 synchronized锁的升级原理

在JDK6之后,锁被优化为无锁、偏向锁、轻量级锁和重量级锁。在编译过程中有锁粗化,锁消除,在运行时有锁升级。

  1. 锁粗化:如果虚拟机探测到有一系列的连续操作都对同一个对象加锁,甚至加锁操作出现在循环中,那么将会把加锁同步范围扩展到整个操作的外部,这就是锁粗化。
  2. 锁消除:经过逃逸分析后,发现同步代码块不可能存在共享数据竞争的情况,那么就会将锁消除。逃逸分析,主要是分析对象的动态作用范围,比如在一个方法里一个对象创建后,在调用外部方法时,该对象作为参数传递到其他方法中,成为方法逃逸;当被其他线程访问,如赋值给其他线程中的实例变量,则成为线程逃逸。
  3. 锁升级:JD6之后分为无锁,偏向锁,轻量级锁,重量级锁。其中偏向锁->轻量级锁->重量级锁的升级过程不可逆。

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。

  1. 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;
  2. 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
  3. 如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;

锁标志位的表示意义

  1. 锁标识 lock=00 表示轻量级锁
  2. 锁标识 lock=10 表示重量级锁
  3. 偏向锁标识 biased_lock=1表示偏向锁
  4. 偏向锁标识 biased_lock=0且锁标识=01表示无锁状态
4.3.1 无锁升级为偏向锁
  1. 线程访问同步代码块,判断锁标识位(01)
  2. 判断是否偏向锁
  3. 否,CAS操作替换线程ID
  4. 成功,获得偏向锁
4.3.2 偏向锁升级为轻量级锁
  1. 线程访问同步代码块,判断锁标识位(01)
  2. 判断是否偏向锁
  3. 是,检查对象头的markword中记录的是否是当前线程ID
  4. 是,获得偏向锁
  5. 不是,CAS操作替换线程ID
  6. 成功,获取偏向锁
  7. 失败,线程进入阻塞状态,等待原持有线程到达安全点
  8. 原持有线程到达安全点,检查线程状态
  9. 已退出同步代码块,释放偏向锁
  10. 未退出代码块,升级为偏向锁,在原持有线程的栈中分配lock record(锁记录),拷贝对象头中的markword到lock record中,对象头中的markword修改为指向线程中锁记录的指针,升级成功
  11. 唤醒线程继续执行
4.3.3 轻量级锁升级为重量级锁
  • 线程访问同步代码块,判断锁标识位(00)
  • 判断是否轻量级锁
  • 是,当前线程的栈中分配lock record
  • 拷贝对象头中的markword到lock record中
  • CAS操作尝试获取将对象头中的锁记录指针指向当前线程的锁记录
  • 成功,当前线程得到轻量级锁
  • 执行代码块
  • 开始轻量级锁解锁
  • CAS操作,判断对象头的锁记录指针是否仍指向当前线程锁记录,拷贝在当前线程锁记录的mark word信息与当前线程的锁记录指针是否一致
  • 两个条件都一致,释放锁
  • 不一致,释放锁(锁已经升级为重量级锁了),唤醒其他线程
  • 5失败,自旋尝试5、
  • 自旋过程中成功了,执行6,7,8,9,10,11
  • 自旋一定次数仍然失败,升级为重量级锁

博文参考

synchronized原理_须佐能乎!的博客-CSDN博客_synchronized原理

关注
打赏
1657692713
查看更多评论
立即登录/注册

微信扫码登录

0.0953s