java基础(六)jvm
1. JVM内存的五大核心区域 + 一个帮手
想象JVM运行程序时,需要划分不同区域干不同的事。主要分为这五大块,外加一个特殊帮手:
1.1 程序计数器 (Program Counter Register) - 你的“任务进度条”
干啥的: 专门记录当前线程执行代码执行到哪一行了!就像你看书时用书签记住看到哪一页。
特点:
每个线程独一份,互不干扰(线程私有)。
是唯一绝对不会发生内存溢出(
OutOfMemoryError
)的区域。执行Java方法时,记录下一条指令地址;执行本地方法(如C/C++写的)时,值为空(
undefined
)。
为啥必须私有? 想象多个工人(线程)共用一台机器(CPU),CPU轮流给每人干活。工人A干到一半被叫停时,计数器帮A记住干到哪步了;等轮回来,A才能接着干。每个人的进度都不同,当然需要自己的“小本本”!
1.2 虚拟机栈 (Java Virtual Machine Stacks) - 你的“方法工作间”
干啥的: 每个线程执行方法时,都需要一个临时空间,存放这个方法自己的东西:局部变量、方法参数、计算的中间结果、方法结束后该回哪去。
怎么工作: 每调用一个方法,就在栈里压入一个“工作台”(叫栈帧)。方法执行完,就把这个工作台弹出销毁。
特点:
每个线程独有一个栈。
局部变量存在这里(基本类型如
int num=10;
的10
,或者对象的引用地址)。可能出问题:
StackOverflowError
:方法调用太深(比如无限递归自己),工作间堆满了。OutOfMemoryError
:如果JVM允许动态扩展栈但内存不足,或者创建线程时申请栈内存失败,就会发生(相对少见,但可能)。
1.3 本地方法栈 (Native Method Stacks) - “外来帮手”的工作间
干啥的: 和虚拟机栈类似,但是给JVM调用的本地方法(Native方法,比如用C/C++写的)用的。
特点: 在常用的HotSpot虚拟机里,它和虚拟机栈是合在一起的。同样可能发生
StackOverflowError
和OutOfMemoryError
。
1.4 Java堆 (Java Heap) - 超级“对象大仓库”
干啥的: 存放所有你
new
出来的对象实例和数组! 这是JVM内存中最大、最重要的一块区域。特点:
所有线程共享! 大家都从这里取对象。
程序一启动就创建。
是垃圾回收(GC)的主战场。
空间不够且无法扩展时,会
OutOfMemoryError: Java heap space
。
内部结构 (重点!): 为了更好地管理对象和垃圾回收,堆又细分成几块:
新生代 (Young Generation): 存放刚出生、活不久的对象。分三小格:
Eden区 (伊甸园): 新对象出生地!
new
出来的对象绝大部分先放这里。空间较小。Survivor区 (幸存者区): 两个大小相等的区域(通常叫
From
和To
,或者S0
和S1
)。经历一次垃圾回收(Minor GC)还活着的对象,会从Eden移到其中一个Survivor区。两个区来回倒腾,活过几次GC的“老油条”才能晋升。
老年代 (Old Generation / Tenured Generation): 存放活得久的对象。从新生代熬过几次GC(默认15次)晋升上来的对象,或者特别大的对象(避免在新生代频繁挪动),都住这里。空间较大。
大对象去哪? 比如一个超级大的数组(需要连续大块内存)。它们通常直接进老年代!为啥?
新生代空间小,放大对象容易塞满,频繁触发GC,性能差。
大对象在新生代频繁挪动容易产生碎片(小空隙),后面可能没地方放新的大对象。
老年代空间大,放大对象更合适。(注意:具体策略可能因JVM实现和参数略有不同)
TLAB (Thread-Local Allocation Buffer): 堆是共享的,但频繁
new
对象时抢堆锁影响效率。JVM给每个线程在Eden区划一小块私有空间(TLAB),线程优先在这里分配对象,提升效率,分配满了才去锁堆申请新TLAB。
1.5 方法区 (Method Area) - “图书馆”+“档案室” (JDK8+ 叫 元空间 Metaspace)
干啥的: 存储已被JVM加载的类信息(类名、父类、方法签名、字段描述)、运行时常量池、静态变量(
static
)、即时编译器(JIT)编译好的机器码。特点:
逻辑上是堆的一部分,但有个别名“非堆”(Non-Heap)。
JDK8后重大变化:永久代(PermGen)被移除,改为元空间(Metaspace),使用操作系统的本地内存(不再占用堆空间),解决了老版本永久代容易内存溢出的问题。
内存不足时也会
OutOfMemoryError: Metaspace
。
运行时常量池 (Runtime Constant Pool): 是方法区的一部分。存放类文件里写好的常量(如字符串字面量
"abc"
、数字100
、符号引用),并且运行时也能往里面加新常量(比如String.intern()
方法)。
1.6 直接内存 (Direct Memory) - “外部仓库”
干啥的: 不属于JVM规范定义的内存区域! 通过NIO库(
ByteBuffer.allocateDirect()
)申请使用的操作系统本地内存。优点: 让数据少在Java堆和操作系统内存之间来回拷贝(零拷贝),显著提升IO性能(文件读写、网络通信快很多)。
缺点: 虽然快,但总量受你电脑总内存限制,申请太多或忘记释放(依赖
Cleaner
机制和PhantomReference
通知)也会导致OutOfMemoryError: Direct buffer memory
。
2. 堆(Heap) vs 栈(Stack) 大不同!
特点 | 堆 (Heap) | 栈 (Stack) |
---|---|---|
用途 | 存对象实例 (new 出来的东西) 和数组 | 存局部变量、方法参数、操作数栈、返回地址等 |
生命周期 | 对象由GC决定何时回收 (不确定) | 方法结束,栈帧销毁,局部变量立即消失 (确定) |
速度 | 相对较慢 (分配、回收、GC有开销) | 非常快 (操作简单,内存分配只是移动指针) |
空间 | 很大,可动态扩展 (-Xmx ) | 较小,固定大小 (-Xss ) |
线程 | 所有线程共享 | 每个线程私有 |
存啥? | 对象本身 | 基本类型的值 和 对象的引用地址(指针) |
关键点:栈里存对象本身吗? 不是!栈里存的是基本类型值(如
int a = 10;
里的10
)和对象的引用地址(理解成指向堆里那个真实对象的门牌号)。比如MyObject obj = new MyObject();
:
new MyObject()
在堆里创建了一个真实对象。
obj
这个变量存在栈的当前方法栈帧里,它的值就是堆里那个对象的门牌号(引用地址)。
3. 方法区里有什么宝贝?
类元数据(类名、父类、方法签名、字段描述、常量池等)
运行时常量池(各种字面常量、符号引用 -> 解析后的直接引用)
静态变量(
static
修饰的变量)即时编译器(JIT)编译好的本地机器码(优化后执行更快)
(在永久代时代) 类的方法字节码 (JDK8+的元空间通常不存字节码本身)
4. String字符串住哪里?有啥讲究?
String
对象本身也是对象,所以实例在堆里。JVM搞了个字符串常量池(String Pool)来优化字符串:
在哪? JDK7及之后移到堆里(之前放在方法区的永久代)。
作用: 对于字面量方式创建字符串(
String s1 = "abc";
),JVM会先去常量池找有没有"abc"
:如果有,直接把池里的引用给你(不创建新堆对象)。
如果没有,就在堆里的字符串常量池区域创建一个
"abc"
对象,再把引用给你,并记录到池里。这样相同的字符串字面量只存一份,省内存。
intern()
方法: 可以将一个字符串对象动态地放入常量池(如果池中没有),并返回池中对象的引用。
经典面试题:
String s = new String("abc");
创建了几个对象?"abc"
是个字面量。JVM先去字符串常量池找:如果池里没有:在堆里的常量池区域创建一个
"abc"
对象 (1个)。无论池里有没有
"abc"
,new String(...)
都会在堆里(常量池区域之外)创建一个全新的String
对象 (另1个),这个新对象的内容(char[]
)指向常量池里的"abc"
的char[]
(或拷贝一份,取决于JVM实现)。
变量
s
(存的是堆里新对象的地址)存在栈里。
结论: 总是创建至少1个堆对象(
new
出来的那个)。如果常量池原本没有"abc"
,则总共创建2个对象(1个在池里,1个是new
出来的);如果常量池已有"abc"
,则总共创建1个对象(只有new
出来的那个)。
5. 引用类型有哪几种?强弱分明!
Java里有4种引用强度,决定了对象被GC回收的优先级:
5.1 强引用 (Strong Reference) - “铁哥们”
最常见!
Object obj = new Object(); 这种就是。
特点: 只要强引用还在(并且可达),GC绝不会回收这个对象。它是对象存活的最强保证。
obj = null;
后,对象就变不可达(如果没其他引用),可被回收。
5.2 软引用 (Soft Reference) - “好朋友”
代表:
java.lang.ref.SoftReference
特点: 描述一些还有用但非必需的对象(比如缓存)。只有在系统内存快不够用,要发生OOM之前,GC才会回收它们。适合做缓存(内存不足时自动清理)。
SoftReference.get()
可能返回null
。
5.3 弱引用 (Weak Reference) - “点头之交”
代表:
java.lang.ref.WeakReference
特点: 强度比软引用更弱。下一次GC发生时(无论Minor还是Full GC),不管当前内存是否充足,都会回收只被弱引用关联的对象。适合做缓存、避免内存泄漏(比如
WeakHashMap
的key)。WeakReference.get()
可能在GC后返回null
。举个栗子:弱引用缓存 (优化版,强调检查null)
import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.Map; public class YA33CacheExample { private Map<String, WeakReference<MyHeavyObject>> cache = new HashMap<>(); public MyHeavyObject get(String key) {// 1. 从缓存拿弱引用WeakReference<MyHeavyObject> ref = cache.get(key);// 2. 关键!弱引用可能还在Map里,但对象可能已被GC回收,所以要用get()取对象MyHeavyObject obj = (ref != null) ? ref.get() : null; // 3. 如果对象是null(被回收了或第一次访问)if (obj == null) {obj = createExpensiveObject(key); // 创建代价高的大对象cache.put(key, new WeakReference<>(obj)); // 用弱引用包装后放入缓存}return obj;} private MyHeavyObject createExpensiveObject(String key) {// 模拟创建一个大对象或耗时操作return new MyHeavyObject();} private static class MyHeavyObject {private byte[] largeData = new byte[1024 * 1024 * 10]; // 10MB数据} }
用
WeakReference
包装大对象放入缓存。当内存吃紧时,GC会自动回收这些只被弱引用指向的大对象。
关键点: 下次
get
时一定要检查ref.get()
是否为null
! 如果为null
,说明对象已被GC回收,需要重新创建并缓存。
5.4 虚引用 (Phantom Reference) - “影子朋友”
代表:
java.lang.ref.PhantomReference
(必须配合ReferenceQueue
使用)特点: 最弱的引用。一个对象是否有虚引用,完全不影响它啥时被回收。你无法通过虚引用拿到对象本身(
get()
总是返回null
)。唯一作用: 对象被GC回收时,你能收到一个通知(通过
ReferenceQueue
)。常用于精细化管理堆外内存(比如NIO的DirectByteBuffer
),确保在回收Java对象时同步释放关联的本地内存,防止内存泄漏。PhantomReference
入队发生在对象被完全回收(finalized)之后。
6. 内存泄漏 vs 内存溢出 (OOM)
6.1 内存泄漏 (Memory Leak) - “垃圾占着地方不走”
定义: 程序中的对象已经不再使用了,但因为某些原因(比如还被意外的引用着)无法被垃圾回收(GC)回收。这些“垃圾”占着内存不释放,导致可用内存越来越少。
后果: 长期累积,最终可能触发内存溢出(OOM),程序崩溃。
常见原因:
静态集合滥用: 用
static
的HashMap
,ArrayList
等长期持有对象引用不放(尤其是集合只增不减时)。未关闭资源: 数据库连接(
Connection
)、文件流(InputStream/OutputStream
)、网络连接(Socket
)、Swing
的Graphics
对象等打开后忘记关闭(或close()
没执行到)。监听器未注销: 注册了事件监听器,对象不用了却没取消注册,导致被事件源长期引用。
ThreadLocal使用不当: 用完没调用
remove()
清理,尤其在线程池(线程复用)场景,会导致线程的ThreadLocalMap
中该Entry的value无法释放(虽然key是弱引用会回收,但value是强引用)。内部类持有外部类: 非静态内部类实例会隐式持有外部类实例的强引用。如果外部类实例本应回收,但内部类实例还被其他地方引用着,就会导致外部类泄漏。
缓存未清理: 使用缓存(如
Map
)时,对象不再使用但未从缓存中移除。
6.2 内存溢出 (OutOfMemoryError, OOM) - “地方实在不够用了”
定义: JVM在申请内存时,没有足够的空间来创建新对象了,并且经过垃圾回收后仍然无法获得足够空间,JVM就会抛出
OutOfMemoryError
错误。常见类型 & 原因:
java.lang.OutOfMemoryError: Java heap space
:堆内存真不够了。原因:创建太多对象、严重内存泄漏、配置堆太小(-Xmx
设小了)、加载超大文件到内存。java.lang.OutOfMemoryError: Metaspace
:元空间(存类信息)不够了。原因:加载了海量类(大量反射、动态代理、CGLib/ASM生成类、OSGi应用)。java.lang.OutOfMemoryError: Direct buffer memory
:直接内存不够了。原因:NIO操作申请太多DirectByteBuffer
没释放(或GC没触发Cleaner
)或-XX:MaxDirectMemorySize
设小了。java.lang.OutOfMemoryError: unable to create new native thread
:创建线程需要的栈空间不够了。原因:线程创建太多(-Xss
设太大或系统限制)、进程地址空间耗尽(32位系统)。java.lang.OutOfMemoryError: Requested array size exceeds VM limit
:尝试分配一个超过堆限制的数组(如Integer.MAX_VALUE
)。java.lang.StackOverflowError
:通常是虚拟机栈或本地方法栈溢出。原因:方法调用太深(无限递归最常见)、栈帧过大(局部变量太多或太大)。(严格来说属于Error
,但常与OOM一起讨论)
关系: 长期严重的内存泄漏必然导致内存溢出(OOM)。但OOM也可能是瞬间需要的内存确实超过了JVM配置的最大值(如加载一个超大文件),不一定是泄漏。
StackOverflowError
通常不是内存泄漏引起。
7. 类加载过程 - 从.class文件到可用类
类需要被加载到JVM才能使用。这个过程分几步走:
加载 (Loading):
找到.class
文件(文件系统、网络、JAR包等),读入二进制数据,在方法区创建代表这个类的java.lang.Class
对象(作为访问入口)。
链接 (Linking): 分三步:
验证 (Verification): 检查
.class
文件格式对不对(魔数、版本),字节码合不合法(指令、跳转),符号引用是否有效,是否符合语言安全约束。防止有害字节码。准备 (Preparation): 给类的静态变量(
static
)分配内存(在方法区),并设置默认初始值(如int
是0,boolean
是false
,对象引用是null
)。注意:final static
常量(编译期常量)这里就赋值了。解析 (Resolution): 把类、方法、字段的符号引用(常量池中的名字)变成具体的直接引用(内存地址或偏移量)。解析可能在初始化前发生,也可能延迟到首次使用时(如
invokedynamic
)。
初始化 (Initialization): 执行类的构造器
<clinit>()
方法(编译器自动收集所有static
变量赋值和static{}
块生成)。给静态变量赋程序员写的初始值。父类先初始化。初始化是类加载的最后一步,也是真正执行类中Java代码的开始。
8. 双亲委派模型 - “孩子有事先找爹”
类加载器有层级关系(像家族):
启动类加载器 (Bootstrap ClassLoader): 顶级大佬(通常C++实现),加载
JAVA_HOME/lib
目录下的核心库(如rt.jar
,charsets.jar
)。扩展类加载器 (Extension ClassLoader): 继承自
java.lang.ClassLoader
(Java实现),加载JAVA_HOME/lib/ext
目录或java.ext.dirs
系统变量指定目录的jar包。应用程序类加载器 (Application ClassLoader / System ClassLoader): 继承自
java.lang.ClassLoader
(Java实现),加载用户程序ClassPath
(-cp
或-classpath
指定)下的类。我们平时默认用的就是它。自定义类加载器 (Custom ClassLoader): 用户自己写的加载器,继承
java.lang.ClassLoader
。可定制加载来源(网络、加密文件等)。tomcat类加载器:tomcat自定义的加载器
双亲委派原则:
当一个类加载器收到加载请求时,先不自己加载,而是递归地委托给父加载器去尝试加载。
只有当所有父加载器都表示找不到这个类时(在自己的加载路径下找不到),子加载器才会自己尝试加载。
好处:
安全: 防止用户写个
java.lang.Object
或java.lang.String
类覆盖核心库的(父加载器会先加载到核心的)。避免重复: 保证同一个类(由同一个类加载器加载)只被加载一次。
破坏双亲委派:
历史原因: JDK1.2引入双亲委派之前的老代码(如
ClassLoader
的findClass()
方法)。SPI (Service Provider Interface): 如JDBC驱动。核心接口在
rt.jar
(由Bootstrap加载),但实现类(驱动)在ClassPath
下。需要线程上下文类加载器(Thread Context ClassLoader)来加载。热部署/热替换: OSGi、Tomcat等容器需要动态加载、卸载模块,需要自定义类加载器并打破委派。
隔离: 同一个应用加载不同版本库(如不同Web应用加载不同Spring版本),需要各自独立的类加载器。
9. 垃圾回收 (GC) - JVM的自动清洁工
9.1 什么是GC?
GC就是JVM自动回收不再使用(不可达)的对象所占用的内存,避免内存泄漏和手动管理的麻烦。
9.2 怎么判断对象是垃圾?
主流用 可达性分析算法 (Reachability Analysis):
从一组 “GC Roots” 对象(根对象集合)出发。GC Roots包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性(
static
)引用的对象。方法区中常量(
final static
)引用的对象。本地方法栈中JNI(即
native
方法)引用的对象(全局引用)。Java虚拟机内部的引用(如基本类型对应的Class对象,常驻异常对象
NullPointerException
等)。所有被同步锁(
synchronized
)持有的对象。
看哪些对象能被这些根对象直接或间接引用到(形成引用链)。
如果一个对象到GC Roots没有任何引用链相连,它就是不可达的,是垃圾,可以被回收。
9.3 垃圾回收算法 - 清洁工的打扫方式
标记-清除 (Mark-Sweep):
步骤:先标记垃圾 -> 直接清除。
缺点: 效率不高(扫描+清除),产生内存碎片(不连续空间)。
复制 (Copying):
步骤:把内存分两块(A/B),只用A。A满了,把A里活着的对象复制到B,清空A。下次用B。
优点: 无碎片,分配快(顺序分配)。
缺点: 浪费一半内存。存活对象多时效率低。
适用: 新生代(存活对象少)。
标记-整理 (Mark-Compact):
步骤:先标记垃圾 -> 让所有活着的对象向一端“搬家” -> 清理边界外空间。
优点: 无碎片,内存利用率高。
缺点: “搬家”开销大。
适用: 老年代。
分代收集 (Generational Collection): 最常用策略!
核心思想:根据对象存活周期不同,将堆划分为新生代(Young)和老年代(Old)。
新生代: 对象“朝生夕死”,回收频繁。采用复制算法(Eden + 2x Survivor)。
老年代: 对象存活时间长。采用标记-清除或标记-整理算法。
对象晋升: 新生代对象熬过一定次数(默认15)GC后,晋升到老年代。大对象也可能直接进老年代。
9.4 垃圾回收器 - 不同的清洁工团队 (简要概述主流)
Serial / Serial Old: 单线程,简单高效(无并发开销),适合Client模式或小内存。
ParNew:
Serial
的多线程版(新生代并行),需配合CMS使用。Parallel Scavenge / Parallel Old: JDK8默认组合。关注吞吐量(Throughput = 用户代码时间 / (用户代码时间 + GC时间))。适合后台计算。
CMS (Concurrent Mark-Sweep): (JDK14废弃) 老年代收集器,目标是减少停顿时间。采用“标记-清除”,过程复杂(初始标记->并发标记->重新标记->并发清除)。有碎片问题,并发阶段占用CPU。
G1 (Garbage-First): JDK9+默认! 将堆分成多个大小相等的Region(物理不连续),可预测停顿时间模型。采用“标记-整理”算法。特点:整体看是“标记-整理”,局部(Survivor->Survivor/Survivor->Old)看是“复制”。兼顾吞吐量和低延迟,适合大堆。无碎片。
ZGC: JDK15+生产可用。追求超低停顿(<10ms),适合超大堆(TB级)。采用染色指针、读屏障等技术。不分代(逻辑分代)。
Shenandoah: OpenJDK提供,类似ZGC,低停顿目标。采用Brooks指针、读/写屏障。不分代(逻辑分代)。
9.5 Minor GC / Major GC / Full GC - 清洁范围不同
类型 | 打扫范围 | 触发条件 | 特点 | 常用算法 (分代) |
---|---|---|---|---|
Minor GC (Young GC) | 只扫新生代 (Eden + Survivor) | Eden区满了 | 频繁,快,停顿短 | 复制 (Eden -> S, S间复制) |
Major GC (Old GC) | 主要扫老年代 (通常伴随一次Minor GC) | 老年代满了 / 根据策略(如CMS阈值) / 对象晋升太快失败 | 较少,较慢,停顿较长 | 标记-清除 / 标记-整理 |
Full GC | 扫整个堆 + 元空间 (可能包含永久代/压缩堆) | 1. 老年代满且Major GC搞不定 2. 元空间满 3. System.gc() 调用 (建议-XX:+DisableExplicitGC 禁用) 4. 堆空间担保失败 5. CMS并发失败 6. JVM自身策略(如HeapDumpOnOutOfMemoryError 前) | 最慢!停顿最长!尽量避免! | 取决于老年代/整堆收集器设置 |
9.6 Stop-The-World (STW) - “世界暂停”/
GC进行某些关键步骤时(比如枚举GC Roots、对象标记阶段的部分过程、对象移动),为了保证结果的准确性(对象引用关系不变)和安全性,必须暂停所有应用线程,就像全世界都停止了。
这是GC产生停顿的主要原因。优化GC的核心目标之一就是减少STW的时间和频率。像CMS、G1、ZGC、Shenandoah都在努力通过并发(应用线程和GC线程同时运行)来减少STW。
10. GC不止扫堆!
JVM的垃圾回收器主要清理堆里的对象垃圾,但也会清理方法区(元空间)!它会清理不再使用的类信息(类卸载)、废弃的常量等。类卸载条件苛刻(类ClassLoader被回收、类所有实例被回收、类Class
对象没被引用)。元空间的垃圾回收通常由Full GC触发。