JVM(十)垃圾回收的基础知识
对象是否存活判断算法:
即判断JVM中的所有对象,哪些对象是存活的,哪些对象可回收的算法。
1:引用计数。
最简单的垃圾判断算法。
在对象中添加一个属性用于标记对象被引用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。
但是这个算法无法解决循环依赖的问题。
2:可达性分析算法
通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系链向下搜索,如果某个对象无法被搜索到,则说明该对象无引用执行,可回收。相反,则对象处于存活状态,不可回收。
在Java中可以作为GC Roots的对象包含下面几种:
1:虚拟机栈(栈帧中的本地变量表)中引用的对象。
2:方法区中类静态属性引用的对象。
3:方法区中常量引用的对象。
4:本地方法中JNI(即一般说的Native方法)引用的对象。
可达性分析主要是通过引用来判断的,但是在java中引用又分为强引用,软引用,弱引用,虚引用。关于他们的解释,看一看下<深入理解java虚拟机>中的解释:
指针碰撞和空闲列表
指针碰撞:
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
但是分配内存是并发进行的,多个线程肯定会遇到同时来申请内存的时候,这个时候都想得到这个指针来为自己分配内存,这种情况就是指针碰撞。openJDK中的做法是采用(cas)自旋锁的方式来获取这个指针。
空闲列表:
上面说的指针碰撞是针对堆是规整的,但是堆并不是一直是规整的,对于不是规整的堆,采用的是空闲列表的方式分配内存。
虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”
注意!!!!
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
内存池相关的概念
我们在Java中创建对象的时候会占用堆内存,JVM会给我们一块内存存放这个对象,但是如果每次需要内存的时候用C++的malloc,calloc的会很耗费性能,就想我们的线程频繁创建销毁一样,所以内存池的出现也是和线程池的出现一样,为了更好的管理内存。
1: Memory Pool
管理内存块,不直接持有内存
它里面有一个List<MemoryChunk *> m_chunks; //是一个列表存放所有向OS申请的内存块(Memory Chunk)。
2: Memory Chunk
申请到的内存块还会分成Memory Cell,这样在给对象分配内存的时候就以Cell的倍数分配,JVM中这个cell是8B,Java中的对象以8字节对齐也是有道理的。
内存池实现的算法逻辑和采用的垃圾回收算法是有关系的。
比如: 标记-清除,标记-整理这两种垃圾回收算法回收的是整个trunk。
这两种算法只需要在内存池中存在两个List就可以完成。 list<MemoryCell *> m_available_table; // 所有可使用的内存 。 list<MemoryCell *> m_used_table; //所有被使用的内存
分代+复制算法:假设内存五五分,一半在用,一半空闲。则需要四个List了。
list<MemoryCell *> m_available_table; //所有可使用的内存
list<MemoryCell *> m_used_table; // 所有被使用的内存
list<MemoryCell *> m_idle_table; //空闲内存
list<MemoryCell *> m_transer_table; // 待交换内存
分代-复制算法中的空间交换数据移动之后还是可以使用的
指针在JVM中是动态计算出来,切换From,To区的时候数据发生移动之后这个数据对应的指针动态计算出来。
垃圾收集器
串行:用户线程STW,一个GC线程运行。
Serial收集器
相关参数:-XX:+UseSerialGC
Serial Old收集器
Serial收集器的老年代版本。基于标记-整理算法实现。它主要给Client模式下的虚拟机使用,如果在Server模式下,它有两大用途,1)在JDK1.5以及之前的版本中与Prallel收集器搭配。2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure使用。
并行:用户线程STW, 多个GC线程运行
ParNew收集器是Serial多线程的版本。唯一能与CMS收集器搭配使用的新生代收集器。
-XX:+UseConcMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器
-XX:+UseParNewGC:强制指定使用ParNew
-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同。
Parallel 收集器,是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,但是它和ParNew收集器只是关注的点不同。
它是关注吞吐量的收集器,吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
相关参数:
- -XX:MaxGCPauseMillis:是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
- -XX:GCTimeRatio:一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。
- -XX:+UseAdaptiveSizePolicy:一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)
Parallel Old收集器
Parallel收集器的老年代版本。基于标记-整理算法实现。它是在JDK1.6中提供的,在此之前新生代的Parallel Scavenge收集器只能搭配Serial Old收集器工作,单线程的老年代无法充分利用CPU资源。
并发:不需要STW,用户线程,GC线程并发运行。
CMS收集器
聚焦低延迟。基于标记-清除算法实现.
由于CMS收集器是并发收集器,即在运行阶段用户线程依然在运行,会产生对象,所以CMS收集器不能等到老年代满了才触发,而是要提前触发,这个阈值是92%。这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction设置
使用它,当内存满了进行垃圾回收,是回收不了的,因为没有地方存储了,而且因为CMS是并发的,并发更占用cpu,所以并发很多的时候可能会出现100%.。
因为并发处理,所以吞吐量比较高。
相关参数:
-XX:+UseConcMarkSweepGC:手动开启CMS收集器
-XX:+CMSIncrementalMode:设置为增量模式
-XX:CMSFullGCsBeforeCompaction:设定进行多少次CMS垃圾回收后,进行一次内存压缩
-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收
-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行CMS回收
-XX:CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发
-XX:+UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理
CMS收集器工作分四个步骤:
1、初始标记
会STW。只标记GC Roots直接关联的对象。时间很短
2、并发标记
不会STW。GC线程与用户线程并发运行。
会沿着GC Roots直接关联的对象链遍历整个对象图。可想而知需要的时间较长,但因为是与用户线程并发运行的,除了能感知到CPU飙升,不会出现卡顿现象。
3、重新标记
会STW。
CMS垃圾收集器通过写屏障+增量更新记录了并发标记阶段新建立的引用关系,重新标记就是去遍历这个记录。
4、并发清除
GC线程与用户线程并发运行,清理未被标记到的对象
默认启动的回收线程数 = (处理器核心数 + 3) / 4
显然CMS收集器依然不是完美的,不然后面就不会出现G1、ZGC等。那有哪些缺点呢?
1、运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点
2、无法处理浮动垃圾(标记结束后创建的对象)
3、内存碎片
具体的介绍可以参见<深入理解Java虚拟机这本书>
G1收集器
G1收集器与之前的所有收集器都不一样,它将堆分成了一个一个Region,这些Region用的时候才被赋予角色:Eden、from、to、humongous。一个region只能是一个角色,不存在一个region既是Eden又是from。
每个region的大小可通过参数-XX:G1HeapRegionSize设置,取值范围是2-32M。
一个对象的大小超过region的一半则被认定为大对象,会用N个连续的region来存储。
吞吐量没有CMS高
G1名字的由来:
回收某个region的价值大小 = 回收获得的空间大小 + 回收所需时间
G1收集器会维护一个优先级列表,每个region按价值大小排序存放在这个优先级列表中。收集时优先收集价值更大的region,这就是G1名字的由来
四个步骤:
1、初始标记
会STW。
做了两件事:
1、修改TAMS的值,TAMS以上的值为新创建的对象,默认标记为存活对象,即多标
2、标记GC Roots能直接关联到的对象
2、并发标记
耗时较长。GC线程与用户线程并发运行。
从GC roots能直接关联到的对象开始遍历整个对象图
3、最终标记
遍历写屏障+SATB记录下的旧的引用对象图
4、筛选回收
更新region的统计数据,对各个region的回收价值进行计算并排序,然后根据用户设置的期望暂停时间的期望值生成回收集。
然后开始执行清除操作。将旧的region中的存活对象移动到新的Region中,清理这个旧的region。这个阶段需要STW。
相关参数:
-XX:G1HeapRegionSize:设置region的大小
-XX:MaxGCPauseMillis:设置GC回收时允许的最大停顿时间(默认200ms)
-XX:+UseG1GC:开启g1
-XX:ConcGCThreads:设置并发标记、并发整理的gc线程数
-XX:ParallelGCThreads:STW期间并行执行的gc线程数
缺点:
1、需要10%-20%的内存来存储G1收集器运行需要的数据,如不cset、rset、卡表等
2、运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点
查看默认收集器
java -XX:+PrintFlagsFinal -version | grep GC
GC日志
相关参数:
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
日志内容:
1、gc类型:GC、Full GC
2、gc原因:Metadata GC Threshold、Last ditch collection……
3、gc前内存数据
4、gc后内存数据
5、花费的时间:用户态、内核态、实际用时
比如堆区gc日志:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现