JVM什么条件下会触发gc? 什么条件下对象会进入老年代?

661 阅读11分钟

前言

本文说话随意, 可能有错别字(大部分语音转文本的)

我就纯当做笔记, 各位观众随意, 但有错一定跟我说

什么情况下会触发gc?

这里的gc包括 young gc, major gc和 full gc

  1. eden区内存不够了, 触发young gc

  2. eden区内存不够触发young gc 还是不足, 判断下老年代是否能够存放, 老年代也不够, 执行一次 major gc, 整理一下老年代, 然后在存放该对象

  3. young gc 之后 survivor to区不足, 那么大多数对象进入老年代

  4. 如果你是 CMS, 那么老年代占用内存达到百分之 97 , 激活 full gc

    1. 为什么是百分之 97? 不是 100%?

      防止浮动对象过多, 导致OOM

  5. 对于G1的话, 一般在老年代的Region占据了堆内存的Region的45%之后,会触发一个混合回收的过程,也就是Mixed GC, 假如堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会开 始触发一个混合回收, 借助global concurrent marking统计计算出高收益的年轻代和老年代的region一起回收

    1. 注意 Mixed GC 不等于 full gc

  6. G1如果在触发回收机制的时候发现没空闲的region了, 那么stw, 进行一起全局的serial old gc(full gc)回收

什么情况下对象进入老年代?

对象默认优先进入eden区

也就你创建的所有对象都会优先进入eden区,如果eden区满了,那么就会执行一次young gc

young gc的目的是将幸存者from区和伊甸区的对象全部特别是朝生夕死的对象全部gc掉,然后剩下的对象存放到幸存者to区

这样一次 young gc 执行完毕

Q: 在jvm新生代中默认的survivor区和eden区的比例是1:1:8,默认对象存入eden区的话,那就是只有8层几率被用到剩下的 2 层新生代都会被浪费掉?

A: 这样说是错误的默认应该是 9 成的内存将会被利用到,只不过在新对象存入新生代的时候会存放到伊甸区,而不是survivor区, 如果eden区内存不够了,对象也不会存放到survivor from区, 而是直接触发y gc

eden快满了无法存放大对象会进入老年代

对于jvm来说, 朝生夕死又频繁创建的大对象是灾难的, 如果eden区刚好无法存储那么更是灾难中的灾难

jvm判断到eden区无法存储,该大对象的时候就会先执行一次youngGC回收一下新生代的内存

然后再次判断到无法存储大对象, 触发保底机制,将会将大对象存放到老年代

但是在存放到老年代之前会先判断老年代是否也有足够的空间存放该大对象如果没有足够空间就会进行major GC,回收一下老年代的内存,然后再存放

如果发现老年代还是无法存放下去,那么就会触发oom

多大算大对象?

Q: 那么多到底多大对象才算大对象呢?

A: 如果是非G1的jdk可以看-XX:PretenureSizeThreshold参数可以定义到底多大的对象才会直接进入老年代,但是这个参数已经被淘汰,或者说在新的jdk版本中已经不再使用该参数, 在旧版本的对上中默认是3m好像才算大对象直接进入老年代

如果是 G1 的话, 那么看region大小的一半就算是大对象了

如果是 zgc 的话, 大对象一般表示大于4MB的对象, 中对象在 256kb ~ 4MB, 小对象~

最恶心的地方在于这些对象明明都是朝生夕死的对象,现在变成了老年代的对象, 这些临时大对象一直占用在堆内存中,而老年代的full gc 和 major gc一般都是很久才会执行一次的, 由于这些临时大对象频繁gc

这会导致什么?

老年代的对象内存空间被大量朝生夕死的大对象占用,然后频繁触发full gc扫描整个堆区, 导致频繁触发 stop the world

同时如果你使用的是"九转大肠"G1垃圾回收器, 它并不能完全清除干净, 它只会计算回收效率, 挑选几块回收效率最高的内存块进行内存回收

肯定还有一部分所谓朝生夕死的对象被留下, 为了那一丝"地道的味道"

当然在下次 g1 回收之后可能会把那一丝味道干掉吧, 可能~~~

这种从侧面可以看出 G1 需要的内存会比 CMS 更高, 以 6G 堆内存为界线, 高于 6G 的使用 g1 低于 6g 使用 jdk8 默认垃圾回收器便可

如果内存更大, 推荐使用 zgc , 不过频繁出现大对象的场景下, 可能不合适zgc, 因为垃圾回收的效率可能比不上大对象分配的效率

如果你是 zgc 用户, 那么可能进入异常几分钟甚至十几分钟连续不断的长跑比赛

频繁创建大对象队员 vs zgc垃圾回收器队员 的长跑比赛

好吧, 有点夸张了

为什么我会这说呢?

首先 zgc 借助多重映射技术, 让 三个 虚拟机地址映射到相同的一个物理地址的方式实现了染色指针技术, 该技术说白了, 就是利用指针地址多余的部分 , 但是正常64位系统用不上那么大的指针地址, 就像linux系统也只支持到40几位好像, 所以剩下的指针的地址位就可以被用配置标志位

有点类似于: 正常系统只能读到这个指针地址: 0x00401000.

但是实际上系统的指针长度是 0x0000 00401000 那么前面多余出来的 0x0000 将被用来作为标记位

分别是 marked0 marked1 remapped finaliable

指针地址将是: 0x000100401000 0x001000401000 ...

image.png image.png

