JAVA基础---synchronize底层证明

网上synchronize分析太多了,但是真正去证明的很少。今天本小白将亲自去验证下网上各种大神的说法是否正确。百度前面的几篇文章我都看了下,感觉写的都差不多偏向于概念性的分析。没看到有人真的去验证种种说法是否真的正确。

Java中提供了两种实现同步的基础语义:synchronized方法和synchronized块。被编译成class文件的时候,synchronized关键字和synchronized方法的字节码略有不同,我们可以用javap -v 命令查class
文件对应的JVM字节码信息:

public class SynchronizedDemo2 {
    public  void sync1(){
        synchronized (this){
            System.out.println("synchronized1");
        }
    }
    public static synchronized void sync2(){
        System.out.println("synchronized2");
    }
}

字节码

 public void sync1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String synchronized1
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 6: 0
        line 7: 4
        line 8: 12
        line 9: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/chihai/graduation_project/synchronize/SynchronizedDemo2;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/chihai/graduation_project/synchronize/SynchronizedDemo2, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public static synchronized void sync2();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String synchronized2
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 11: 0
        line 12: 8
}

从上面可以看到,对于synchronized关键字而言,javac在编译时,会生成对应的monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHR
ONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。在JVM底层,对于这两种synchronized语义的实现大致相同。传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数在linux上使用mutex互斥锁,最底层实现依赖于futex。

对象组成

在分析synchronize时肯定避不开对象头这个最关键的点,这里我们使用openjdk提供jol-core包就能帮助我们打印出对象信息方便我们下面的判断。我这里随便创建了两个类用于演示。

public class ChiHai {
    int age;
}
public class SynchronizeDemo {
    static ChiHai chiHai = new ChiHai();
    public static void main(String[] args) {

    // chiHai.hashCode();

        System.out.println(ClassLayout.parseInstance(chiHai).toPrintable()); //解析并打印当前对象

        synchronized (chiHai){ // 测试synchronized锁的是对象而不是代码
            System.out.println("locking...");
        }

    }
}

控制台打印信息:

com.chihai.graduation_project.synchronize.ChiHai object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           18 0a 23 17 (00011000 00001010 00100011 00010111) (388172312)
     12     4    int ChiHai.age                                0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

locking...

Process finished with exit code 0

注释掉age字段对象信息

com.chihai.graduation_project.synchronize.ChiHai object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           18 0a 05 17 (00011000 00001010 00000101 00010111) (386206232)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

locking...

Process finished with exit code 0

我们可以清晰的看到一个对象由对象头,示例数据,和对齐填充组成。至于为什么要存在对其填充,因为在我们64位的JVM中一个对象大小必须是8的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。假设一个对象只有10byte,虚拟机在分配内存时就会分配16个byte。
这里有一点需要注意一下,市面上能见到大部分文章在介绍对象头时都说64位系统下对线头占16个字节,这里我不知道他们是从何得出。个人猜测可能是在古老的版本下是这样的,就目前我所使用的1.8环境下对象头可以清楚的看到是3X4=12这里涉及到指针压缩我暂时还不懂----------以后懂了再回来详细说明吧。希望后来能看到我这个小白博客的看官能及时纠正过来,很多文章放在当前环境已经不适用

对象头:

这里需要说明下JVM其实只是sun公司定义的一套规范,而我们平时使用到的hotspot是sun公司提供的一套落地实现。实际上虚拟机并非只有一种像IBM,阿里等都有自己的JVM产品。

openJDK又是啥子呢,其实openJDK可以理解为是一个项目,它是hotspot的源码,是用C++开发的。openJDK编译后其实就是我们的java.exe

openJDK官网,接下来我们就去官网找下有关对象头的信息

看下官方是如何定义的

object header

Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object’s layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.感觉自己英语实在垃圾。。。

谷歌翻译一下好了:
每个GC管理的堆对象开头的通用结构。 (每个oop都指向一个对象标头。)包括有关堆对象的布局,类型,GC状态,同步状态和标识哈希码的基本信息。 由两个词组成。 在数组中,紧随其后的是长度字段。 请注意,Java对象和VM内部对象都具有通用的对象标头格式。

这里提到对象头由两部分组成,是哪两部分?Mark Word、Class Metadata Address这是网络上百分之99的答案。到底是不是这样呢?在官方文档中我找到了关于Mark Word的描述。

Mark Word:
The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.

