java synchronized 锁机制原理详解

目录
  • 前言:
  • 1、synchronized 的作用:
  • 2、synchronized 底层语义原理:
  • 3、 synchronized 的显式同步与隐式同步:
    • 3.1、synchronized 代码块底层原理:
    • 3.2、synchronized 方法底层原理:
  • 4、JVM 对 synchronized 锁的优化:
    • 4.1、锁升级:偏向锁->轻量级锁->自旋锁->重量级锁
      • 4.1.1、synchronized 的 Mark word 标志位:
      • 4.1.2、锁升级过程:
    • 4.2、锁消除:
    • 4.3、锁粗化:
  • 5、偏向锁的废除:
  • 总结

前言:

线程安全是并发编程中的重要关注点,造成线程安全问题的主要原因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。

1、synchronized 的作用:

synchronized 通过当前线程持有对象锁,从而拥有访问权限,而其他没有持有当前对象锁的线程无法拥有访问权限,保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,从而保证线程安全。synchronized 可以保证线程的可见性,synchronized 属于隐式锁,锁的持有与释放都是隐式的,我们无需干预。synchronized最主要的三种应用方式:

  • 修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块:指定加锁对象,进入同步代码库前要获得给定对象的锁

2、synchronized 底层语义原理:

synchronized 锁机制在 Java 虚拟机中的同步是基于进入和退出监视器锁对象 monitor 实现的(无论是显示同步还是隐式同步都是如此),每个对象的对象头都关联着一个 monitor 对象,当一个 monitor 被某个线程持有后,它便处于锁定状态。在 HotSpot 虚拟机中,monitor 是由 ObjectMonitor 实现的,每个等待锁的线程都会被封装成 ObjectWaiter 对象,ObjectMonitor 中有两个集合,WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表 ,owner 区域指向持有 ObjectMonitor 对象的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合尝试获取 moniter,当线程获取到对象的 monitor 后进入 _Owner 区域并把 _owner 变量设置为当前线程,同时 monitor 中的计数器 count 加1;若线程调用 wait() 方法,将释放当前持有的 monitor,count自减1,owner 变量恢复为 null,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 并复位变量的值,以便其他线程获取 monitor。如下图所示:

3、 synchronized 的显式同步与隐式同步:

synchronized 分为显式同步(同步代码块)和隐式同步(同步方法),显式同步指的是有明确的 monitorenter 和 monitorexit 指令,而隐式同步并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

3.1、synchronized 代码块底层原理:

synchronized 同步语句块的实现是显式同步的,通过 monitorenter 和 monitorexit 指令实现,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置,当执行 monitorenter 指令时,当前线程将尝试获取 objectref(即对象锁)所对应的 monitor 的持有权:

  • 当对象锁的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。
  • 如果当前线程已经拥有对象锁的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加1。
  • 若其他线程已经拥有对象锁的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit 指令被执行,执行线程将释放 monitor 并设置计数器值为0,其他线程将有机会持有 monitor。

编译器会确保无论方法通过何种方式完成,无论是正常结束还是异常结束,代码中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令。为了保证在方法异常完成时,monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

3.2、synchronized 方法底层原理:

synchronized 同步方法的实现是隐式的,无需通过字节码指令来控制,它是在方法调用和返回操作之中实现。JVM 可以通过方法常量池中的方法表结构(method_info Structure)中的 ACC_SYNCHRONIZED 访问标志 判断一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,标识该方法是一个同步方法,执行线程将先持有 monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。在方法执行期间,执行线程持有了 monitor,其他任何线程都无法再获得同一个 monitor。

如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。

4、JVM 对 synchronized 锁的优化:

在早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁 monitor 是依赖于操作系统的 Mutex 互斥量来实现的,操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在 JDK6 之后,synchronized 在 JVM 层面做了优化,减少锁的获取和释放所带来的性能消耗,主要优化方向有以下几点:

4.1、锁升级:偏向锁->轻量级锁->自旋锁->重量级锁

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,只能从低到高升级,不会出现锁的降级。重量级锁基于从操作系统的互斥量实现的,而偏向锁与轻量级锁不同,他们是通过 CAS 并配合 Mark Word 一起实现的。

4.1.1、synchronized 的 Mark word 标志位:

synchronized 使用的锁对象是存储在 Java 对象头里的,那么 Java 对象头是什么呢?对象实例分为:

  • 对象头

    • Mark Word
    • 指向类的指针
    • 数组长度
  • 实例数据
  • 对齐填充