看图便知, 我说话说累了

image.png

看起来好像很复杂, 其实也很简单, zgc 的整个过程就三个步骤(书上或者网络上都说4个),

第一, 并发标记(少了一步预分配, 说白了就是看看哪些region需要转移并且找个地方存放转移对象)

第二, 并发转移(转移时, 会有一个转移表, 转以后region将被回收)

第三: 并发重映射(引用从旧地址改成指向新地址; 可以不立即完成, 可以合并到下次gc的第一阶段, 也就是标记阶段, 只要转移表存在就行)

三个步骤全都是并发的, 就是跟java线程并发, 所以前面说的长跑, 的本质就是 gc 线程 vs java 线程

转移表的作用: 转移, 将对象从A region转移到 B region, 此时 java线程执行拿到了旧引用去 A region 找对象, 时会根据转移表设置新的对象, 也就是进行一次指针自愈的过程(旧引用指向新对象)

对象分代年龄大于等于15进入老年代

进行一次young gc 存活, 那么该对象年龄 +1 , 这个数据存储在对象头中

如果你是 g1 或者 Parallel Scavenge+CMS套餐, 那么会有这种情况

如果你是zgc的话, zgc暂时不支持分代, 所以也就不存在这个事情

新生代老龄化的进入老年代

如果survivor区(注意不是新生代)大多数对象都是同一个年龄段, 比如超过百分之50的对象都大于10, 那么大于等于 10 的所有对象都进入老年代

不需要等待 15 岁

课外面试题

最近看到的一个面试题

面试题: 可以跟我讲讲对象创建时jvm都做了什么?

答: 这个问题我会从简单到入门,从总体到部分回答
简单的部分是这样子的,对象在堆区分配一块内存存放对象数据, 然后在栈区存在一个指针指向堆区, 对象会先填充堆区, 也就是初始化各个字段的值为0, 然后再调用init方法也就是构造函数, 这是简单回答

而对象new的时候他会先去检查一下,new对象的类是否在已经被jvm类加载器加载到方法区(元空间), 没有就加载

然后接着就是分配内存的时候,jvm会在java堆中查找一块合适的内存空间, 为什么我会说是查找呢? 因为java堆逻辑上是完整的,但是在物理上实际上它是分散的,所以需要有一些机制去管理java堆内存, Jvm提供的几种方案

  • 指针碰撞: 说白了讲就是拿一个指针,指向一个界限,在指针左边是已使用的内存,在指针右边就是未使用的内存. 但是也是存在一些问题的
    • jvm是多线程的,而指针的前移和后移,可能会收到高并发多线程影响, 导致指针指向错误的位置, 解决方案是使用cas
    • 指针碰撞还有一个前提条件,指针的左边是一整块内存,指针的右边也是一整块内存,但是如果内存是碎片化的,到处都有占用和未被未被占用的情况,所以就指向碰撞就不可用, 解决方法就是对整块得益进行整理, 当然还有<空闲列表>
  • 空闲列表: 空闲列表类似于一些文件系统结构,就是在内存的前一个部分存放一堆指针,这些指针指向的地址是空闲内存的位置,这样新对象在创建的时候就会去查询指针从而得到空闲的内存进行分配
  • thread local allocation buffer: 当然为了提高堆内存内存分配的速度,jvm还提供了线程本地分配缓冲, 该技术就是为每一个线程预先创建一块"内存池"(类似但不是),如果是这个线程创建了一个对象,该对象会优先在线程池中分配内存并且在创建这块内存池的时候,其空间已经被"置零",所以在该内存池中分配的对象,其字段也将被置零, 这里置零的时机和前面使用指针碰撞和空闲列表的时机不同, 前面是对象分配好内存后将会置零, 这里是线程分配缓冲区就已经被置零

既然已经找到了内存,并事先分配了现在的,就到初始化对象头的时候了,初始化对象头其实也是非常简单的 对,对象头主要关注就是gc的分代年龄, 锁标志位,还有一些比如hashcode的计算, 线程ID等

(课外题, 面试的时候不用回答)

一般初始化对象头的话,指初始化分代年龄和锁标志, 而 hashCode 一般是不初始化的, 因为初始化之后, 偏向锁将失效, 因为 对象头的空间不足, 如果hashcode计算出来, 那么就没有多余的位置存放线程ID了

偏向锁需要记录线程ID也就是说当前偏向锁所偏向的线程到底是哪一个,只要遇到这个线程ID,那么就一直偏向下去, 如果线程ID不同的话就会根据条件触发各种机制,比如说如果发现线程ID不同

  • 在锁的范围内已经没有线程,那么就会触发偏向撤回, 并将新的线程ID填写到对象头中

  • 在锁的范围内还有线程存在,那么就会进行锁膨胀, 膨胀到轻量级锁, 此时将引入新的概念叫 LOCK RCORD锁记录, 将对象头的部分参数转移到lock record中(比如分代年龄), 然后在对象头中存放该 lock record的指针

image.png

对象头除了Mark Word初始化外, 还需要初始化Klass Pointer(类型指针), 指向对象所属类的指针,用于确定对象的类型信息。它指向对象的类元数据(Class Metadata)或类型指针(Class Pointer),从而可以获取对象的方法、字段等类型相关信息。

image.png

对象头初始化完毕之后,就开始调用init方法方法对对象的字段进行初始化(这里是第二次初始化),也就是构造函数

最后返回一个引用给调用者