JVM基础:内存结构与垃圾回收
JVM内存结构
Java运行时数据区:
- 线程不共享:程序计数器、JVM栈、本地方法栈
- 线程共享:方法区、堆区
不属于Java运行时内存:直接内存
PC:
- PC的作用是控制指令的执行。
- 多线程下,需要通过PC记录CPU切换前的执行位置。程序计数器只会保存固定长度的内存地址,不会发生内存溢出。
- 程序员无需对PC做任何处理
栈
JVM栈介绍:
- JVM中,每个方法的调用使用一个栈帧来保存。使用idea的Debug工具可以查看栈帧的内容。在程序抛出异常的时候,也会打印出栈帧。
栈帧组成:
- 局部变量表:this对象、方法参数、方法体中的局部变量。
- 使用jclasslib查看字节码文件中的局部变量表:{% image https://obj.cagurzhan.cn/blog/202402/jvm2/Pasted%20image%2020240225100320.png, width=400px %}
- 解释:起始PC和长度构成了生效范围。序号指的是槽的起始位置。
- 本质是数组:栈帧中的局部变量表本质上是数组,每个位置是一个槽,long和double占用两个槽,其它占用一个槽。
- 可复用:局部变量的槽可以复用,一旦某个局部变量不生效,当前槽可以再次被使用。
- 操作数栈:存放临时数据。
- iconst就是将常量入栈,iadd就是将操作数栈顶两个数相加
- 帧数据:动态链接、方法出口、异常表的引用。
- 每个虚拟机可以添加自己需要的数据。
- 动态链接:保存了编号到运行时常量池的内存地址的映射关系
- 方法出口:存储此方法的出口地址。即存储下一行指令的地址
- 异常表:异常处理信息。包含异常捕获的生效范围和异常发生后跳转到的字节码指令位置。
栈内存溢出:
- 栈大小:如果不指定栈大小,JVM会创建一个默认大小的栈。取决于操作系统和计算机体系结构。
- Linux的x86默认是1MB
- BSD:1MB
- Windows:基于操作系统默认值
- 修改JVM栈大小的参数:
-Xss字节数
,例如-Xss1024k
、-Xss1m
- HotSpot对栈大小的限制:Windows下JDK8最小是180K,最大时1024m
- 局部变量过多、操作数栈深度过大会影响栈大小。
一般情况下,工作中即使用了递归,栈深度最多几百。因此可以手动指定
-Xss256k
节省内存。
本地栈帧和JVM栈帧:
- Hotspot中,JVM和本地方法栈使用同一个栈空间。
- 本地方法栈:存储native方法的栈帧。
- JVM栈:存储Java方法调用时的栈帧。
堆
堆:
- 堆是Java最大的内存区域,创建的对象都位于堆上。
- 栈上的局部变量表,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用。通过静态变量可以实现线程之间共享。
堆内存分布:
- used:已使用堆内存
- total:JVM已分配可用堆内存
- max:JVM可分配最大堆内存
并非used=max=total就发生堆溢出,与GC有关。
堆大小:
- 堆内存有上限,一直向堆放数据上限后,会抛OutOfMemory
- max默认是系统内存的四分之一,total默认是系统内存六四分之一。
JVM堆大小参数:
- 虚拟参数:
-Xmx值
设置最大值、-Xms值
设置初始total - Xmx必须大于2MB,Xms必须大于1M
- 建议将Xmx和Xms设置为相同值,这样程序启动后可用内存就是最大内存,无需再次申请,减少申请开销。
Q:为什么Arthas显示的堆大小和设置的不一样
A:arthas的堆内存采用JMX技术获取,与GC有关,计算的是可分配对象的内存,不是整个内存。
方法区
方法区存放基础信息,线程共享,包含三部分信息:
- 类的元信息:保存了所有类的元信息,一般称之为InstanceKlass对象,在类加载阶段完成。
- 运行时常量池:保存字节码文件中的常量池内容。
- 字符串常量池:保存了字符串常量。
方法区是一个虚拟概念,Hotspot的方法区设计:
- JDK7以及以前:方法区存放在堆区域的永久代空间(PermGen Space),堆大小由JVM控制:
-XX:MaxPermSize=值
- JDK8以及以后:方法区存放在元空间(MetaSpace)中,元空间位于OS维护的直接内存中,只要不超过OS上限,可以一直分配。
-XX:MaxMetaspaceSize=值
可以设置元空间大小。一般设置256M
Arthas查看方法区
memory
静态变量的存储:
JDK7之后,静态变量放到了Class中,脱离永久代。
字符串常量池StringTable
存储:代码中定义的常量字符串内容。
经典题:
String s1 = new String("abc"); // 堆区
String s2 = "abc"; // 字符串常量池
s1 == s2 // false
字符串常量池和运行时常量池的关系:
- JDK7之前:字符串常量池属于运行时常量池。在永久代中。
- JDK7以后:字符串常量池放到堆中。
练习题1:
String a = "1"; // 字符串常量池
String b = "2"; // 字符串常量池
String c = a+b; // 堆内存,使用StringBuilder连接
String d = "1" + "2"; // 字符串常量池,编译阶段连接
String.intern()方法会手动将字符串放入常量池。
String s1 = new StringBuilder().append("a").append("1").toString();
s1.intern() == s1;
String s2 = new StringBuilder().append("ja").append("va").toString();
s2.intern() == s2;
- JDK6:false、false
- JDK8:true、false
- JDK7之后:字符串常量池在堆中,intern会把第一次遇到的字符串的引用放到字符串常量。
{% image https://obj.cagurzhan.cn/blog/202402/jvm2/Pasted%20image%2020240229183954.png, width=400px %}
- JDK7之后:字符串常量池在堆中,intern会把第一次遇到的字符串的引用放到字符串常量。
直接内存
- 直接内存不在JVM规范中,不属于运行时内存区域。
JDK1.4的NIO机制使用了直接内存。
- 解决1:Java对象回收会影响对象创建和使用。
- 解决2:IO读文件需要先将文件放入直接内存,再读到堆区。
- (现在直接放入直接内存即可,堆上维护直接内存的引用,减少了数据复制开销)
直接内存的管理:
- 创建直接内存:
ByteBuffer bf = Bytebuffer.allocateDirect(size)
- 查看直接内存:可以使用Arthas的memory命令查看direct的内存。
- 手动调整直接内存大小:
-XX:MaxDirectMemorySize=大小
- (建议使用了NIO,就要设置这个值。)
JVM垃圾回收
垃圾回收的概念:
- C/C++内存管理:需要手动释放内存,容易内存泄漏。内存泄漏累计容易导致内存溢出。
- Java的GC:负责堆上的内存的回收,属于执行引擎的一部分。
- 优点:降低程序员实现难度,降低内存泄漏可能性。
- 缺点:程序员无法控制内存回收的及时性。
- 应用场景
- 解决系统僵死问题:与频繁的垃圾回收有关。
- 性能优化:对gc进行合理设置。
方法区的回收
方法区回收在实际开发场景很少遇到。
不需要回收的内存:
PC、Java虚拟机栈、本地方法栈会伴随线程的创建而创建,销毁而销毁。 方法的栈帧执行完方法之后就会自动出栈释放。
哪部分内存需要回收:方法区、堆。
一个类被卸载,需要满足三个条件:
- 此类所有实例已被回收,堆中不存在任何该类的实例对象以及子类对象
- 加载类的类加载器已经被回收。
- 该类的java.lang.Class对象没有在任何地方被引用。
System.gc
:
System.gc()
:手动触发垃圾回收。但是不一定立即回收垃圾,只是向JVM发送垃圾回收请求。
堆回收
引用计数法和可达性分析法
判断对象是否可以被回收:
- 即判断对象是否被引用来决定,被引用了则不会回收
案例:
- 如下案例中,如何做才能将回收?
- 答案:a1 = null、b1.a = null
- 只是删掉a1和b1,能否回收A和B?
- 可以回收,因为方法内无法再访问A和B对象了。
引用计数法:
- 为每个对象维护一个引用计数器,被引用+1,取消-1。实现简单,C++的智能指针就采用了此法。
- 缺点:
- 维护计数器,系统性能有一定影响。
- 存在循环引用问题,即上面的案例。
查看垃圾回收日志:
- 可以使用JVM参数:
-verbose:gc
- 日志结果:
可达性分析算法:
- JVM实际上没有采用引用计数法,而是采用可达性分析算法来判断对象是否可以被回收。
- 可达性分析将Java对象分两类:GC Root、普通对象。在引用链中,如果从某个GC Root对象是可达的,对象就不可被回收。 GC Root是不会被回收的。
垃圾回收的根对象GC Root
- 线程Thread对象:线程Thread对象会引用线程栈帧中的方法参数、局部变量等。在上面的案例中,因为Thread引用了主线程的局部变量,所以只需要删除主线程中的局部变量即可符合GC条件。
- 系统类加载器加载的java.lang.Class对象,包含Launcher,Launcher指向应用程序类加载器,引用类的静态变量。
- 监视器对象:例如用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象
查看GC Root:
- 使用arthas的heapdump命令,将堆内存快照保存到本地。
heapdump xxx.hprof
。 - 打开Memory Analyzer,打开hprof文件。通过工具内Java basics下的GC Roots,就能打开。
- 通过Path to GCRoot,可以查看某个对象的引用链。
五种对象引用
五种对象引用:强引用、软引用、弱引用、虚引用、终结器引用。
- 强引用:可达性算法描述的对象引用。
- GCRoot对象和普通对象有引用关系,就不会被回收。
- 软引用:JDK1.2后提供了SoftReference类
- 当程序内存不足时,软引用的数据会被回收。
- JDK1.2后提供了SoftReference类实现软引用。常用于缓存。
- GC机制:内存不足-->开启GC-->仍然不足-->开启软引用回收-->仍不足-->OOM。
- 软引用对象回收机制:SoftReference提供了队列机制:
- 软引用创建时,通过构造器传入引用队列。
- 软引用包含的对象被回收时,该软引用对象会被放入引用队列
- 通过遍历引用队列,可以将SoftReference的强引用删除。
应用场景:实现简单缓存,思想很厉害,利用软引用的自动清理功能。
package Java.JVM;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.HashMap;
class Student {
private Integer id;
private String name;
public Integer getId(){
return id;
}
public Student(Integer id){
this.id = id;
}
}
public class StudentCache {
private HashMap<Integer, StudentRef> studentRefs;
private ReferenceQueue<Student> q;
// 内部使用软引用
private class StudentRef extends SoftReference<Student>{
private Integer _key = null;
public StudentRef(Student st, ReferenceQueue<Student> q){
super(st,q);
_key = st.getId();
}
}
private StudentCache(){
studentRefs = new HashMap<Integer, StudentRef>();
q = new ReferenceQueue<Student>();
}
private void cacheStudent(Student stu){
cleanCache();
StudentRef studentRef = new StudentRef(stu, q);
studentRefs.put(stu.getId(), studentRef);
System.out.println(studentRefs.size());
}
private void cleanCache(){
StudentRef ref = null;
while((ref = (StudentRef) q.poll()) != null) {
studentRefs.remove(ref._key);
}
}
public Student getStudent(Integer id){
Student st = null;
if(studentRefs.containsKey(id)){
StudentRef studentRef = studentRefs.get(id);
st = studentRef.get();
}
// 不存在
if(st == null){
st = new Student(id);
this.cacheStudent(st);
}
return st;
}
}
- 弱引用:
- 弱引用的整体机制和软引用基本一致。区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接回收。
- WeakReference来实现弱引用。弱引用主要在ThreadLocal中使用,开发过程中一般不使用。
- 虚引用:常规开发中不会使用。
- 也叫幽灵引用,不能通过虚引用获取到对象。
- 唯一的用途:对象被GC回收的时候可以接到通知。
- Java中使用PhantomReference来实现虚引用,直接内存(DirectByteBuffer)中为了及时知道直接内存对象不再使用,从而回收内存。
- 终结器引用:常规开发中不会使用。
垃圾回收算法
垃圾回收算法核心思想:
- 找到内存中存活的对象。
- 释放不再存活对象的内存,使得程序能够再次利用这部分空间。
四种垃圾回收算法:
- 标记-清除算法。Mark Sweep GC
- 复制算法。Copying GC
- 标记-整理算法。Mark Compact GC
- 分代GC。Generational GC
垃圾回收算法评价标准:
- STW:Stop the world,指的是GC会有部分阶段需要停止所有的用户线程。如果时间过长会影响用户的使用。
- 吞吐量:执行用户时间 / 执行用户时间 + GC时间,越高越好。
- 最大暂停时间:STW的最大值。最小越好。
- 堆使用效率:比如复制算法会将堆内存一分为二,一次只能使用一半内存,堆使用效率低。
不同的垃圾回收算法,适合不同场景。
标记清除算法
算法过程:
- 标记阶段:使用可达性分析算法堆存活对象标记。即从GC Root开始通过引用链遍历所有存活对象。
- 清除阶段:从内存中删除没有被标记的对象。
优点:
实现简单,维护标志位即可。
缺点:
- 存在内存碎片化问题。
- 分配速度慢,由于内存碎片化问题存在,需要维护空闲链表,可能要遍历到链表尾部才能获得合适空间。
复制算法
算法过程:
- 将堆区分为两块内存,From区域和To区域。
- GC阶段开始,将GCRoot搬运到To区域。
- 将GC Root关联的对象放到To区域。
- 清理From区域,将名字互换。
算法优点:
- 吞吐量高。只需要遍历一次即可,比标记整理算法少了一次。但是不如标记清理算法,因为后者不需要复制。
- 不会碎片化。
算法缺点:
内存使用效率低,只能使用一半内存。
标记整理算法
算法过程:
- 标记阶段,使用可达性分析算法标记所有对象。
- 整理阶段,将存活对象移动到堆一端,清理。
算法优点:
- 内存使用效率高。整个堆内存都可以使用。
- 不会碎片化。
缺点:
整理阶段效率不高。例如Lisp整理算法需要堆整个堆搜索3次。可以用其它整理算法提高性能。
分代垃圾回收算法
此算法将内存区域划分为年轻代和老年代。
- 年轻代:分为Eden区、S0S1两个存活区域
- 老年代:存活时间比较长的对象。
查看分代垃圾回收器的JVM参数:-XX:+UseSerialGC
使用Arthas查看内存情况:memory
JVM参数总结:
算法流程:
- 新创建的对象会被放入Eden伊甸园区。
- Eden区满,会触发年轻GC(年轻代一般比较小)(复制算法),即MinorGC,把eden区域和From需要回收的对象回收,把没有回收对象放入To。
- S0会变成To区,S1变成From。MinorGC会回收eden和S1内的对象。每次MinorGC都会为存活对象记录年龄,初始值是0,每次加1.
- MinorGC区域的对象达到阈值(最大15)就会移动到老年代。
- 老年代空间不足时,先做MinorGC,还是不行触发FullGC,堆整个堆进行垃圾回收。仍然不足,则OOM。(之所以先做minorGC,是为了避免新生代满了导致一些未满阈值的对象被放到老年区。)
区分年轻代和老年代的依据:
- 大部分对象,创建出来后很快就会被回收。
- 老年代存长期存活的对象,如Spring的Bean。
- 虚拟机参数中,新生代远小于老年代大小。
为什么区分年轻代和老年代:
- 通过调整两个区域的比例来适合不同类型的应用,提高利用率。
- 新生代一般使用复制算法,老年代可以选择标记类算法。
- 分代设计算法只允许回收新生代,如果能满足对象分配要求旧不会做FullGC,STW时间就会减少。
垃圾回收器
垃圾回收器是对垃圾回收算法的实现。
垃圾回收器搭配:
Serial和Serial Old
Serial是一种单线程串行回收年轻代的垃圾回收器。采用复制算法。
- 优点:单CPU吞吐量高。
- 缺点:多CPU吞吐量不好。
- 适合场景:资源有限的场景。
SerialOld是Serial的老年代版本,也是单线程串行回收。
使用参数:-XX:+UseSerialGC
ParNew和CMS
ParNew:在Serial基础上支持多线程垃圾回收。
- 优点:多CPU停顿时间短。
- 缺点:吞吐和停顿不如G1回收器。JDK9后不建议使用。
- 适合:JDK8以及之前版本,与CMS搭配。
- 使用:
-XX:UseParNewGC
CMS:关注系统暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少等待时间。
- 使用:
-XX:+UseConcMarkSweepGC
- 优点:停顿时间短。
- 缺点:
- 内存碎片问题(CMS会在FullGC做碎片整理,导致用户线程暂停)
- 退化问题:老年代内存不足会退化为SerialOld
- 浮动垃圾问题:(无法做完全垃圾回收)
- 适合场景:高并发场景,数据量大场景。如订单接口、商品接口。
- 执行步骤:
- 初始标记,短时间标记GCRoot的关联对象。
- 并发标记:标记所有对象,用户线程不需要暂停。
- 重新标记:并发标记存在错标、漏标等情况。
- 并发清理:清理死亡对象。
Parallel Scavenge和Parallel Old
Parallel Scavenge:
- JDK8默认的年轻代垃圾回收器。
- 多线程并行回收,关注吞吐量。具备自动调整堆内存大小特点。
- 优点:吞吐高,手动可控。
- 缺点:不能保证单次停顿时间。
- 场景:后台任务。如大数据处理、大文件导出。
- 允许设置最大暂停时间和吞吐量:不要设置为堆的最大值。
- 最大暂停时间:
-XX:MaxGCPauseMills=xxx
- 吞吐量:
-XX:GCTimeRatio=n
- 自动调整内存大小:
-XX:+UseAdaptiveSizePolicy
- 最大暂停时间:
Parallel Old:
- 参数:
-XX:+UseParallelGC
或-XX:+UseParallelOldGC
- 优点:多核效率高
- 缺点:暂停时间长
- 场景:与Parallel Scavenge搭配
G1垃圾回收器
JDK9后默认的垃圾回收器:结合PS和CMS的优点。
- PS关注吞吐量,允许设置最大暂停时间,但是会减少年轻代可用空间
- CMS关注暂停时间,但是吞吐量不太行。
G1:
- 支持巨大的堆空间回收,吞吐量高
- 支持多CPU并行GC
- 允许用户设置最大暂停时间
内存结构:
G1会将堆分为多个大小相同的Region,不要求连续。分为Eden、Survivor、Old。Region是堆空间/22048得到,也可以用参数 -XX:G1HeapRegionSize=
来指定,必须是2的指数幂。范围从1到32M。
G1垃圾回收的两种方式:
- 年轻代回收Young GC
- 混合回收Mixed GC
年轻代回收:回收Eden区和Survivor区。
- 会导致STW,G1可以通过参数
-XX:MaxGCPauseMills=n
设置最大暂停时间毫秒数,默认是200ms。G1会尽可能保证暂停时间。
执行步骤:
- 新创建对象存放在Eden区,当G1判断年轻代不足(max默认60%),会执行YoungGC。
- 标记出Eden和Survivor区域的存活对象。
- 根据配置的最大暂停时间,选择某些区域将存活对象复制到一个新的Survivor区,年龄+1,清空这些区域。G1在进行YoungGC的过程中,会记录每个垃圾回收时每个Eden区域和Survivor区域的平均耗时,作为下次回收的依据。这样就可以依据配置的最大暂停时间,计算出本次回收最多能回收多少个Region区域。比如配置最大暂停时间200ms,每个Region耗时40ms,那么最多回收4个Region。
- 后续Young GC和之前相同,不过Survivor区中存活对象会被搬运到另外一个Survivor区域。
- 当某个存活对象年龄达到阈值(默认15),将被放入老年代。
- 部分对象如果超过Region 的一半,会直接放入老年代。这个老年代叫做Humongous区。比如每个Region是2M,如果一个对象大于1M就会被放入。
- 多次回收之后,会出现很多Old老年区,此时总堆占有率达到阈值
-XX:InitiatingHeapOccupancyPercent
会触发混合回收MixedGC。回收所有的年轻代和部分老年代对象以及大对象区。采用复制算法。
混合回收的阶段:
- 初始标记:标记GCRoot引用的对象为存活。
- 并发标记:和用户线程一起执行,将第一步中标记的对象引用的对象为存活。
- 最终标记:标记一些引用改变漏标的对象。不管新创建和不再关联的对象,和CMS不一样。
- 并发清理:将存活对象复制到别的Region,不会产生内存碎片。
G1的老年代清理会选择存活度最低的区域来回收,可以保证回收率最高,这也是Garbage First名字的由来。
如果清理过程中发现没有足够的空Region存放转移i对象,会执行Full GC。采用单线程标记整理算法,会导致用户线程暂停。所以要尽量保证堆内存有足够多空间。
G1的参数:
参数1:-XX:+UseG1GC 打开G1开关,JDK9后默认不需要打开
参数2:-XX:MaxGCPauseMills=ms数 最大暂停时间
优点:
- 对比较大的堆,如超过6G的堆回收时,延迟可控。
- 不会产生内存碎片
- 并发标记的SATB算法效率高
缺点:JDK8之前还不成熟。
适用场景:JDK8最新版本、JDK9之后的版本
总结
JDK8之前:
- 关注暂停时间:使用ParNew + CMS
- 关注吞吐量:使用Parallel Scavenge + Parallel Old
JDK9以及以后:G1
- 感谢你赐予我前进的力量