其中,Mark Word 记录了对象的 hashcode、分代年龄、锁标记位相关的信息,由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,在 32位 JVM 中的长度是 32 位,具体信息如下图所示:

4.1.2、锁升级过程:

(1)偏向锁:如果一个线程获得了锁,那么进入偏向模式,当这个线程再次请求锁的时候,只需去对象头的 Mark Word 中判断偏向线程ID是否指向它自己,无需再进入 monitor 中去竞争对象,这样就省去了大量锁申请的操作,适用于连续多次都是同一个线程申请相同的锁的场景。偏向锁只有初始化的时候需要一次 CAS 操作,但如果出现其他线程竞争锁资源,那么偏向锁就会被撤销,并升级为轻量级锁。

(2)轻量级锁:不需要申请互斥量,允许短时间内的锁竞争,每次申请、释放锁都至少需要一次 CAS,适用于多个线程交替执行同步代码块的场景

(3)自旋锁:自旋锁假设在不久将来,当前的线程可以获得锁,因此在轻量级锁升级成为重量级锁之前,虚拟机会让当前想要获取锁的线程做几个空循环,在经过若干次循环后,如果得到锁,就顺利进入临界区,如果还不能获得锁,那就会将线程在操作系统层面挂起。

这种方式确实可以提升效率的,但是当线程越来越多竞争很激烈时,占用 CPU 的时间变长会导致性能急剧下降,因此 JVM 对于自旋锁有一定的次数限制,可能是50或者100次循环后就放弃,直接挂起线程,让出CPU资源。

(4)自适应自旋锁:自适应自旋解决的是 “锁竞争时间不确定” 的问题,自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
  • 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

但自旋锁带来的副作用就是不公平的锁机制:处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

(5)重量级锁:适用于多个线程同时执行同步代码块的场景,且锁竞争时间长。在这个状态下,未抢到锁的线程都会进入到 Monitor 中并阻塞在 _WaitSet 集合中。

4.2、锁消除:

消除锁属于编译器对锁的优化,JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译)会使用逃逸分析技术,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

4.3、锁粗化:

JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。

5、偏向锁的废除:

在 JDK6 中引入的偏向锁能够减少竞争锁定的开销,使得 JVM 的性能得到了显著改善,但是 JDK15 却将决定将偏向锁禁用,并在以后删除它,这是为什么呢?主要有以下几个原因:

  • 为了支持偏向锁使得代码复杂度大幅度提升,并且对 HotSpot 的其他组件产生了影响,这种复杂性已成为理解代码的障碍,也阻碍了对同步系统进行重构
  • 在更高的 JDK 版本中针对多线程场景推出了性能更高的并发数据结构,所以过去看到的性能提升,在现在看来已经不那么明显了。
  • 围绕线程池队列和工作线程构建的应用程序,性能通常在禁用偏向锁的情况下变得更好。

锁升级过程详细解析推荐阅读:https://www.jb51.net/article/186708.htm

参考文章://www.jb51.net/article/221033.htm

总结

本篇文章就到这里了,希望能给你带来帮助,也希望您能够多多关注我们的更多内容!

(0)

