javaJava基础JVM&并发&多线程@花城鱼

Synchronized原理剖析

2020-06-24  本文已影响0人  冬狮郎

本文大量引用简书、csdn、公开课等知识。如有冒犯请联系我删除。我也会在后面标出相关引用。

本文可以转载,引用烦请表明出处。

目录:(双击复制,CTRL+F进行文章定位)
- # 引子 - 锁膨胀流程概图简述
- # 一、Synchronized作用
- # 二:Synchronized用法
- # 三:Synchronized背景知识介绍
    - ## 3.1、对象在内存中的布局
    - ## 3.2、Mark Word基础介绍
    - ## 3.3、类指针基础介绍
    - ## 3.4、管程(Monitor)(内置锁)
    - ## 3.5、安全点(safe-point)
- # 四:Synchronized的底层原理
    - ## 4.1、锁升级
        - ### 4.1.1、锁升级之偏向锁
        - ### 4.1.2、锁升级之轻量级锁
        - ### 4.1.3、锁升级之重量级锁
    - ## 4.2、锁优化(自旋锁、锁消除、锁粗化、减小锁力度)
        - ### 4.2.1、锁优化之自旋锁
        - ### 4.2.2、锁优化之锁消除
        - ### 4.2.3、锁优化之锁粗化
        - ### 4.2.4、锁优化之减小锁粒度

引子:锁膨胀流程概图简述

https://blog.dreamtobe.cn/2015/11/13/java_synchronized/ 简易版流程

简而言之:
偏向锁情况下,会自旋CAS修改MarkWord的线程ID。CAS成功即加锁成功。
轻量级锁的情况下,将MarkWord拷贝到当前线程栈桢的Lock Record中,并把Lock Record的引用CAS修改到MarkWord中。
重量级锁的情况下,会去竞争ObjectMonitor。并将Monitor的_owner指向当前线程。

一、Synchronized作用

synchronized关键字是Java中解决并发问题的一种常用也是最简单的方法,其作用有三个:

互斥性就不需要解释了,加锁后当前线程独有该资源,其他线程无法访问。

可见性是依赖于内存屏障来实现的。至于内存屏障,我会在之后的文章中单独讲一篇。

有序性并不是我们所理解的禁止指令重排序。它的有序仅可以保证被加锁的代码块内部的数据不会和代码块外部的代码重排续。

二:Synchronized用法

不同场景下的用法及作用
public class Test {
    
    private Object object1 = new Object();
    private static Object object2 = new Object();
    
