面试必备之synchronized

概述

synchronized,关键字,可以使用在以下场景:

  1. 普通方法,锁是当前实例对象
  2. 静态方法,锁是当前类的class对象
  3. 同步方法块,锁是括号里面的对象:
    1. 对实例对象加锁synchronized (this),锁是当前实例对象
    2. 对Class加锁synchronized (Xxx.class),锁是当前类的class对象

实现原理

依据上面的不同使用场景,有不太完全一样的实现原理。

  • 修饰方法
    反编译字节码文件,可看到在方法的flags中设置ACC_SYNCHRONIZED访问标识。每个对象都与一个monitor相关联,当且仅当monitor被线程持有时,monitor处于锁定状态。当方法执行时,线程将先尝试获取对象相关联的monitor所有权,然后再执行方法,最后在方法完成(无论是正常执行还是非正常执行)时释放monitor所有权。在方法执行期间,线程持有monitor所有权,其它任何线程都无法再获得同一个对象相关联的monitor所有权。
  • 修饰代码块
    反编译字节码文件,在逻辑代码前添加monitorenter指令,在逻辑代码尾添加monitorexit指令。当方法执行时,当前线程执行monitorenter指令尝试获取对象相关联的monitor所有权时,如果此时这个monitor的计数器是0,那么当前线程持有该monitor,同时monitor计数器设置为1;如果当前线程已经持有对象相关联的monitor所有权,只是想重新获取,那么继续持有该monitor,同时monitor计数器加1;如果有其它线程已经持有对象相关联的monitor所有权,当前线程阻塞,直到monitor计数器为0,再次尝试获取所有权。方法正常执行或发生异常时,会执行monitorexit指令,释放monitor所有权,monitor计数器减1。

当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中。

  1. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  2. Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中
  3. Wait Set:哪些调用wait方法被阻塞的线程被放置在这里
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
  5. Owner:当前已经获取到所资源的线程被称为Owner
  6. !Owner:当前释放锁的线程
    在这里插入图片描述

Monitor

Java虚拟机中,synchronized支持的同步方法和同步语句都是使用monitor来实现的。每个对象都与一个monitor相关联,当一个线程执行到一个monitor监视下的代码块中的第一个指令时,该线程必须在引用的对象上获得一个锁,这个锁是monitor实现的。在HotSpot虚拟机中,monitor是由ObjectMonitor实现,使用C++编写实现,具体代码在HotSpot虚拟机源码ObjectMonitor.hpp文件中。
主要属性:
_count(记录该线程获取锁的次数)
_recursions(锁的重入次数)
_owner(指向持有ObjectMonitor对象的线程)
_WaitSet(处于wait状态的线程集合)
_EntryList(处于等待锁block状态的线程队列)

当并发线程执行synchronized修饰的方法或语句块时,先进入_EntryList中,当某个线程获取到对象的monitor后,把monitor对象中的_owner变量设置为当前线程,同时monitor对象中的计数器_count加1,当前线程获取同步锁成功。

当synchronized修饰的方法或语句块中的线程调用wait()方法时,当前线程将释放持有的monitor对象,monitor对象中的_owner变量赋值为null,同时monitor对象中的_count值减1,然后当前线程进入_WaitSet集合中等待被唤醒。

monitorenter

每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,规则:

  1. 如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者。
  2. 如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁。
  3. 如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。

monitorexit

只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor。

加锁原理

说到 synchronized 加锁原理,就不得不先说 Java 对象在内存中的布局:
在这里插入图片描述
在创建一个对象后,在 JVM 虚拟机(HotSpot)中,对象在 Java 内存中的存储布局 可分为三块:

对象头区域此处存储的信息包括两部分:

  1. 对象自身的运行时数据( MarkWord )
    存储 hashCode、GC 分代年龄、锁类型标记、偏向锁线程 ID、CAS 锁指向线程 LockRecord 的指针等, synconized 锁的机制与这个部分( markwork )密切相关,用 markword 中最低的三位代表锁的状态,其中一位是偏向锁位,另外两位是普通锁位。
    在这里插入图片描述
  2. 对象类型指针( Class Pointer )
    对象指向它的类元数据的指针、 JVM 就是通过它来确定是哪个 Class 的实例。如果是数组对象,还会有一个额外的部分用于存储数组长度。因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。也就是 arr.len 调用很方便。

实例数据区域
此处存储的是对象真正有效的信息,比如对象中所有字段的内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在 Java 源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)(这里基本可以确定 Java 的类型也就 8 种)。相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的+XX:CompactFields参数值为 true(默认就为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐填充区域
JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍,即 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍的数据,也就是 8 个字节,所以 HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。

synchronized锁优化

JavaSE1.6为了减少获得锁和释放锁带来的性能消耗,引入 偏向锁 和 轻量级锁。

在JavaSE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

当线程进入到 synchronized 处尝试获取该锁时, synchronized 锁升级流程如下:
在这里插入图片描述
synchronized 锁升级的顺序为:偏向锁->轻量级锁->重量级锁,每一步触发锁升级的情况如下:

偏向锁

在JDK1.8 中,默认是轻量级锁,但如果设定-XX:BiasedLockingStartupDelay = 0 ,那在对一个 Object 做 syncronized 时,会立即上一把偏向锁。当处于偏向锁状态时, MarkWord会记录当前线程 ID 。

这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。偏向锁解决的问题是有些时候就一个线程在运行,不存在多线程,当出现第二个线程去竞争的情况下才会出现降级。

原理:当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 01、把偏向模式设置为1,同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 MarkWord之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对MarkWord的更新操作等)。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。

