抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

JVM对象分配

对象的创建

这里的对象限于普通Java对象,不包括数组和Class对象。

虚拟机遇到一条new指令时:根据new的参数是否能在常量池中定位到一个类的符号引用,如果没有,说明还未定义该类,抛出ClassNotFoundException;

检查加载

​ 虚拟机遇到一条new指令时,首先检查指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。

分配内存

对象所需要的内存在类加载完成后可以被完全确定,所以只需要把一块确定大小的内存区域从堆中划分出来给这个对象即可。

指针碰撞(Bump the Pointer)

java堆内存空间规整的情况下使用

​ 如果堆的内存是规整的,所有使用的内存在一边,未使用的内存在另一边,中间是一个作为分界点的指针,那分配空间只需要移动作为分界点的指针(移动距离等于该对象需要的空间)。

空闲列表(Free List)

java堆空间不规整的情况下使用

​ 如果堆的内存是不规整的,那么虚拟机需要维护一个列表,记录那些内存块是可用的,在分配的时候划分一个足够大的内存块给该对象,并且更新列表的记录。Java堆内存是否规整取决于所采用的垃圾收集器是否带有压缩整理的功能。

并发安全

​ 除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

CAS机制

​ 对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。

分配缓冲

​ 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),如果设置了虚拟机参数 -XX:+UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。

​ TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间(Eden区,默认Eden的1%),减少同步开销。

​ TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。

分配内存后,虚拟机设置对象的一些必要信息,这些信息存在在对象的对象头中。一般来说,执行new指令后,会接着执行方法,将对象初始化。这样一个真正可用的对象才算完全产生出来。

​ 编译器为每个类生成至少一个实例初始化方法,即()方法。此方法与源程序里的每个构造方法对应。如果类没有声明构造方法,则生成一个默认构造方法,该方法仅调用父类的默认构造方法,同时生成与该默认构造方法对应的()方法。()方法内容大概为

  • 调用另一个()方法(本类的另外一个()方法或父类的()方法);
  • 初始化实例变量;
  • 与其对应的构造方法内的字节码

内存空间初始化

​ (注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置

​ 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

对象初始化

​ 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

对象在内存中的布局可以分为3块区域:

  • 对象头(header);
  • 实例数据(Instance Data);
  • 对齐填充(Padding);

对象头(header)

对象头包括两部分信息

  • 第一部分用于存储对象自身的运行时数据。如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程id,偏向时间戳等。考虑到虚拟机的空间效率,此部分在32位和64位虚拟机中只占32位或者64位的大小。
  • 第二部分是类型指针。即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(并不是所有的虚拟机实现都保留类型指针,查找对象 的元数据信息不一定要通过对象本身)

如果该对象是数组,那么对象头还会保留一块数据,用于记录数组长度。

实例数据(Instance Data)

​ 实例数据是对象真正存储的有效信息,也是在代码中定义的各种类型的字段内容。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。Hotspot虚拟机默认策略是longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Pointers),相同宽度的字段总是分配到一起。在这个去前提下,父类中定义的变量会出现在子类之前。

对齐填充(Padding)

​ 对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。对象正好是9字节的整数,所以当对象其他数据部分(对象实例数据)没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

​ Java程序通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机中的规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机的实现而定的。目前主流的访问方式有试用句柄和直接指针两种:

句柄访问

句柄是一种特殊的智能指针 。句柄与普通指针的区别在于,指针包含的是引用对象的内存地址,而句柄则是由系统所管理的引用标识,该标识可以被系统重新定位到一个内存地址上。

​ Java堆划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息

直接指针访问

如果使用直接指针访问, reference中存储的直接就是对象地址。

​ 这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使 用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

​ 对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问的。

堆内存分配策略

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from(S1)和to(S2))

​ Eden和Survival的默认分配比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理,后面会说到),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

​ 因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

​ 在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

​ 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

​ 永久代主要用于存放静态文件,Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持永久代空间来存放这些运行过程中新增的类。永久代大小通过-XX: MaxPermSize = 进行设置。

优先使用Eden区域

​ 大多数新生代对象都在Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接放入老年代

​ 大对象是指需要大量内存空间的Java对象,最典型的大对象就是那种很长的字符串和数组(byte[ ]就是典型的大对象)。出现大对象很容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

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

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

​ Java虚拟机采用分代收集的思想来管理虚拟机内存。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC仍然存活,且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代(长期存活的对象进入老年代)。但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代(PretenureSizeThreshold参数的设定)。

动态判断对象年龄

​ 虚拟并不是永远都要求对象年龄必须达到MaxTenuringThreshold才能晋升为老年代的,如果在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接进入老年代,无需要等到MaxTenuringThreshold中要求的年龄。

空间分配担保

​ 在发生Minor GC之前,虚拟机会先检查老年代可用的连续空间是否大于所有新生代的总空间,如果大于的话,那么这个GC就可以保证安全,如果不成立的,那么可能会造成晋升老年代的时候内存不足。在这样的情况下,虚拟机会先检查HandlePromotionFailure设置值是否允许担保失败,如果是允许的,那么说明虚拟机允许这样的风险存在并坚持运行,然后检查老年代的最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果大于的话,就执行Minor GC,如果小于,或者HandlePromotionFailure设置不允许冒险,那么就会先进行一次Full GC将老年代的内存清理出来,然后再判断。

​ 上面提到的风险,是由于新生代因为存活对象采用复制算法,但为了内存利用率,只使用其中的一个Survivor空间,将存活的对象备份到Survivor空间上,一旦出现大量对象在一次Minor GC以后依然存活(最坏的计划就是没有发现有对象死亡需要清理),那么就需要老年代来分担一部分内存,把在Survivor上分配不下的对象直接进入老年代,因为我们不知道实际上具体需要多大内存,我们只能估算一个合理值,这个值采用的方法就是计算出每次晋升老年代的平均内存大小作为参考,如果需要的话,那就提前进行一次Full GC.

​ 取平均值在大多数情况下是可行的,但是因为内存分配的不确定性太多,保不定哪次运行突然出现某些大对象或者Minor GC以后多数对象依然存活,导致内存远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。这样的情况下,担保失败是要付出代价的,大部分情况下都还是会将HandlePromotionFailure开关打开,毕竟失败的几率比较小,这样的担保可以避免Full GC过于频繁,垃圾收集器频繁的启动肯定是不好的。

评论