内存结构
1.介绍一下JVM内存模型
JVM内存模型通常指JVM运行时数据区
主要包括:
- 程序计数器:记录当前线程执行到哪条字节码指令
- Java虚拟机栈:每个线程私有,方法调用时创建栈帧
- 本地方法栈:给native方法使用
- 堆:线程共享,主要存放对象实例,是GC管理的重点区域
- 方法区:线程共享,存放类元信息、常量、静态变量、JIT编译后的代码
- 运行时常量池:方法区的一部分,存放字面量和符合引用
- 直接内存:不属于JVM运行时数据区,但经常被NIO使用,可能OOM。
2.String s = new String("abc")执行过程中分别对应哪些内存区域?
分布在3处内存区域:堆、字符串常量池、虚拟机栈
- "abc"是字符串字面量,会放入字符串常量池
- new String("abc"):执行new关键字,在堆中创建一个新的String对象
- s局部变量,存在当前方法的虚拟机栈栈帧里
- s最终指向堆里新建的String对象
创建几个对象:
如果常量池之前没有"abc",可能会创建两个对象:常量池里的"abc"和堆里的new String对象
如果常量池之前有"abc",只会在堆里新建一个String对象
3.堆为什么分新生代和老年代?比例默认是多少
堆分代是因为大多数对象都有"朝生夕死"的特点。很多对象创建后很快就不用了,少数对象会存活很久。
如果把所有对象混在一起回收,每次GC都扫描整个堆,成本很高。
- 新生代存放新创建、生命周期短的对象
- 老年代存放存活时间长、体积较大的对象
常见默认内存比例:
新生代:老年代=1:2
新生代内部划分:Eden区:Survivor0:Survivor1=8:1:1
4.Eden、From、To区作用?为什么会有两个Survivor
新生代一般分为Eden、From Survivor、ToSurvivor。
大多数对象先在Eden区分配。Minor GC时,Eden和当前From区中存活的对象会被复制到To区,然后From和To角色互换
两个Survivor的作用:
- 配合复制算法使用
- 让存活对象在两个Survivor之间来回复制
- 记录对象年龄,达到阈值后晋升老年代
为什么不是一个Survivor
复制算法需要一个空区域作为目标空间。如果只有一个Survivor,清理和复制会混在一起,不好保证空间连续和回收效率
为什么不是多个Survivor
两个已经足够完成复制和年龄判断,更多会浪费空间
5.方法区/元空间作用?存放什么?
方法区是JVM规范中的概念,用来存放类相关的信息。
通常存放:
- 类的元信息,比如类名、父类、接口、字段、方法
- 运行时常量池
- 静态变量
- JIT编译后的代码缓存
JDK 8 以后,HotSpot用元空间Metaspace实现方法区。元空间使用的是本地内存,不再使用永久代
6.永久代和元空间的区别?JDK 8为什么废除永久代改用元空间
永久代PermGen是JDK 8之前HotSpot对方法区的实现,使用JVM堆内存的一部分
元空间Metaspace是JDK 8之后的方法区实现,使用本地内存
为什么废除永久代:
- 永久代大小不好设置,容易出现OutOfMemoryError:PermGen space
- 类元信息放在本地内存中更加灵活,受限更少
- 方便HotSpot与其他JVM实现统一方法区概念
- 字符串常量池等内容逐步移到堆中,永久代的职责变的不合适。
主要元空间不是无限的,也可能OOM,可以通过-XX:MaxMetaspace限制大小
7.直接内存是什么?会不会OOM?
直接内存Direct Memory也叫堆外内存,不属于JVM运行时数据区,是操作系统本地内存
Java NIO的ByteBuffer,allocateDirect()会使用直接内存,它减少了Java堆和native内存之间的数据拷贝,提升IO读写性能,适合IO场景。
直接内存也会OOM
OutOfMemoryError:Direct buffer memory
- 大量direct buffer没有被释放
- Netty等框架使用直接内存过多
- -XX:MaxDirectMemorySize设置太小或没有规范
类加载机制
1.类加载的五步
类加载通常分为五个阶段:
- 加载 Loading:通过类全限定名获取字节码,生成Class对象
- 验证 Verification:检查字节码是否合法,防止破坏JVM
- 准备 Preparation:静态变量分配内存,赋默认零值
- 解析 Resolution:把符号引用替换成直接引用
- 初始化 Initialization:执行类构造器<clinit>(),给静态变量赋真实值,执行静态代码块
2.什么是双亲委派机制?
双亲委派机制是类加载器加载类时的一种委派规则
当一个类加载器收到类加载请求时,不会自己先加载,而是先把请求交给父类加载器。父类加载器还会继续向上委派。只有父类加载器加载不了,子类加载器才会尝试自己加载
它的核心思想是:先让上层加载器加载基础类,避免核心类被随意替换
双亲委派机制的优点:
- 保证类的唯一性:确保所有请求都能传到启动类加载器,避免了不同类加载器重复加载相同类的情况
- 保证了安全性:由于Java的核心类库在启动类加载器中加载,而启动类加载器只加载信任路径中的类,这样可以防止不可信的类假冒核心类
- 实现了类的复用:核心类只需要加载一次,所有子加载器复用,减少内存消耗
3.双亲委派机制的缺点是什么?
主要缺点:
- 不够灵活,父加载器无法使用子加载器加载的类
- 不适合某些插件化、热部署、模块隔离场景
- SPI场景下,Java核心类需要加载第三方实现类,双亲委派机制会受限制
比如JDBC中DriverManager是核心类库里的类,但具体数据库驱动由应用提供,所以需要线程上下文类加载器来解决
4.如何打破双亲委派机制?
打破双亲委派机制通常是自定义类加载器,重写loadClass()或调整加载顺序
默认情况下,loadClass()实现了双亲委派。如果只重写findClass(),一般仍然遵循双亲委派。
要打破它,通常会在loadClass()中改成自己先加载,加载不了再交给父加载器。
典型场景:
- Tomcat Web应用隔离
- OSGi模块化
- 插件系统
- 热部署框架
不要随意打破双亲委派机制,可能导致类冲突、安全问题和类型转换异常。
5.如何实现自定义类加载器?
常见做法是继承ClassLoader,重写findClass()。
垃圾回收机制
1.如何判断对象是否存活?可达性分析?引用计数?
判断对象是否存活主要有两种思路:引用计数法和可达性分析法
- 引用计数法:每个对象维护一个计数器,有引用指向它就+1,引用失效就-1,计数为0就可以回收。
缺点是无法解决循环引用。比如对象A引用B,B引用A,但它们都不被外部访问,计数仍然不为0.
Java主流JVM使用可达性分析法
- 可达性分析法:从GC Roots出发,沿引用链向下搜索。可以被GC Roots直接或间接到达的对象是存活对象,无法到达的对象可以被回收。
2.GC Roots有哪些?
常见的包括:
- 虚拟机栈中引用的对象。比如局部变量
- 本地机栈中JNI引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 被synchronized持有的对象
- JVM内部引用,比如系统类加载器、基本数据类型Class对象。
3.三大垃圾回收算法以及各自的优缺点
- 标记-清除:先标记存活对象,再清楚未标记对象
优点:实现简单,不需要移动对象。缺点:会产生内存碎片,回收效率不稳定
- 标记-复制:标记存活对象,将所有存活对象复制到另一块空闲内存区域,原来整块区域全部清空
优点:没有内存碎片,分配块。缺点:浪费一部分空间,存活对象多时复制成本高
- 标记-整理:标记存活对象,再把存活对象向一端移动,然后清理边界外空间
优点:没有碎片,适合老年代。缺点:移动对象成本较高
4.为什么新生代使用复制算法,而老年代使用标记整理
新生代对象大多数朝生夕死,存活对象少。使用复制算法只需要复制少量存活对象,效率很高。
老年代对象存活率高,如果用复制算法,要复制大量对象,成本很高而且还要额外保留一大块空闲区域
5.新生代中的对象符合哪些条件之后会晋升到老年代
常见情况:
- 对象年龄达到阈值,默认最大常见值是15,因为对象头中年龄字段通常是4bit
- 大对象直接进入老年代,比如很大的数组或字符串
- Survivor空间放不下,存活对象会提前晋升
- 动态年龄判断:某个年龄及以上对象总大小超过Survivor一半时,这些对象可能直接晋升
6.什么是Minor GC、Major GC、Full GC?触发条件?
Minor GC:回收新生代。通常在Eden区空间不足时触发
Major GC:通常指回收老年代
Full GC:回收整个Java堆和方法区相关区域,停顿时间通常更长
常见Full GC触发条件:
- 老年代空间不足
- 方法区或元空间不足
- 调用System.gc(),不一定立刻执行,但可能触发
- Minor GC前判断老年代担保空间不足
- 大对象分配失败
面试重点说:Minor GC比较频繁,Full GC成本高,线上尽量减少频繁Full GC。
7.什么是STW?
STW是Stop The World,意思是垃圾回收时暂停用户线程
GC过程中,为了保证对象引用关系不再乱变,JVM可能会暂停所有业务线程,只让GC线程工作
STW影响接口响应变慢、系统短暂停顿。停顿时间越长,用户感知越明显
现代垃圾收集器如G1,ZGC,Shenandoah都在尽量降低STW时间
8.常见的垃圾收集器有哪些?
- Serial:单线程收集器,适合客户端或小内存场景
- ParNew:Serial的多线程版本,常与CMS搭配
- Parallel Scavenage:关注吞吐量,适合后台计算机任务
- Serial Old:老年代单线程收集器
- Parallel Old:Parallel Scavenage的老年代版本
- CMS:低停顿老年代收集器,使用标记-清楚,可能产生碎片
- G1:面向服务端应用,按Region管理堆,兼顾吞吐和低停顿
- ZGC:超低延迟收集器。适合大内存低停顿场景
- Shenandoah:低停顿收集器,目标类似ZGC
9.什么是三色标记法?
三色标记法是并发标记中最常用的对象标记思想
它把对象分为三种颜色:
- 白色:还没被访问,最终仍是白色可能被回收
- 灰色:已经被访问,但它引用的对象还没被扫描完
- 黑色:已经被访问,并且它引用的对象也扫描完了
GC从GC Root开始,把对象从白变灰,再从灰变黑,逐步完成标记
并发标记时,用户线程还在运行,引用关系可能变化,所以需要写屏障和读屏障等机制保证标记正确。
JVM实战调优
1.常见的OOM有哪几种?分别什么原因?
常见的OOM包括:
- 堆内存溢出:java.lang.OutOfMemoryError:Java heap space
原因通常是对象太多、缓存无界、集合持续增长,内存泄漏
- 元空间溢出:java.lang.OutOfMemoryError:Metaspace
原因通常是动态生成类太多,比如CGLIB、反射代理、热部署类加载器泄漏
- 直接内存溢出::java.lang.OutOfMemoryError:Direct buffer memory
原因通常是NIO、Netty直接内存使用过多或释放不及时
- 栈溢出::java.lang.StackOverflowError:
原因通常是递归太深、方法调用链太长、栈帧过大
2.常见JVM参数:-Xms、-Xmx、-Xss、-XX:MetaspaceSize含义?
- -Xms:堆初始大小
- -Xmx:堆最大大小
生产中常把-Xms、-Xmx设置成一样,避免运行时频繁扩缩容
- -Xss:每个线程的栈大小。设置太大,能创建的线程数变少;设置太小,容易栈溢出
- -XX:MetaspaceSize:元空间初始触发GC的阈值,不是最大值
- -XX:MaxMetaspaceSize:元空间最大值
- -XX:MaxDirectMemorySize:直接内存最大值
- -XX:+HeapDumpOnOutOfMemoryError:OOM时自动导出堆dump,线上排查很有用。
3.如何线上排查OOM?完整排查流程?
- 先看错误类型,是heap、metaspace、direct memory还是stack
- 查看应用日志,确认OOM发生时间和业务操作
- 确认JVM参数,比如堆大小,元空间大小,GC日志配置
- 如果是堆OOM,获取heap dump
- 用MAT、VisualVM、JProfiler等工具分析dump
- 查看大对象、对象数量、GC Roots引用链
- 判断是内存泄漏还是瞬时流量导致内存不够
- 修复代码或调整参数,再压测验证
注意:生产dump可能很大,抓取和拷贝要注意磁盘空间和业务影响
4.如何排查频繁Full GC
频繁Full GC常见原因:
- 老年代空间不足
- 大对象频繁进入老年代
- 内存泄漏,对象回收不掉
- 元空间不足
- 显式调用System.gc()
- 新生代太小,导致对象过早晋升
排查流程:
- 查看GC日志,确认Full GC频率、耗时、回收前后内存变化
- 如果Full GC后老年代下降明显,可能是内存压力大或参数不合理
- 如果Full GC后老年代几乎不下降,重点怀疑内存泄漏
- 用jstat观察各区域变化
- 用jmap导出dump分析大对象和引用链
- 检查是否有大缓存。无界队列、大集合、ThreadLocal未清理
调优方向通常是先定位对象来源,再考虑调整堆、新生代比例或更换GC
5.什么的内存泄漏?和内存溢出的区别?
内存泄漏是指对象已经不在被业务需要,但仍然被引用,导致GC无法回收
内存溢出是指程序申请内存时,JVM没有足够内存可以分配,于是抛出OOM
内存泄漏持续发生可能导致内存溢出
常见内存泄漏场景:
- 静态集合一直添加不清理
- ThreadLocal用完不remove
- 监听器、回调未注销
- 连接、文本句柄没关闭
- 本地缓存没有容量和过期限制
6.线上CPU飙高、线程死锁怎么排查?
CPU飙高排查流程:
- 用top找到CPU高的Java进程pid
- 用top -Hp pid找到CPU高的线程tid
- 把tid转成16进制
- 用jstack pid导出线程栈
- 在栈里搜索16进制线程id,定位具体代码
线程死锁排查:
- 使用jstack pid,它通常会直接显示deadlock信息
- 查看哪些线程持有哪些锁、等待哪些锁
- 根据堆栈定位代码中的锁顺序问题
常见修复方式:
- 统一加锁顺序
- 减少锁粒度
- 避免嵌套锁
- 使用tryLock超时退出
7.jps、jstack、jmap、jhat、jstat常用命令作用
- jps:查看当前机器上的Java进程
- jstack:查看线程栈,排查死锁、线程阻塞、CPU飙高
- jmap:查看堆信息,导出heap dump
- jhat:分析heap dump的老工具,现在不太推荐,更多用MAT、VisualVM.
- jstat:查看GC、类加载、JIT等运行时统计
面试重点:CPU高看jstack,堆问题看jmap,GC趋势看jstat
8.堆dump怎么抓取,怎么分析?
常见heap dump抓取方式:
OOM自动抓取:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
手动抓:
jmap -dump:format=b,file=heap.hprof <pid>
也可以
jcmd <pid> GC.heap_dump heap.hprof
分析思路:
- 用MAT或VisualVM打开dump
- 看占用内存最大的对象
- 看Dominator Tree
- 找GC Roots引用链
- 判断对象为什么没有被回收
- 回到代码里定位集合、缓存、ThreadLocal、类加载器等来源
注意:线上抓取dump可能导致短暂停顿,文件也可能很大,操作前要确认磁盘空间和业务影响
总结
- JVM运行时数据区包括程序计数器、虚拟机栈、本地方法栈、堆、方法区,直接内存也常被问到
- 堆分代是因为大多数对象生命周期短,新生代用复制算法,老年代更适合标记整理
- 两个Survivor是为了配合复制算法,一个作为来源,一个作为目标
- JDK 8 后永久代被元空间替代,元空间使用本地内存
- 类加载的五步是加载、验证、准备、解析、初始化
- 双亲委派是先交给父加载器加载,父加载不了子加载器再加载
- Java判断对象存活注意靠可达性分析,而不是引用计数
- GC Roots常见有栈引用、静态变量、常量、JNI引用、锁持有对象
- STW是暂停用户线程,现代GC都在减少STW时间
- OOM排查先看类型,再抓日志。GC、dump,最后分析引用链
内存结构:知道每块区域存什么,是否线程共享、会不会OOM
类加载:掌握五步流程、双亲委派、自定义类加载器
垃圾回收:理解对象存活判断、GC Roots、回收算法、分代收集、STW
实战调优:会看OOM、Full GC、CPU飙高、死锁和常用工具