JVM系列(四)之不同层级的内存屏障与对象在JVM中的分布

2021/03/26 JVM 共 3926 字,约 12 分钟

本篇介绍硬件层面和JVM层面的不同内存屏障以及介绍一个对象在JVM中的详细细节

内存屏障

内存屏障介绍

  • 内存屏障的目的是为了解决cpu指令重排序现象,虽然cpu指令重排序可以极大提高性能,但是在某些情况下我们不希望发生指令重排序,这个时候我们就可以使用内存屏障解决这个问题
  • 总的来说,内存屏障就是为了保证有序性

CPU层次的内存屏障

  • 不同的CPU的内存屏障的实现是不一样的,我们这里就介绍一下intel的内存屏障

  • sfence:在sfence指令前的写操作必须在sfence指令后的写操作之前执行

    我们现在有两条写指令,这两条指令我们不想让它重排,那么我们就可以在这两条指令之间加上sfence,这样就可以防止写指令重排序

  • lfence:在lfence指令前的读操作必须在lfence指令后的读操作之前执行

    我们现在有两条指令想要读,那么我们就可以在这两条指令之间加上lfence,这样就可以防止两条读指令重排序

  • mfence:在mfence指令前的读操作和写操作必须在mfence指令后的读操作和写操作之前执行

    有了mfence,那么mfence之前的读写操作都要在mfence之后的读写操作前执行完毕

  • 注意:CPU的内存屏障影响的范围就是内存屏障上下的两条指令,如果你想要让多条指令有序性的化那么就要加上多条内存屏障

JVM层次的内存屏障

  • JVM的内存屏障与其说是实现,不如说是规范,在JVM虚拟机规范里面规定了JVM虚拟机的内存屏障要做到的点,至于真正用来实现JVM的内存屏障的底层原理,不一定是使用的CPU的内存屏障,也有可能是使用了CPU层次的锁来实现

  • StoreStore

    在两条存储的指令之间添加,这两条存储指令不能重排序

  • LoadLoad

    在两条读操作之间添加,这两条读操作指令不能重排序

  • StoreLoad

    在先执行的一条存储指令和一条后执行的读操作指令之间添加,两条指令之间不能重排序

  • LoadStore

    在先执行的一条读操作指令和一条后执行的存储指令之间添加,两条指令之间不能重排序

  • JVM层次的内存屏障和CPU层次之间的内存屏障之间没有明显的联系,只能说CPU层次的内存屏障有可能是JVM层次的内存屏障的保障,JVM层次的内存屏障只是一种规范,只要能实现这个规范,JVM并不关心底层是如何实现的,底层有可能使用内存屏障,也有可能使用锁

volatile和sync的实现

volatile的字节码实现

image

image

image

  • 我们可以看到,在字节码层次,volatile的实现其实就是在access_flag上添加上一个sync标记,在标记了volatile的标记的成员变量被JVM识别后会被JVM处理

volatile的JVM实现

image

  • 在JVM层次,就是在volatile的操作前后分别加上JVM的内存屏障
  • 从这里我们可以发现,如果我们有一个对象是++操作,那么++其实就等价于先将这个对象读出来然后将这个对象+1,最后再写回去,我们可以发现,在读和写之间并没有锁来保证同步,也就是说在对象被读出来的时候就没有同步机制了,此时可以有其他的cpu来访问这个对象然后修改这个时候本来的cpu读到的数据就是无效数据了

volatile的OS硬件实现

  • 这里我们就介绍一下windows的实现,windows的实现方式是通过lock实现的,没有通过cpu内存屏障实现

sync的字节码实现

image

image

image

image

image

  • 我们可以发现,如果是加载方法上面的sync锁,那么在字节码实现层面就是加上Access_flag上添加上sync标记;如果是添加的同步代码块,那么不会在方法头上看到标记,但是会在具体的指令上看到变化,这里面最核心的两个指令是 monitorenter 和 monitorexit ,再仔细地看我们会发现,monitorexit有两条,那是因为除了退出同步代码块的exit以外,还有一条退出异常监视的exit,在sync里面如果发生了异常就会终止执行,直接进行异常抛出,这个监视就是通过这个exit退出的

sync的JVM实现

  • JVM是通过C、C++编写的,JVM实现sync的方法就是通过调用C、C++操作系统提供的同步方法

sync的硬件实现

  • 其实是调用了 lock compareAndChange XXX 指令,当我在代码上面写道 sync(obj){} 的时候,其实就是调用了lock compareAndChange obj 指令,这样一个同步代码块里面就锁定了这个对象,其他想要访问这个锁资源对象的就只能不断地尝试给 obj 上锁,上锁不成功就无法执行后续的指令

对象的细节描述

