关于Java的对象,锁和对象的内存布局、访问定位

1)对象的创建和分配

创建对象(如克隆、反序列化)通常仅仅一个new关键字,但在虚拟机中,对象的创建的过程需要如下步骤:

  1. 类加载检查
    先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析、初始化过,若没有,则必须先执行相应的类加载过程。
  2. 为新生对象分配内存
    对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。根据java堆内存是否绝对规整,划分方法不同:
  • 1)指针碰撞(Bump the Pointer): Java堆中内存绝对规整(所有用过的内存放在一边,空闲的内存放在另一边,中间 放一个指针作为分界点的指示器),所分配的内存仅需要把指针向空闲空间那边挪动一段与对象大小相等的距离。
  • 2)空闲列表(Free List),Java堆中内存并不规整(已使用的内存和空闲的内存相互交错),虚拟机通过维护一个记录哪些内存可用的列表,在分配时从列表中找到一块中够大的空间划分给对象实例,并更新列表上的记录。
    分配方式由Java堆是否规整决定,而是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。

​ 【注】由于对象创建在虚拟机中是非常频繁的行为,仅修改一个指针所指向的位置,并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存情况。解决方案有两种:

  • 1)对分配内存空间的动作进行同步处理,虚拟机采用CAS配上失败重试方式保证更新操作的原子性;
  • 2)本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),给每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的TLAB上分配,TLAB用完分配新的时才需要同步锁定。可以通过-XX:+/-UseTLAB参数来设定是否使用TLAB。
  1. 将分配的内存空间初始化为零值(不包括对象头)
    若使用TLAB,可以提前至TLAB分配时进行。这一步操作保证了对象实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  2. 对对象头进行必要设置
    如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  3. 执行<init>方法
    把对象按照程序员意愿进行初始化,即执行<init>方法。
    ————————————————
    原文链接:https://blog.csdn.net/smileiam/article/details/80364641

2)对象头

​ 在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例
数据(Instance Data)和对齐填充(Padding)。

1583122999885

注:如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。

​ HotSpot虚拟机对象的对象头部分包括两类信息。

​ 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下所示。

img

上面是32位JVM的MarkWord,64位JVM的Mark Word如下:

1583124184181

​ 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

注:这里的“查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论”,这里指的是通过句柄的方式来定位到堆中的对象,与之相对的是直接定位方式,此时对象头中的“Class Metadate Address”就发挥了作用,它用于定位对象的类型,指向的是Mete Space(元空间 JDK1.8)的某个区域。

​ 接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(OrdinaryObject Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空
隙之中,以节省出一点点空间。

​ 对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3)对象头和锁

​ java中锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

关于偏向锁的获取和释放:

​ 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出
同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程(即替换线程ID),注意这里存在两种情况

  1. 获取锁成功:那么它会直接将mark word中的线程ID,由第一个线程变成自己(偏向锁标记位保持不变),这样该对象依然会保持偏向锁的状态。
  2. 获取锁失败:则表示这时会有多个线程同时在尝试争抢该对象的锁,那么这时偏向锁就会进行升级,升级为轻量级锁。(这里存在一个偏向锁撤销的过程,先撤销偏向锁,然后升级为轻量级锁)

下图描绘了两个线程在争抢偏向锁时的状态:

1583220971641

​ 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

​ 偏向锁只适用于一个线程访问同步块时的场景。如果有两个或以上的线程同时竞争锁,则可能会出现竞争失败,导致升级为轻量级锁的情形。

关于轻量级锁的加锁和释放

(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。这里存在自旋后仍旧没有获取到锁的情形,此时同样也会升级为重量级锁。

(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是两个线程同时争夺锁,导致锁膨胀的流程图。

1583222047115

​ 因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

4)对象的访问定位

​ 创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就
    是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图2-2所示。

1583223807362

  • 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图2-3所示。这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

1583223831312

​ 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访
问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

猜你喜欢

转载自www.cnblogs.com/cosmos-wong/p/12403256.html