每个对象标头的第一个单词。 通常,一组位域包括同步状态和标识哈希码。 也可以是指向与同步相关的信息的指针(具有特征性的低位编码)。 在GC期间,可能包含GC状态位。

klass pointer:
The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the “klass” contains a C++ style “vtable”.

每个对象标头的第二个字。 指向另一个对象(元对象),该对象描述原始对象的布局和行为。 对于Java对象,“容器”包含C ++样式“ vtable”。
官方给出的定义是klass pointer,Class Metadata Address更像是它的解释这里基本没什么问题

在hotspot中是如何实现的,我们需要去源码里查看:
https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/markWord.hpp

下面是截取的一段关键注释

//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)

64位下Mark Word就占64 ,klass pointer32

在了解完这些基础知识后正式进入synchronize分析

synchronize通过修改对象头同步状态信息从而达到锁定对象的功能。那么同步状态又存储在对象头的那一部分?
拿出源码中的注释逐步看一下:
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
一个对象无锁的情况下:前25位什么都没有存,后面31位hashcode,再后面1位没有存,接着4位存放着我们的GC分代年龄这里其实也为我们解释了为什么young区要经过16次GC才能到达老年区四位的情况下最大数1111为15,所以在到达15后再经过一次GC就超出了限制此时就会在16次时进入老年区。所以在我们64位环境下前54位存的是hash,为什么不是31呢。因为在java当中在我们计算时是精确不到每一个位数的只能拿bit,像31我们是拿不到3bit+7位。最低只能拿4bit,这里就是将我们前56位合起来计算的原因。 biased_lock表示是否是偏向锁。最后两位是锁标示位不同类型的锁对应不同标识。下面会进行详细介绍

下面我们拿出前56位hashcode看一下

00000001 00000000 00000000 00000000
00000000 00000000 00000000

看到这里不知道大家有没有疑惑,这也太扯淡了一个hashcode只有一位为1。到底是哪里出问题了呢?因为此时我们的对象还没有计算过hashcode,所以这里还没有hashcode.文章开头代码部分特意将// chiHai.hashCode();注释掉了,我们取消注释重新打印看看结果:

现在是不是正常多了

com.chihai.graduation_project.synchronize.ChiHai object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 0b 35 80 (00000001 00001011 00110101 10000000) (-2144007423)
      4     4        (object header)                           5b 00 00 00 (01011011 00000000 00000000 00000000) (91)
      8     4        (object header)                           18 0a 1a 17 (00011000 00001010 00011010 00010111) (387582488)
     12     4    int ChiHai.age                                0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

locking...

Process finished with exit code 0

但是我们这里又出现问题了按理说前25位是unused没有使用应该都为0为什么这里不是呢?反而倒数几个字节都为0。这里涉及到我们的处理器,在我们一般的电脑上存储数据的方式叫做小端存储。Mac好像有所不同

大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点
儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;这和我们的阅读习惯一致。
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址
的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。

这是百度给的例子

小端模式
所谓的小端模式(Little-endian),是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址
中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低,和我们的逻辑方法一致。
例子:
0000430: e684 6c4e 0100 1800 53ef 0100 0100 0000
0000440: b484 6c4e 004e ed00 0000 0000 0100 0000
在小端模式下,前32位应该这样读: 4e 6c 84 e6( 假设int占4个字节)
记忆方法: 地址的增长顺序与值的增长顺序相同

回到我们的实际案例中分析在我们64bit中,最高的字节就是我们unused:1 age:4 biased_lock:1 lock:2这8个bit,就对应存放在我们第一个byte中即00000001,剩余56个就保存在:

--------------00001011 00110101 10000000
01011011 00000000 00000000 00000000
00011000 00001010 00011010 00010111

hash中25个unused就对应最后面的00000000 00000000 00000000和01011011 中的首位0

我们打印下对应16位hash对照一下,或者也可以单独拿出对应2进制转换为16进制进行对比。这里为了方便就一次性转换了

 System.out.println(Integer.toHexString(chiHai.hashCode()));
 打印结果:5b80350b

在这里插入图片描述
我们要研究的关键是synchronize,synchronize就是由 biased_lock:1 lock:2 这三位控制的。在这之前我们首先需要知道一个知识在我们java代码中thread.start()一个线程,底层start0()就会调用c语言中的pthread_create()函数在操作系统创建一个对应线程。在我们hotspot环境下java中的线程跟操作系统中的线程是一一对应的。当然在别的JVM中可能并不是这样。或者是别的版本中的hotspot也并非如此。但是我们的synchronize在1.6进行了重大优化,1.6之前与现在是完全不同的。1.6之前synchronize是利用操作系统(linux mutex)的同步机制群实现同步的。在ReentrantLock源码分析那篇博客中也谈到了这件事情。在1.6优化后在java级别完成锁的实现从而大大提升synchronize的性能。

