JAVA对象的创建和内存分配策略

1 对象的创建

1.1 对象的创建

虚拟机遇到new指令:

  1. 检查指令的参数是否可以在常量池中定位到一个类的符号引用,且检查这个符号引用代表的类是否已被加载。如果没有执行类加载过程。

  2. 为生成的对象分配内存

    • 对象的大小在类加载后已被确定。
    • 目前主流的是两种分配方式:指针碰撞和空闲列表。具体哪种方式由堆采用的GC是否带有压缩整理功能决定。
      • 指针碰撞:已分配空间和未分配空间规整时,中间放置一个指针表示分界点,指针移动即为分配空间。使用serial/parnew等带有压缩compact过程(也就是标记整理算法中的整理过程)的收集器时,系统的分配算法是指针碰撞。
      • 空闲列表:空闲列表:如果内存不规则,已分配和未分配空间犬牙交错,虚拟机必须维护一个列表,记录哪些内存块可用。使用CMS这种标记清除算法的收集器时,系统的分配算法通常用空闲列表。
      • 内存分配会产生并发问题,具体详见 1.2 内存分配并发问题
  3. 将分配到的内存空间都初始化为零值(不代表就为0)(不包括对象头),如果使用TLAB,这一工作也可以提前至TLAB分配时进行。

  4. 填充对象的对象头,具体详见1.3 对象的内存布局

  5. init方法还没执行,所有字段还都为零值,执行init方法,将字段初始化。

1.2 内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS: CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • TLAB:为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。设置-XX:UseTLAB参数会开启TLAB。默认是开启的。

1.2.1 TLAB

JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB,全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

如果设置了虚拟机参数-XX:UseTLAB ,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

TLAB的本质其实是三个指针管理的区域:start,top和end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中start和end是占位用的,标识出eden里被这个TLAB所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为线程私有分配区更为合理一点

当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

不过TLAB也有自己的缺点。因为TLAB通常很小,所以放不下大对象:

  1. TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
  2. TLAB空间还剩一点点没有用到,有点舍不得。(比如100kb的TLAB,装了80KB,又来了个30KB的对象)

故而开发人员对于大对象的创建做了优化,最终的分配流程如下:

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
  2. 如果top + size <= end,说明TLAB还放得下,则在在TLAB上直接分配对象并增加top的值,如果现有的TLAB不足以存放当前对象则进入3.
  3. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
  4. 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end,说明Eden区放得下,则将对象存放在Eden区,增加eden_top的值,如果Eden区不足以存放,则5.
  5. 执行一次Young GC(minor collection)。
  6. 经过Young GC之后,如果Eden区仍然不足以存放当前对象,则直接分配到老年代。

1.3 对象的内存布局

