Java对象分配及垃圾回收机制

前言

上一篇我们学习了JVM相关的一些基础概念深入理解Java虚拟机,这一篇我们重点来学习下Java对象相关的知识;

Java对象分配

JVM中对象的创建过程

对象的创建过程
对象的内存分配
虚拟机遇到一个new指令,首先检查是否被类加载器加载过,如果没有,那必须先执行相应的类加载过程。
类加载就是把class加载到JVM的运行时数据区的过程。

1. 检查加载
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用。(符号引用:符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。

符号引用 : 符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段(后续JVM类加载会具体讲到)就是为了把这个符号引用转化成为真正的地址的阶段。
一个java类(假设为People类)被编译成一个class文件时,如果People类引用了Tool类,但是在编译时People类并不知道引用类的实际内存地址,因此只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以既将符号org.simple.Tool替换为Tool类的实际内存地址。

2. 分配内存
接下来虚拟机将为新生对象分配内存,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

分配内存的方式

  • 指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
    指针碰撞
  • 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相关交替,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪块内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
    空闲列表
    选择哪种分配方式由Java堆是否规整决定。而Java堆是否规整又由采用的垃圾收集器是否带有压缩整理功能决定。
    如果是SerialParNew等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。
    如果是使用CMS这种不带压缩整理的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

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

解决并发安全问题的两种方式:
CAS机制:
CAS机制
线程分配缓冲:另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。
参数:
-XX:+UseTLAB
允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用TLAB,请指定-XX:-UseTLAB。

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

4.设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes在Java hotspot VM内部表示为类元数据)、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

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

对象内存结构

对象内存结构
HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域,对象头实例数据对齐填充

对象头:包含两部分信息,第一部分用于存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标识、线程所持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,如果是一个java数组,那么在对象头中还有一块用于记录数组长度的数据。

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

对象的访问方式

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作操作堆上的具体对象,目前主流的访问方式如下:

1. 句柄
如果使用句柄访问的话,那么Java堆中将会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而reference本身不需要修改;
句柄方式访问对象

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

直接指针方式访问对象

垃圾回收机制

判断对象的存活算法

1. 引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效时,计数器减1.
缺点:无法解决循环引用的问题;
2. 可达性分析算法
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

作为GC Roots的对象包括以下几种【重点前四种(局部变量、静态变量、常量、JNI指针)】

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java类的引用类型静态变量。
  • 方法区中常量引用的对象;比如:字符串常量池里的引用。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
  • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器);
  • 所有被同步锁(synchronized关键)持有的对象;
  • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等;
  • JVM实现中的“临时性”对象,跨代引用的对象;

Class类要被回收,需要同时满足以下条件:
1.该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2.加载该类的ClassLoader已经被回收。
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4.参数控制:
参数控制
Finalize方法
即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救。
大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了finalize方法!因为在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好;

Java引用类型

  • 强引用
    一般的Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。
  • 软引用
    一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出);
  • 弱引用
    一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。
  • 虚引用
    幽灵引用,最弱(随时会被回收掉)垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。JDK中还有一个典型运用:DirectByteBuffer。

对象的分配策略

对象分配策略
栈上分配
什么是栈上分配
针对那些作用域不会逃逸出方法的对象,在分配内存时不在将对象分配在堆内存中,而是将对象属性打散后分配在栈(线程私有的,属于栈内存)上,这样,随着方法的调用结束,栈空间的回收就会随着将栈上分配的打散后的对象回收掉,不再给gc增加额外的无用负担,从而提升应用程序整体的性能;
为什么需要栈上分配
在我们的应用程序中,其实有很多的对象的作用域都不会逃逸出方法外,也就是说该对象的生命周期会随着方法的调用开始而开始,方法的调用结束而结束,对于这种对象,是不是该考虑将对象不在分配在堆空间中呢?因为一旦分配在堆空间中,当方法调用结束,没有了引用指向该对象,该对象就需要被gc回收,而如果存在大量的这种情况,对gc来说无疑是一种负担。
栈上分配如何开启
栈上分配需要有一定的前提
开启逃逸分析 (-XX:+DoEscapeAnalysis)
逃逸分析的作用就是分析对象的作用域是否会逃逸出方法之外,再server虚拟机模式下才可以开启(jdk1.6默认开启)
开启标量替换 (-XX:+EliminateAllocations)
标量替换的作用是允许将对象根据属性打散后分配再栈上,默认该配置为开启
如何查看逃逸分析的筛选结果
可以通过配置 -XX:+PrintEscapeAnalysis 开启打印逃逸分析筛选结果

对象优先在Eden区分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到“朝生夕灭”的大对象,我们写程序的时候应注意避免。
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。
而当复制对象时,大对象就意味着高额的内存复制开销。
HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作

目的
1.避免大量内存复制;
2.避免提前进行垃圾回收,明明内存有空间进行分配;

PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。-XX:PretenureSizeThreshold=4m

长期存活对象进入老年区
HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor区中每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(并发的垃圾回收器默认为15),CMS是6时,就会被晋升到老年代中。【-XX:MaxTenuringThreshold参数调整】

对象年龄动态判定
诞生背景
JVM 通过 -XX:MaxTenuringThreshold 参数来控制晋升年龄,每经过一次 GC,年龄就会加一,达到最大年龄就可以进入 Old 区,最大值为 15(因为 JVM 中使用 4 个比特来表示对象的年龄)。设定固定的 MaxTenuringThreshold 值作为晋升条件。会诞生以下问题:
1、MaxTenuringThreshold 如果设置得过大,原本应该晋升的对象一直停留在 Survivor 区,直到 Survivor 区溢出,一旦溢出发生,Eden + Survivor 中对象将不再依据年龄全部提升到 Old 区,这样对象老化的机制就失效了。
2、MaxTenuringThreshold 如果设置得过小,过早晋升即对象不能在 Young 区充分被回收,大量短期对象被晋升到 Old 区,Old 区空间迅速增长,引起频繁的 Major GC,分代回收失去了意义,严重影响 GC 性能。
解决方案
为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

java中什么样的对象能够进入老年代
1.大对象:
2.长期存活的对象:
3.动态对象年龄判定

垃圾回收基础

垃圾回收器
当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:
1、绝大部分的对象都是朝生夕死。
2、熬过多次垃圾回收的对象就越难回收。
根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

新生代回收(Minor GC/Young GC):指只是进行新生代的回收;
老年代回收(Major GC/Old GC:只是进行老年代的回收。目前只有CMS垃圾回收器会有这个单独的回收老年代的行为。
整堆回收(Full GC):收集整个Java堆和方法区(注意包含方法区)

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
但是要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

复制算法
Appel式回收
一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden区和两块较小的Survivor空间(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)
专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
Appel式回收

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。
回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。
它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。
标记-清楚算法

标记-整理算法

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。
标记-整理算法

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值