张治峰的博客

java对象概述及创建过程

2021-01-07

对象结构

java 对象主要分为三部分:对象头、实例数据、对齐填充。对象头又分为 mark word 和 Kclass word、数组长度(数组对象才有这块区域),具体描述如下图:

创建过程

java对象在创建时主要有下面几个过程: 类加载检查->申请内存->初始化->设置对象头->执行方法(构造方法)。如下图:

类加载检查

当虚拟机遇到创建对象指令时,会先去常量池中查询是否有 有类的符号引用,并判断类有没有加载完成,没有没有 必须先进行 类加载过程

比如下面代码在创建 A类对象 时 需要先完成A类的加载工作

public void method(){
A = new A();
}

内存分配

在类加载检查完成之后,JVM虚拟机就要为新建的对象分配内存了。对象所需要的内存大小在类加载完成的时候就能完全确定。为对象分配空间 就是 从堆里面划分一块 固定 大小的内存出来。

JVM又是如何分配内存的? 在多个线程同时申请内存的时候又如何保证不分配到同一个内存地址?

内存划分方式

  1. 指针碰撞(Bump the Pointer):

    指针碰撞需要内存是规整的,所有使用过的内存在一侧,没有使用过的内存在另一侧。在中间放着一个指针作为 三八线 分隔,需要分配内存时,只需要将指针向未使用过的那端移动 对象大小 的距离即可。

  2. 空闲列表(Free List)

    如果内存空间不规整的话,使用过的内存和未使用的内存相互交措就不能使用指针碰撞了。虚拟机就需要维护一个列表记录哪些内存是可用的,在分配时找到一块足够大的空间分配给记录并更新列表中的值。

并发问题解决方式

在多个线程同时申请内存的时候保证不分配到同一个内存地址有如下两种方式。

  • CAS

    内存分配的时候采用CAS加重试方式进行内存分配。

  • 本地线程分配缓冲(TLAB)

    堆在线程创建的时候先划分一块区域给到线程使用,创建对象的时候优先使用自己线程内部的空间,不够的话在使用CAS在共享内存中分配.

通过­XX:+UseTLAB\­XX:­UseTLAB参数来设定虚拟机是否使用TLAB(JVM默认开启­XX:+UseTLAB),­XX:TLABSize 指定TLAB大小。

初始化

给分配到的内存空间设置零值,比如其中 int 设置为 0,boolean 设置为 false,引用类型为 null,这样可以保证字段属性不被赋值也能被访问(访问的是零值)。

如果使用的是TLAB方式分配内存,则该步骤在分配时可以直接完成。

对象头设置

对象头主要包含两部分:Mark Word(标记字)和Class Pointer(类型指针,指向所属类的元信息),如果是数组对象还得再加一项Array Length(数组长度)
设置零值以后,JVM需要给对象的一些必要属性就行设置。比如 对象所属类信息、 锁状态、分代年龄、Hash码等等,这些都在对象头当中。

对象在不同的状态 以及 32位和64位下都有所不同 其字段以及占用字节数如下:

                             32位操作系统
|------------------------------------------------|-------------------| 
|                  Mark Word (32 bits)           |       State       |
|------------------------------------------------|-------------------|
| identity_hashcode:25|age:4|biased_lock:1|lock:2|       Normal      |
|------------------------------------------------|-------------------|
| thread:23 | epoch:2|age:4|biased_lock:1 |lock:2|       Biased      |
|------------------------------------------------|-------------------|
|           ptr_to_lock_record:30         |lock:2|Lightweight Locked |
|------------------------------------------------|-------------------|
|       ptr_to_heavyweight_monitor:30     |lock:2| Heavyweight Locked|
|------------------------------------------------|-------------------|
|                                         |lock:2|    Marked for GC  |
|------------------------------------------------|-------------------|

                                    64位操作系统   
