likes
comments
collection
share

对象组成与Java内存模型JMM分析

作者站长头像
站长
· 阅读数 20

ps: 本来是想把这篇文章放到 synchroized(下一篇) 文章中的,但是思来想去还是分开吧,否则很唐突,另外也显得文章主次不分(synchroized主要讲的 使用&原理分析&锁)。

本文分两部分:

第一部分:我们理论+实践看看对象都由哪些部分组成,以及各个组成的作用和功能。

第二部分:讲解Java内存模型JMM,因为 Java内存模型并发 知识的基础! 二者密不可分!

1、对象的组成

说到对象的组成真的是很复杂。尤其是mark word 它会随着对象当前的状态如 锁定/重入/偏向等,来发生动态变化,但是他的总大小永远是8的倍数(如果不够的话会使用占位来进行补齐(专业点叫 对齐填充,这么做的原因是减少内存碎片的产生 更高效些)),这一点我们要清楚。

  • 一个完整的对象包含了这几部分,如下: 对象组成与Java内存模型JMM分析

ps: 由于实例数据和对其填充没什么可说的,下边我们重点看 mark word部分,class pointer也会提起

1.1、 对象头理论知识

这小节,我们来看看对象的脑袋里, 到底装了什么东东~~~

我们直接来到hotspot的代码,为了高效我直接在github搜的,github网页版hotspot源码,当然也可以在oracle下载源码阅读,oracle下载hotspot源码

首先我们找到 hotspot src/share/vm/oops/markOop.hpp 类中如下这段注释:

src/share/vm/oops/markOop.hpp

注意:这里我们只看64位的 32位的对象头结构忽略!

***********从下边这个注释可以看出 无锁和偏向锁状态下的 mark word 存储结构***********

// 64 bits: |                                                                                                    |
| ----------- | -------------------------------------------------------------------------------------------------- |
|             |  --------                                                                                        |
|             |  unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)//无锁情况下 mark word占用情况                    |
|             |  JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)//偏向锁时候, mark word占用情况                      |
|             |  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)        |
|             |  size:64 ----------------------------------------------------->| (CMS free block)             |
|             |                                                                                                  |
|             |  unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)          |
|             |  JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)           |
|             |  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object) |
|             |  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)


***********从mark word 后三位/后两位可以看出该对象上的 锁状态,如下:***********

//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack  //轻量级锁
//    [header      | 0 | 01]  unlocked           regular object header               //未锁定
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)//重量级锁
//    [ptr             | 11]  marked             used by markSweep to mark an object //被GC标记为可回收对象后 
//                                               not valid at any other time


附(markOop文件中锁状态枚举值)
附上这个就是要说明,我们上边说的:mark word后两位不同的值代表不同的锁状态时,不是凭空得来的,而是从源码获取的.

 enum {  locked_value             = 0, // 二进制= 00 轻量级锁
         unlocked_value           = 1, // 二进制= 0 01 无锁
         monitor_value            = 2, // 二进制= 10 重量级锁
         marked_value             = 3, // 二进制= 11 gc标志
         biased_lock_pattern      = 5  // 二进制=  1 01 偏向锁
  };

上边代码都是 hotspot源码:src/share/vm/oops/markOop.hpp文件中的,我摘选出来,就是想用源码说话,让内容更准确,论述真实。 虽然上边代码中的注释好像很清楚了,但是我还是要画一张图,来用更简洁的方式呈现出来。

  • 下图显示了: 一个对象的组成结构,以及他在无锁被标记为偏向锁升级为轻量级锁升级为重量级锁被GC标记为可回收 各个不同时机的mark word的存储结构(注意:对象总大小始终是8的倍数)。示意图如下: 对象组成与Java内存模型JMM分析

对上图的补充说明:

  1. mark word: 可以理解为对象的头
  2. class pointer: 可以理解为该实例对象对应的类指针(类在元空间(jdk1.8后)中放的,堆中对象会保存一个指向其对应的类的地址值)

注意:关于上图中的 ptr_to_lock_recordptr_to_heavyweight_monitor我们不展开分析,放到synchroized (即下篇文章)中去。

