• JAVA多线程(六) synchronize原理分析


    Synchronized基本特性

    1.在普通方法上加上Synchronized锁,则使用this锁

    2.在静态同步方法上,则使用当前类的class字节码

    3.可以自定义Synchronized锁的对象

    测试代码

    public class TestThread implements Runnable{
    
        @Override
        public void run() {
            callA();
        }
    
         void callA(){
            synchronized (TestThread.class){
               callB();
            }
        }
    
         void callB(){
            synchronized (TestThread.class){
    
            }
         }
    
        public static void main(String[] args) {
            new Thread(new TestThread()).start();
        }
    }

    Javap反汇编分析方法callA() 

    使用javap反编译命令: javap -p -v  class文件位置 

    分析汇编指令可以发现,在执行callB()方法,有执行monitorenter 和monitorexit,其中monitorexit指令有两个,分别代表正常退出和异常退出。下面我们看看这两个指令的官方文档的介绍

    monitorenter官方文档解释

    https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

    每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的
    monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:
    1.若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者) 2. 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1 3. 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直 到monitor的进入数变为0,才能重新尝试获取monitor的所有权。 

    monitorenter小结:

    synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待

    monitor才是真正的锁,是一个c++对象

    owner 拥有锁的线程

    recursions 记录获取锁的次数

    monitorexit官方文档解释

    https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit

    1.能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
    2.执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,
    此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

    monitorexit释放锁:monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。为什么会有两个monitorexit,因为 Synchronized锁的同步代码块如果抛出异常的情况下,则自动释放锁。

    synchronized Monitor源码分析(基于JAVA虚拟机的C++的源码分析)

    http://hg.openjdk.java.net/jdk8 下载hotspot虚拟机

    锁池

    锁池: 假设线程A已经拥有了某个对象的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),
    由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,
    所以这些线程就进入了该对象的锁池中。

    等待池

    等待池: 假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,
    这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程
    调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。
    如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.

    它们之间的规则如下:

    如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

    当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。

    优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

    在Java虚拟机(HotSpot)中,monitor是有ObjectMonitor实现的(C++实现的,位于HotSpot虚拟机ObjectMonitor.hpp文件,github 地址:https://github.com/unofficial-openjdk/openjdk/blob/jdk8u/jdk8u/hotspot/src/share/vm/runtime/objectMonitor.hpp),其主要数据结构如下

    // are simple integers or pointers
      ObjectMonitor() {
        _header       = NULL;
        _count        = 0;  // 记录个数
        _waiters      = 0,
        _recursions   = 0;   // 递归次数/重入次数
        _object       = NULL;  //存储Monitor关联对象
        _owner        = NULL; // 记录当前持有锁的线程ID
        _WaitSet      = NULL;  // 等待池:处于wait状态的线程,会被加入到_WaitSet
        _WaitSetLock  = 0 ;
        _Responsible  = NULL ;
        _succ         = NULL ;
        _cxq          = NULL ;  //多线程竞争时的单向链表
        FreeNext      = NULL ;
        _EntryList    = NULL ;  // 锁池:处于等待锁block状态的线程,会被加入到该列表
        _SpinFreq     = 0 ;
        _SpinClock    = 0 ;
        OwnerIsThread = 0 ;
        _previous_owner_tid = 0;
      }

    JAVA对象布局

    在JVM中,对象在内存中的布局分为三个部分:对象头(Mark Word,Klass pointer)、实例数据和对齐填充 

    对象头

    HotSpot虚拟机的对象头(Object Header)包括两部分信息:
    第一部分"Mark Word":用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、
    线程持有的锁、偏向线程ID、偏向时间戳
    等. 第二部分
    "Klass Pointer":对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(
    数组,对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,
    但是从数组的元数据中无法确定数组的大小。 )

    注意:在64位的虚拟机情况下 mark word 占用64位 ,32位虚拟机占32位。64位等于8个字节(1Byte=8bit)

    虚拟机源码中查看Mark Word

    markOop.hpp, github地址:https://github.com/unofficial-openjdk/openjdk/blob/jdk8u/jdk8u/hotspot/src/share/vm/oops/markOop.hpp

     

    Klass Pointer

    这一部分用于存储对象的类型指针,该指针指向它的类元数据,jvm通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,
    即32位的JVM为32位,64位的JVM为64位。如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64的JVM将会比32位的JVM多耗费50的内存。
    为了节约内存可以使用选项
    -XX:+UseCompressedOops 开启指针压缩。
    其中 oop即ordinary object pointer 普通对象指针。
    -XX:+UseCompressedOops 开启指针压缩 -XX:-UseCompressedOops 不开启指针压缩 对象头:Mark Word+Klass Pointer类型指针 未开启压缩的情况下 32位 Mark Word =4bytes ,类型指针 4bytes ,对象头=8bytes =64bits 64位 Mark Word =8bytes ,类型指针 8bytes ,对象头=16bytes=128bits; 注意:默认情况下,开启了指针压缩 可能只有12字节。

    实例属性

    就是定义类中的成员属性

    对齐填充

    对齐填充并不是必然存在的,也没有特定的含义,仅仅起着占位符的作用。
    由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。
    而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。

    查看Java对象布局

     需要引入maven依赖

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.10</version>
    </dependency>

    测试代码:

    public class ObjectLayout {
        private int o_a;
        private boolean o_b;
    
        public static void main(String[] args) {
            System.out.println(ClassLayout.parseInstance(new ObjectLayout()).toPrintable());
        }
    }

    测试结果:

    可以发现可以指针压缩的对象头只有12个字节,int 占4个个字节,boolean占1个字节,然后填充7个字节,刚好是8的整数倍 

     基本数据类型占多少字节

    1、bit --位:位是计算机中存储数据的最小单位,指二进制数中的一个位数,其值为"0"或"1"2、byte --字节:字节是计算机存储容量的基本单位,一个字节由8位二进制数组成。在计算机内部,一个字节可以表示一个数据,也可以表示一个英文字母,
    两个字节可以表示一个汉字。 64位
    /8 1Byte=8bit (1B=8bit) 1KB=1024Byte =8*1024bit 1MB=1024KB 1GB=1024MB 1TB=1024GB 基本数据类型占用( int 32bit =4byte short 16bit =2byte long 64bit =8byte byte 8bit char 16bit float 32bit double 64bit boolean 1bit

    Synchronized锁升级

    锁的升级  偏向锁→轻量级锁(短暂自旋)→重量级锁

       

    偏向锁

    InterpreterRuntime.cpp的InterpreterRuntime::monitorenter函数

    当一个线程获取到锁之后,会在锁的对象头中会记录该线程的id,下次再进入到该同步代码块的时候,
    不需要再重复的加锁CAS操作和解锁,从而提高效率,整个过程简单理解为就是偏向锁。 最乐观的锁,认为从始至终只有一个线程请求一把锁。 偏向锁的来源是因为Hotsopt的作者研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由统一线程多次获得,
    而线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,
    为了让线程获得锁的代价更低而引入了偏向锁 在实际应用运行过程中发现,锁总是同一个线程持有,很少发生竞争,也就是说锁总是被第一个占用他的线程拥有,
    这个线程就是锁的偏向线程。 那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁,直到竞争发生才释放锁。以后每次同步,
    检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步,无需每次加锁解锁都去CAS更新对象头,如果不一致意味着发生了竞争,
    锁已经不是总是偏向于同一个线程了,这时候需要锁膨胀为轻量级锁,才能保证线程间公平竞争锁。

    偏向锁过程

    当一个线程访问同步代码块并获取锁时;会在 对象头 和 自己的栈帧中 的锁记录中记录存储偏向锁的线程ID;以后该线程再次进入
    同步代码块时不再需要CAS来加锁和解锁;只需要简单测试一下对象头的 markword 中偏向锁线程的ID是否是当前线程ID; 如果成功;表示线程已经获取到锁直接进入代码块运行, 如果测试失败;检查当前偏向锁字段是否为0; 如果为0(表示线程还不是偏向锁,是无锁状态);采用CAS操作将偏向锁字段设置为1;并且更新自己的线程ID到markword 字段中; 如果为1;表示此时偏向锁已经被别的线程获取;则此时线程不断尝试使用CAS获取偏向锁; 或者将偏向锁撤销,升级成轻量级锁; (升级概率较大)

    偏向锁原理

    当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。
    同时使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord中。若CAS操作成功,持有偏向锁的线程以后每次进入到
    这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

    开启偏向锁

    偏向锁在Java 1.6之后是默认启用的,但在应用程序启动几秒钟之后才激活,可以使用 -XX:BiasedLockingStartupDelay=0
    参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过 XX:-UseBiasedLocking=false
    参数关闭偏向锁。

    偏向锁撤销

    偏向锁使用一种等待竞争出现才释放锁的机制;当有其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁, 偏向锁的撤销开销较大;
    需要等待线程进入全局安全点 safepoint 1.偏向锁的撤销必须等待全局安全的点 2.暂停拥有偏向锁的线程,判断锁对象是否被锁定 3.撤销偏向锁,将对象头中的标记01恢复为00 然后升级为轻量级锁或者重量级

    偏向锁的好处

    偏向锁适合于只有一个线程重复获取锁的时候,没有任何的竞争,从而提高锁的效率。 

    轻量级锁

    当多个线程在间隔的方式竞争我们的锁对象,短暂结合自旋控制。当前我们的线程的栈帧创建存储锁记录的空间,并且将该对象头中markWord
    复制到该锁记录中,然后线程尝试使用CAS将对象头中markWord替换指向锁的记录指针。如果成功下,当前获取锁,
    如果失败的情况下,说明没有获取锁,尝试短暂的自旋获取锁。

    轻量锁竞争

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

    轻量锁释放

    轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
    1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
    2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
    3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

    重量级锁

    线程的竞争不会使用自旋,不会消耗cpu资源,适合于同步代码执行比较长的时间。

    偏向锁/轻量锁/重量锁总结

    1.偏向锁:加锁和解锁不需要额外的开销,只适合于同一个线程访问同步代码块,如果多个线程同时竞争的时候,会撤销该锁。
    2.轻量级锁:竞争的线程不会阻塞,提高了程序响应速度,如果始终得不到锁的竞争线程,则使用自旋的形式,消耗cpu资源,
    适合于同步代码块执行非常快的情况下。
    3.重量级锁: 线程的竞争不会使用自旋,不会消耗cpu资源,适合于同步代码执行比较长的时间。

    锁的消除

    锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。锁削除的主要判定依据
    来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,
    认为它们是线程私有的,同步加锁自然就无须进行。
    public class ObjectLayout {
        public static void main(String[] args) {
            concat("kawa", "brian");
        }
    
        public static String concat(String s1, String s2) {
            return new StringBuffer().append(s1).append(s2).toString();
        }
    }  

    在多线程的情况下该会new出 多个不同的StringBuffer产生多个不同的 this锁,等于每个线程有自己独立的锁,有可能编译器会做优化 消除synchronized锁。

    锁的粗化

    通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是在某些情况下,一个程序对同一个锁不间断、高频地请求、
    同步与释放,会消耗掉一定的系统资源,因为锁的请求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,
    虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,
    以降低短时间内大量锁请求、同步、释放带来的性能损耗。

    Synchronized优化方案

    1.减少Synchronized同步的范围
    2.类似ConcurrentHashMap底层实现原理,将锁的粒度拆分细(读写分离,分段锁)
  • 相关阅读:
    Android拍照、摄像方向旋转的问题 代码具体解释
    Java 实现桥接(Bridge)模式
    C++标准I/O库:iostream, fstream, sstringstream
    Android 使用 DownloadManager 管理系统下载任务的方法
    JavaScript提高:005:ASP.NET使用easyUI TABS标签显示问题
    使用c++的cocos2d-x-3.0rc1程序公布apk
    UVA 146 ID Codes(下一个排列)
    C/C++:C++伪函数
    开源项目AndroidUtil-採用Fragment实现TabHost
    系统编程是什么
  • 原文地址:https://www.cnblogs.com/hlkawa/p/13388046.html
Copyright © 2020-2023  润新知