|-------------------------------------------------------------------|-------------------|
|                  Mark Word (64 bits)                              |       State       |
|-------------------------------------------------------------------|-------------------|
| unused:25|identity_hashcode:31|unused:1|age:4|biased_lock:1|lock:2|       Normal      |
|------------------------------------------------------------|------|-------------------|
| thread:54|     epoch:2        |unused:1|age:4|biased_lock:1|lock:2|       Biased      |
|------------------------------------------------------------|------|-------------------|
|                           ptr_to_lock_record:62            |lock:2|Lightweight Locked |
|----------------------------------------------------------- |------|-------------------|
|                        ptr_to_heavyweight_monitor:62       |lock:2| Heavyweight Locked|
|------------------------------------------------------------|------|-------------------|
|                                                            |lock:2|    Marked for GC  |
|---------------------------------------------------------------------------------------|

执行 init 方法

执行方法,就是对象按照编码人员的需求进行初始化。包括父类的构造方法以及自己的构造方法。

内存大小计算(JOL)

对象的大小我们可以通过 JOL 工具进行打印分析,这里我们需要引入工具依赖包如下:

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol‐core</artifactId>
<version>0.9</version>
</dependency>

我们来分析一下 Object 对象 int数组 以及 String 的对象大小

/**
* 对象内存大小计算(JOL)
* @author zhangzhifeng
*/
public class ClassLayoutTest {

public static void main(String[] args) {

System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());

System.out.println(ClassLayout.parseInstance(new int[4]).toPrintable());

System.out.println(ClassLayout.parseInstance(new String()).toPrintable());
}
}

结果

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
// Object 对象头 markword 占 8 字节 class point 占 4 字节 ,对齐填充 4 字节 总共 16 字节


「I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 04 00 00 00 (00000100 00000000 00000000 00000000) (4)
16 16 int [I.<elements> N/A
Instance size: 32 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
// Object 对象头 中 markword 占 8 字节 class point 占 4 字节 | 实例数据4 个 int 占 字节 总 16 字节 共 32 字节


java.lang.String object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) da 02 00 f8 (11011010 00000010 00000000 11111000) (-134216998)
12 4 char[] String.value []
16 4 int String.hash 0
20 4 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
// String 对象头 中 markword 占 8 字节 class-point 占 4 字节 | 实例数据 中 char[] 数组指针 占 4 字节,int hash 字段 占 4字节 |对齐填充 4 字节 总共 24 字节

内存分配流程

栈上分配

通常我们创建的对象都是放在堆中的,在对象不再使用时通过GC进行内存回收,当对象多的时候,会给GC带来较多的压力,所以为了减少临时对象在堆中分配的数量,JVM 通过逃逸分析 将对象进行栈上分配,这样在栈帧出栈的时候对象就会销毁,从而减少GC的压力

逃逸分析

分析对象的作用域是否在当前栈帧(是否只存在创建它的栈帧中)。只能在server模式下才能启用。

// method1 中 user 被返回了 作用域不确定 不能进行栈上分配
public User method1(){
User user= new User();
user.setName("zhangsan");
return user;
}

// method2 中 user 只在当前方法中生效 作用域确定 可以对user进行栈上分配,栈帧结束直接进行回收。
public void method2(){
User user= new User();
user.setName("zhangsan");
}

对于上面method2 这种情况 JVM 通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优 先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换

如果通过逃逸分析确定对象不会被外界访问(如上method2),那么jvm会将对象的属性 分解成 多个 被方法使用的成员变量,这些成员变量在栈帧或者寄存器中。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认 开启。

测试

通过 逃逸分析、标量替换的开启和关闭查看堆上的内存分配和GC情况分析栈上分配。

Object 对象占用16字节 总对象大小为80M,堆内存大小设置为256m,开启GC日志打印 -XX:+PrintGCDetails

public class EscapeAnalysisTest {
public static void main(String[] args) {
int length = 5*1024*1024;
for (int i = 0; i < length; i++) {
Object obj = new Object();
}
}
}

1.启动参数开启逃逸分析,开启标量替换: -Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+PrintGCDetails,结果如下:

结果分析:没有发生GC 年轻代占用内存 大概7M。(这里JVM启动会加载一些对象)

2.启动参数关闭逃逸分析,开启标量替换: -Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+EliminateAllocations -XX:+PrintGCDetails,结果如下:

结果分析:发生了一次 年轻代GC 释放空间大概65M,同时年轻代还存在21.8M对象,堆内存对象大小总和为86.8M**

3.启动参数开启逃逸分析,关闭标量替换: -Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:-EliminateAllocations -XX:+PrintGCDetails,结果如下:

