java并发之 Synchronized关键字 详详详解

Synchronized概念

  • 它主要用于java并发中的同步
  • 该关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行
  • 它可以以任意一个java对象做为锁,来实现同步

Synchronized用法

主要有三种使用方式

  • 修饰实例方法
synchronized void method() {
    
    
  //业务代码
}

以当前实例对象作为锁

  • 修饰静态方法
synchronized void staic method() {
    
    
  //业务代码
}

以当前类的class对象作为锁(这里class对象和实例对象是分开的)

  • 修饰代码块
synchronized(this) {
    
    
  //业务代码
}

此处是用户自己指定作为锁的对象,当然这里可以指定为当前实例对象即this,就和修饰实例方法很像了;当然也可以指定为类对象,即xxx.class,则此时和修饰静态方法就很像了。

对象为什么可以作为锁

在简单的介绍了Synchronized的概念和用法之后,我们需要一个前铺知识,就是java对象为何可以作为锁,以及如何作为锁。

首先java对象在内存中的结构是这样的:

在这里插入图片描述
分为对象头,实例数据,和对齐填充。
(对象的头部;对象自身的一些数据;对象数据不够,为了满足jvm的标准,从而填充数据进行补齐)

Synchronized用的锁是存在于java对象头里的。
java对象头的结构为三部分:

  • mark word:存储对象的hashcode或锁信息,和分代年龄(GC相关)
  • class metadata address:存储到对象类型数据的指针(指向class对象,表明该对象是什么类型)
  • array length:数组长度(如果是数组)

而mark word的结构如下所示:(这里包括下面统一看32位的了,懒得再看64位的,都差不多)
在这里插入图片描述

总体来说,就是对象头中的markword存储着该对象的锁的相关信息,该对象锁是什么类型什么状态,就看这里的mark word是什么值就ok了。

Synchronized底层实现

(此处针对的是jdk1.6之前的Synchronized与jdk1.6之后的重量级锁的实现,此处意为和偏向锁,轻量锁区分开,它们不使用monitor)
针对代码块和方法,Synchronized的实现方式是不一样的,这里需要区分开。

代码块

  • 如果自己写一个demo,然后用javap查看字节码指令,就可以发现
  • 在同步代码块的前后是有monitorenter和monitorexit指令的,这是jvm帮我我们加的
  • 当执行monitorenter指令时,线程试图获取锁也就是获取对象监视器monitor的持有权
  • 在 Java 虚拟机(HotSpot)中,Monitor 是基于 **C++**实现的,由ObjectMonitor实现的。
  • 当获取到锁时(重量锁),markword中会存储指向monitor对象的指针
    在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
    在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
  • (另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。)

总的来说,就是jvm帮我们加了两个指令,让线程去获取C++实现的monitor对象,如果获取到了,则将该对象的指针放到markword中去,从而获取锁完毕(重量锁)

方法

  • 如果自己写一个demo,然后用javap查看字节码指令,就可以发现
  • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

两者的本质都是对对象监视器 monitor 的获取。本质一样

Synchronized的优化

在jdk1.6之前,Synchronized是一个很沉重的关键字。
早期的Synchronized是一个重量级锁,是一个悲观锁。

因为上面介绍的monitor是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

而实际上,在我们的应用场景中,并不是每时每刻都需要这么沉重的锁。
很多时候,我们会出现很长时间的,只有一个线程访问同一个同步代码块的情形;也会出现,多个线程交替访问同步块的情形;当然也会出现多个线程同时竞争同步代码块的情形。
针对不同的情形,如果我们都采用单一的重量锁来解决,那么效率也太低了。

所以jdk1.6在JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

细谈java中的锁

(jdk1.6之后)
这里也算是Synchronized原理的实现。
在java对象头的markword中定义的锁类型有四种:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

为什么分成这状态上面一节已经说过了。

这四种类型,是级别由低到高的,且是单向的,锁只能保级或者升级,不能降级。