1.2、 代码实践

接下来手动验证一下我们上边讨论的 无锁状态下对象结构

注意:被标记偏向,轻量级锁,重量级锁的对象结构我们暂时不在这里讨论,会放到后续的文章细说)。

在项目中添加 jolmaven包,关于jol详见: openjdk/jol

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

定义一个简单的Lock对象如下:

public class Lock {
   private int name;
   get()/set(String name);
}

使用 ClassLayout.parseInstance(lock).toPrintable())打印对象信息如下图所示: 对象组成与Java内存模型JMM分析

前两行是mark word的信息,第三行是class pointer的信息,中间的(第4行)是的是类中字段占用的大小

控制台输出内容如下:

当前vm信息: # Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.//开启class pointer 压缩后的输出(意思)
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] // Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] 对应的是:[Oop(Ordinary Object Pointer 普通对象指针4bytes),boolean,byte,char,short,int ,float,long,double] `从这个里边可以看出各个数据类型占用的空间大小`
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]


锁对象没被持有的时候:com.xzll.test.mianshi.Lock object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)//mark word
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)//mark word
      8     4        (object header)                           58 5e 0f 00 (01011000 01011110 00001111 00000000) (1007192)//class pointer
     12     4    int Lock.name                                 0//对象属性占用大小
Instance size: 16 bytes                      //对象总大小
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
  • 上边这个输出略微有点不好理解,这里我们说明一下:

    说明一: 由于是小端存储(不明白的自己google下),所以输出的 mark word 值(Value列) 应该反过来看,即完整的mark word是: (16进制)00 00 00 00 00 00 00 05 = (二进制)00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101 (关于这里倒数第三位为什么为1 会在下边的说明三 解释)

    说明二: Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] 对应的是:[Oop(Ordinary Object Pointer 普通对象指针4bytes),boolean,byte,char,short,int ,float,long,double] 从这个里边可以看出各个数据类型占用的空间大小

    说明三: 由于Jvm默认开启了偏向锁的开关,所以我们上边输出的mark word值: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101(二进制),的 倒数第三位是1 标志着偏向锁开启(注意我们这个对象可是啥也没干呀,在synchroized块之前打印的,我们可以通过 -XX:-UseBiasedLocking 参数来修改默认值(默认使用偏向锁),这样刚new出来的对象也就不会被标识为偏向了)如下:。 对象组成与Java内存模型JMM分析 对象组成与Java内存模型JMM分析

    说明四: 由于对class pointer的压缩是默认开启的,所以我们上边输出的第三行(class pointer信息)的size是4byte 也就是占32个位(bit),(如果不压缩的话占64个位(bit) 也就是8byte)。 从 Using compressed klass with 3-bit shift. 这句输出可以看出来,对类指针(class pointer)的压缩是开启的。 我们关闭对class pointer的压缩来看下输出结果有什么不一样,在vm参数栏加上 -XX:-UseCompressedOops后 启动main方法,输出如下: 对象组成与Java内存模型JMM分析

  • 对上边说明三的一个疑问点补充: 对象组成与Java内存模型JMM分析 (在openjdk 的介绍中说在jdk6和jdk7中,程序启动几秒后默认的偏向锁才会被激活,但是我在实际测试时候代码中并没有sleep更无复杂逻辑(可以说是小于1秒),在没配置关闭偏向锁参数时(即默认开启偏向锁)的情况下,无需等待 new出来的lock对象 就立马被标记为偏向(也就是说明三中的情况),可以推测: 高版本jdk(我用的jdk11)中已经将偏向锁延迟时间置为0了, 也就是说,默认情况是: -XX:BiasedLockingStartupDelay=0

对对象的内存探究就到此为止了,注意这里我们只是看了对象的一种状态(即无锁状态下的情况) 当对象被当做锁来使用时候,也就是说其被:(标记为可偏向,升级为轻量级锁,升级为重量级锁的情况我们暂时不在这里讨论,会放到后续的文章中细说)

本小节的目的是让读者在脑海中有个对对象的认识,包括他都有哪几部分,都存放了些什么,以及存放的内容是干啥的,另外我们需要看懂 JOL 打印输出的对象信息(因为后续文章中我们要通过观察某对象的输出,来分析他在各种状态(偏向,轻量级,重量级) 下的不同表现)。

2、Java内存模型(JMM)

由于后边的(下篇文章)论述(比如synchronized如何保证:原子性,可见性,有序性)等等和并发相关的东西,都和(Java内存模型)JMM,密不可分,所以我们这里很有必要来捋一捋这块的知识点。

2.1、 JMM介绍与示意图

  • JMM(Java Member Model)介绍。

    规定: 了线程主内存之间的抽象关系,简单来说就是:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程用来 读/写 共享变量的 副本,同时,本地内存的数据是线程之间不可见的。当某个线程对共享内存中的变量进行操作时,需要将目标变量值Load到工作内存中,进行操作后,再Save到共享内存。示意图如下:

    目标: 定义了程序中对共享变量的访问规则,如:将变量存储到共享内存、从共享内存将变量取出这样的底层细节

  • JMM示意图如下: 对象组成与Java内存模型JMM分析 从上可知JMM的这种模型,是线程间通信的一种(即通过共享内存来进行线程间通信

2.2、 JMM龟腚的8种 工作内存和主内存 交互方式和规则

工作内存和共享内存之间,规定了 8种类型的操作 我在下边列出来。值的注意的是,下边8种操作,每一个都是原子的 这个原子特性后边我们在分析synchroized时候会用到!

  1. lock (锁定): 作用于主内存变量,他把一个变量标识为某线程独占的状态
  2. unlock (解锁): 作用于主内存变量,他把一个变量从锁定状态变为解锁状态,解锁后才可以被其他线程锁定
  3. read (读取): 作用于主内存变量,他把一个变量从主内存读到工作内存
  4. load (载入): 作用于工作内存变量,他把上一步read读到的值,放入工作内存的变量副本中
  5. use (使用): 作用于工作内存变量,使用这个变量进行一些操作,比如++ -- 等等
  6. assign(赋值): 作用于工作内存变量,将操作后的值赋值给工作内存的变量
  7. store(存储): 作用于工作内存变量,他把工作内存中的变量,传递给主内存
  8. write(写入): 作用于主内存变量,他把store的值写入到主内存中去

JMM规定:执行上述8种操作时,必须遵守的规则如下:

  1. 不允许read 和load 单独出现、不允许 store和write 单独出现 ,即不允许一个变量从主内存读取了但是工作内存不接受, 或者从工作内存发起写回操作时,主内存不接受的情况出现
  2. 不允许一个线程丢弃在工作内存中修改后的变量,也即:修改后,必须把修改后的值同步到主内存
  3. 不允许一个线程没发生过赋值(assign)操作,就将变量同步会主内存中去
  4. 一个新的变量只能在主内存中诞生。不允许在工作内存中,使用一个未被初始化的变量,即对一个变量实施use/store操作前,必须先执行了load/assign操作
  5. 一个变量在同一时刻,只允许一条线程对其进行lock操作,lock操作可以被同一个线程执行多次,同一个线程执行过n次lock的话,那么也必须有n次unlock,该变量才是解锁状态
  6. 如果对一个变量进行lock操作,那将会清空工作内存中次变量的副本值,在线程使用这个变量时候,需要重新load
  7. 如果一个变量事先没有被lock锁定,那就不允许对他执行unlock操作,也不允许unlock其他线程lock住的变量
  8. 对一个变量执行unlock之前,必须先把此变量同步回主内存中去,也即(store,write操作)

值的一提的是,Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的(实际上只要能把并发问题中的这三个问题解决掉,那么并发编程已经可以算是 比较 安全 了),我们下边来逐个来看一下这三个操作都是如何定义的 ,以及 哪些个操作实现了这三个特性

2.3、 原子性,可见性,顺序性简述

原子性: 是一组 不可细分 的操作,这组操作要么成功,要么失败。在JMM中,提供了lock和unlock操作来满足原子性,但是开发时我们并不会直接用lock和unlock,而是提供了更高层次的字节码指令monitorenter和monitorexit(或者给方法打ACC_SYNCHRONIZED标记)隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块:synchronized 关键字,因此可以得出结论: synchronized 修饰的方法或者代码块,也具备原子性。

可见性: 是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。在java中,有三个关键字可以保证可见性,下边我们简单分析下:

  1. volatile::volatile的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。更多: 我的另一篇文章:多线程之volatile 关键字
  2. synchroiezd: 可以保证可见性,是由 JMM规则中的:(上边规则8)“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操 作)” 和 (上边的规则6)“如果对一个变量进行lock操作,那将会清空工作内存中次变量的副本值,在线程使用这个变量时候,需要重新load” 这 两条 规则获得的。
  3. final: 可以保证可见性 ,是通过 ”被final修饰的字段在构造器中一旦被初始化完 成,那么在其他线程中就能看见final字段的值“ 这条特殊的 final 特性而获得的。

补充内容:什么是指令重排序?

//由于此处属于补充性内容,因此不再进行排版~~

在说有序性之前,我们必须先来聊下指令重排,因为如果没有指令重排的话,也就不存在有序性问题了。
指令重排是指编译器和处理器在  **不影响代码单线程执行结果** 的前提下,对源代码的指令进行重新排序执行。这种重排序执行是一
种优化手段,目的是为了处理器内部的运算单元能尽量被充分利用,提升程序的整体运行效率。
重排序分为以下几种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,
处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
- 值的注意的是:指令重排只能保证单线程执行下的正确性,在多线程环境下,指令重排会带来一定的问题,其中一个最突
出明显的就是,在多线程场景下 指令重排会引起结果不一致的问题,具体指令重排引发的问题,代码演示请参考:
[我的另一篇文章:多线程之volatile 关键字](https://juejin.cn/post/7001013974149890062)

顺序性: 是指程序执行的顺序按照代码的先后顺序执行,由于在编译期间,编译器会做一些优化,将指令重排序,想要保证有序性就需要解决指令重排序的问题,在Java中有两个关键字保证了顺序性(事实上他们也符合下边即将要说的happens-before原则

  1. volatile: 通过内存屏障来禁止指令重排序。更多请见(里边有内存屏障相关的内容): 我的另一篇文章:多线程之volatile 关键字
  2. synchroized: 严格意义上来说,并不能禁止重排序。but!由于synchroized可以保证同一时间只能由同一个线程执行同步逻辑,所以说即使发生指令重排(此处的重排序在同一时刻,只能被拿到锁的线程看到),他最后的执行结果还是正确的。

2.4、 先行发生原则happens-before(JMM中不得不讲的重要内容)

何为happens-before

先行发生是Java内存模型中定义的两项操作之间的偏 序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,
操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

如果synchronized和volatile来保证有序性,那么我们的代码将会在每一处都需要考虑重排序带来的风险,变得非常麻烦,(试想如果没有先行发生原则,那么代码中将会到处可见的 volatile和synchroized来保证顺序性 ,但是实际上编码时候我们却没有这样做)。原因就在于,在JMM中规定了8种天生存在的Happens-before关系,这些先行发生关系无需任何协助(也即无需使用volatile或者synchroized来保证)就已经存在,可以在编码中直接使用。换句话说:如果两个操作的关系符合先行发生原则的其中某一个定义,就不会发生指令重排!也就是说,这两个操作的顺序性必然会得到保障,如果不符合,则虚拟机可以对它们随意地进行重排序。8种先行发生原则定义如下:

  1. 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循 环等结构。

  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是“同一个锁”,而“后面”是指时间上的先后。

  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后。

  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检 测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止 执行。

  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。

  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论。

3、总结:

  • 看完第一部分,我们了解了Java对象的组成结构,以及各个组成的作用
  • 看完第二部分,我们了解了Java内存模型相关的知识。这两部分将会在我们后边讲解synchroized 的时候用到,所以理解对象的组成和Java内存模型是基础也是必须的!
  • 下一篇,我们将在此基础上,一窥synchroized的底层!

参考&感谢: