深入剖析synchronized关键字的底层原理

转发自知乎: https://zhuanlan.zhihu.com/p/114132797

前序

在Java并发编程当中, 我们会非常的熟悉synchronized关键字, 在JDK1.5之前, 仅仅只能通过该关键
字来实现线程的同步, 在很多的文章中, 我们都或多或少的会听到[synchronized关键字是一个非常重的
操作,但是在JDK1.6开始, 已经有了很大的改观]这样的一句话, 这时我就会疑惑, 为啥synchronized关键
字是一个非常重的操作呢?1.6开始到底有了什么样的改观呢?然而这些文章仅仅说出了这句话, 并没有进
一步说明, 于是花了一周的时间, 我查看了大量的博客以及StackOverflow上的讨论, 总算是从原理理解
了这个熟悉又陌生的关键字了。
    本文篇幅较长, 会从操作系统, JVM, 以及open JDK的源码等各个角度对synchronized关键字进行深
入的分析, 从而让大家非常的熟悉这个关键字, 我会先通过一些操作系统的前置知识开始讲起, 以人性化
的文字进行描述, 从而让大家在遇到知识盲区时能够理解我所讲解的内容, 进而一步步的深入分析, 如果
想要完全掌握本文的内容, 需要有一定的JVM基础, 对字节码、内存结构、垃圾回收有过相应的知识体系

操作系统前置知识(参考计算机操作系统-汤晓丹[考研408标准教材])

  • 用户态与内核态
现代操作系统一般将OS划分为若干层次, 再将OS的不同功能设置在不同的层次中, 之所以这样做的原
因是为了细分功能, 就跟我们写代码一样, 尽量做到函数功能细分, 即设计模式中的单一职责, 大家也许
会听过这样的一个名词[微内核], 这就是操作系统细分从而产生的, 你可以把它理解为我们一个项目中的
library即公共库, 比作公共的工具类[SpringUtil这样的]
    现代操作系统中通过会将一些与硬件紧密相关的模块(如中断处理程序)、常用的设备驱动以及运行频
率较高的模块(进程调度)和许多模块的公用的基本操作, 都安装在紧靠硬件的层次中, 将他们常驻内存, 
这些通用的模块也被称为了OS内核, 总而言之, 就是操作系统的通用功能称之为操作系统内核。那么这与
我们的次标题用户态与内核态有什么关系呢?OS的设计者为了防止OS本身以及关键数据(PCB: 进程控制块,
存储了进程运行必要的信息)受到应用程序的有意或者无意的破坏, 通常也将CPU的执行状态分为用户态和
内核态
用户态: 具有较低特权的执行状态, 仅能执行规定的指令, 访问指定的寄存器和存储区, 敲黑板!!==> 
         指定的
内核态: 具有较高的特权, 能执行一切指令, 访问所有寄存器和存储区
通常情况下, 应用程序只能在用户态运行, 不能去执行OS指令和访问OS区域 从而防止对OS造成破坏, OS
内核最接近于硬件(如CPU), 在内核中执行的指令均为内核态, OS内核也有很多种类, 但大多数的内核都
包含了以下的
功能:
  <1> 中断处理, OS内核的基本功能, 各种类型的系统调用、键盘命令的输入、进程调度等都离不开中断
      处理
  <2> 时钟管理, OS内核的基本功能, 许多OS的活动都需要依赖于该功能, 如CPU的轮转调度算法
  <3> 原语操作, 也叫原子操作, 处于CPU的原语操作是一个不可分割的, 要么都执行, 要么都不执行, 
      原语操作在内核态执行, 比如实现进程同步的原语等
总结, 敲黑板!!用户态只能处理一些特定的操作系统指令, 而内核态能执行所有的指令, 中断处理和原
语操作都需要CPU处于内核态才能得到执行, 也就是说正常情况下, 应用程序处于用户态执行, 一旦需要
执行中断处理和原语操作就需要将用户态转为内核态
  • 系统调用
