目录
1、简介
对象通过new,反射,clone等方式创建完毕后被存储到堆中,那么对象是以什么样的方式存储在堆中的?
2、对象的创建于内存分配
2.1、对象的加载简介
当虚拟机遇到一条new字节码指令时,会先检查这个类型是否已经加载。如果还没有,就进行类加载过程,类加载检查通过后,虚拟机将为新生对象分配内存。
一个对象所需的内存大小,在类加载完成后就可以确定下来,所以只需要把一块确定大小的内存从堆中划分出来。
2.2、如何从堆上划分内存
2.2.1、 Java堆的内存管理有两种方式
1、指针碰撞:
如果Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为
“指针碰撞”(Bump The Pointer)。
2、
空闲列表:如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 “空闲列表”(Free List)。
虚拟机选择哪种内存管理方式,与Java堆是否规整有关。而堆是否规整,又取决于该虚拟机使用的垃圾回收器是否带有“空间压缩整理”功能决定。
- 当使用Serial、ParNew这类带空间压缩整理的收集器,就采用指针碰撞,简单高效
- 使用CMS这种基于清除(Sweep)算法的收集器,理论上就需要采用空闲列表来分配内存
注意:
- java8大部分版本默认使用 Parallel Scavenge(新生代) + Parallel Old(老年代),推荐手动开启G1收集器,jvm参数:-XX:+UseG1GC
- Java 8几种主要的垃圾回收器,包括Serial、Parallel、CMS(Concurrent Mark-Sweep)和G1(Garbage-First)
2.2.2、划分内存如何保证并发安全
对象创建是虚拟机中非常频繁的操作,如果不作处理,很可能出现正在给A分配内存,还没有完成,B又来使用原先的内存状态分配内存的情况。
虚拟机使用两种方式确保线程安全:
- 对分配内存空间的动作进行同步。(具体是采用乐观锁+失败重试的方式)
- 把内存分配的动作按照线程分配在不同的空间进行。
每个线程在堆中的Eden区
预先分配一小块内存,作为“本地线程分配缓冲(TLAB)”,线程优先在自己的TLAB中分配内存
,不够用了再进行同步。
虚拟机是否启用TLAB,通过这个参数来设定:
-XX:+/-UseTLAB
堆是所有线程所共享的区域,在多线程的环境下,在堆上创建对象是线程不安全的。TLAB是JVM在堆内存的Eden区划分出来的一块专用于原始线程进行对象分配的区域。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,如需要分配内存,在自己的TLAB上分配。
3、内存分配完成之后的工作
内存分配出来了,虚拟机对分配到的这部分空间进行处理,把除了对象头之外的地方都初始化为零值,接着填充对象头。
此时对虚拟机来说,对象就创建出来了。但这是一个空白对象,Java代码中的构造函数还未执行,所以对象还尚未初始化。
只有new指令执行完后,执行<init>()构造方法,一个可用的Java对象才被完整创建出来。
4、总结 Java对象的创建过程
类型检查、分配内存、初始化零值、设置对象头、执行构造方法
- 遇到new关键字,先检查这个指令的参数是否能在常量池中找到该类型的符号引用。
- 如果找到了,检查这个类型是否已经完成加载并初始化
- 果没有找到,说明类还没有加载,先进行类加载过程。
- 类加载的检查阶段通过后,这个类的对象需要占用的内存大小就已经确定了。JVM就会给对象分配内存
- 分配内存涉及到三个细节:
- 内存分配的两种方式:指针碰撞、空闲列表
- 内存分配的线程安全:乐观锁+失败重试
- 对于小对象,线程优先在堆中自己的“本地线程分配缓冲区 TLAB”上分配内存
- 对于大对象,可以选择直接放入老年代
- 分配内存涉及到三个细节:
- 处理分配到的内存空间,把除了对象头之外的地方都初始化为零值,这样对象的成员变量就有默认值了
- 填充对象头,设置对象的类型、分代年龄、是否启用偏向锁。hashcode会在第一次调用时懒加载。
- 执行该对象的构造方法,然后就获得了一个可用的Java对象。
5、对象内存分配的基本策略
- 新对象优先在Eden区分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
5.1、新对象优先在Eden区分配
如果Eden区空间不足,就会触发一次Minor GC。
- 如果在新生代GC的期间,Eden区的存活对象很多,survivor区放不下,就会通过分配担保机制,把新生代的对象复制到老年代中。
- 如果老年代的空间也不足,就会触发一次Full GC,这个比较耗时。
5.2、为什么大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
有两方面考虑:
- 可能新生代的Eden区内存空间不足,不得不提前触发一次GC。因为大对象有更大的概率会遇到内存不足的情况
- 以后的新生代GC,如果大对象存活了下来,suvivor区可能放不下,还是会通过分配担保机制进入老年代。所以可以选择直接放入老年代。
有一个参数:
-XX:PretenureSizeThreshold
大于这个数量直接在老年代分配。缺省为0 ,表示不会直接分配在老年代。
5.3、长期存活的对象将进入老年代
每个对象会保存一个分代年龄,每熬过一次GC,分代年龄就+1
当分代年龄超过阈值,就会晋升到老年代。
可以通过这个参数调整,默认15
-XX:MaxTenuringThreshold
HotSpot在这里用了动态分代年龄的机制,在分代收集理论中有记录。
6、对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
6.1、对象头
对象头部分包含两类信息:
- mark word:用于存储对象自身的运行时数据,如哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- Class word:类型指针,即对象指向它的类型元数据的指针,JVM通过这个指针来确定该对象是哪个类的实例。
如果对象是一个数组,对象头还必须存储它的长度,否则无法确定数组对象的大小。
6.2、实例数据
存放对象的有效信息,包括代码中定义的和从父类中继承的。存储顺序有两方面影响:
- 代码中的书写顺序
- 虚拟机分配策略
默认的分配策略是,相同宽度的字段会被分配到一起存放。以这个条件为前提,父类的变量会在子类的之前。
如果HotSpot开启这个参数,那么子类中的较窄变量可以允许插入父类变量的空隙中,节省一点空间
+XX:CompactFields:true //默认就为true
6.3、对齐填充
这部分起到占位符的作用。
HotSpot虚拟机的自动内存管理系统要求,对象起始地址必须是8字节的整数倍,所以任何对象的大小都必须是8字节的整数倍。
对象头的部分已经被精心设计成了8字节或16字节,而实例数据部分内容无法保证长度,不够8字节的地方,用对齐填充来补齐即可。
7、对象的访问定位
对象的访问定位方式是指,栈上的引用如何指向堆上的对象。
Java程序会通过栈上的 reference 数据来操作堆上的具体对象
,这个 reference 类型只被固定成是一个引用,而没有指定实现方式。
所以虚拟机可以自由实现对象的访问方式。主流的方式有这两种:
- 使用句柄访问
- Java堆中划分出一块内存作为句柄池
- reference 中存储对象的句柄地址
- 句柄中包含了对象的“实例数据”与“类型数据”各自具体的具体地址信息。
- 使用直接指针访问:
- reference 中直接存储对象的具体地址
两种方式各有优势:
- 句柄访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动时(比如发生垃圾回收),只会改变句柄中的实例数据指针,而不需要修改 reference 。
- 直接指针访问的好处就是速度更快,节省了一次指针定位的开销。对象访问操作非常频繁,这也是HotSpot使用的方案。