1.对象创建的流程
graph TD
A[类加载检查] --> B{类是否已加载?}
B -- 否 --> C(加载类)
C--> D
B -- 是--> D[分配内存]
D -->E[初始化] --> F[设置对象头] --> G[执行<init>方法]
1.类加载检查
当 JVM 执行一条new指令时,最开始会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、验证、解析、初始化。如果没有,执行响应的类加载过程。new,new关键词、对象克隆、对象序列化等。
2.内存分配
类加载完成后,JVM 会对新的对象分配内存空间。对象所需的内存大小在类加载完成后就已经可以完全确定。内存分配方式有 两种: “指针碰撞” 和 “空闲列表” ,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 指针碰撞: 如标记整理法和复制算法,JVM内存的没有内存碎片,用过的内存和没用的内存完美区分,中间有一个指针作为分界点,那么再分配内存的时候只需要将指针挪动与对象大小相等的距离。
- 空闲列表: 如垃圾回收的标记清除法清理后的内存区域,JVM中有内存碎片,无法使用简单的指针碰撞进行内存的划分。所以在JVM内部就维护着一张列表,记录这哪一块的内存是可用的。在分配内存时,从中找到足够的的一片分配给该对象,最后更新记录的表。
- 并发问题: 当同时有多个对象创建时难免会出现并发问题,为了解决此问题,JVM 也提供了相应的解决方式。
-
-
CAS(Compare And Swap)
JVM 结合 CAS 和 volatile 可以实现无锁并发来保证更新操作的原子性,CAS详解见链接: JVM内存模型.
-
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存。通过XX:+UseTLAB 参数来控制虚拟机是否使用TLAB(默认开启),XX:TLABSize 指定缓冲区大小。
-
3.初始化
内存分配完成后,虚拟机需要将分配到的内存空间(除对象头外)都初始化为零值(如:String a = 1;赋零值 null),若使用 TLAB,这一过程也可以提前至 TLAB 分配时进行。该操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头 Object Header 之中。 在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data) 和对齐填充(Padding)。HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
什么是 Java 对象的指针压缩?
-
- 1.JDK1.6 开始,在 64bit 操作系统中,JVM 支持指针压缩。
-
- 2.启用指针压缩:XX:+UseCompressedOops (默认开启),禁止指针压缩:XX:-UseCompressedOop (use compressed ordinary object pointer 使用压缩对象指针)。
-
为什么要进行指针压缩?
-
- 1.在64位平台的 HotSpot 中使用 32 位指针,内存使用会多出 1.5 倍左右,使用较大指针在主内存和缓存之间移动数据, 占用较大带宽,同时 GC 也会承受较大压力。
-
- 2.减少 64 位平台下内存的消耗。
-
- 3.在 JVM 中,32 位地址最大支持 4G 内存(2^32bit),可以通过对对象指针的压缩编码、解码方式进行优化,使得 JVM 只用 32 位地址就可以支持更大的内存配置(小于等于 32G)。
-
- 4.堆内存小于 4G 时,不需要启用指针压缩,JVM 会直接去除高 32 位地址,即使用低虚拟地址空间。
-
- 5.堆内存大于 32G 时,压缩指针会失效,会强制使用64位(即8字节)来对 Java 对象寻址,故堆内存最好不要大于 32G。
5.执行init方法 赋属性值(非零值)及执行构造方法。
2.对象的内存分配
对象在栈上的分配 : JVM内存分配对象内存都是在堆中进行分配的,但是对象较多时,会给GC带来较大的压力,直接影响程序的性能。因此出现了栈上分配,当JVM通过逃逸分析判断该对象是否会被外部引用,如果不会且栈上的空间足够,就会将该对象占用的内存在栈上分配。这样该对象的内存就随着栈帧出栈而销毁,从而减少GC压力。链接: 逃逸分析.
JVM通过逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置(JDK7后默认开启),使其通过标量替换优先分配在栈上。
标量替换: 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。 开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启,关闭逃逸分析此参数失效。
标量与聚合量:
- 标量是不可被进一步分解的量,JAVA 的基本数据类型就是标量(如:int,double 等基本数据类型以及 reference 类型等)
- 聚合量是可以被进一步分解的量。JAVA 中一些对象就是可以被进一步分解的聚合量
对象在新生代的分配 : 绝大多数情况下,对象分配在 Eden 区。当 Eden 区空间不足时,触发 Minor GC,剩余存活的对象复制到 survivor 区,下一次 Eden 区满后,又会再次触发 Minor GC,将 Eden 区和 survivor 区垃圾对象回收,并将剩余的存活对象移动到另一块存活的 survivor 区。
注意: 当 Eden 区空间不足且 survivor 不足以容纳新生代的对象时会提前移到老年代中去
新生代对象大多存活时间很短,JVM 默认 eden:from:to区8:1:1,如果不开启参数-XX:-UseAdaptiveSizePolicy 关闭自适应这个比例会动态变化。设置时 Eden 区尽量的大,survivor 区够用即可。
大对象的分配 : 大对象 ParNew、SerialGC 垃圾回收器有效。参数设置为 -XX:PretenureSizeThreshold=1024(字节) -XX:+UseSerialGC,其他垃圾收集器不支持,如 G1 有自己的定义大对象定义,直接挪动到老年代能减少垃圾回收 ,逃逸分析后栈存储对象相对来说较少
长期存活的对象将进入老年代 : 虚拟机给每个对象设置了一个对象年龄(Age)计数器。 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度 (默认为15岁,CMS 收集器默认 6 岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
对象动态年龄判断 : 当前放对象的 Survivor 区域里(From区),一批对象的总大小大于这块 Survivor 区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如 Survivor 区域里现在有一批对象,年龄1 + 年龄2 + ... + 年龄n 的多个年龄对象总和超过了 Survivor 区域的50%,此时JVM就会把年龄n (含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
老年代空间分配担保机制 :