通过上一段落的描述, 我们知道了用户态和内核态这两个概念, 应用程序正常是在用户态执行的, 当需
要执行中断处理与原语操作的时候会转向内核态执行, 那么问题就是, 应用程序是怎么从用户态转向内
核态的?没错, 就是通过系统调用, 根据计算机操作系统书中的描述, 为了能够让应用程序间接的使用
OS的功能, OS提供了系统调用, 从而使得应用程序能够间接的取得相应的服务, 系统调用运行在内核
态, 而正常的用户程序的函数调用是运行在用户态的, 比如OS会提供一个系统调用使得应用程序能够
使用OS内核态中的原语操作, 那么系统调用的一般流程是怎么样的呢?
  <1> 将CPU的状态由用户态转为内核态, 由内核程序对系统调用进行一般性处理, 你可以理解为构造
      函数的初始化, 即做一些准备性操作
  <2> 首先发出一个中断信号, 通知内核执行相关程序, 保护CPU环境, 因为系统调用是由内核完成的, 
      而不是应用程序完成的, 应用程序和内核程序是完全不同的两个程序, 所以需要先保存当前程序
      的CPU环境, 当前应用程序会陷入等待状态, 直到系统调用完成
  <3> 分析系统调用的类型, 不同系统调用由不同的内核程序执行, 你可以理解成Java中的if-else
  <4> 系统调用结束后, 恢复CPU执行环境, 发出中断处理信号, 告知应用程序调用执行完毕
总结: 执行原语操作需要由OS内核来执行, 应用程序可以通过系统调用来间接的执行原语操作, 执行
      原语操作的是OS内核的程序, 通过系统调用的执行流程, 我们知道系统调用是需要CPU的中断的, 
      这一步骤跟进程的切换是类似的, 如果对进程调度有所了解的话, 就会知道, 频繁的CPU切换是
      会造成非常大的开销的, 这也是为啥OS引入线程的主要原因
  • 信号量mutex实现进程的互斥(mutex lock)
根据计算机操作系统进程同步章节, 为了使得多个进程能够互斥的访问某临界资源(进程间需要互斥访问
的资源),只需要为该资源设置一个互斥信号量mutex, 设置其初始值为1, 各个进程在访问该资源的时候
需要置于wait(mutex)以及signal(mutex)操作之间, 即每个想要访问该临界资源的进程在进入临界区之
前, 都要执行wait(mutex)操作,而在走出临界区的时候就要执行signal(mutex)操作, wait(mutex)和
signal(mutex)是两个原语操作, 需要在内核态中执行, 通过执行相应的系统调用应用程序才能使用这
两个操作, 原语指令操作流程:
  wait(mutex)
      访问临界区资源, 并对其进行一定的修改, 比如改变某个内存地址的值等
  signal(mutex)
在wait(mutex)的时候, 会使得互斥信号量mutex + 1, 在signal(mutex)的时候, 会使得互斥信号量
mutex - 1
  • 管程
通过上一段落对wait和signal原语的介绍, 我们知道在OS底层, 如果一个进程要访问临界资源, 那么就需
要将所有的访问操作置于这对原语操作中, 这对原语操作必须成对出现, 这里会遇到一个问题, 每个线程
的同步操作都要自备这两个同步操作, 这就使得大量的同步操作分布在不同线程中, 造成系统管理的麻烦
并且错误的使用该同步操作还会造成死锁, 为了解决这个问题, 管程这一新的同步工具便诞生了, 通俗的
解释, 我们可以认为管程就是对原语操作的一种封装, 这里用到了面向对象的思想, 管程是一个对象, 在
该对象中有很多的方法, 不同的方法作用不一样, 其中便有对wait和signal进行封装的方法
根据计算机操作系统中描述, 管程是一种数据结构, 也是一个资源管理模块, 里面包含了共享资源的数据
结构以及对该共享资源数据结构实施操的一系列操作, 这组操作能同步进程和管理管程中的共享资源, 管
程名称为Monitor,下面是管程的抽象表示形式:
  Monitor monitorName {
    int shareVariable;   // 共享变量的声明
    public void method1 () {} // 对共享变量的第一个操作
    public void method2 () {} // 对共享变量的第二个操作
    static {
      初始化代码
    }
  }
管程标称了共享资源的数据结构以及对数据结构操作的一组过程, 包括同步机制, 都集中的封装在一个
对象的内部,隐藏了实现细节, 所有管程外的过程都不能直接访问管程的数据结构, 只能通过管程的方
法来间接访问(如method1)
总结: 敲黑板!!!管程很重要, 它封装了共享资源, 外界只能通过管程暴露的方法来间接访问这些资源, 
访问这些资源的时候管程封装了wait, signal原语操作(在method1或者其它方法的内部有该操作)
  • 总结