在markword中,各种类型的锁是这样定义的
在这里插入图片描述
下面这个表中是不同锁状态下markword中记录的信息。(很关键)。重量锁那里存储的是指向monitor的指针。
在这里插入图片描述

偏向锁

  • 用来解决只有一个线程访问同步块的场景
  • 偏向锁不会主动释放(什么时候释放?有竞争时)
  • 大部分情况,都是同一个线程进入同一块代码块,所以没有必要针对这种情况来获取锁,释放锁
  • 下面来说一下偏向锁的获取与升级流程:
  1. 线程A访问markword中偏向锁的标识是否为1,锁标识位是否为01–来确认可偏向装填
  2. 如果是可偏向状态,则测试线程ID是否指向当前线程A,如果是进入步骤5,否则进入步骤3
  3. 如果线程ID未指向当前线程A,则通过CAS操作竞争锁。如果竞争成功,则将markword中线程设置为当前线程ID,获取偏向锁成功,然后执行步骤5,如果失败,则执行步骤4
  4. 如果CAS获取偏向锁失败,则表示有竞争。此时,说明已经有其他线程B已经获得了偏向锁。(因为偏向锁不会主动释放),所以当前线程A不知道持有偏向锁的线程B到底还在不在运行。于是,先在到达全局安全点时(没有字节码指令执行的时间点),暂停持有偏向锁的线程B。jvm去检查持有偏向锁的线程B是否存活,如果死了,则将锁变为无锁状态,然后重新偏向新的线程;如果活着,则执行拥有偏向锁的栈,当前锁升级成轻量级锁。(撤销偏向锁时会导致STW)

总的来说,只要超过一个存活的线程访问同一个同步区域,一般都会升级成轻量级锁,偏向锁只应付单线程场景

轻量级锁

  • 用来解决多个线程交替访问同步代码块的场景。
  • 例如线程A执行完之后,退出同步区域,然后线程B就进入同步区域,此时就是轻量级锁。
  • 对于这种超过一个线程的交替访问,明显不需要沉重的重量锁,所以使用轻量锁来解决。
  • 下面来说一下轻量级锁的获取与释放流程:
    • 在线程A进入同步块之前,jvm会帮忙把锁对象头的markword的数据(称为displaced
      markword)复制到线程A的栈帧中的锁记录中去
    • 然后线程A尝试用CAS将锁对象头的markword替换为指向自己的锁记录的指针(竞争锁,加锁)
      如果成功,就是获取到了轻量级的锁
      如果失败,则一直自旋尝试CAS(消耗cpu的,不过也只有一次,当持有锁的线程释放锁时,会膨胀为重量级锁。同时,自旋是有阈值的,如果超过这个阈值,就会直接升级为重量锁)
    • 解锁:解锁时,尝试将锁记录中的Displaced mark word复制回对象头(还原之前的状态)
      如果失败,则意味着有竞争,则膨胀为重量级锁
      如果成功,则不用升级(当然也不可能降级)

重量级锁

  • 其实上面的Synchronized一节中已经讲过重量级锁的实现了,是通过指令,monitor对象等实现的
  • markword中存储了指向monitor对象的指针
  • 当线程A持有重量级锁之后,其他锁再访问同步区域时会被阻塞,等线程A释放锁之后才会将它们唤醒,然后它们再去竞争。

除了在偏向锁状态可以回到无锁状态,其他是不能降级的。

补充一个问题:当在有锁状态时,hashcode放到哪里去了呢?
我在看并发这一块的时候,也在考虑这个问题,hashcode不是没有地方放么。
经过查询,我找到了答案:

  • 当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
  • 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
  • 重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁可以存下identity hash code。
  • 这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

参考资料

RednaxelaFX 知乎的回答,hashcode去哪里了
Java并发基石——所谓“阻塞”:Object Monitor和AQS(1)
知乎用户 知乎的回答 java锁为什么相互膨胀
java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁
15.多线程编程中锁的4种状态-无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态
javaGuide并发部分

猜你喜欢

转载自blog.csdn.net/qq_34687559/article/details/114164849
今日推荐