一篇文章彻底搞懂synchronized(深度剖析)

文章目录

  • 前言
  • 一、想知道synchronized之前,需要知道的以下几个问题
    • 1、设计同步器的意义
    • 2、引出的问题
    • 3、如何解决线程并发问题?
  • 二、synchronized是什么?
    • 2.1、使用的场景
    • 2.2、什么是可重入锁?
  • 三、synchronized底层原理实现
    • 3.1、Monitor监视器锁
    • 3.2、monitor的底层实现
    • 3.3、对象的内存布局
    • 3.4、 对象头
  • 四、syn锁的优化
    • 4.1、锁的膨胀升级过程
    • 4.2、偏向锁:
      • 1、为什么需要偏向锁?
      • 2、偏向锁的核心思想、使用场景
      • 3、偏向锁什么时候失效?
    • 4.3、轻量级锁:
      • 1、为什么需要轻量级锁?
      • 2、轻量级锁的核心思想、使用场景
      • 3、轻量级锁什么时候失效?
    • 4.4、自旋锁:
      • 1、为什么需要自旋锁?
      • 2、自旋锁的核心思想、使用场景
      • 3、自旋锁什么时候失效?
    • 4.5、消除锁:
    • 6、逃逸分析


前言

synchronized是什么?
synchronized解决了什么问题?
synchronized实现原理?
带着问题出发


一、想知道synchronized之前,需要知道的以下几个问题

1、设计同步器的意义

在并发编程里,有可能会出现多个线程同时访问统一共享、可变资源,这个情况被视为临界资源。这种资源可以是变量、对象、文件等。
共享:资源可以由多个线程访问。
可变:资源可以在生命周期内被修改。

2、引出的问题

由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变资源的访问。

3、如何解决线程并发问题?

所有的并发模式在解决线程安全问题时,采用的方法都是序列化访问临界资源。即同一时刻只允许一个线程访问临界资源,也称作为同步互斥访问。java中提供了2种方式来实现同步互斥访问:synchronizedLock

同步的本质就是加锁!
不过有一点需要区别的是:多个线程执行执行同一个方法时,该方法的内部的局部变量并不是临界资源,因为这些局部变量都是私有的,所以不具备线程安全问题。只有那些可以共享并且可变的资源,才算是临界资源。


二、synchronized是什么?

synchronized是java中的一个关键字,synchronized内置锁是一种对象锁(锁的是对象,而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问、是可重入的锁。

2.1、使用的场景

同步实例方法:锁的是当前对象。
同步类方法:锁的是当前类对象。
同步代码块,锁的是括号里的对象。

2.2、什么是可重入锁?

可重入锁,又被称为递归锁
即在一个synchronized方法/块的内部调用本类的其他synchronized方法/块时,是永远可以继续得到锁的。

可重入锁的好处:
1、可以避免死锁的情况。
2、可以递归调用,如果当前拿到锁的实例,在调用具有synchronized方法时,不需要再次尝试拿锁,直接直接获取到锁。

重入锁实现原理
简单来说就是,为每个锁关联一个计数器持有者线程,当计数器为0时候,这个锁被认为是没有被任何线程持有;
当有线程持有锁,计数器自增,并且记下锁的持有线程,当同一线程继续获取锁时候,计数器继续自增;当线程退出代码块时候,相应地计数器减1,直到计数器为0,锁被释放;此时这个锁才可以被其他线程获得。

简而言之,同一线程的外层函数获得锁之后,内层函数可以直接获取改锁,这样可以避免内外层死锁。


三、synchronized底层原理实现

synchronized是基于JVM内置锁实现的,通过内部对象Monitor(监视器锁)实现,基于进入和退出,monitor对象实现和代码块的同步。
监视器锁的实现依赖底层操作系统的Mutex Lock(互斥锁)实现,他是一个重量级锁,性能低下。

在jdk版本1.5之后做了重大的优化,如锁粗化、锁消除、轻量级锁、偏向锁、适应性自旋等技术减少锁操作的开销,内置锁的并发性能基本和Lock性能持平。

synchronized的关键字在被编译成字节码后,会被翻译成monitorentermonitorexit 二条指令分别在同步块逻辑的开始位置结束位置
一篇文章彻底搞懂synchronized(深度剖析)_第1张图片


3.1、Monitor监视器锁

可以把它理解为一个同步工具,也可以理解是一种同步机制,它通常被描述为一个对象,所有的java对象都是天生的minitor,每一个java对象都有成为monitor的潜质,因为在java 的设计中,每一个java对象都有一把看不见的锁,它叫做内部锁或者monitor锁。也就是通常说的synchronized对象锁。

加锁过程如下:
每个同步对象都有一个自己的Monitor(监视器锁,隐式的继承该锁)
一篇文章彻底搞懂synchronized(深度剖析)_第2张图片
mininor.Enter
当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

minitor.Exit
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit,如果指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁

synchronized的底层实现就是基于monitor的对象实现的,其实wait、notify等方法也是基于monitor对象这也是为什么只有在同步块内才能使用wait/notify的原因。


synchronized案例说明
看一个同步方法:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反编译后结果
一篇文章彻底搞懂synchronized(深度剖析)_第3张图片
注意:从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符

JVM就是根据该标示符来实现方法的同步的
1、当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置
2、如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。


3.2、monitor的底层实现

在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

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

ObjectMonitor核心参数:
_WaitSet :保存处于挂起(wait)状态的线程。
_EntryList :保存处于阻塞(block)的线程。
_owner :指向持有ObjectMonitor对象的线程。
_count : 记录当前线程的数量。(重入锁的加一,减一)。


当多个线程同时访问一段同步代码时的执行步骤
1、首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;。
2、若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
3、若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);


3.3、对象的内存布局

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)实例数据(Instance Data)和对齐填充(Padding)。

一篇文章彻底搞懂synchronized(深度剖析)_第4张图片

3.4、 对象头

对象头又包含3部分;
第一部分:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等等,它是实现轻量级锁和偏向锁的关键。
第二部分:元数据指针。
第三部分:数组长度。
java对象头一般占有2个机器码(1个机器码等于8字节,也就是64bit),但是如果对象是数组类型的话,则需要3个机器码,因为JVM虚拟机可以通过对象的元数据信息确定java对象的大小,但是无法从数组的元数据来确定数组大小,所以用一块来记录数组长度

一篇文章彻底搞懂synchronized(深度剖析)_第5张图片

四、syn锁的优化

4.1、锁的膨胀升级过程

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。


4.2、偏向锁:

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段。

1、为什么需要偏向锁?

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

2、偏向锁的核心思想、使用场景

如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

3、偏向锁什么时候失效?

对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

4.3、轻量级锁:

1、为什么需要轻量级锁?

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”。

2、轻量级锁的核心思想、使用场景

轻量级锁所适应的场景是线程交替执行同步块的场合

3、轻量级锁什么时候失效?

如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁


4.4、自旋锁:

1、为什么需要自旋锁?

轻量级锁失败后,虚拟机为了避免线程,真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段

2、自旋锁的核心思想、使用场景

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因).

3、自旋锁什么时候失效?

一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。


4.5、消除锁:

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

-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除。

6、逃逸分析

使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。


你可能感兴趣的:(并发编程,java,jvm,面试)