学好计算机操作系统是我们学好并发编程的前提, 也是必要条件, 脱离了计算机操作系统的理论谈并发
是没有任何意义的, Java作为一个编译语言, 其主要依赖于JVM来运行, 而JVM是由C++来实现的, C++是
较为接近硬件的语言, 深入理解计算机操作系统, 能够使得我们站在api设计者的角度去思考并发代码
的执行, 而不是仅仅会调用Thread这样的api, 笔者是不能够把所有计算机操作系统都一股脑拎上来的,
但是如果能对上述我讲解的四个概念有较深的体会, 那么在并发学习中将会如鱼得水, 在此, 我提前的
跟大家透露一个秘密, synchronized关键字的底层实现便是管程, 是不是很惊喜, 接下来我们就继续深
入的讨论这个熟悉又模式的关键字吧

JVM前置知识

  • JVM内存结构
JVM的内存结构主要分为以下几个部分:
  <1> 本地方法栈
  <2> 虚拟机栈
  <3> 程序计数器
  <4> 方法区
  <5> 堆空间
其中前三者是线程独有的区域, 而后两者是线程共享区域, 本地方法栈是调用native方法的方法栈, 虚拟
机栈是调用Java方法的栈, 如果对栈这种数据结构很熟悉的话, 相信不会很难理解这两个概念, 后进先出
是其特点, 而利用非递归方式来实现递归时也是要用到这个数据结构的, 如果想要更加深入的了解栈的应
用场景, 可以去我的github的算法实践笔记中查找二分搜索树的前中后序非递归实现来深入了解, 程序计
数器是用来记录程序指令的执行步骤的
而后两者是线程共享区域, 独属于Java进程, 方法区是用来存储一个类的信息, 以及static变量的, 在以
前的JDK版本中也称为永久代, 堆空间是用来存放对象的, 所有的Java对象均存在这里(除了Class对象), 
Class对象到底存在于堆还是方法区, 这个在不同的虚拟机实现中是不一样的, hotspot虚拟机将其置放在
堆空间, 方法区和堆空间还涉及到垃圾回收相关的知识, 内容较多, 这里就不进行展开了, 大家如果有兴
趣, 可以去我写的JVM文章中查看: https://fightzhong.github.io/JVMStudy/
  • Java对象结构
Java对象存在于堆中, 主要由三个部分组成:
  <1> Java对象头
  <2> 实例数据(如非static变量)
  <3> 填充数据
后两者比较好理解, 实例数据即一个对象的数据, 内部变量等都存在于此, 填充数据是用于填充一个对象
到达指定大小的, 如果了解TCP/IP协议簇的话, 就会知道在TCP协议以及IP协议的公共部分都是有填充字
段的, 目的就是为了使得协议的公共部分的数据大小一致, 在Java对象结构中的填充数据同样也是这个意
思,Java对象头: 主要保存了描述一个Java对象的信息以及垃圾回收相关的信息, 主要由三个部分组成:
  <1> Mark Word
  <2> 指向类的指针
  <3> 数组长度
后两者比较容易理解, 在此就不展开说明了, 第一个部分是MarkWord, 它记录了Java对象、锁以及垃圾回
收相关的内容, 通过bit位来标记, 如果是64位系统, 那么MarkWord也是64位来表示, 主要有以下标记位
  <1> 无锁标记
  <2> 偏向锁标记
  <3> 轻量级锁标记
  <4> 重量级锁标记
  <5> GC标记
第一个到第四个表示当前对象的锁的状态, 这里先不进行称述, 当我们真正讲到synchrnized关键字的时
候, 我会重点讲这四个标记位, 第五个标记位涉及到JVM的垃圾回收, 这里简单的说一下, 防止大家有知
识盲区, 在JVM的分代垃圾回收算法中, 将堆空间分为新生代和老年代, 新生代分为三个区域, 一个Eden
空间和两个survivor空间, 一个对象初始创建时处于Eden空间, 经历了一次垃圾回收后, 如果该对象没有
被回收, 那么就会由Eden空间转移到to-space(其中一个survivor空间)空间, 与此同时, 该对象的对象头
的GC标记中就会加1, 表示其年龄加1, 当该对象经历了多次垃圾回收后, 其会不停的从from-space转移到
to-space, 每一次的转移都会使得GC标记加1, 即年龄加1, 当年龄到达阈值之后, 下一次的GC一旦执行,
那么该对象就会直接晋升到老年代了, 所以总的来说, GC标记指的是该对象经历的垃圾回收的次数, 是
用于判断一个对象是否晋升老年代的重要标记, 如果想了解更多关于JVM的知识, 可以去我写的文章中进
行阅读, 在那里, 我用了相关实验来验证了GC的执行流程, 从而脱离了纯理论的学习