    public synchronized void test1() {
        try {
            System.out.println("synchronized锁实例方法:" + Thread.currentThread().getName());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public static synchronized void test2() {
        try {
            System.out.println("synchronized锁静态方法:" + Thread.currentThread().getName());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public void test3() {
        try {
            synchronized (this) {
                System.out.println("synchronized锁this:" + Thread.currentThread().getName());
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public void test4() {
        try {
            synchronized (Test.class) {
                System.out.println("synchronized锁class对象:" + Thread.currentThread().getName());
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public void test5() {
        try {
            synchronized (object1) {
                System.out.println("synchronized锁实例对象:" + Thread.currentThread().getName());
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public void test6() {
        try {
            synchronized (object2) {
                System.out.println("synchronized锁静态实例对象:" + Thread.currentThread().getName());
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class Synchronized {
    public static void main(String[] args) throws Exception{
        Test a1 = new Test();
        new Thread(() -> a1.test1()).start();
        new Thread(() -> a1.test1()).start();
        Test a2 = new Test();
        new Thread(() -> a2.test1()).start();
        Test a3 = new Test();
        new Thread(() -> a3.test1()).start();
    }
}

类锁是所有线程共享的。所以我们看到,上面的test1-6方法中,凡是添加了类锁或锁住的静态变量的方法,都是延时1秒顺序打印的。

我们查看被编译后的代码,就需要通过命令或插件进行实现了。
1、javac编译,然后javap -verbose反编译。
2、Idea可安装jclasslib bytecode viwer插件查看编译后的数据。


jclasslib插件

我们来看下,synchronized修饰代码块和修饰方法时,反编译的结果:

  // synchronized修饰代码块
  public void testSyn();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0              
         1: dup                 
         2: astore_1            
         3: monitorenter        //申请获得对象的内置锁
         4: aload_1             
         5: monitorexit         //释放对象内置锁
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit         //释放对象内置锁
        12: aload_2
        13: athrow
        14: return
// synchronized修饰方法
  public synchronized void testSyn();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 同步锁标识
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 3: 0

我们知道,synchronized修饰代码块时,在编译成字节码后,代码块会被monitorentermonitorexit包裹住。当线程执行到monitorenter时,会去获取被synchronized锁住对象的锁,在执行到monitorexit时,会释放对象的锁。

而当synchronized修饰类时,我们看字节码会发现,多了一个ACC_SYNCHRONIZED标识。ACC_SYNCHRONIZED的标志位是1,当线程执行方法的时候会检查该标志位,如果是1,就自动的在该方法前后添加monitorentermonitorexit指令,可以称为monitor指令的隐式调用

面试时会被问:synchronized修饰代码块修饰方法修饰静态方法有什么区别?结合上面的图示和代码,我们可以得到以下两点结论:
1、显示调用和隐式调用的区别;
2、锁的对象区别:实例对象或类对象(静态代码块存在于方法区中,所有的线程共享)。

三:Synchronized背景知识介绍

3.1、对象在内存中的布局

根据JVM的区分,对象分配在堆内存中。分为几个部分:对象头、类型指针、实例数据和填充数据。

对象在堆内存中的布局

为什么总的数据字节数要保证是8的倍数?

根据深入理解Java虚拟机第三版2.3.2对象内存布局里面描述,HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。而对象头正好是8字节的倍数,所以实例数据没有对齐时,需要填充对齐。而它并没有什么含义,仅仅起着占位符的作用。

上述布局,我们可以通过openJdk提供的一个工具类来进行验证。

<--  引入依赖 -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>
    // 通过JVM命令-XX:+/-UseCompressedClassPointers可以开启/关闭类指针压缩。默认开启。
    public static void main(String[] args){
        long i = 1;
        System.out.println(ClassLayout.parseInstance(i).toPrintable());
        long[] i1 = {1,2,3};
        System.out.println(ClassLayout.parseInstance(i1).toPrintable());
    }
[64位系统默认开启类指针压缩]输出结果 [64位系统默认关闭类指针压缩]输出结果

3.2、Mark Word基础介绍

Mark Word用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,锁指针等。这部分数据在32bit和64bit的虚拟机中大小分别为32bit和64bit,官方称为Mark word。考虑到虚拟机的空间效率,Mark word被设计成一个非固定的数据结构以便在极小的空间中能够存储尽量多的信息,他会根据对象的状态复用自己的存储空间。

32位 对象头Mark Word 64位 对象头Mark Word

我们知道,新生代的数据,在minor gc存活15次后,会回收到老年代。
也可以通过GC命令-XX:MaxTenuringThreshold设置晋升老年代的年龄阈值,而这个参数的最大值也是15。Parallel Scavenge中默认值为15,CMS中默认值为6,G1中默认值为15。
原因就是因为在对象头中,分代年龄4bit,最大为1111,换算成十进制就是15。

3.3、类指针基础介绍

此处参考深入理解Java虚拟机。

我们的Java程序通过栈上的reference数据来操作具体堆上的具体对象。而《Java虚拟机规范》仅规定它是一个指向对象的引用,并没有定义这个引用是通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定。

如果使用句柄访问,Java堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象实例数据与类型数据各自具体的地址信息。

使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如果放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要对一次间接访问的开销。

使用句柄方式的好处在于reference中存储的是稳定句柄地址,reference中存储的直接就是对象地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时,只会改变句柄中的实例数据指针,而reference本身不需要被修改。而直接指针访问的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

就HotSpot而言,使用的第二种方式进行对象访问(例外情况:如果使用了Shenandoah收集器的话也会有一次额外的转发)。

3.4、管程(Monitor)(内置锁)

JVM的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor是由ObjectMonitor实现,而ObjectMonitor是由C++的ObjectMonitor.hpp文件实现。

//结构体如下
ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count        = 0;  
  _waiters      = 0,  
  _recursions   = 0;      //线程的重入次数
  _object       = NULL;  
  _owner        = NULL;   //标识拥有该monitor的线程
  _WaitSet      = NULL;   //等待线程组成的双向循环链表,_WaitSet是第一个节点
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;  //多线程竞争锁进入时的单向链表
  FreeNext      = NULL ;  
  _EntryList    = NULL ;  //_owner从该双向循环链表中唤醒线程节点,_EntryList是第一个节点
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}

整个monitor运行的机制过程简述如下:两张图里面的某些名词不一样。比如entrySet=entryList

1、如果monitor对象的_owner为空,那么将_owner设置为当前线程,当_recursions设置为1;
2、_owner非空的情况下,如果_owner是当前线程,那么是重入操作,将_recursions的值+1;
3、如果_owner不是当前线程,先通过多次自旋尝试获取锁(自旋次数是轻量级锁膨胀时设置的),获取失败后,将当前Thread插入_cxq队列并调用本地park()方法挂起。
4、只有获取到锁的线程执行wait()方法时,线程会被插入到_waitSet中。等待notify()或notifyAll()方法或是根据不同的策略,判断是进入_cxq队列还是_entryList中。
5、当_owner线程执行monitorexit命令后,并且_entryList为空、_cxq不为空时,会将cxq中的数据移动到_entryList中。

所以,_cxq队列为等待竞争锁的队列,_entryList为竞争锁的线程队列。很多地方也会将_cxq队列也写作ContentionList。

_cxq或叫ContentionList并不是真实存在的队列。它是由Node和next指针组成的逻辑队列。

此段流程建议参考大佬博客,非常详细。

线程出入队列流程 - 简述版 线程入队出队流程图 - 大佬版

1、在_waitSet中被唤醒的对象,如果竞争获取monitor,会去读取它保存在pc计数器中的地址,从它调用wait方法的地方继续执行代码。
2、在竞争获取monitor时,会涉及到自旋和重入的场景。

上面所介绍的通过synchronzied实现同步用到了对象的内置锁(ObjectMonitor)。而在ObjectMonitor的函数调用中会涉及到Mutex lock等特权指令,那么这个时候就存在操作系统用户态和核心态的转换,这种切换会消耗大量的系统资源,因为用户态和核心态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给很多变量、参数给内核,内核也要保护好用户态在切换时的一些寄存器值、变量等,这也是synchronized效率低的原因。在jdk1.6版本之后,从jvm层面做了优化

3.5、安全点(safe-point)

OpenJdk官方名词解释。在官方文档上,我们可以看到对safe point的解释:

A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run. (As a special case, threads running JNI code can continue to run, because they use only handles. During a safepoint they must block instead of loading the contents of the handle.)

安全点是指代码中的一些特定位置。当线程执行到这些位置的时候,线程的状态是确定的,GC root状态是已知的,并且堆里面的对象是一致的。这样JVM就可以安全的进行一些操作。比如GC等。这些特定位置主要有以下几种:

以HotSpot VM为例,
在解释器里每条字节码的边界都可以是一个safepoint,
因为HotSpot的解释器总是能很容易的找出完整的“state of execution”。
而在JIT编译的代码里,
HotSpot会在所有方法的临返回之前,
以及所有非counted loop的循环的回跳之前放置safepoint。
HotSpot的JIT编译器不但会生成机器码,
还会额外在每个safepoint生成一些“调试符号信息”,
以便VM能找到所需的“state of execution”。

作者:RednaxelaFX
链接:https://www.zhihu.com/question/29268019/answer/43762165
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

判断当前线程是否在安全点,一般有两种方式。hotspot采用的是第一种。

引用:Java全局安全点源码

本小结参考:Hotspot GC研究- GC安全点 | JVM-安全点 | Java-JVM-安全点SafePoint |

四:Synchronized的底层原理

4.1、锁升级

在了解了synchronized重量级锁效率特别低之后,jdk自然做了一些优化,出现了偏向锁,轻量级锁,重量级锁,自旋等优化。我们应该改正monitorenter指令就是获取对象重量级锁的认知。很显然,优化之后,锁的获取判断次序是:偏向锁->轻量级锁->重量级锁

4.1.1、锁升级之偏向锁

在某些情况下,大部分时间是同一个线程竞争所资源,如果线程每次都需要获取和释放锁,每次操作都会发生用户态和内核态的切换。

偏向锁的产生,是为了优化同一线程多次申请同一把锁的竞争,线程只需要去对象头Mark Word中去判断是否有偏向锁指向它的ID,无须进入Monitor竞争对象。当对象被当作同步锁,并且有一个线程抢到锁时,锁的标志位还是01,"是否偏向锁"标志位设置为01,并且记录抢到锁的线程ID,表示进入偏向锁状态。

当其他线程竞争锁资源时,偏向锁会被撤销。撤销时需要等待全局安全点,暂停有用偏向锁的线程,检查该线程是否已经退出代码块。如果已退出,那么其他线程抢占该资源,否则会触发锁升级。

获取偏向锁

偏向锁的撤销过程:


偏向锁的撤销过程

高并发场景下,大量的线程竞争同一个锁资源时,偏向锁会被撤销。而撤销动作的具体执行需要等待JVM的stop_the_world,会带来了更大的性能开销。我们可以根据情况,添加JVM参数关闭偏向锁来提高系统的性能。

总结:在stw期间,不光需要操作gc,还要额外的执行锁撤销的动作。虽然每次撤销的时间耗时不长,但是如果非常频繁的话,也会消耗很多的系统资源。
另附:jvm标准并未规定synchronized一定要降级。像Hotspot是支持降级的,降级对象成仅仅支持VMThread访问而无法被JavaThread访问。本段引用

//关闭偏向锁(默认打开)
-XX:-UseBiasedLocking 

// 或直接设置成重量级锁
-XX:+UseHeavyMonitors

4.1.2、锁升级之轻量级锁

当有另外的线程竞争这个锁时,由于该锁已经是偏向锁,当发现对象头中的线程ID不是自己的线程ID时,就会进行CAS自旋操作竞争锁。如果竞争成功,替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁状态。如果竞争锁失败,代表当前锁有一定的竞争,则会升级为轻量级锁。

轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内,都不存在长时间的竞争。

获取轻量级锁

4.1.3、锁升级之重量级锁

轻量级锁CAS失败后,线程将会被挂起进入阻塞状态。如果持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑需要重新申请锁资源。涉及到线程上下文的切换,耗费资源。

基于大多数情况下,线程持有锁的时间都不会太长,所以JVM提供了自旋锁,通过自旋的方式不断尝试去获取锁,从而避免线程被挂起阻塞的情况。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM设置。我们不要设置过多的重试次数,因为这样会长时间的占用CPU资源。

自旋锁重试之后,如果抢锁依旧失败,同步锁就会升级成重量级锁,锁的标志位改为10。在这个状态下,未抢到锁的线程都会进入Monitor,之后会被阻塞到_waitSet队列中。

竞争进入重量级锁
重量级锁执行流程

在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统的性能。

-XX:-UseSpinning //参数关闭自旋锁优化(默认打开) 
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制

4.2、锁优化(自旋锁、锁消除、锁粗化、减小锁力度)

4.2.1、锁优化之自旋锁

现在我们知道了,在整个锁升级过程中,会通过多次自旋,防止线程阻塞。但是,在高并发场景下,自旋会极大的消耗系统的资源。所以我们要根据实际业务来判断是否需要开启自旋锁。

4.2.2、锁优化之锁消除

在JIT编译器在动态编译同步代码块时,借助了一种逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其他线程。如果是的话,JIT编译器在编译同步块时不会再生成synchronized所标识的锁的申请与释放的机器码(monitorenter或monitorexit),消除了锁的使用。

4.2.3、锁优化之锁粗化

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

4.2.4、锁优化之减小锁粒度

我们可以通过代码层面来实现锁粒度的减小。当我们的锁对象是一个数组或队列时,集中竞争一个对象会非常激烈,锁也会升级为重量级锁。但是如果我们将数组或队列拆分成多个小对象,可以降低锁的竞争,提高并行效率。

同理,我们知道ConcurrentHashMap在1.8版本之前就是通过使用分段Segment来降低锁竞争,1.8版本后再次取消分段概念,而是将锁粒度设置在数组的每个对象列表上,更加降低了锁的粒度。

上一篇 下一篇

猜你喜欢

热点阅读