jvm-Java对象创建、存储、定位

Java基础

浏览数:43

2020-5-30

AD:资源代下载服务

一、对象创建

  1. 触发 : 程序创建对象,例如clone,反序列化,new等。

  2. 验证类加载 : 当虚拟机接收到new指令时,检查指令的参数能否在常量池定位到一个类的符号引用,并且检查此符号引用的类是否已经被加载、解析、初始化过,如果没有,则先执行对应的初始化过程。

  3. 分配内存空间 : 为新生代对象分配内存,所需内存在类加载完成后便可完全确定。分配内存空间即从堆中划分一块确定大小的内存,此时分两种情况:

    • ①堆内存规整,使用中内存与空闲内存被一个指针隔离在两边,此时只需要将指针向空闲空间方向挪动此对象大小距离即可,这种方式称为指针碰撞;

    • ②如果内存不规整,使用中内存与空闲内存相互交错,此时虚拟机需要维护一个列表,记录哪些内存块可用,在分配空间时需要找到一块足够大的空间划分给对象实例,并更新维护的列表,这种方式为空闲列表。

           选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有压缩整理(compact)功能决定,例如Serial、PerNew等使用指针碰撞,CMS基于Mark-Sweep采用空闲列表。
           由于对象创建对象非常的频繁,在并发情况下很多操作都不是线程安全的,例如修改一个指针所指向的位置,可能为A分配内容时还没来得及修改指针,对象B又引用了此指针位置为碰撞点来划分内存,两种解决方案:

    • ①对内存分配过程同步处理-实际虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
    • ②把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(ThreadLocal Local Allocation Buffer,TLAB)(在1.6+才有,在1.8默认开启,可以通过 jinfo -flag UseTLAB pid 来查询),哪个线程需要分配内存,就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定,可以通过-XX:+/-useTLAB来开启关闭
  4. 零值初始化 : 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包含对象头),这一步操作保证了对象的实例字段在java代码中可以不赋值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  5. 设置对象头 : 对对应对象头进行设置必要设置,例如这个对象属于哪个类,如果找到类的元数据,对象的哈希码,对象的gc分代年龄等信息,根据虚拟机的运行状态,如果是否启用偏向锁(主要为了解决无锁的性能问题,现在锁基本都是可重入锁,在A线程获得锁后,会被标识为偏向锁,简单说就是有个标记,这个线程已经获取这个锁了,在锁的过程中再竞争锁无需进行cas等操作,不会延迟本地调用,在释放偏向锁的会有一定的性能损耗,但对比偏向锁带来的提升,总体性能还是有提升的)等,对象头会有不同的设置方式。

  6. 调用init方法 : 在上面工作完成后,从虚拟机的角度新对象已经产生了,但从java程序角度来说,对象创建才刚刚开始,在new指令后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个对象就真正的产生了。

二、对象内存布局

       在HotSpot中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

  1. 对象头:对象头包含两部分信息,第一部分存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等、这部分数据的长度在32位合64位的虚拟机(未开启压缩指针)中分别为32bit和64位,官方称它为 “Mark Word”。对象状态在无锁、可偏向、轻量级锁定、重量级锁定、GC标记、状态下Mark Word存储内容有所差别。
  • 偏向锁 : A线程获得锁,会在A线程的栈帧中变量和锁对象的对象头中存储该线程ID,当该线程再次尝试获取该对象锁时,不需要cas操作,只需要判断是否是当前线程,当栈帧出栈完成,A线程并不会释放锁,需要等到B线程竞争该锁才释放,(偏向锁的释放需要等到全局安全点,即在此时间点没有字节码执行),可以通过-XX:-UseBiasedLocking=false关闭偏向锁,则默认会进入轻量级锁。

  • 轻量级锁 : A线程获得锁,会在a线程的栈帧里创建lock record(锁记录变量),让lock record的指针指向锁对象的对象头中的mark word,再让mark word 指向lock record,这就是获取了锁。B线程在锁竞争时,发现锁已经被A线程占用,则B线程不进入内核态,让B线程自旋,执行空循环,等待A线程释放锁。如果完成自旋策略还是发现A线程没有释放锁,或者让C线程占用了,则B线程试图将轻量级锁升级为重量级锁。

  • 重量级锁 : 让争抢锁的线程从用户态转换成内核态,使cpu借助操作系统进行线程协调。

  1. 实例数据:存储真正的有效信息,即代码中定义的字段内容,无论是从父类中继承下来的,还是再子类中定义的,都需要记录下来。HotSpot虚拟机默认的分配策略为longs/doubles,ints,shorts/chars,bytes/booleans,oops,相同宽度的字段总是被分配到一起。再满足这个前提条件下,再父类中定义的变量会出现在子类之前。

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

三、对象访问定位

建立对象即为了使用对象,我们的Java程序需要根据栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所有具体的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种。

  • 句柄 : 从Java堆中划分出一块内存来作为句柄池,栈帧中reference储存句柄的地址,而句柄中包含了对象实例数据和类型数据各自的地址信息 。
  • 直接指针 : 栈帧中reference储存对象地址,堆对象需要考虑如何访问类型数据的相关信息。

优缺点:使用句柄时,在对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中的实例数据指针,而reference本身不需要修改。只用直接指针速度更快,它节省了一次指针定位的时间开销(reference->对象相对于句柄的reference->句柄->对象),由于对象的访问非常频繁,因此这类开销积少成多也是一项客观的执行成本成本。HotSpot使用直接指针来实现对象访问。

参考文献: 周志明.深入理解Java虚拟机[M].第2版.北京:机械工业出版社

作者:s1808