深入分析Synchronized关键字

从wait以及notify的JavaDoc开始讲起

  • wait的JavaDoc翻译
造成当前线程陷入等待直到其它任意的线程调用了notify和notifyAll方法或者等待的时间已经达到, 当
前线程必须拥有当前对象的Monitor对象, 这个wait方法会将当前线程放置到当前对象的waitSet这个等
待集合当中, 同时会放弃对该object对象的同步, 线程此时不可以再参与到线程调度中, 直到下面几个
事件的发生:
  <1> 其它的线程调用了这个对象的notify方法, 并且恰好选择了当前线程去被唤醒
  <2> 其它线程调用了这个对象的notifyAll方法
  <3> 其它线程打断了当前等待的线程(interrupt)
  <4> 等待的时间到达了wait方法传进去的时间参数, 如果参数为0, 那么该线程将会等待到被唤醒前, 
      其实就是一直等待, 0表示无限等待
当前线程被唤醒后将会从该对象的WaitSet等待集合中被移除, 之后可以重新拥有被CPU调度的的能力, 
它之后会以通常的方式与其它线程去竞争这个对象的同步, 一旦它获得了对象的同步权, 那么该线程
的同步装态就会恢复原样, 即这个wait方法被调用时的那个状态, 之后线程会从wait方法返回
一个线程也可能会在没有被notify, 打断或者等待时间到达这三种情况发生时被唤醒, 这也叫伪唤醒, 
但是这是很少发生在实际开发中的, 应用程序一定要保证当前的条件确实是唤醒线程时需要达到的环
境, 如果条件没有满足,线程应该继续等待, 换句话说, wait方法应该一直处于一个循环当中, 就像下
面这样:
  synchronized (obj) {
    while (<condition does not hold>)
        obj.wait(timeout);
    ... // Perform action appropriate to condition
  }
需要注意的时, wait方法会将当前线程放置在这个对象的等待集合waitSet中, 也仅仅能被该对象解锁, 
与此同时, 当前线程也能够同步其它对象的锁
这个方法(wait)仅仅能够被一个拥有当前对象的Monitor的线程调用, 通过查看notify方法来了解一个
线程如何称为一个对象的Monitor
  • notify方法的javaDoc翻译
唤醒一个等待该对象Monitor对象的线程, 如果有多个, 那么就会唤醒其中一个, 至于唤醒哪个取决于具
体的实现,即不同的策略将会导致不同的结果, 一个线程通过调用其中一个重载的wait方法来获取该对象
的监视器Monitor这个被唤醒的线程不能够继续执行直到当前线程放弃了对该对象的锁之前, 当持有该对
象锁的线程放弃了该对象的锁之后, 这个被唤醒的线程将会以通常的方式与其它线程竞争这个同步锁, 
例如, 这个唤醒的线程是没有优先权也没有缺点去称为下一个锁定该对象的线程的(这句话通俗的意思就
是, 这个唤醒的线程会公平的去争夺对象的锁)这个notify方法仅仅能够被拥有当前线程Monitor对象的
线程调用, 一个线程如果想成为一个对象的监视器只能通过如下三种方式的任意一种:
  <1> 通过执行该实例对象的synchronized方法
  <2> 通过执行该实例对象中方法的synchronized同步块
  <3> 通过执行一个类对象的static静态方法
仅仅只能有一个线程在同一时间内获取到一个对象的Monitor
  • 总结
<1> 调用一个对象的wait和notify方法只能在synchronized同步块或者方法中
<2> 调用一个对象的wait和notify方法的线程必须拥有该对象的Monitor监视器, 而只能通过<1>才能获取
    到该对象的监视器
<3> 同一时间只能有一个线程获取到该对象的监视器
<4> 调用wait方法后, 该线程会被放入到该对象的waitSet等待集合中, 一旦线程被唤醒, 其会接着wait
    方法继续执行
