一.对象的创建
1.1 什么是符号引用,什么是直接引用,真实的类加载过程是怎样的?
- 类加载过程:
加载:通过类的全限定名获取此类的二进制文件流
连接: 验证->准备->解析
验证,确保Class文件的字节流中包含的信息符合JVM规范(文件格式,元数据,字节码,符号引用)
准备,为类静态变量分配内存并设置初始化值
解析,将常量池内的符号引用替换为直接引用,包括类/接口,字段,类方法,接口方法,方法句柄
初始化:执行类中定义的java代码,执行() 类构造器,触发初始化的时机
() 特点:
- 由编译器收集所有静态变量赋值和静态代码块合并而成,顺序按源文件出现顺序
- 类没有静态变量和静态块时,不会生成 ()
- 父类的 () 先于子类执行
- 多线程初始化时 JVM 会加锁,保证只初始化一次
- 接口的 () 不要求父接口先初始化(除非用到父接口的字段)
- 符号引用和直接引用:
在JVM 的类加载过程中,在解析阶段,会用一组符号来描述所引用的目标,编译后的字节码里,所有对类,字段和方法的引用都是符号引用,放在常量池中;而直接引用是能定位到目标内存位置的引用,不是字符串,比如:方法区中类型/字段的指针,指向堆中实例或静态变量的指针,调用方法时相对偏移量或句柄,解析就是将符号引用转化为直接引用;
符号引用的作用:
1.编译期不知道运行时的内存布局,一个雷在编译时,目标类可能还没加载,甚至还没有存在,所以只能用"名字"这种符号占位
2.支持java 的动态特性:多态,重写,动态加载都依赖先符号后解析这套机制,如果编译时就写死, 就失去了动态的能力
总结:
符号引用是常量池里有那个字符串描述的应用--类的权限定名,字段名+描述符,方法名+描述符,与内存地址无关
直接引用是解析完成后指向真实内存地址的指针和偏移量,转换发生在类加载的解析阶段;
JVM允许延迟解析,真正执行到指令时才把符号引用替换成直接引用,之所以编译时用符号引用,是因为编译时目标类还没加载,内存地址为知,先占位,运行时再解析, 这正好支撑了Java运行时的动态加载和多态机制。
1.2 对象创建过程中,内存内配时如何解决并发安全性问题,这个设计思想的本质是什么?
为什么会有并发:
1.对象一般分配在堆的年轻代(Eden区),堆是所有线程共享的
2.多个线程同时 new,就同时抢Eden 的空闲内存,就会发生竞争
3.分配方式有两种:指针碰撞(空闲连续,移动指针)和空闲列表 (空闲不连续, 查链表)两种都保证 "分配状态"被并发更新时安全
怎样解决并发:
第一层: TLAB 线程本地分配缓冲区 ,默认开启
1.每个线程在Eden 预申请一块私有缓冲区
2.平时分配时对象在自己的TLAB里指针碰撞,零同步。零CAS
3.TLAB 耗尽时才去 Eden 全局申请块
4.大对象放不进TLAB ,直接在共享区或老年区分配
设计思想的本质:
1.减少共享:TLAB 让每个线程有自己的空间, 避免分配时产生竞争
2.块路径+慢路径 双轨:大部分分配走本地无锁块路径,只有少数次(TLAB耗尽)才进全局同步慢路径,把高频操作的同步成本剥离,让同步只发生在低频边界
3.空间换时间: 给每个线程预留一块 Eden 的空间冗余,换掉 每次分配都要同步的时间开销
总结:
JVM 对象分配并发安全靠 "双轨"解决,日常分配走线程私有的TLAB无锁块路径,TLAB 耗尽才走CAS 全局同步慢路径,
本质是共享变私有,把全局竞争压倒最低频,用空间换时间
1.3对其填充的作用是什么, 这个设计思想的本质是什么
作用:
1.CPU高效访问: CPU 读内存是按字为单位对齐访问的,如果数据跨越字的边界, CPU要读两次拼起来
2.硬件层面,原子性保证:CPU的原子操作要求操作数对齐
3.JVM内部,指针压缩与GC: 8字节对齐让JVM能做压缩指针,用32 位偏移量 4字节代替8字节指针,因为对象地址对齐后低3位恒为0,可以省略,省一半内存;GC 移动对象 Card Table 标记都依赖固定对齐,简化计算
4.缓存友好: 对齐让对象尽量落在更少的缓存行
设计思想本质:
1.空间换时间
2.适配CPU的访问模型
总结:
JVM要求对象总大小必须是8字节的整数倍,如果对象头+实例数据算下来不是8的倍数,就在末尾加对应的字节填充补齐
二.对象的使用
2.1 动态链接是什么
1.是什么:JVM在运行期间,将字节码常量池里的符号引用(类吗,字段+描述,方法名+描述等字符串)解析为直接引用(指向方法区,堆的真实地址/vtable 偏移),这个过程就是动态链接
2.为什么:
支撑多态:只有运行期才绑定实际类型,才能实现方法重写, 接口多实现
支撑动态类加载: 符号引用保证"编译时不知道类是否存在"运行时ClassLoader 加载进来在用
实现类可替换: 符号引用只认名字,运行期换成哪哥是嫌类都行
节省内存: 延迟解析, 没用到的方法不解析 ,不占用空间
三.对象的销毁
3.1 哪些对象可以成功GC ROOT ,为什么这些对象是GC ROOT
五类对象可以成为GC ROOT(随时可能被用到的对象, 和JVM生命周期一致的对象,锁对象等)
1.虚拟机栈(栈帧本地变量表):正在执行的方法里的局部变量,参数引用的对象
2.静态变量:方法区中静态属性引用的变量
3.常量引用:方法区常量池里引用的对象(字符串常量)
4.Native方法栈:JNI (native方法)正在使用的对象
5.其他补充: 被synchronized持有的对象,系统类加载器,java虚拟机内部引用(Class对象),JVM内部对象
为什么这些对象是GC ROOT
1.栈帧里的局部变量 → "正在执行的代码还要用"
方法是当前正在运行的代码,它的局部变量、参数是这段代码马上要读写的对象。方法没执行完,这些对象绝不能回收;
2.静态变量 → "类活着,它就活着"
static 变量属于类,不随实例消亡。类被类加载器引用着(没被卸载)时,静态变量指向的对象就是"常驻资源"------比如全局配置、连接池、单例,程序随时可能访问。类存活 → 静态变量必须存活。
3.常量引用 → "编译期就定死的,永远需要"
常量(如 final 字符串、常量池里的引用)在编译期就确定,程序生命周期内都需要,自然不能回收。
4.Native 方法栈 → "Java 看不见,但底层还在用"
native 代码(JNI)直接操作内存,不经过 JVM 的引用管理。Java 侧可能已经"不引用"了,但 native 代码还在用这个对象,回收了 native 那边就悬空指针。所以必须作为根保护。
5.synchronized 持有的对象 → "当前有线程在用它做锁"
一个对象正被作为锁对象(synchronized(obj))持有,说明正在被并发代码使用,回收会破坏锁语义 → 必须作为根。
总结:
GC Root 的判定标准只有一个 : "从程序外部世界(正在运行的线程、系统级资源)还能直接触达"。它们共同代表"程序此刻依赖的东西",从它们出发可达 = 程序还要用,不可达 = 程序永远不再访问 = 可以回收。
3.2 为什么停顿时间和吞吐量不可兼得,我们应该如何选择
吞吐量 = 应用运行时间 / (应用运行时间 + GC停顿时间) -->追求GC 总开销量最小
停顿时间 = 单次GC 引起应用暂停的最长时间 -> 追求每次暂停最短, 可控
为什么不可兼得:
- GC 必须 Stop-The-World,这个"暂停"本身就得用时间来换
任何 GC 都要在安全点暂停应用线程做标记、搬移对象。你逃不掉暂停,只能选择:暂停少但久(吞吐型) 还是 暂停频但短(停顿型)------总量和单次是一对零和指标。
2.两种 GC 的干活方式针锋相对
并行 GC(吞吐型) :多个 GC 线程一起停,一口气把活干完,单次停顿时间长,吞吐量高
并发/增量 GC(停顿型):应用线程和 GC 线程并发跑,小步增量处理 ,总GC代价高(并发要加写屏障、记录对象、抢占 CPU、频率更密)→ 吞吐下降
关键:要想单次停顿短,就得把完整的 GC 工作拆成很多小份并穿插在应用执行中------但切碎 + 并发本身有额外成本(写屏障、增量标记的记录、并发线程抢占 CPU),总 GC 时间反而变长,吞吐就掉了。
3.堆大小也是零和
堆越大 → GC 次数越少但每次扫得久(吞吐型);堆越小 → 每次快但 GC 频繁(停顿型)。在固定的物理内存上,两头的优化互相挤压。
如何选择:
- 先看数据:
先明确业务红线:接口最大可容忍延迟(如 200ms)、GC 停顿不超过多少;
开 GC 日志(-Xlog:gc)或上监控,量化当前停顿峰值、GC 次数、GC 总耗时占比;
再选 GC 器 + 堆大小 + 参数,压测验证,迭代。
2.看业务类型:
在线实时交易/支付/接口服务(用户请求、资金扣款、电商下单):低停顿优先(一个卡顿=超时=丢单) ,G1 / ZGC,设 -XX:MaxGCPauseMillis=200
批处理/离线任务(对账、报表、数据仓库、日志清洗):吞吐优先(晚一点没关系,跑得快就行),Parallel GC + 大堆,-XX:ParallelGCThreads 拉满
吞吐和停顿都敏感 :折中 ,G1(有目标停顿参数可调)、ZGC(停顿 <10ms 但 CPU/内存开销更大)
总结:
"吞吐量和停顿时间矛盾的根源是:GC 要 STW,而'总量'和'单次峰值'是零和的。要吞吐,就用并行 GC 把活一口气干完,单次停顿长但总开销小;要低停顿,就得用并发增量 GC 把工作拆碎穿插执行,但增量 + 并发要加写屏障、更频繁地跑,总 GC 时间反而变长,吞吐下降。此外堆大小也是零和------大堆次数少但久,小堆快但频繁。选择上按业务定:在线实时交易、接口服务选低停顿,用 G1/ZGC 并设目标停顿;批处理、对账选吞吐优先,用 Parallel + 大堆。实际做法是先定业务红线,再用 GC日志量化当前停顿和吞吐,最后选型压测迭代。比如我们资金系统,在线扣款接口用 G1 保停顿,对账批处理 Job 独立 JVM 保吞吐,同一体系不同模块用不同策略。"
3.3 CMS 和G1 如何技术选型
JDK 版本 :
- JDK 8:CMS 可用、成熟;G1 也可用(-XX:+UseG1GC),但 JDK 8 的 G1 在某些场景(如大堆、RSet 内存)口碑一般;
- JDK 9+:默认就是 G1,CMS 已废弃;
- JDK 14+:CMS 已被移除,只能选 G1 / ZGC / Shenandoah。
JDK 8 用 CMS 是可以的,但如果新项目、或能升级 JDK,直接 G1;JDK 14 以后 CMS 都没了,不用纠结
核心区别:
停顿可控性:CMS 停顿大小由堆大小决定 不可控,G1 可设定停顿目标时间,通过控制回收Region 数量让停顿落在目标时间内
内存碎片:CMS 标记-清楚不整理,产生碎片, 大对象分配提前full gc,Region间复制整理,无碎片,大对象走Humongous区
浮动垃圾+CMF :CMS 并发清除新垃圾只能下次清,预留空间不足,G1 有预测模型+增量回收,规避退化风险
引用处理: 无Rset,全堆遍历依赖记忆集粒度组,G1 RSet 记录region间引用,避免全堆扫描
堆内存布局: CMS新生代老年代物理连续,G1 堆切成Region (默认2048个),新生代/老年代只是逻辑上由Region 组成, 可动态调整配比
如何选:
1.先开 GC 日志压测:对比 CMS 和 G1 在你的堆大小 + 对象分配速率下的实际停顿峰值、GC 频率、总耗时、内存占用;
2.关注 G1 的 RSet 占比、Humongous 区膨胀(大对象多会拖累 G1);
3.用监控(GC 日志 + 应用 RT)验证,而不是凭感觉选。
在线实时交易/支付/接口服务(用户请求、资金扣款、电商下单):低停顿优先(一个卡顿=超时=丢单) , G1 / ZGC,设 -XX:MaxGCPauseMillis=200
批处理/离线任务(对账、报表、数据仓库、日志清洗): 吞吐优先(晚一点没关系,跑得快就行) │ Parallel GC + 大堆,-XX:ParallelGCThreads 拉满
四.生产实践
4.1 生产问题如何排查?
排查总思路:
从外到内、先定性后定位:
① 变更关联:先看是不是刚上线/改配置后出现的(发布系统、Apollo、配置中心)
② 定性:什么症状?CPU高 / 内存OOM / GC频繁 / 接口慢 / 死锁 / 数据异常
③ 保现场:先留证据(GC日志、堆dump、线程栈、监控)再动手,避免现场丢失
④ 分层排查:负载/网络 → 中间件 → 应用进程 → JVM → 代码
⑤ 一步一验证,避免盲改;排查完沉淀文档形成案例库
总结:
按症状分类处理:CPU 高用 top -Hp + jstack 定位线程和代码行,或者直接 Arthas thread -n 3;OOM 先区分是泄漏还是峰值不足,用 jmap dump + MAT 看 GC Root 到大对象的引用链;GC 频繁用 jstat + GC 日志看频率和停顿,配合 GCEasy 分析,再调堆或代码;死锁 jstack自带检测。慢接口先分清是入口慢还是下游慢,看调用链和数据库慢 SQL。最后每次排查完写案例复盘,沉淀成团队的知识库
4.2生产如何调优?
YGC 太频繁 │ ① 查代码分配速率(Arthas trace 热点方法)→ ② 调大 -Xmn
FGC 频繁 │ ① 排查泄漏(MAT)→ ② 减少大对象/优化晋升 → ③ 调大堆
停顿过长 │ ① 换停顿可控的 GC(G1 设目标)→ ② 检查是否堆过大
大对象过多 │ ① 代码层改流式/分页(别让大数组进老年代)→ ② 调大 Region
OOM 崩溃 │ ① 开 HeapDump 保现场 → ② MAT 定位泄漏 → ③ 代码修复
JVM 生产调优我的方法论是数据驱动、先调堆再选 GC 再细参:
第一步必须开 GC 日志和堆 dump,jstat 观察。
第二步用数据判断问题在哪:YGC 频繁说明新生代小或分配速率高,FGC 频繁说明老年代满------可能是晋升快、大对象、堆小或泄漏;停顿长看GC 选型。
第三步调堆:-Xms 和 -Xmx 设相等避免扩容抖动,新生代占 1/3~1/2 让短命对象在年轻代回收。第四步按业务选 GC:在线交易用 G1 + MaxGCPauseMillis=200 保停顿,批处理用 Parallel 保吞吐。第五步细调:MaxMetaspaceSize
防元空间膨胀,开 HeapDumpOnOutOfMemoryError 保现场。最后压测对比调优前后指标,一次只改一个变量,验证通过后加 GC 停顿和堆使用率告警防复发。核心就一句:不量化不调优,先改代码再调 JVM。"
4.3如何写出优雅的代码?
1.命名规则是最大的可读性
2.函数短小 + 单一职责
3.消灭重复(DRY)+ 合理复用
- 重复代码抽公共方法/模板方法;相同行为抽策略;
- 但不要为复用而抽象------过度抽象比重复更糟(这点是 8 年经验的成熟度体现)。
4.防御式编程 + 错误处理规范 - 入参校验、边界判断、空值兜底;不信任外部输入;
- 异常分类:业务异常(可预期)用自定义异常 + 状态码,系统异常才抛 RuntimeException;
- 事务边界、日志规范(关键路径打点)、幂等------金融代码尤其。
5.注释写"为什么",不写"是什么"
6.面向对象 + 设计模式"对症下药"