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虚拟机采用的是第二种):
- 使用句柄访问对象。即reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。
- 直接指针访问对象。即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 |
|
栈上分配
栈上分配可支持方法逃逸,但不能支持线程逃逸。
标量替换
标量 :若一个数据已经无法再分解成更小数据,JVM中的原始数据类型(如 int、long 等数值类型及 reference 类型)都不能再进一步分解,这些数据即为标量。
聚合量:若一个数据可继续分解,则称为聚合量(Aggregate),比如 Java 对象就是聚合量。
标量替换:
把一个Java对象拆散,根据程序访问情况,将其用到的成员变量恢复为原始类型来访问。
假如逃逸分析能证明一个对象不会被方法外部访问,并且该对象可被分解,那么程序真正执行时将可能不去创建该对象,而改为直接创建它的若干个被这方法使用的成员变量。
将对象拆分后:
可让对象的成员变量在栈上 (栈上存储的数据,很大概率会被JVM分配至物理机器的高速寄存器中存储)分配和读写。
为后续进步优化创建条件。
适用场景:标量替换可视为栈上分配一种特例,实现更简单(不用考虑对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
同步消除
线程同步是个相对耗时的过程,若逃逸分析能确定一个变量不会逃逸出线程,即不会被其他线程访问,则该变量的读写肯定不会有线程竞争, 也可安全消除对该变量实施的同步措施。
代码实战验证
全无优化的代码
1 |
|
优化step1:内联构造器和getX()方法
1 |
|
优化step2:标量替换
逃逸分析后,发现在整个test()方法的范围内Point对象实例不会发生任何程度逃逸, 便可对它进行标量替换:把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免了Point对象实例的创建
1 |
|
优化step3:无效代码消除
数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:
1 |
|
观察测试结果,实施逃逸分析后的程序在MicroBenchmarks中往往能得到不错的成绩,但在实际应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定,或分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)下降,所以曾经在很长的一段时间,即使是服务端编译器,也默认不开启逃逸分析(从JDK 6 Update 23开始,服务端编译器中开始才默认开启逃逸分析。),甚至在某些版本(如JDK 6 Update 18)中还曾完全禁止这项优化,一直到JDK 7时这项优化才成为服务端编译器默认开启的选项。