<5> notify方法唤醒哪个线程是不确定的, 这个取决于不同的实现策略
<6> wait方法应该被放在一个循环中被调用, 从而防止虚假唤醒

在这里我们先对wait和notify有一个了解(其实大家应该对他们很了解了), 这里需要引入一些小插曲, 之后我们再来深入分析这两个方法
从字节码的角度来分析Synchrnozied关键字的三种情况
注意: 如果不熟悉字节码的话可以去查看我的JVM笔记, 在字节码章节我对字节码进行了深入的讲解, 并对虚方法invokevirtual来讲解了方法重载和方法重写的原理

  • 情况一: 用Synchrnozied代码块来修饰一段代码
// 测试用例
public class TestClass {
  private static final Object object = new Object();
  public static void main (String[] args) {
    synchronized ( object ) {
      System.out.println( "abc" );
    }
  }
}
// 反编译查看字节码[javap -v TestClass.class]
0: getstatic     #2                  // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter
6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc           #4                  // String abc
11: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: aload_1
15: monitorexit
16: goto          24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
总结: 由上面的字节码可以看出, 当我们用synchronized代码块来修饰一段代码的时候, 映射成字节码就
      会变成两个指令monitorenter以及monitorexit, 换句话说, JVM就是通过这两个指令来完成同步的
      之所以monitorexit会出现两次的原因是, JVM为了确保在同步块发生异常的情况下, 也能够将锁归
       还
  • 情况二: 用synchronized关键字来修饰一个方法(取对象的方法为例, static方法类似)
// 测试用例
public class TestClass {
  public synchronized void test () {
    System.out.println( "Hello, World" );
  }
}
// 反编译查看字节码
public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
  stack=2, locals=1, args_size=1
      0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      3: ldc           #3                  // String Hello, World
      5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      8: return
总结: 当用synchronized关键字来修饰一个方法的时候, 会在当前方法的flags中添加一个标记位ACC_SYNCHRONIZED,
      JVM通过该标记位来实现方法的同步语义
  • 总结
当我们使用synchronized关键字修饰代码块时, 线程进入到monitorenter指令后, 会持有该Monitor对象,
执行monitorexit指令时, 线程会释放该Monitor对象
JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否为同步方法, 当方法被调用时, 调用指令会检
查该方法是否拥有ACC_SYNCHRONIZED标志, 如果有那么执行线程将会先持有方法所在对象的Monitor对象,
然后再去执行方法体, 在该方法执行期间, 其它任何线程无法再获取到这个Monitor对象, 当线程执行完
该方法后, 它会释放掉这个对象

聊聊Monitor对象

  • 引入
通过上面的javaDoc描述, 我们得知了一个对象是有一个Monitor的, 那么这个Monitor到底是什么呢?其就
是一个对象, 当一个对象Object被创建的同时, 一个Monitor对象也会被创建, 该Monitor对象是由C++创
建的, 也由C++来进行管理, 当一个线程进入到一个同步方法或者同步块的时候, 其就会获取到该Monitor
对象, 退出的时候就会归还该Monitor对象, 换句话说, Java的同步是通过进入与退出Monitor对象作为实
现原理的, 而这个Monitor对象就是大名鼎鼎的管程对象, 哈哈哈, 惊不惊喜, 是不是又一次看到这个管
程对象了, 这是不是意味着我所说的脱离了计算机操作系统谈并发是毫无意义的这句话嘞=,=
在此, 我进一步描述一下Monitor对象, 之后会另外写一篇文章来带领大家利用openJDK的C++源代码来看
看这个Monitor对象的庐山真面目
  • Monitor对象原理
JVM中的同步是基于进入与退出监视器对象(管程对象)(Monitor)来实现的,每个对象实例都会有一个
Monitor对象,Monitor对象会和Java对象一同创建并销毁。Monitor对象由c++实现的
当多个线程同时访问一段同步代码块的时候,阻塞的线程会进入到一个EntryList集合中,处于阻塞这状
态的线程会被放入到该列表中。接下来,当线程获取到Monitor对象时,Monitor是依赖于操作系统的
mutex lock来实现互斥的线程获取mutex成功,则持有该mutex,这时其它线程就无法再获取到该mutex
如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到waitSet集合中中
,等待下一次被其它线程调用notify/notifyAll唤醒。如果当前线程顺利执行完毕方法,那么它也会释放
所持有的mutex。mutex lock是底层操作系统提供的锁, 是由内核直接调用的, 那么一旦涉及到内核调用,
那么就必然与系统调用分离不开, 而一旦涉及到系统调用, 就必然会导致用户态与内核态之间的转换
  • 总结