结果分析:和结果2 相当

通过结果可以分析如下结论:

  • 创建对象不一定会在堆当中。
  • 需要同时开启逃逸分析 和标量替换才会进行栈上分配
  • 栈上分配可以减少垃圾回收次数

堆分配内存

JVM 垃圾回收大部分采用分代算法,所以一般会将堆分为年轻代和老年代,默认比例1:2。年轻代采用标记-复制算法进行内存回收,所以又分为 eden区 和两个 survivor 区,如下图所示:

一般新建的对象内存空间都由新生代 eden 区 进行分配,在eden 区空间不够的时候 会进行 Minor GC。第一次 Minor GC 后将剩下的对象会放入其中一块空的survivor中,接下来每一次
Minor GC 会将剩余存活对象(包含eden区和上一次放对象的survivor区) 移动到 空的survivor区 然后清空eden 和上一次的存放对象的survivor。年轻代的对象一般存活时间都很短,在一次 Minor GC 后大部分的对象都会被回收所以8:1:1的比例很合适
eden区 尽量大,survivor区 够用即可。

JVM使用个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想比例变化可以设置参数-XX:-UseAdaptiveSizePolicy

接下来我们用几个例子看一下新建的对象 在堆中内存分布的几种情况。

测试参数 堆大小256M 年轻代 约 85.3M 老年代约170.6M

1.eden容量足够时分析, 启动参数: -Xmx256m -Xms256m -XX:+PrintGCDetails

/**
* new对象 堆内存分配测试
* @author zhangzhifeng
*/
public class HeapAllocationTest {

public static void main(String[] args) {
int space = 1024*1024;
byte[] allocation= new byte[60*space];
}
}

根据结果可以知道 当对象所需内存 小于 eden区 大小时 eden 进行内存分配。

2.新建对象 eden容量 不足时分析, 启动参数: -Xmx256m -Xms256m -XX:+PrintGCDetails

/**
* new对象 堆内存分配测试
* @author zhangzhifeng
*/
public class HeapAllocationTest {

public static void main(String[] args) {
int space = 1024*1024;
byte[] allocation= new byte[60*space];

// allocation1创建时 eden内存不够会进行垃圾回收 ,allocation 在垃圾回收后 survivor 区放不下 会进入老年代
byte[] allocation1= new byte[10*space];
}
}

当创建对象 Eden 空间不足时会进行年轻代的垃圾回收, 垃圾回收后如果在survivor 区放不下 会进入老年代,新建的对象继续放入eden区中。

上面两个案例是正常的 内存 分配 方式 ,对于 堆内存分配 JVM还有 一些自己的优化方式。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大 对象的大小(默认值为0 表示任何对象都在新生代中分配,单位是字节),如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 SerialParNew两个收集器下 有效。

源码查看

示例: 启动参数 -Xmx256m -Xms256m -XX:PretenureSizeThreshold=1000000 -XX:+UseSerialGC -XX:+PrintGCDetails

/**
* new对象 堆内存分配测试
* @author zhangzhifeng
*/
public class HeapAllocationTest {

public static void main(String[] args) {
int space = 1024*1024;
byte[] allocation= new byte[1*space];
}
}

为什么要把大对象直接放入老年代呢?

为了避免大对象在 minor GC 时的内存复制操作降低效率,也避免造成更多的minor GC。

长期存活的对象将进入老年代

如果对象产生在Eden 区 都会有一个分代年龄(标记在对象头 占一个字节 最大15岁),当它经历过一次minor gc 且 能够被复制进 survivor 区时 那么它的分代年龄就会被 +1 ,对象的分代年龄达到一定的阈值(默认15岁,CMS 默认6 岁 不同的垃圾回收器会有所不同)就会被移入到老年代。
阈值 可以通过 -XX:MaxTenuringThreshold 设置。

动态年龄判断

当前放对象的Survivor区域里,存在各个年龄对象内存和 年龄1+年龄2+…+年龄n > Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么survivor 中 年龄 大于等于 n 的对象 就会直接进入老年代了。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年 龄判断机制一般是在minor gc之后触发的。

c++源码分析(长期存活的对象将进入老年代/动态年龄判断)

空间担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会判断一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生”OOM”
当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM

Tags: java
使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章