jvm对象创建

对象创建流程

  • 虚拟机遇到一条new指令时,首先检查这个对应的类能否在常量池中定位到一个类的符号引用

  • 判断这个类是否已被加载、解析和初始化

  • 为这个新生对象在Java堆中分配内存空间,其中Java堆分配内存空间的方式主要有以下两种

    • 指针碰撞
      • 分配内存空间包括开辟一块内存和移动指针两个步骤
      • 非原子步骤可能出现并发问题,Java虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
    • 空闲列表
      • 分配内存空间包括开辟一块内存和修改空闲列表两个步骤
      • 非原子步骤可能出现并发问题,Java虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • 将分配到的内存空间都初始化为零值

  • 设置对象头相关数据

    • GC分代年龄
    • 对象的哈希码 hashCode
    • 元数据信息
  • 执行对象方法

对象结构

  • 对象头用于存储对象的元数据信息:
    • Mark Word 部分数据的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,存储对象自身的运行时数据如哈希值等。Mark Word一般被设计为非固定的数据结构,以便存储更多的数据信息和复用自己的存储空间。
    • 类型指针 指向它的类元数据的指针,用于判断对象属于哪个类的实例。
  • 实例数据存储的是真正有效数据,如各种字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。父类定义的变量会出现在子类定义的变量的前面。
  • 对齐填充部分仅仅起到占位符的作用

访问对象

当我们在堆上创建一个对象实例后,就要通过虚拟机栈中的reference类型数据来操作堆上的对象。现在主流的访问方式有两种(HotSpot虚拟机采用的是第二种):

  1. 使用句柄访问对象。即reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。
  2. 直接指针访问对象。即reference中存储的就是对象地址,相当于一级指针。

对比:

垃圾回收分析:

​ 方式一当垃圾回收移动对象时,reference中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;

​ 方式二垃圾回收时需要修改reference中存储的地址。

访问效率分析:

​ 方式二优于方式一,因为方式二只进行了一次指针定位,节省了时间开销,而这也是HotSpot采用的实现方式。

内存分配

对象分配的规则有哪些

  • 对象主要分配在新生代的 Eden 区上
  • 如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配
  • 少数情况下也可能会直接分配在老年代中

TLAB

TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)是 Java 中内存分配的一个概念,它是在 Java 堆中划分出来的针对每个线程的内存区域,专门在该区域为该线程创建的对象分配内存。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。

如果没有启用 TLAB,多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。

启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域,在预留这个动作发生的时候,需要进行加锁或者采用 CAS 等操作进行保护,避免多个线程预留同一个区域。一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作。

大对象:

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组

虚拟机提供了一个-XX: PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制

实战代码演练大对象配置

  • -verbose:gc -XX:+PrintGCDetails 开启GC日志打印

  • -Xms20 M 设置JVM初始内存为20M

  • -Xmx20 M 设置JVM最大内存为20M

  • -Xmn10 M 设置年轻代内存大小为10M

    1
    -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=3145728

栈上分配

逃逸分析

JVM中高深的优化技术,如同类继承关系分析,该技术并非直接去优化代码,而是一种为其他优化措施提供依据的分析技术。

分析对象的动态作用域,当某对象在方法里被定义后,它可能

方法逃逸

​ 被外部方法引用,例如作为参数传递给其他方法

线程逃逸

​ 被外部线程访问,例如赋值给可以在其他线程中访问的实例变量

所以 Java 对象由低到高的逃逸程度即为:

不逃逸 =》

方法逃逸 =》

线程逃逸

若能确定一个对象,不会逃逸到方法或线程外(即其它方法、线程无法访问到该对象),或逃逸程度较低(只逃逸出方法而不逃逸出线程),则可为该对象实例采取不同程度的优化方案。

开启逃逸分析后的优化

栈上分配就是把方法中的变量和对象分配到栈上,方法执行完后自动销毁,而不需要垃圾回收的介入,从而提高系统性能

1
2
3
4
5
6
7
8
9
10
若有需要或确认对程序有益,可使用参数:
-XX:+DoEscapeAnalysis 手动开启逃逸分析

开启后可通过参数:
-XX:+PrintEscapeAnalysis 查看分析结果

有逃逸分析支持后,用户可使用如下参数:
-XX:+EliminateAllocations 开启标量替换
+XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 查看标量的替换情况

栈上分配

栈上分配可支持方法逃逸,但不能支持线程逃逸。

标量替换

标量 :若一个数据已经无法再分解成更小数据,JVM中的原始数据类型(如 int、long 等数值类型及 reference 类型)都不能再进一步分解,这些数据即为标量。

聚合量:若一个数据可继续分解,则称为聚合量(Aggregate),比如 Java 对象就是聚合量。

标量替换:

把一个Java对象拆散,根据程序访问情况,将其用到的成员变量恢复为原始类型来访问。

假如逃逸分析能证明一个对象不会被方法外部访问,并且该对象可被分解,那么程序真正执行时将可能不去创建该对象,而改为直接创建它的若干个被这方法使用的成员变量。
将对象拆分后:

​ 可让对象的成员变量在栈上 (栈上存储的数据,很大概率会被JVM分配至物理机器的高速寄存器中存储)分配和读写。

​ 为后续进步优化创建条件。

适用场景:标量替换可视为栈上分配一种特例,实现更简单(不用考虑对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

同步消除

线程同步是个相对耗时的过程,若逃逸分析能确定一个变量不会逃逸出线程,即不会被其他线程访问,则该变量的读写肯定不会有线程竞争, 也可安全消除对该变量实施的同步措施。

代码实战验证

全无优化的代码

1
2
3
4
5
public int test(int x) { 
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}

优化step1:内联构造器和getX()方法

1
2
3
4
5
6
7
8
9
10
public int test(int x) { 
int xx = x + 2;
  // 在堆中分配P对象 
Point p = point_memory_alloc();
  // Point构造器被内联后  
p.x = xx;
  p.y = 42;
  // Point::getX()被内联后 
  return p.x;
}

优化step2:标量替换

逃逸分析后,发现在整个test()方法的范围内Point对象实例不会发生任何程度逃逸, 便可对它进行标量替换:把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免了Point对象实例的创建

1
2
3
4
5
6
public int test(int x) { 
int xx = x + 2;
int px = xx;
int py = 42
return px;
}

优化step3:无效代码消除

数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:

1
2
3
public int test(int x) { 
return x + 2;
}

观察测试结果,实施逃逸分析后的程序在MicroBenchmarks中往往能得到不错的成绩,但在实际应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定,或分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)下降,所以曾经在很长的一段时间,即使是服务端编译器,也默认不开启逃逸分析(从JDK 6 Update 23开始,服务端编译器中开始才默认开启逃逸分析。),甚至在某些版本(如JDK 6 Update 18)中还曾完全禁止这项优化,一直到JDK 7时这项优化才成为服务端编译器默认开启的选项。


jvm对象创建
http://hanqichuan.com/2019/07/23/jvm/jvm之4对象创建/
作者
韩启川
发布于
2019年7月23日
许可协议