Java的进程同步是通过Monitor对象(管程)实现的, 管程中通过利用计算机操作系统的mutex lock即信号
量机制来实现, 这个mutex lock是处于计算机内核中的, 当线程进入到synchronized修饰的代码时, JVM
就会通过系统调用来操作特权指令, 使得线程对象被锁住, 其实就是通过调用管程对象的一个方法来实
现的, 只不过这个方法中操作了系统调用而已, 假设我们的代码如下:
  synchronized ( object ) {
    System.out.println( "abc" );
  }
映射成底层就是这样的:
  object.Monitor.wait(mutex)
    System.out.println( "abc" );
  object.Monitor.signal(mutex)
咦, 是不是很熟悉???wait和signal不就是一开始我们讲的同步原语吗???是不是有一种看穿了一切的感
觉=,=别急, 接着我们继续讨论:
当我们执行synchronized修饰代码前, 会有如下的动作:
  java代码运行在用户态, 当我们调用synchronized关键字修饰的方法时, 必然会涉及到系统调用, 根据
  系统调用的原理, 此时会发出一个中断信号, 保存当前线程的CPU环境, CPU由用户态转向内核态, 转向
  内核处理程序进行处理该系统调用, 系统调用处理完毕后内核同时也会发出一个中断信号, CPU由内核
  态转向用户态, 恢复CPU线程,开始执行Java代码
当并发量较大的时候, 频繁的从用户态切向内核态, 频繁的保存CPU环境和恢复CPU环境, 会导致性能非常
的低下,这也是为什么在JDK1.5之前synchronized关键字性能很差的根本原因!!!!!

Java中线程同步的执行流程

<1> 调用wait方法 => 线程进入Monitor对象的waitSet等待集合
<2> 调用notify方法 => 从waitSet中选择其中一个线程拿出来, 放入Monitor对象的EntryList
<3> EntryList放置了所有未获取到锁的线程, 当对象的锁可用的时候, 处于EntryList中的线程就会开始
    竞争该锁, 竞争成功的锁就开始执行代码, 竞争失败的线程仍然会被放入EntryList

synchronized关键字优化手段

  • 引入
通过上面的描述, 我们知道了synchronized关键字的底层原理了, 同时也知道了为什么在JDK1.5之前该关
键字性能低下的原因, 那么在JDK1.6开始, JDK对该关键字进行了优化, 这也是偏向锁, 轻量级锁, 重量
级锁的由来,在真正讲解这些之前, 我们需要了解两个知识点:
知识点一: Java对象头中的锁标记
  <1> 无锁标记: 表示没有线程持有该对象的锁
  <2> 偏向锁标记: 表示该对象的锁是一个偏向锁
  <3> 轻量级锁标记: 表示该对象的锁是一个轻量级锁
  <4> 重量级锁标记: 表示该对象的锁是一个重量级锁
知识点二:
  synchronized关键字在底层会有偏向锁, 轻量级锁, 重量级锁三种形态, 不同的场景, synchronized关
  键字表现出来的锁的形态是不一样的, 对于synchronized锁的演化来说, 它会经历如下阶段:
    无锁 => 偏向锁 => 轻量级锁 => 重量级锁
  • 何为偏向锁
偏向锁是针对于一个线程来说的, 当一个线程第一次访问一个synchrnized方法的时候, 该锁对应的对象
的对象头中就会标记为偏向锁, 同时用另外一个标记位来标记当前线程id, 即意义是某个线程访问过该
synchrnized方法,并且没有其它线程访问过, 这样做的意义是当该线程多次访问该同步方法并且没有其
它线程访问的情况下, 通过偏向锁标记, 该线程不用每次都获取该锁, 即不用每次都从用户态进入到内
核态, 从而提高性能
  • 偏向锁的升级