相关推荐

  • Java 动态数组的实现示例

    目录 静态数组 动态数组的实现原理 1.添加元素 2.删除元素 3.数组扩容 4.数组缩减 静态数组 Java中最基本的数组大家肯定不会陌生: int[] array = new int[6]; for (int i = 0; i < array.length; i++){ array[i] = 2 * i + 1; } 通过循环把元素放入指定的位置中,类似于这样: 这是一个静态数组,因为我们在第一步初始化的时候就已经固定了它的长度,后面再也无法改变.所以,由于有这个限制,静态数组不适用于那些不

  • java多线程模拟实现售票功能

    铁道部发布了一个售票任务,要求销售1000张票,要求有3个窗口来进行销售,请编写多线程程序来模拟这个效果. 1 线程类 测试方法: public static void main(String[] args) { MyThread t1 = new MyThread("窗口1"); MyThread t2 = new MyThread("窗口1"); MyThread t3 = new MyThread("窗口1"); t1.start(); t

  • Java实现递归计算n的阶乘

    本文实例为大家分享了Java实现递归计算n的阶乘的具体代码,供大家参考,具体内容如下 问题描述 利用递归的思想实现阶乘的计算,以 n!为例 (一).n的范围 1.n<0:n!无意义 2.n=0或n=1:n!=1 3.n>2:n!=n(n-1)! 关于 0!=1 的一个合理性解释: 根据阶乘的定义n!=n(n-1)!, 可变形为n=(n+1)!/(n+1), 带入有0=1!/1=1 (二).问题分析 1.n<0时提醒用户输入有误 (1)在未知循环次数时,可以采用while语句来提醒 (2)

  • Java面试题冲刺第二十五天--并发编程3

    目录 面试题1:你了解线程池么?简单介绍一下. 追问1:连接池 和 线程池是一个意思么?有什么区别? 不同点 面试题2:线程池中核心线程数量大小你是怎么设置的? 追问1:核心线程数量过大或过小会造成什么后果? 面试题3:线程池都有哪些状态呀? 追问1:什么条件下会进入TERMINATED状态 总结 面试题1:你了解线程池么?简单介绍一下. java提供的一个java.util.concurrent.Executor接口的实现用于创建线程池. 线程池是一种多线程处理形式,处理过程中将任务提交到线程

  • Java面试题冲刺第二十六天--实战编程

    目录 面试题1:你们是怎样保存用户密码等敏感数据的? 面试题2:怎么控制用户请求的幂等性的? 1.设置唯一索引:防止新增脏数据 2.token机制:防止页面重复提交 3.悲观锁 4.乐观锁 5.分布式锁 面试题3:你们是如何预防SQL注入问题的? 预防方式: 1.PreparedStatement(简单有效) 2.使用正则表达式过滤传入的参数 3.使用正则表达式过滤传入的URL 总结 面试题1:你们是怎样保存用户密码等敏感数据的? 本题回答参考朱晔的<Java业务开发常见错误100例> 我们知

  • Java中的equsals和==

    目录 Java的equsals和== 1.Java 中的== 2.Java 中equals方法 Java的equsals和== 前言:在我们常用的类中equals被重写后,作用就是为了比较对象的内容,==是比较对象的内存地址.但并不能说所有的equals方法就是比较对象的内容. 1.Java 中的== 1).对于对象引用类型:"=="比较的是对象的内存地址. 比如说: String s1 = "Hello"; String s2 = new String (&quo

  • Java CharacterEncodingFilter过滤器的理解和配置案例详解

    在web项目中我们经常会遇到当前台JSP页面和JAVA代码中使用了不同的字符集进行编码的时候就会出现表单提交的数据或者上传/下载中文名称文件出现乱码的问题,这些问题的原因就是因为我们项目中使用的编码不一样.为了解决这个问题我们就可以使用CharacterEncodingFilter类,他是Spring框架对字符编码的处理,基于函数回调,对所有请求起作用,只在容器初始化时调用一次,依赖于servlet容器.具体配置如下: <filter> <filter-name>character

  • Java DatabaseMetaData用法案例详解

    目录 一 . 得到这个对象的实例 二. 方法getTables的用法 三. 方法getColumns的用法 四.方法getPrimaryKeys的用法 五.方法.getTypeInfo()的用法 六.方法getExportedKeys的用法 一 . 得到这个对象的实例 Connection con ; con = DriverManager.getConnection(url,userName,password); DatabaseMetaData dbmd = con.getMetaData(

  • Java面试题冲刺第二十五天--实战编程2

    目录 面试题2:怎么理解负载均衡的?你处理负载均衡都有哪些途径? 1.[协议层]http重定向 2.[协议层]DNS轮询 3.[协议层]CDN 4.[协议层]反向代理负载均衡 5.[网络层]IP负载均衡 面试题3:你平时是怎样定位线上问题的? 总结 面试题1:当你发现一条SQL很慢,你的处理思路是什么? 发现Bug 确定Bug不是自己造成的,如果无法确定,不要理会步骤1 向组内宣传"程序里有一个未知Bug,错不在我" 谁响应,谁对Bug负责 没人响应,就要求特定人员配合调试 如果不配合

  • java实现多客户聊天功能

    java 实现多客户端聊天(TCP),供大家参考,具体内容如下 1. 编程思想: 1).要想实现多客户端聊天,首先需要有多个客户端,而这些客户端需要随时发送消息和接受消息,所以收发消息需要放入不同的线程中. 2).多客户聊天并不是多个客户之间进行两两通信,而是需要所有客户端与服务端进行交互,再由服务端统一下发信息到其他参与聊天的客户端. 2. 代码实现: 2.1 实现专用于接收消息的子线程ReceiveThread 将接收消息和发送消息分为两个线程,其中将发送消息写入主线程中,开启新的线程用于接

随机推荐