hotspot设计了一个OOP-Klass Model,这里的OOP指的是Ordinary Object Pointer(普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而Klass则包含元数据和方法信息,用来描述Java类。

    1. Klass : Klass简单的说是Java类在HotSpot中的c++对等体,用来描述Java类。那Klass是什么时候创建的呢?一般jvm在加载class文件时,会在方法区创建instanceKlass,表示其元数据,包括常量池、字段、方法等。
    1. OOP: Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。一个OOP对象包含以下几个部分:

      • 2.1 instanceOopDesc,也叫对象头

        • Mark Word,主要存储对象运行时记录信息,如hashcode, GC分代年龄,锁状态标志,线程ID,时间戳等。这些字段并不是固定的,而是不断变化的,对象在不同的阶段,mark word的值不一样。 在64位的虚拟机上标记字段一般是8个字节,类型指针也是8个字节,总共就是16个字节. 可以使用-XX:UseCompressedOops来开启压缩指针, 以减少对象的内存使用量, 默认是开启的

        • 元数据指针,即指向方法区的instanceKlass实例

        • 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。

      • 2.2 实例数据

        • 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。
        • 各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。父类定义的变量会出现在子类定义的变量的前面
      • 2.3 对齐填充。仅仅起到占位符的作用,并非必须。

        • 对齐填充是最常见的优化手段,CPU一次寻址一般是2的倍数,所以一般会按照2的倍数来对齐提高CPU效率.这个似乎没什么好讲的。此外,JVM上对齐填充也方便gc, JVM能直接计算出对象的大小, 就能快速定位到对象的起始终止地址.
- ![](https://oscimg.oschina.net/oscnet/e6d2c492581b2287ec2f0720ebd999c3b47.png)

1.4 压缩指针

为了减少对象内存的使用,64位JVM引入了压缩指针的概念(虚拟机选项-XX:+UseCompressedOops,默认开启),将堆中的64位指针压缩成32位,这样以来,对象头占用的内存就从16字节下降到了12字节。

那么压缩指针是什么原理呢?

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在0号和1号停车位上的叫0号车,停在2号和3号停车位上的叫1号车,依次类推。

原本的内存寻址用的是车位号。比如说我有一个值为6的指针,代表第6个车位,那么沿着这个指针可以找到3号车。现在我们规定指针里存的值是车号,比如3指代3号车。当需要查找3号车时,我便可以将该指针的值乘以2,再沿着6号车位找到3号车。

这样一来,32位压缩指针最多可以表示2的32次方辆车,对应着2的33次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号*2的寻址系统。

上述模型有一个前提,你应该已经想到了,就是每辆车都从偶数号车位停起。这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为8)。

1.5 对象大小

JVM的数据类型分为基本数据类型和引用数据类型。基本数据类型有:

1
2
3
4
long/double: 8字节, 长整型和双精度浮点型
int/float: 4字节, 整数和浮点数
char,short: 2字节,字符型和短整型
byte: 1字节, 整数

基本数据类型没啥好说的,这里我们有必要讲一下引用(reference),引用的实现主要有两种:

  1. 句柄访问:

    • 在堆中分配一块句柄池,reference中存的就是句柄地址,而句柄中包括了实例对象和类型对象的地址,如图:
  2. 直接指针:

    • reference中存的直接就是对象地址:

二者之间,我们可以看到,句柄方式,类型数据得到了安置,而直接指针,则需要额外安排类型数据的放置

HotSpot虚拟机使用的是直接指针,至于对类型数据的安排,前文我们也说过了,类型指针在对象头里

1.5.1 对象大小的计算

  • 在JDK8, 64位HotSpot上, 引用数据类型都是直接指针, 如果开了压缩指针,就是4字节,没开就是8字节。
  • 对象头在64位的虚拟机上开了压缩指针就是12字节,没开就是16字节。
  • 实例数据的大小依据数据类型的大小来计算, 注意要子类的对象大小要把父类的实例数据大小也计算进去。
  • 对齐填充是按照对象里最宽的数据类型的大小来对齐的, 比如最大的是long 8字节, 那么就是按照8的倍数来对齐。

接下来我们如果有这么一个对象

1
2
3
4
5
6
public class ObjectByteTest {

private double a;
private int b;
private String c;
}

按照理论,开启压缩指针后,对象头占12字节, 实例数据最长的double是8个字节, int是4字节, String是引用类型,占4字节, 按照8字节对齐。

总共是12+8+4+4=28字节,按照8字节对齐是32字节,要4个字节的对齐填充。

故而得到,Instance size=32字节。

1.5.2 字段重排

其实上面的对对象大小的计算,是jvm对对象重排之后的结果,对象重排,目的为了减少填充,节约空间,过程不多说,一张图就足以看懂:

对于对象:

1
2
3
4
5
6
7
8
9

class A {
long l;
int i;
}
class B extends A {
long l;
int i;
}

字段重排列前后如图:

可以看到通过字段重排列,节省了空间。

2 对象的内存的分配

对象的内存分配,就是在堆上分配(如果经过JIT编译器逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配),对象主要分配在eden区,少数情况下也可能直接分配至老年代中,分配的规则视当前使用的垃圾收集器组合和内存参数规则决定。

2.1 对象优先在eden分配

对象在绝大多数情况下,在新生代eden区分配,当eden区没有足够空间进行分配的时候,JVM会发起一次Minor GC。

相关内存参数如下:

  • -Xms:最小堆内存值
  • -Xmx:最大堆内存值
  • -Xmn:新生代内存值
  • -XX:SurvivorRatio:新生代中eden区与一个survivor区的空间比

比如,设置的参数是-Xms20M、-Xmx20M、-Xmn10M、-XX:SurvivorRatio=8,可得知,最小堆和最大堆内存一致,即堆内存固定为20MB,新生代为10MB,而老年代=堆内存-新生代,得知老年代为10MB,eden区与survivor区的比例是8:1,eden区=新生代 * SurvivorRatio / 10,eden区的大小为8MB,survivor区为2MB,s0和s1区都为1MB,那么新生代的总可用空间为9MB(eden区 + 1个survivor区)。

2.2 大对象直接进入老年代

大对象,即需要大量连续内存空间的对象。经常出现大对象就容易导致内存还有不少空间时就提前触发了GC,以便获取更大的连续空间来分配。大对象对虚拟机来说是个坏消息,更坏的消息是那些“朝生夕死”的大对象。

虚拟机提供了一个参数-XX:PretenureSizeThreshold,大于此设置值的对象将直接进入老年代分配内存,这样做的目的是避免在eden区和两个survivor区之间发生大量的内存复制(因为新生代采用复制算法收集)。

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

与大对象相对应,小对象在GC过程中通常不会因为内存空间不够分配而直接进入老年代。为了确定哪些是“稳定”的对象(应该放入老年代),哪些是“朝生夕死”的对象(不应该进入老年代),jvm通过给每个对象定义一个对象年龄计数器的方式定义对象的年龄。对象在eden区出生,经过第一次Minor GC后仍然能存活,并且能被survivor区容纳,将被移动到survivor区中,并且对象的年龄设为1。对象在survivor区每经过一次Minor GC,对象的年龄就加1岁,当它的年龄增加到一定程度时(默认为15岁),就会晋升到老年代中去。

对象晋升老年代的年龄阈值,可通过参数-XX:MaxTenuringThreshold调整。

2.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,而是有个机智的策略:如果在survivor区中处于某个年龄的对象内存总和大于survivor区内存的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无须达到MaxTenuringThreshold中要求的年龄。

2.5 空间分配担保

  • 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间,
    • 如果条件满足,那么Minor GC就是安全的,
    • 否则继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
      • 如果大于,则“尝试”进行一次Minor GC
      • 如果小于,则要进行Full GC。

为什么是尝试进行Minor GC呢?因为新生代采用复制收集算法,只使用其中一个survivor空间来作为轮换备份,因此出现大量对象在Minor GC后仍然存活的情况下(最极端的就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把survivor区无法容纳的对象直接移至老年代。

老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的空间,然而一共会有多少对象存活下来,在实际完成内存回收的过程中是无法明确知晓的,所以只好取之前每一次回收晋升到老年代的对象容量的平均大小值作为参考值,与老年代的剩余空间比较,来决定是否进行Full GC来让老年代腾出更多空间。

2.6 总结

  1. 对象优先在eden区分配内存,如果eden没有足够的空间,则会触发Minor GC,清理空间
  2. 对象达到了MaxTenuringThreshold设定的年龄,或survivor区中相同年龄的所有对象大小的总和大于survivor区空间的一半时,年龄大于或等于该年龄的对象,就可以直接进入老年代
  3. 新生代对象的总大小或者历次晋升的平均大小大于老年代的连续空间时,就会进行Full GC,反之进行Minor GC

2.7 触发Full GC的方式:

  1. Perm(永久代)空间不足;
  2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
  3. 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间;
  4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。

参考资料

java 对象a内存布局和大小计算

0%