实验简介
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们会在下面的内容去介绍虚拟机中的垃圾收集算法,现在我们来探讨一下给对象分配内存的那点事儿。
预期效果
知道JVM对象分代,了解虚拟机优化。
实验目的
1.了解对象在各个年代的状态。
2.了解垃圾回收机制。
实验流程
JVM会怎么分配内存?大多数人的回答是内存分为堆和栈,但实际上内存的分配比堆栈更严格
内存被划分为5个大区:程序计数器、本地方法栈、虚拟机栈、堆、方法区。
程序计数器(线程独享)当前线程所执行的字节码的行号指令器,通过改变计数器的值选取虚拟机下一条需要执行的字节码指令,分支、循环、跳转、异常、线程恢复等基础功能。
虚拟机栈(线程独享)JAVA方法执行的内存模型,每个方法在执行的同时都会存建一个栈帧用于存储方法出口、局部变量表、操作数栈、动态连接等信息,局部表量表(大多数人认为的栈)用于存放可知各种数据类型(Boolean、byte、char、short、int、float、long、double)、对象引用类型和returnAddress(指向了一个字节码指令地址)
本地方法栈与虚拟机栈的作用非常相似,它们之间的区别是虚拟机栈为虚拟机执行JAVA方法服务,本地方法栈为Native方法服务。
方法区(线程共享)被虚拟机加载的常量、静态量、方法描述、类信息等。JAVA虚拟机规范把方法区描述为堆的一个逻辑部分,但它还有一个别名叫做Non-Heep 运行时常量池:方法区的一部分,用于存放编译时的字面量、符号引用。
堆(线程共享)在分配对象时,对象包括了三部分内容:对象头(存储自身运行数据、类型指针)、实例数据(对象真正有意义的数据)、对齐填充(并不是所有的JVM都有,以0填充)。
对象头中用于存储自身的运行数据部分:如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等,官方称为Mark Word Mark Word 32位空间中的25位用于存储HashCode, 4位存储对象分代年龄,2位用于锁标志位,1位固定0
对象头中用于存储类型指针部分:即对象指向的类元数据指针,虚拟机通过这个指针来确定是这个对象是哪个类的实例。如果对象是一个JAVA数组,那在对象头中还必须有一块用于记录数组长度的数据,虚拟机可以通过普通JAVA对象的元数据确定JAVA对象大小,但是从数组的元数据中无法确定数组大小
实例数据:对象真正有效信息,也是程序代码中所定义的各种类型的字段内容。无论从父类继承还是子类定义的,都需要纪录。这部分的存储顺序受虚拟机分配策略影响,部分虚拟机分配为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)可以看出。相同宽度总是被分配到一起。
那么对象是如何被访问的?AVA程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在JAVA虚拟机中只规定了一个指向对象的引用,并没有规定如何访问,所以对像的访问方式取决于虚拟机的实现而定,访问的方式大体分为使用句柄访问和使用直接指针访问。
使用句柄访问:JAVA堆中将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体信息。
使用直接指针访问: JAVA堆对象的存局中就必须考虑如何放置访问数据的相关信息,而reference中存储的直接就是对象地址。
JVM中对象的分配
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们会在下面的内容去介绍虚拟机中的垃圾收集算法,现在我们来探讨一下给对象分配内存的那点事儿。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。了解一下几种收集器的内存分配策略
对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。 虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不多,直接阅读就能看得很清楚。
如果对象全部无法放入Survivor空间,那么就只好通过分配担保机制提前转移到老年代去
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
思考总结
1.思考我们平时创建的对象处于哪个年代?
下周推送:扩展知识:垃圾回收。