JVM底层
类加载机制
Java有以下加载器
- 引导类加载器:加载JRE核心类库,如rt.jar charsets.jar
- 扩展类加载器:加载JRE中ext扩展目录下的jar包(父加载器是引导类加载器,但是它是C++写的,所以是null)
- 应用程序类加载器:加载自己写的类(父加载器是扩展类加载器)
- 自定义类加载器:父加载器是应用程序类加载器
双亲委派
加载某个类会委托父加载器寻找目标(不是父类),找不到再委托父加载器,所有的都找不到,就在自己类加载路径找,并载入。
例如一个User类被使用
- 检查这个User类名称是否被加载过
- 没有加载,就判断它有没有父加载类,有就一直委托,没有就在自己类加载路径找,直到回到最初,自己加载(先往上走,让最父加载器【引导类加载器】加载,然后往下走,依次判断能不能加载)
这样就最先从JDK核心类里面去找类加载,然后再回到自己写的类加载
为什么这样做
- 沙箱安全机制:自己写的和JDK一样的类就不会被加载,防止核心API库被随意篡改
- 避免类重复加载:父加载器加载了类后,就没必要子加载器再加载一次,保证被加载类的唯一性
全盘负责委托机制
当一个ClassLoder装载一个User类,除非显示的使用另外一个ClassLoder,否则这个User类所依赖的类,以及它引用的类也由同样的Classloder加载
自定义类加载器
- 需要继承java.lang.ClassLoader,该类有两个核心方法,loadClass(String,boolean),实现了双亲委派机制,还有findClass方法,默认实现空方法,自定义需要重写findClass方法
- defineClass方法:实现类加载的逻辑(验证,准备,解析…),这里复用它就行了
package com.zwq;
import java.io.FileInputStream;
import java.lang.reflect.Method;
/**
* 自定义类加载器
*
* @author zwq
* @date 2023/4/10 19:51
*/
public class MyClassLoaderTest extends ClassLoader {
private String classPath;
public MyClassLoaderTest(String classPath) {
this.classPath = classPath;
}
private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 通过类名,从磁盘加载出来,字节数组
byte[] data = loadByte(name);
// 加载,放到内存
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException(name);
}
}
public static void main(String[] args) throws Exception {
// 初始化自定义类加载器,会把AppClassLoader作为它的父加载器,也就是会先在应用项目路径下找
MyClassLoaderTest classLoaderTest = new MyClassLoaderTest("D:/test");
Class clazz = classLoaderTest.loadClass("com.zwq.User");
Object object = clazz.newInstance();
Method method = clazz.getDeclaredMethod("out", null);
method.invoke(object, null);
System.out.println(clazz.getClassLoader().getClass().getName());
}
}
// 如果项目目录下存在User.class,那么最后加载器会是AppClassLoader
// 如果删除项目目录下的User.class,那么最后由自定义加载器加载,去D盘路径下找User.class
打破双亲委派机制
loadClass(String,boolean)方法实现了双亲委派机制,重写这个方法即可
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
// 删掉下面双亲委派代码
// ...
long t1 = System.nanoTime();
// 打破双亲委派机制后,加载User类所依赖的类(Object)也会用自定义加载器加载,但是JDK核心类不允许自定义加载,所以会报错
// 需要判断,让指定类用自定义加载器,JDK核心类用引导类加载器
if (!name.startsWith("com.zwq")) {
// 不打破双亲
c = this.getParent().loadClass(name);
} else {
// 直接加载
c = findClass(name);
}
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
类加载过程
- 加载:从硬盘IO读入字节码文件,使用到类才会加载,会在内存生成一个代表这个类的class对象,作为方法区这个类的各种数据访问入口
- 验证:校验字节码文件正确性
- 准备:给类的静态变量分配内存,赋默认值
- 解析:将符号引用转为直接引用。会把一些静态方法转为指向数据内存的指针(直接引用),这也叫静态链接
- 初始化:对类的静态变量初始化值,执行静态代码块
Tomcat类加载器
Tomcat中每个war包要加载很多包,难免要加载同一包名(不同版本),这时Java双亲委派机制只会加载一个。所以Tomcat打破了双亲委派机制,有自定义类加载器
commonLoader:Tomcat基本类加载器,加载路径中的class可以被Tomcat本身和各个Webapp访问
catalinaLoader:Tomcat容器私有类加载器,加载路径中的class对于Webapp不可见
sharedLoader:Webapp共享的类加载器,加载路径中的class对于Webapp可见,对于Tomcat容器不可见
WebappClassLoader:每个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见(自己war包中的类)每个war包应用都有自己的WebappClassLoader,实现相互隔离。比如每个war包用了不同spring版本,这样各自能加载自己的版本。
委派关系
commonLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,实现公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离
WebAppClassLoader可以使用SharedClassLoader加载到的类,但WebAppClassLoader实例之间相互隔离
Tomcat每个webappClassLoader加载自己目录下的class文件,不会传递给父类加载器,打破了双亲委派机制
不同应用从不同路径加载(自己目录下)
// 初始化自定义类加载器,会把AppClassLoader作为它的父加载器,也就是会先在应用项目路径下找 MyClassLoaderTest classLoaderTest = new MyClassLoaderTest("D:/test"); Class clazz = classLoaderTest.loadClass("com.zwq.User"); Object object = clazz.newInstance(); Method method = clazz.getDeclaredMethod("out", null); method.invoke(object, null); System.out.println(clazz.getClassLoader().getClass().getName()); // 从不同路径加载 MyClassLoaderTest classLoaderTest2 = new MyClassLoaderTest("D:/test/2"); Class clazz2 = classLoaderTest2.loadClass("com.zwq.User"); Object object2 = clazz2.newInstance(); Method method2 = clazz2.getDeclaredMethod("out", null); method2.invoke(object2, null); System.out.println(clazz2.getClassLoader().getClass().getName());
JVM
内存结构
- 类装载系统:类加载,把数据放到JVM内存
- 运行时数据(内存):内存结构
- 字节码执行引擎:输入的是字节码文件,然后对字节码进行解析并处理,最后输出执行的结果(将字节码指令解释/编译为对应平台上的本地机器指令)
栈(线程)
- 每个线程都有各自栈(线程独有)
- 每个栈都有若干栈帧,对应每个方法。一个方法一个栈帧
程序计数器
- 每个线程会有一个独有的程序计数器,记录线程运行到哪一行代码,程序计数器值的改变由字节码执行引擎去改变。
- 当cpu的执行权由其他线程抢去,当前线程要挂起,程序计数器保存当前线程执行到的位置,用于恢复。
栈帧
- 局部变量表:一个Table,存放临时变量。
- 操作数栈:一个栈,操作数据的中转内存空间,临时的一块内存区域,用来程序运行中临时存放操作数
- 动态链接:调用的方法无法在编译时确定,运行时将调用方法的符号引用转换为直接引用
- 方法出口:记录当本方法执行完之后,回到主方法时改从哪一行执行。
本地方法栈
- 执行本地方法(native)所占内存
堆
- 存放对象,所有线程公有
- 年轻代
- Eden:最开始对象放这里,放满之后minor GC
- s0:第一次minor GC后存活的对象到这里,标记它经过几次GC
- s1:第二次minor GC后存活的对象到这里,后面存活的对象在s0 s1交替存放
- 老年代
- GC N(15)次后存活的对象放到老年区
- 老年代放满之后进行full-GC,然后进行STW(stop the world),将用户的线程停掉,这时用户体验很不好。
可达性分析算法
- 以GC Root为对象起点,从这些起点开始向下搜索引用对象,找到的对象标记为非垃圾对象,其余都是垃圾对象
- GC Root根节点:线程本地变量,静态变量,本地方法栈的变量。
方法区
- 常量,静态变量,类元信息(类的相关信息),编译后的代码缓存
Class常量池和运行时常量池
- Class静态常量池:存放编译期间生成的各种字面量,符号引用,加载类时放到运行时常量池
字符串常量池
- 字符串常量池:放在堆,为字符串开辟的,类似缓存区,创建字符串常量时,首先查看字符串常量池是否存在该值,存在就返回实例,不存在就实例化字符串并放入常量池
- 底层类似HashTable,K-V结构(Table存引用,引用指向对象(这里的对象是字符串常量池独有的,不是整个堆里的))
- String.intern():是一个native方法,String s2 = s1.intern()如果池中存在s1的对象,返回池中的对象,没有:(1.6把s1的对象复制到字符串常量池,返回池中对象)(1.7不复制,常量池中的Table key指向s1堆中的对象,返回常量池的引用|这时候s2s1使用的都是堆里的对象,同一个)
八种基本类型包装类和对象池
- Java中大多基本类型的包装类也实现了常量池技术(严格来说是对象池,在堆上),实现的有Byte,Short,Integer,Long,Character,Boolean。
- 其中Byte,Short,Integer,Long,Character只在<127时才可以使用对象池
对象创建
类加载检查
- 虚拟机检测到new指令后,首先判断参数在常量池中有没有类的符号引用,检查符号引用所代表的类有没有被加载,解析,初始化,没有就要执行相关类加载过程
分配内存
- 划分内存方法:
- 指针碰撞,如果Java堆中内存是绝对规整,用过的在指针左边,没用过的在指针右边,分配内存就是把指针翔空闲空间那边挪动对象大小想等的距离
- 空闲列表,Java堆不是规整的,虚拟机会维护一个列表,记录哪些内存块是可用的,分配时找到一块足够的空间分给对象,然后更新列表记录
- 并发问题:
- CAS,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理
- (默认使用这个)本地线程分配缓存(Thread Local Allocation Buffer TLAB),把内存分配的动作按照线程划分在不同空间之中进行,每个线程在堆中预先分配一小块内存,设定虚拟机TLAB设定:-XX:+UseTLAB(JVM默认是开启),-XX:TLABSize=64k(设置初始化大小)
初始化
设置默认值,将分配到的内存空间初始化为零值(不包括对象头),如果使用TLAB这个过程可以提前至TLAB分配时,这个操作保证对象的实例字段在Java中可以不赋初始值就可以使用
设置对象头
对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
对象头里面存放着对象是哪个类的实例,类的元数据信息,对象的哈希码,对象GC分代年龄等
一部分是存储对象自身运行时数据:哈希码,GC年龄,锁状态
一部分是类型指针,指向它的类元数据,通过这个指针确定对象是哪个类的实例
执行init方法
为属性赋值,并且执行构造方法
指针压缩
32位机器最多支持4G内存,64位机器支持表示2 64次方内存
64位机器中对象指针占用内存会更多(1.5倍左右),占用较大宽带,GC也会更多
指针压缩就是在对象指针存入堆中时进行压缩,减少内存占用。CPU使用的时候再解码
启动指针压缩(压缩所有指针):-XX:+UseCompressedOops(默认启动) 禁止:-XX:-UseCompressedOops
启动指针压缩(只压缩对象头中类型指针Klass Pointer):-XX:+UseCompressedClassPointers
堆内存小于4G不需要指针压缩,JVM直接去除32位地址,使用低虚拟地址空间
堆内存大于32G,压缩指针会失效,强制使用64位对java对象寻址,所以堆内存不宜大于32G
对象分配内存
是否分配到栈
- 对象逃逸分析:分析对象作用域
public User test() { User user = new User(1); return user; } // 这个对象被返回了,无法确定作用域,只能在堆
开启逃逸分析:-XX:+DoEscapeAnalysis(JDK7以后默认开启)public void test2() { User user = new User(1); } // 这个对象在方法结束就是无效对象,作用域只在方法内,这样的对象可以放在栈(当然对象太大,栈放不下也不行)
关闭逃逸分析:-XX:-DoEscapeAnalysis - 标量替换:栈帧里面没有连续的空间来放对象,可以利用标记的方法,把对象分开放到栈帧(分配不连续的空间给对象)
标量:不可进一步分解的量,Java基本数据类型就是标量
聚合量:可以进一步分解的量,Java对象就是聚合量
其中Eden和Survivor区默认8:1:1,但是JVM默认自适应,让Eden尽量大,Survivor够用即可,会导致8:1:1自动变化,如果不想这个比例变化,需要设置参数:-XX:-UseAdaptiveSizePolicy
分配到堆
- 一般对象分配到Eden,Eden不够时,Minor GC,其中from和to区不够时放到老年代
- 大对象直接分配到老年代(JVM大对象配置:-XX:PretenureSizeThreshold=1000000 单位字节)另外还需要在年轻代设置这两个垃圾收集器(Serial或ParNew)-XX:+UseSerialGC
- 大对象如果不直接进入老年代,会复制对象,挪过去挪过来,降低效率。直接进入老年代,把年轻代空出来
- 对象动态年龄判断:Survivor区一批对象总大小大于Sruvivor总内存大小50%,那么大于等于这批对象年龄最大值的对象就可以直接进入老年代。这个机制一般在minorGC后触发。(老年代满了触发fullGC,所以特定情况可以调大年轻代内存大小)
- 老年代空间分配担保机制:
minorGC之前做一次判断,老年代剩余空间太小就先full gc,然后minorGC,这样full gc回收更多,minorGC速度更快
对象内存回收
引用计数法
每个对象有一个引用计数器,没有一个地方引用这个对象,计数器+1,引用失效-1,计数器为0,就是这个对象没有被使用
这个方法实现简单,效率高,但是对象之间相互引用的问题无法解决,一般虚拟机使用可达性分析算法
可达性分析算法
将GC Roots对象作为起点,从这些节点开始向下搜索引用对象,找到的对象标记为非垃圾对象,其它的都是垃圾对象
GC Roots根节点:线程栈的本地变量,静态变量,本地方法栈的变量
常见引用类型
java引用一般分为4种:强引用,软引用,弱引用,虚引用
- 强引用:普通变量引用:User user = new User();
- 软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会回收,但是GC后发现没有空间存新对象,就会干掉软引用的对象.(一些可有可无的对象可以用软引用)
public static SoftReference<User> user = new SoftReference<User>(new User());
- 弱引用:和没引用差不多,GC会直接回收:WeakReference
user = new WeakReference (new User()); - 虚引用:最弱的引用,几乎不用
finalize()最终判断对象是否存活
- 对象被回收前会调用finalize方法,如果这个方法没有被覆盖,那么就直接回收
- 如果对象覆盖了finalize方法,那么执行finalize方法,该对象可以在方法里面自救一次,即把自己赋值给某个类变量
- 一个对象finalize方法只会被执行一次,即自救只有一次
一般不会用这个
回收方法区的无用类
满足3个条件才能算“无用的类”
- 该类的所有实例被回收(堆中不存在该类的实例)
- 加载该类的ClassLoader已经被回收(自带的3个类加载器几乎不被回收,一般自定义类才可能会被回收)
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾收集算法
分代收集理论
当前虚拟机垃圾收集都采用分代收集理论,根据各个年代选择合适的垃圾收集算法
复制算法
标记-复制算法,将内存分为大小相同的两块,每次使用一块,一块使用完后,将存活的对象复制到另一块,然后无用的清理掉,每次使用的内存回收都是对内存区的一半进行回收
一般标记存活的对象
特点:浪费空间
一般年轻代使用
标记清除算法
分为标记和清除两个阶段,标记存活的对象,回收未标记的对象(也可以反着来)
问题:
- 效率:如果需要标记的对象太多,效率不高
- 空间:标记清除后会产生大量不连续的碎片
一般老年代使用
标记整理算法
先标记存活对象,让所有存活的对象向一端移动,然后清理其余对象
一般老年代使用
垃圾收集器
垃圾收集算法是内存回收的理论,那么垃圾收集器就是内存回收的具体实现
Serial收集器(串行收集器)
-XX:+UseSerialGC -XX:+UseSerialOldGC
最基本,最悠久的。单线程收集器。收集时会stop the world
年轻代使用复制算法,老年代使用标记整理算法
Parallel Scavenge收集器
-XX:+UseParallelGC -XX:+UseParallelOldGC
Serial收集器的多线程版本,默认收集线程数和CPU核心数相同,可以设置-XX:ParallelGCThreads
不能和CMS收集器配合使用
JDK8默认使用这个收集器(年轻代,老年代都是)
年轻代使用复制算法,老年代使用标记整理算法
ParNew收集器
-XX:+UseParNewGC
只有年轻代
和Parallel类似,主要区别可以和CMS收集器配合使用
ParNew + CMS(年轻代 | 老年代)这两个配合是很常用的垃圾收集器
复制算法
CMS收集器
-XX:+UseConcMarkSweepGC
只有老年代,标记清除算法
一种以获取最短回收停顿时间为目标的收集器,符合注重用户体验的应用。
HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现垃圾收集线程和用户线程同时工作(基本上)
- 初始标记:STW,记录gc roots直接能引用的对象,速度很快
- 并发标记:从gc roots的直接关联对象开始遍历整个对象图的过程,耗时较长,但不停顿用户线程(会有对象状态改变的可能)
- 重新标记:STW,为了修复并发标记期间因为用户线程运行而导致标记产生变动的记录(用到三色标记中的增量更新算法)
- 并发清理:对未标记的对象进行清理,这个时候如果有新增对象会标记为黑色,不做任何处理
- 并发重置:重置本次GC中对对象的标记
用户体验更好,GC整个过程稍长,但是STW时间更少。内存大一点的适用CMS(>2,3G)(一般>8G用G1)
缺点:
- 对CPU资源敏感,和服务抢资源
- 无法处理浮动垃圾(并发标记和并发清理阶段产生的垃圾,这种垃圾只有下一次GC清理)
- 使用标记清除算法,产生碎片空间(可以配置参数-XX:+UseCMSCompactAtFullCollection,整理空间)
- concurent mode failure 执行过程中可能再次触发full gc,可能没回收完,再次full gc,这时会STW,用serial old垃圾收集器来回收(GC时用户线程又放对象到老年区,放不下了,会触发)
核心参数:
-XX:ConcGCThreads 并发GC线程数
-XX:+UseCMSCompactAtFullCollection FullGC后整理内存(减少碎片,这个也会STW)
-XX:CMSFullGCsBeforeCompaction 多少级FullGC后进行内存整理(减少碎片),默认0,每次GC都整理
-XX:CMSInitiatingOccupancyFraction 老年代使用达到该比例会触发FullGC(默认92,百分比)如果系统大对象比较多,可以调小点,避免垃圾回收并发失败
-XX:+UseCMSInitiatingOccupancyOnly 触发FullGC比例只使用上面那个指定值,不配置这个参数就是JVM自己动态调整(老年代使用率到达多少触发FullGC),一般和上面那个参数配合
-XX:+CMSScavengeBeforeRemark 在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低标记阶段耗时
-XX:+CMSParallelInitialMarkEnabled 初始标记使用多线程执行,缩短STW(默认开启)
-XX:+CMSParallelRemarkEnabled 重新标记使用多线程执行,缩短STW(默认开启)
三色标记算法
在CMS并发标记过程中,应用线程还在跑,对象间的引用可能发生变化,标记可能出错(多标,漏标)
黑色:对象已被垃圾收集器访问,且对象所有引用都扫描过,它是安全存活的(任一对象指向黑色对象,就无需扫描)
灰色:对象已被垃圾收集器访问,但这个对象存在引用没有扫描过
白色:对象未被垃圾收集器访问,如果分析结束,仍然是白色,即代表不可达
多标:下次GC就行
漏标:两种解决方案
- 增量更新:当黑色对象插入新的指向白色对象的引用关系时,就记录下来,等并发扫描结束后,再将这些记录的引用关系中的黑色对象为根,重新扫描一次(重新标记阶段)(黑色对象新插入指向白色对象的引用后,黑色就变灰色)
- 原始快照(SATB):当灰色对象删除指向白色对象的引用时,会记录下来,并发扫描结束后,将记录下来的白色对象直接标为黑色(漏标肯定是首先用户线程删除了引用,所以这里记录下来可能漏标的情况,直接把它变成黑色,待下一轮GC处理)
这两种方案都是通过写屏障来实现
CMS是使用增量更新实现
G1使用原始快照
写屏障
增量更新和原始快照的插入新引用关系和删除引用关系都是赋值操作
写屏障就是在赋值操作前后,做一些处理
记忆集与卡表
年轻代GC时扫描可能碰到跨代引用的对象(年轻代的对象被老年代的对象引用着),这个时候要是去老年代扫描效率就太低了
所以JVM在年轻代维护了一个记忆集(Remember Set)(写屏障实现),记录跨代引用的对象地址
卡表:Cardtable:是记忆集的具体实现方式,是目前最常用的一种方式。
安全点 | 安全区域
- 安全点
GC并不是想什么时候做就什么时候做,需要等待所有线程运行到安全点后才能做
安全点主要有以下几种
- 方法返回之前
- 调用某个方法之后
- 抛出异常位置
- 循环末尾
- 安全区域
安全区域指在一段代码片段中,引用关系不会发生改变,这个区域内任何地方GC都可以做
如果一个线程处于Sleep或中断,它就不能响应JVM中断请求,再运行到安全点上。
G1收集器
Garbage-First:面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
JDK9默认G1
物理上没有分区的概念,逻辑上有Eden,Survivor,Old,Humongous
它将堆分为多个大小相等的独立区域(Region),JVM最多可以有2048个独立区域,一般Region大小等于堆大小/2048,Region可能会变化,Region做了垃圾回收后,可能由年轻代变成老年代
默认年轻代占用堆5%,可以配置-XX:G1NewSizePercent设置年轻代占比
JVM会不停给年轻代增加Region,最多不会超过60%,可以配置这个参数-XX:G1MaxNewSizePercent
G1对象进入老年代和以前相同,但是大对象处理不一样。G1有专门分配大对象的区域:Humongous,对象大小>Region的50%,会放到Humongous(一个放不下放多个Humongous)
MixedGC收集年轻代,老年代,还有Humongous巨型对象区也一起回收
G1收集器步骤:
- 初始标记:STW,标记gc roots,很快
- 并发标记:和CMS一样
- 最终标记:STW,和CMS重新标记一样
- 筛选回收:STW,首先对Region回收价值,成本排序,根据用户期望STW时间(默认200MS),来回收垃圾,期望时间配置:-XX:MaxGCPauseMillis(在时间范围内,能回收多少就多少,没回收完的等下次GC)
G1最后回收垃圾用的复制算法(CMS标记清除),把存活的对象从一个Region移动到另一个Region,然后清除其它垃圾对象(所以几乎不会有太多内存碎片)
- Region回收成本:
G1在后台维护了一个优先列表,对每个Region会提前计算,回收大小/回收时间,排序,优先回收效率高的(花费时间多的一般是Region中存活对象多的,存活多,复制就越多,花费时间更长) - G1垃圾收集分类
YoungGC:
和以往不同,当Eden区放满了不会马上minorGC,先计算回收大概时间,如果远远小于用户期望STW时间(默认200MS),就增加年轻代Region,直到下一次minorGC
MixedGC:
相当于以往的FullGC,老年代堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent默认45%),触发回收(复制算法),如果没有足够Region承载拷贝对象,就会FullGC
FullGC:
停止应用系统,采用单线程进行标记清理(速度很低,尽量不要触发) - G1参数配置
-XX:+UseG1GC 使用G1 -XX:ParallelGCThreads 指定GC工作线程数 -XX:G1HeapRegionSize 指定Region大小(1-32M,2的N次幂),默认堆大小/2048 -XX:MaxGCPauseMillis 期望SWT时间,默认200MS -XX:G1NewSizePercent 年轻代内存初始空间占比(默认5%,配置整数) -XX:G1MaxNewSizePercent 年轻代最大空间占比(60%) -XX:TargetSurvivorRatio 动态年龄判断机制(50%)Survivor一批对象总和超过Survivor50%,会把年龄N以上的所有对象放入老年代 -XX:MaxTenuringThreshold 最大年龄阈值(15),大于这个年龄进入老年代 -XX:InitiatingHeapOccupancyPercent 老年代堆占用率阈值(45%),大于等于这个值就会触发MixedGC(100个Region,老年代占45个,触发GC) -XX:G1MixedGCLiveThresholdPercent (85%)region中存活对象低于这个值才会回收该Region,如果超过这个值,存活的对象过多,回收意义不大 -XX:G1MixedGCCountTarget 一次回收过程指定做几次筛选回收(默认8次),最后筛选阶段可以回收一会,然后暂停回收,停一会再开始回收,让系统不至于单次停顿时间过长 -XX:G1HeapWastePercent gc过程中空出来的region是否充足阈值,回收的时候会把存活对象移动到另一个Region,会不断空出新的Region,当空闲出来的Region达到堆5%,就会立即停止回收,本次回收就结束了
- G1适用场景
- 50%以上堆被存活对象占用
- 对象分配和晋升速度变化非常大
- 垃圾回收时间特别长,超过1S
- 8G以上堆内存
- 停顿时间500MS以内
4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
- 为什么G1用SATB(原始快照),CMS用增量更新
增量更新会在重新标记阶段再次扫描被删除引用的对象,G1是很多不同Region,每个Region都维护记忆表,遇到跨代扫描代价会更高,所以再次扫描G1的代价会比CMS高
SATB相比增量更新会更快,它不重新扫描对象,而是留着下次GC处理,这样会多一些浮动垃圾,但是G1本身就不会在一次GC回收所有垃圾,所以影响不是很大
Shenandoah
G1升级版本,回收阶段进行了优化,不会全程STW
redhat开发,没有被Oracle认可
ZGC收集器
Linux JDK11
Windows mac JDK14
ZGC目标
- 支持TB级别的堆
- 最大GC停顿不超过10MS
- 奠定未来GC特性的基础
- 最糟糕情况下吞吐量降低15%
目前不分代
内存布局:分为小型,中型,大型Region
NUMA-aware
每个CPU对应有一块内存,这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存。ZGC自动感知NUMA架构ZGC运作过程
初始标记:STW
并发标记:和G1差不多,不同点是ZGC标记在指针上,不是对象上进行,标记阶段会更新颜色指针的Marked0,Marked1标志位
最终标记:STW
并发预备重分配(算一下哪些要回收):根据特定查询条件,得出收集过程需要清理哪些Region,将这些Region组成重分配集。每次回收都会扫描所有Region,省掉维护G1记忆集的成本
分配初始化:STW
并发重分配:核心,把存活对象复制到新的Region,并为重分配集中的每个Region维护一个转发表(记录从旧对象到新对象的转向关系),如果用户线程并发访问了位于重分配集合的对象,那么这次访问会被预置的内存屏障(读屏障)截获,然后根据Region上的转发表将访问转发到新复制的对象上,同时修正更新该引用的值,使其直接指向新对象,这个称为指针的自愈能力
并发重映射:修正整个堆中指向重分配集合中旧对象的所有引用,但是ZGC中对象引用存在自愈功能,所以这个操作不是很迫切。所以ZGC把这个阶段合并到下一次垃圾回收的并发标记阶段一起完成。一旦所有指针都被修正后,原来记录新旧对象关系的转发表就可以释放掉
触发时机
- 定时触发:默认不使用,ZCollectionInterval参数配置
- 预热触发:最多三次,堆内存达到10%,20%,30%触发,主要统计GC时间,为其它GC机制使用
- 分配速率:计算内存99%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC
- 主动触发:默认开启,ZProactive参数配置,距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC间隔时间,超过则触发
- 颜色指针
每个对象有一个64位指针: - 18位:未使用
- 1位:Finalizable标识,并发引用处理有关,表示这个对象只能通过finalizer才能访问
- 1位:Remapped标识,表示对象未指向relocation set(需要GC的Region集合)中
- 1位:Marked1标识:标记对象用于辅助GC
- 1位:Marked0标识:标记对象用于辅助GC
- 42位:对象地址,支持2^42 = 4T内存
颜色指针优势:
- 一旦某个Region存活对象被移走后,这个Region就能立刻释放,不必等待整个堆中所有指向该Region的引用对象都被修正后才能清理。
- 大幅减少垃圾收集过程中内存屏障使用数量,ZGC只使用了读屏障
- 扩展性强,可以作为一种可扩展的存储结构来记录更多的数据
- 读屏障
在标记和移动对象的阶段,每次从堆里对象的引用类型中读取一个指针的时候,都加上一个Load Barriers。在对象引用赋值给另一个引用时,如果对象在GC时被移动了,JVM会架上读屏障,读屏障会把读出的指针更新到对象的新地址,并且把堆里的这个指针修正到原本字段中。这样GC把对象移动了,读屏障也会发现并修正指针,应用代码就永远持有更新后的有效指针,不需要STW