在很多时候我们会加上synchronize关键字来保证有资源竞争情况下的安全性,但是这种竞争的发生率并不是百分之百,在应用在实际运行时,很可能只有一个线程会调用相关同步方法。所以1.6之后sun公司大佬针对这一点做了优化,为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。还有如适应性自旋,锁消除,锁粗化等。锁又分为无锁、偏向锁、轻量级所、重量级锁,GC5种状态。

unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)之前我们介绍的这种情况就是无锁状态。这里前56位存储的是hash,下面就会发现当升级为偏向锁时我们的对象状态会有很大的改变。其实这里也可以理解为没有偏向任何线程的偏向锁。

JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)当有线程来第一次调用我们的对象时就会膨胀升级为偏向锁,偏向锁就像名字一样顾名思义它会偏心于某一线程。此时就不需要调用操作系统原语去进行互斥。仅仅需要把此时的对象头JavaThread*:54 epoch:2,前54位用来表示线程id。当我们线程第二次来时就不会进行CAS上锁,而是直接对比当前对象中存的线程和访问线程是否相同。如果相同则可以直接拿到锁不需要执行任何操作系统和CPU级别指令。

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁,ptr_to_lock_record:62 lock:2 的概念。线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record.

在这里插入图片描述
在多个线程同时竞争的情况下就会再次膨胀升级为重量级锁,也即我们1.6之前的锁,调用底层系统函数。

我们的锁总共有5种状态而描述锁状态的只有两个bit,lock:2。2个bit我们知道最多只能有4种组合情况那么它是如何描述5种状态的呢?

在上面我说过无锁可以看做为特殊的偏向锁,即没有指定偏向目标的偏向锁。hotspot中正式利用了这一点来区分无锁与偏向锁。无锁与偏向锁的锁状态都为01,但是无锁的biased_lock:表示为0,偏向锁表示为1这就很好的区分了无锁与偏向锁。
无锁------------001
偏向锁---------101
轻量级锁------00
重量级锁------10
GC--------------11
到底是不是这样我们可以去打印对象信息验证一下:
将上面测试代码进行小的调整

public class SynchronizeDemo {
    static ChiHai chiHai = new ChiHai();
    public static void main(String[] args) {

         chiHai.hashCode();

        System.out.println(Integer.toHexString(chiHai.hashCode()));

        test();

//        IntStream.rangeClosed(0,10).forEach(ics ->
//            new Thread(()->
//                test()
//            ).start()
//        );

    }
    static void test(){
        synchronized (chiHai){ // 测试synchronized锁的是对象而不是代码
            System.out.println("locking2...");
            System.out.println(ClassLayout.parseInstance(chiHai).toPrintable()); //解析并打印当前对象
        }
    }
}

打印结果:

在这里插入图片描述

此时只有主线程一个线程调用为什么锁状态是00呢,我们知道00代表轻量级锁,按道理说这里应该是偏向锁啊???

这里是因为JVM默认启动了偏向锁延迟功能(默认延迟4秒),JVM为什么要这么做呢?因为在我们JVM启动时,并非只有一个main线程,在JVM启动时自己也会启动很多线程来运行运行一些内核的功能。而这些功能的实现代码中也有很多采用了synchronize关键字,JVM知道这些代码大概率不会只有一个线程使用,将来还会有其他很多线程来访问。这里涉及到我们的偏向锁撤销问题,而这个撤销过程比较复杂。所以JVM在启动时就暂时取消了偏向锁来消除不必要的性能开销。

我们可以用如下参数进行设置
启用参数:
-XX:+UseBiasedLocking
关闭延迟:
-XX:BiasedLockingStartupDelay=0
禁用参数:
-XX:-UseBiasedLocking

最后我们将上面注释打开看一下当有很多线程同时竞争锁时重量级锁标识是否为10:

在这里插入图片描述
到这里我们差不多也证明了上面的几种规范是否真的如此,关于偏向锁,轻量级锁加锁,撤销,解锁等我感觉现在个人理解还有点困难等未来有了一定研究会单独开一篇博客来进行学习。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值