当前位置:   article > 正文

并发编程笔记3--synchronize底层实现原理

并发编程笔记3--synchronize底层实现原理

1、synchronize实现原理

Java中每一个对象都可以作为锁,具体的表现为以下3中方式

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是Synchronize括号里配置的对象

当一个线程试图访问同步代码块时,他必须先获得锁。退出或抛出异常必须释放锁。那么锁到底存在哪里呢?锁中存储的是什么信息呢?
从JVM规范中可以看到Synchronize在JVM中的实现原理,JVM是基于进入或退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
1、代码块同步是使用monitorenter和monitorexit指令实现,
2、而方法同步是使用另一种方式实现的,细节在JVM规范中并未详细说明。但是方法同步同样是是用这两个指令实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入在结束处或者异常处。JVM规定每一个monitorexter都有一个monitorexit与之相对应。任何对象都有一个monitor对象与之关联,当monitor对象被持有时,它将处于锁定状态。线程执行到monitorenter时,将会尝试获取对象所对应的monitor的所有权,即尝试获取对象锁。

public class SynchronizeDemo {
    public synchronized void test1(){}
    
    public void test2(){
        synchronized (this){
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

先用javac -g SynchronizeDemo.java文件成class文件
再用javap -v SynchronizeDemo.class文件查看指令信息
同步方法块:使用monitorenter和monitorexit
在这里插入图片描述

同步方法:使用ACC_SYNCHRONIZED来实现
在这里插入图片描述

现在回到前面的问题,锁到底存在哪里呢?答案就是存放在Java的对象头里,接下来让我们看下Java对象头的结构。

2、Java对象头

Synchronize用的锁是存放在Java对象头里的。如果对象是数组类型,那么虚拟机用3个字宽(word)来存储对象头,如果对象是非数组结构,则用2个字宽存储对象头,在32位虚拟机中1字宽等于4字节,即32位。
Hotspot虚拟机对象头中主要存放两部分数据,一个是Mark Word(标记字段),一个是Klass Pointer(类型指针),其中Klass pointer是对象指向它的类元数据指针,虚拟机通过这个指针用来确认这个对象属于哪个类的实例。Mark Word用来存储对象的HashCode和锁信息,他是实现偏向锁和轻量级锁的关键。
Java对象头的结构如下:

长度内容说明
32/64bitMark Word存储hashcode或者锁信息等
32/64bitClass Meatadata Adress
即 Klass Pointer存储到对象类型数据的指针
32/32bit数组长度数组的长度

Mark Word结构如下:

锁状态25bit4bit1bit是否偏向锁2bit锁标志位
无锁状态hashcodeGC分代年龄001

在运行期间Mark Word里存储的数据会随着锁状态位的变化而变化,,Mark Word里可能存储以下4中数据:

锁状态25bit4bit1bit2bit
23bit2bit是否偏向锁锁标志位
轻量级锁指向栈中的锁记录的指针00
重量级锁指向互斥量的(重量级锁)指针10
GC标记11
偏向锁23bit线程IDEpoch
3bit3bit
GC分代年龄101

3、锁的优化和升级

为了减少锁的性能消耗,从jdk1.6开始对锁的实现进行大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在4种状态依次为:无锁状态,偏向锁,轻量级锁,重量级锁。他们会随着竞争的升级,而逐渐升级,但是锁只能升级而不能降级。这种策略是为了提高获得锁和释放锁的效率。

3.1、锁优化

3.1.1、自旋锁

线程的阻塞和唤醒需要CPU从用户态转换到内核态,对CPU来说频繁的阻塞和唤醒是比较消耗性能的。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。

3.1.2、自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
它怎么做呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

3.1.3、锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。

3.1.4、锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步。这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
那什么是锁粗化?
就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

3.1.5、偏向锁

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的逻辑如下:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时就不需要进行CAS操作来加锁和解锁。只需要简单测试下对象头中的Mark Word中是否存储了当前线程的ID,如果测试成功表示已经获取锁成功。如果失败,则需要在测试下Mark Word中的是否偏向锁是否为1,如果没有设置,则尝试CAS竞争锁。如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
流程如下:

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块。

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是没有正在执行的代码)。
可以使用JVM参数: -XX:BiasedLockingStartupDelay=0 来关闭偏向锁激活延迟
使用:-XX:-UseBiasedLocking=false来关闭偏向锁

3.1.6、轻量级锁

引入轻量级锁的主要目的是在只有少量线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时当前线程尝试使用自旋来获取锁。这时锁升级为重量级锁,并阻塞其他线程。

释放锁轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在栈帧中Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,这时锁会膨胀为重量级锁。且需要在释放锁的同时需要唤醒被挂起的线程。

4、锁的对比

优点缺点使用场景
偏向锁加锁和解锁不需要额外的消耗,与执行非同步方法仅存在纳秒级的差距如果线程间存在竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的情况
轻量级锁竞争的线程不会堵塞,提高了程序的响应速度始终得不到锁的线程,使用自旋会消耗CPU追求响应时间,同步块执行速度非常块
重量级锁线程竞争不使用自旋,不会消耗CPU线程堵塞,响应时间缓慢追求吞吐量,同步块执行速度比较慢

image.png

从图中可以看到。任意线程对Object的访问,首先要获取Monitor监视器,如果获取失败,线程进入同步队列中,线程状态变为BLOCKED。当获取锁的线程释放了锁,同时在释放锁之后唤醒阻塞在同步队列中的线程使其尝试对监视器的重新获取。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小丑西瓜9/article/detail/621596
推荐阅读
相关标签
  

闽ICP备14008679号