一、Java内存模型
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存
1、程序计数器
当前线程执行的字节码的行号指示器,通过计数器决定字节码解释器下一条要执行的字节码指令。
为什么要私有?
为了线程切换后能够恢复到正确的执行位置,所以每条线程都需要有一个。
生命周期
随着线程创建而创建,结束而死亡。不会产生OOM。
2、虚拟机栈
Java方法的调用都是通过栈来实现的。每调用一个方法就会有一个对应栈帧被压入栈中,结束后则弹出一个栈帧。
栈内存不允许动态扩展:如果函数陷入无限循环的话,就会爆出StackOverflowError
栈内存可以动态:扩展如果虚拟机在动态扩展栈时无法申请到足够的内存,那么就会爆出OOM。
栈随着线程创建/消亡,栈帧随着方法调用创建,结束消亡。
每个栈帧的组成如下:
局部变量表
存放编译器可知的数据类型和对象引用。
操作数栈
存放方法执行中的中间计算结果和临时变量。
动态链接
一个方法需要调用另一个方法时,需要将Class文件中常量池的符号引用转化为内存地址中的直接引用。
本地方法栈
为了执行Native方法服务的,和虚拟机栈为Java方法配合 相似。
所以和虚拟机栈类似,不用单独记忆。
堆
几乎存放了Java中所有的对象,除了某型方法中的对象引用没有被返回或被外部使用,那么可以直接在栈上分配。堆是最容易出现OOM的区域!
主要的内容在后面的GC中会介绍。
方法区
虚拟机加载一个类的时候,读取并解析Class文件来获取类的信息,然后将信息存入到方法区。
方法区有两种实现:JDK1.8之前为永久代,之后为元空间(本地内存中)。
为什么要进行这样的变化呢?
这样可以不受JVM的大小限制,使用本地内存。避免了GC的不必要复杂度。
运行时常量池
一直存放在方法区中,不论是永久代还是元空间。有可能OOM
之前在类的加载和栈帧的动态链接中提到过,Class文件中有编译期生成的字面量和符号引用存放的常量池表,在类加载完后,常量池表就存放在了运行时常量池中。
字符串常量池
JVM为了提升性能和减少字符串的内存消耗,避免重复创建字符串而建立了这个区域。
存放位置:
JDK1.7之前,字符串常量池属于运行时常量池,存放在永久代(方法区)中。
JDK1.7,字符串常量池和静态变量移动到了堆中,运行时常量池位置不变。
JDK1.8,只改变了方法区的实现方式,其他常量池位置没有变更。
为什么要改变?
永久代GC回收效率,但是有大量的字符串等待回收,所以存放在了堆中。
直接内存
这并不是虚拟机运行时数据区的一部分,也有可能OOM。
会受到本机总内存大小和寻址空间的限制。
二、垃圾回收
堆的组成
堆是垃圾回收中对象分配与回收的主要场所。也就是GC主要发生的场所。
JDK1.7及之前,堆内存分为如下区域:
1、新生代(Eden、S0、S1)
2、老年代
3、永久代
JDK1.8之后,永久代就被元空间替代了,使用直接内存存储。
内存分配规则
1、对象优先在Eden区分配
如果Eden区没有足够空间分配,那么JVM将发起一个 Minor GC
如果GC期间发现 Survivor 区域没法分配,则使用分配担保机制,把新生代提前转移到老年代中。
2、长期存活的对象进入老年代
经历一次新生代垃圾回收(Minor GC)后,存活的对象会进入S0或S1,同时年龄+1(Eden区的年龄为0),当增加到15岁时,就会晋升到老年代中。
为什么年龄只能是0-15
因为对象头中记录了年龄,而这个区域大小是4位,四位最大的二进制数为1111即二进制15。当然不同虚拟机的默认设置不同。
3、大对象直接进入老年代
大对象就是需要大量连续内存空间的对象:字符串和数组
为了避免占用新生代大量空间,导致垃圾回收频率和成本上升
4、GC区域分类
部分GC:
- 新生代GC:Minor GC / Young GC
- 老年代GC:Major GC / Old GC
- 混合GC : 对于新生代和部分老年代GC
整堆GC:对整个Java堆和方法区GC
5、空间分配担保
因为Minor GC 之后老年代要容纳新生代的对象。
所以只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC
MinorGC和FullGC的流程和效果
MinorGC / yongGC
对象存放在Eden区,当Eden区域不够的时候,就会触发Minor GC:
1、S区域的大小如果也不够的时候,就会进行分配担保,让S区域的对象存入老年代
2、Eden区域的对象如果经历一次Minor GC后还存在,就晋升到S区域
3、后续的对象还是会存放在Eden区域
FullGC / Major GC
当MinorGC发现,之前平均晋升到老年代的对象大小比老年代的剩余大小要大的时候,转而进行MajorGC,因为会将三块区域全都进行GC,所以无需先进行一次MinorGC
当然,如果永久代的空间不足的情况下,也会自动触发MajorGC
因为对新生代也会进行GC,所以Eden和S区的数据也会同MinorGC一样进行重新分配。
死亡对象判断方法
在对对象回收之前,需要判断对象是否已经死亡。
首先介绍一些不会被采纳的方法:
引用计数法:无法解决循环引用的问题
可达性分析算法
使用GC Roots 对象作为起点,往下开始搜索,如果一个对象到GC Roots没有引用链。那证明其不可达需要被回收。
GC Root :
public class GcRootExample {
// 虚拟机栈中的引用对象
public void stackRoot() {
MyClass myObj = new MyClass(); // GC ROOT对象
// ...
}
// 方法区中静态属性引用的对象
public static MyClass staticRoot;
// 方法区中常量引用的对象
public static final String CONSTANT_ROOT = "Constant"; // GC ROOT对象
// 本地方法栈中JNI引用的对象
public native void nativeRoot();
// 虚拟机内部的引用对象
public static void main(String[] args) {
Thread thread = Thread.currentThread(); // GC ROOT对象
// ...
}
}
引用类型总结
1、强引用
有强引用的对象即便 OOM 也不会将其随意回收
2、软引用
如果内存不够了就会回收软引用,足够的话就不会回收
3、弱引用
只要GC线程扫面到了弱引用,不管内存情况如何都会回收
4、虚引用
任何时候都有可能回收。
主要是被用来追踪对象被回收的活动的。
因为其在被回收之前,就会加入到引用队列中,这样就可以让程序感知到并采取措施避免被回收。
废弃常量
在常量池的内容,没有被任何对象所引用,那么说明其就是废弃了,发生GC就会被清除出常量池
无用的类
无用的类不意味着一定会被回收
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
标记-清除算法
1、标记出所有不需要回收的对象
2、标记完成后,统一回收所有没被标记的对象
问题:
1、效率不高
2、内存碎片
复制算法
将内存分为大小相同的两块,每次只使用其中的一块
1、一块内存用完后,将存活对象复制到另一块中
2、将使用过的空间一次性清理掉
问题:
1、可用空间变为原来的一半
2、存活对象多的情况,复制性能低
标记-整理算法
1、标记出所有不需要回收的对象
2、将标记对象移动到一端,清理掉一端边界外的内存
问题:
1、整理导致效率也不高
适用情况:
1、老年代这种回收频率低的情况
分代收集算法
虚拟机使用的都是这种算法,即将堆分为新生代的老年代,根据不同年代特点选择合适的垃圾收集算法。
例如:
1、新生代:大量对象死去,选择“复制”算法
2、老年代:大量对象存活,且对连续空间要求比较高,选择“标记-清除”或“标记-整理”算法
垃圾收集器
首先是不同版本的HotSpot虚拟机选择的垃圾收集器:
- JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK22: G1
所以也就主要介绍这三种垃圾收集器
Parallel Scavenge / Parallel Old
关注点在吞吐量,并不会显著提高用户线程工作时间,但是可以高效调节。
新生代采用标记-复制算法,老年代采用标记-整理算法。
在GC线程运行时,暂停所有用户线程。
G1
四个步骤:
1、初始标记:从 GC Roots 标记可直接到达的活跃对象,耗时一个STW
2、并发标记:和用户线程并发执行标记所有可达对象
3、最终标记:STW后,处理并发标记中产生的垃圾,即更小范围的重新标记
4、筛选回收:根据标记结果,回收高价值区域复制到新区域,回收旧区域内存,历时一个或多个STW。
优点:
1、可预测停顿:明确设置停顿时间
2、多核CPU并发方式同时GC和用户线程
3、Region区域的概念保留了分代的概念
4、减少碎片化,对需要整理的Region进行回收。