轻量级锁

当下一个线程参与到偏向锁竞争时,会先判断MarkWord中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个 LockRecord ( LR ),然后每个线程通过 CAS (自旋)的操作将锁对象头中的 markwork 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着获得锁。synchronized 中此时执行的 CAS 操作是通过 native 的调用 HotSpot 中 bytecodeInterpreter.cpp实现。

重量级锁

如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值, JDK1.6 之后,由 JVM 自己控制该规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间。

其他

可重入性

synchronized 拥有强制原子性的内部锁机制,是一把可重入锁。因此,在一个线程使用 synchronized 方法时调用该对象另一个 synchronized 方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。在 Java 中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。 synchronized 锁的对象头的 markwork 中会记录该锁的线程持有者和计数器,当一个线程请求成功后, JVM 会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,减为 0 则释放该锁。

悲观锁(互斥锁、排他锁)

synchronized 是一把悲观锁(独占锁),当前线程如果获取到锁,会导致其它所有需要锁该的线程等待,一直等待持有锁的线程释放锁才继续进行锁的争抢。

拓展

synchronized vs Lock

同:Lock和synchronized都能正常的保证数据的一致性。
异:

  1. 用法自不一样:一个是关键字,可用于方法和语句块;另一个是API
  2. 性能:JDK1.5中,synchronized性能较低。synchronized是托管给JVM执行,需调用系统操作接口,是重量级操作,导致有可能加锁消耗的系统时间比加锁以外的操作还多。JDK1.6后,对synchronized加以优化,如自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等。synchronized内置锁与ReentrantLock相比有例外一个优点:在线程转储中能给出在哪些调用帧中获得哪些锁,并能够检测和识别发生死锁的线程。Reentrant的非块状特性意味着,获取锁的操作不能与特定的栈帧关联起来,而内置锁却可以。内置锁是 JVM 的内置属性,所以未来更可能提升synchronized而不是ReentrantLock的性能。例如对线程封闭的锁对象消除优化,通过增加锁粒度来消除内置锁的同步。
  3. 乐观与悲观:synchronized采用的是CPU悲观锁机制,即线程获得的是独占锁,意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。Lock是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。原理是CAS操作(CompareAndSwap),获得锁的方法是Unsafe.compareAndSetState(),调用CPU提供的特殊指令。
  4. 底层实现原理:synchronized基于JVM来保证数据同步;lock则是在硬件层面依赖特殊的CPU指令实现数据同步的。现代的CPU提供指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。
  5. 用途区别:一般可以混用,但在非常复杂的同步应用中,推荐使用ReentrantLock,尤其是:
    1. 某个线程在等待一个锁的控制权的这段时间需要中断
    2. 需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
    3. 具有公平锁功能,每个到来的线程都将排队等候

ReentrantLock的lock机制有2种,忽略中断锁和响应中断锁

synchronized锁,也叫对象监视器;可重入锁,

synchronized实现同步的基础:Java中的每一个对象都可以作为锁。当一个线程试图访问同步代码块时,它首先必须得到锁,推出或抛出异常时必须释放锁。

重排序

synchronized无法禁止指令重排,却能保证有序性?

Java里只有volatile变量是能实现禁止指令重排的。

解决有序性问题的办法,就是禁止处理器优化和指令重排,就像volatile中使用内存屏障一样。

as-if-serial语义:在Java中,不管怎么排序,都不能影响单线程程序的执行结果。所有硬件优化的前提都是必须遵守as-if-serial语义。
synchronized,对Java对象加锁,并且是排他锁、可重入锁。synchronized通过排他锁保证同一时间内,被synchronized修饰的代码是单线程执行的

synchronized 块里的非原子操作依旧可能发生指令重排

volatile vs synchronized

都可以用来保证多线程之间操作的有序性:

  1. volatile关键字通过加入内存屏障指令来禁止指令的重排序;
  2. synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现。synchronized 块里的非原子操作依旧可能发生指令重排。在单线程程序中,不会发生指令重排和工作内存和主内存同步延迟现象,只在多线程程序中出现。

所以,synchronized 和 volatile 的有序性与可见性是两个角度来看的:

  1. synchronized 是因为块与块之间看起来是原子操作,块与块之间有序可见
  2. volatile 是在底层通过内存屏障防止指令重排的,变量前后之间的指令与指令之间有序可见

问题

IDEA warning synchronization on local variable

注,IDEA版本:
IntelliJ IDEA 2020.3.4 (Ultimate Edition)
Build #IU-203.8084.24, built on April 27, 2021
在这里插入图片描述
Inspection false positive: synchronization on local variable
Inspector puzzle with synchronization via a local variable
synchronization-on-the-local-variables
Is it reasonable to synchronize on a local variable?

参考

深入研究Java Synchronize和Lock的区别与用法

posted @ 2022-11-08 21:56  johnny233  阅读(31)  评论(0编辑  收藏  举报  来源