当一个对象被置为偏向锁的时候, 会有三种情况
情况一: 线程A第一次进入到同步方法,则该同步方法对应的对象的Mark Word就会标记为偏向锁, 同时记
        录线程A的id,则在没有其它线程访问该方法的情况下, 线程A之后再次访问该同步方法的时候就
        不用进入内核态, 而是直接执行同步方法
情况二: 线程A在执行该同步方法的时候, 正处于同步方法的执行过程中, 此时如果有其它线程来访问该
        同步方法, 那么这些线程被会放入同步方法对象的EntryList中, 同时线程的状态标记为Block状
        态, 此时该对象的偏向锁就会变成轻量级锁, 即锁的升级
情况三: 线程A执行完同步方法后(此时该同步方法对象是标记成了偏向锁的), 如果有其它线程来访问该
        方法, 那么就会导致偏向锁被取消掉, 这时候偏向锁的线程id标志位会指向新的线程
  • 何为自旋锁
轻量级锁有很多, 在此以自旋锁作为例子进行描述, 自旋锁是轻量级锁中的其中一种, 也是Java底层对轻
量级锁的实现:
当一个线程因为争抢锁失败时, 此时会进入阻塞状态, 在Java中即Block状态, 此时线程会由用户态转为
内核态,当其之后获得了该锁的时候, 又会由内核态转为用户态, 我们知道, 用户态和内核态的频繁切换
是会导致一定的性能开销的, 自旋锁的出现便是为了解决这个问题, 在Java中利用synchronized来实现锁
机制的时候, 如果A线程获取到了该锁, 那么就会去执行, 此时B线程来获取锁自然就失败了, 正常情况
下, B线程就会进入阻塞状态,但是通过上面的讲解我们知道, 用户态和内核态的转变是会导致性能开销
的, 从而影响锁的性能, JVM在这里进行了优化, 会粗略的计算A线程的执行时间, 如果时间较短, 那么
就会让B线程进行自旋, 可以理解临时进入了一个死循环, 这样B线程就不会进入内核态, 这就是
synchronized成为自旋锁的体现, 但是这也是有一定的开销的,因为此时B线程仍然会持有CPU的使用权, 
从而造成一定程度的浪费,但是这种自旋也是有限制的,如果在一定的时间内,B线程还是没有争用到
锁的拥有权,其也会进入内核态。
总体的思想就是,线程在争用锁的时候,先自旋,不成功再进行阻塞,也就在多处理器的情况下才有意义
  • 何为轻量级锁
若第一个线程已经获取到了当前对象的锁, 这时第二个线程又开始争抢该对象的锁, 由于该对象的锁已经
被第一个线程获取到, 因此它是一个偏向锁, 而第二个线程在争抢的时候, 会发现该对象头中的MarkWord
已经是偏向锁,但里面存储的线程ID并不是自己(而是第一个线程), 那么它就会用CAS(Compare And Swap)
来尝试获取锁:
  <1> 如果获取锁成功, 那么它会直接将Mark Word中的线程ID由第一个线程变成自己(但是此时偏向锁的
      标志位是不会变的), 这样线程依旧保持偏向锁的状态
  <2> 获取锁失败, 则表示这时可能有多个线程同时在尝试争抢该对象的锁, 那么这时候锁就会进行升级
      ,成为轻量级锁, 即自旋锁, 自旋锁最大的特点就是避免了线程从用户态转向内核态
  • 何为重量级锁
即真正获取到了锁, 这个才是真正的锁, 即之前我们说的mutex lock, 此时CPU会从用户态转向内核态
  • 总结
从JDK1.6开始,synchronized锁的实现发生了很大的变化,JVM引入了相应的优化手段来提升
synchronized锁的性能,这种提升涉及到偏向锁、轻量级锁及重量级锁,从而减少锁的竞争
所带来的用户态与内核态之间的切换;这种锁的优化实际上通过Java对象头中的一些标志位
来去实现的,对于锁的访问与改变,实际上与Java对象头息息相关

总结

Java并发必然是脱离不了的synchronized关键字的, 对该关键字的深入理解能够帮助我们在写程序的时候
会站在不同的角度去思考, 其次Java并发是必然离不开计算机操作系统的理论的, 也必然离不开JVM, 相
信通过本文对该关键字的深入分析, 会让大家看到不一样的并发编程, 而不是仅仅只会调用Executor这样
的api, 路漫漫其修远兮, 如果有研究底层的心, 相信会有不一样的编程体验
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值