介绍对象的创建过程

  1. 类加载

    i: loading

    ii: linking

    • verification
    • preparation
    • resolution

    iii:initialization

  2. 在内存中申请地址

    在内存中申请一个地址,这个地址用来放置创建出来的对象

  3. 给对象赋予默认值

    将内存中对象的成员变量全部赋予默认值

  4. 调用init方法

    i: 给对象中的成员变量赋予初始值

    ii:执行构造方法语句

对象在内存中的存储布局

  • 一个对象的具体大小和虚拟机有很大的关系不光是虚拟机的位数,还有虚拟机的配置,我们这里先按照64位默认配置的HotSpot的虚拟机来介绍

  • 一个普通对象

    1. 对象头(markword) 8byte
    2. 类对象指针(class pointer)4byte
    3. 成员变量(fields)由成员变量决定,int 4 byte,long 8 byte,一个对象引用 4 byte
    4. 对齐填充(padding)会将对象填充到8字节的倍数
  • 一个数组对象

    1. 对象头(markword) 8byte
    2. 类对象指针(class pointer)4byte
    3. 数组长度(array size)4byte
    4. 成员变量(fields)由成员变量决定,int 4 byte,long 8 byte,一个对象引用 4 byte
    5. 对齐填充(padding)会将对象填充到8字节的倍数
  • 不同的虚拟机配置对对象大小的影响

    1. -XX:+UseCompressedClassPointers

      字面意思理解,使用类对象指针压缩技术,如果调用了这个命令,那么对象中的指向这个对象的class对象的指针就会被压缩,压缩之前是8byte,压缩之后是4byte,默认开启

    2. -XX:+UseCompressedOops

      Oops的意思是 Ordinary Object Pointers 这个指的是对象中的引用成员变量,也就是成员变量中的对象成员的指针大小,压缩之前是8byte,压缩之后是4byte,默认开启

  • 不同的对象实例占内存的空间大小为多少

image

image

image

对象头的信息

  • 因为32位和64位的虚拟机之间的区别,对象头的大小也是有区别的,我们这里拿32位的对象头来详细解释,最后在对32位和64位之间的区别做介绍

    image

    我们仔细对这张图进行研究,我们可以发现,无锁状态下有25bit被用来存储hashcode,4bit用来存储分代年龄,1bit来标记是否是偏向锁,2bit来标记锁标志

    无锁状态和偏向锁状态下对象头的锁标志位都是一致的,但是在是否是偏向锁上有区别,无锁是0,有锁是1

  • 我们平时在说对象头上的两位标记这个对象的锁状态,这句话其实不完全正确,确切说应该有3位都有判断锁状态的能力

  • hashcode是有区别的,这个区别在于你是否自己重写了hashcode,如果你没有重写hashcode,那么hashcode就是identity hashcode,这个hashcode会被存储在无锁状态下的对象头上;如果你重写了hashcode方法,那么这个hashcode就不是identity hashcode,这个hashcode是会被存储在其他位置上面的

  • hashcode与不同锁状态之间的冲突(这里的hashcode是identity hashcode,如果不是identity hashcode,那么不会产生冲突)

    1. 当对象被标记为轻量级锁或者重量级锁的时候,整个对象头的markword都会被放置到线程栈空间里面的一块专门用来放置对象头的空间里面,当锁撤销的时候会被粘贴回来,所以identity hashcode和轻量级锁、重量级锁可以共存

    2. 当对象计算过identity hashcode后,这个对象无法进入偏向锁状态;当一个对象正在处于偏向锁状态,如果此时计算这个对象的identity hashcode方法,这个对象将退出偏向锁状态并且锁膨胀为重量级锁这说明identity hashcode和偏向锁无法共存

    3. identity hashcode只可以计算一次,因为它的生成是带有随机性的;自定义的hashcode是可以反复计算的;

    4. 什么时候对象会计算identity hashcode?

      调用未覆盖的Object.hashCode()方法或者System.identityHashCode(Object o)

  • 32位虚拟机对象头和64位虚拟机对象头之间的区别

    image

    我们可以看到,以未上锁的状态为例子,在记录hash的区域,64位比32位多了25位没有使用并且记录hash的值要多6位,在分代年龄的区域,64位比32多了1位没有使用

    我们把这些加起来就发现没有使用的位26位,hash多的位6位,加起来就是32位,这就是64位和32位的区别所在

对象定位的方式(访问一个对象的方式)

  • 直接指针

    栈空间中的引用直接指向堆空间中的对象,对象中有一个ClassPointer指向这个对象的类对象指针

  • 句柄

    栈空间中的引用会指向堆空间的一个句柄,这个句柄有两个指针,一个指向这个对象,另一个指向这个对象的类对象

  • 两种方式的优劣:使用句柄来访问的最大好处是 栈空间中的对象引用 的中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

文档信息

Search

    Table of Contents