(¥)什么是跨代引用,有什么问题?
扩代引用就是年轻代的对象指向了老年代对象,或是老年代对象指向了年轻代对象,这种跨越不同代之间的引用就是跨代引用
跨代引用如果处理不好的话,会导致一些应该存活的对象被删除

JVM每次进行可达性分析的时候都是从 GC roots 出发,当判断到一个对象是在老年代的时候会认定这个对象是存活的,直接跳过,但是如果老年代的对象指向了年轻代的对象,并且这个年轻代的对象E只有老年代对象D一个指向它,最终GC的时候因为E没被扫描标记过,会直接被清除
两种解决方式:
- YoungGC时,扫描到对象是老年代对象时,不提前终止,直接继续往下扫
- YoungGC时,新增一个步骤,把老年代对象同样作为GC roots 进行扫描
无论哪一种都很耗时,最好的方式是用一个Remember Set,记录所有老年代对新生代的引用,后续直接将这个引用里面指向的新生代对象强制标记为存活
那么Remember Set这种思路比较好的实现方案就是:Card Table
(¥)为什么初始标记和重新标记需要STW,而并发标记不需要?
因为初始标记的时候需要精准的遍历所有的GC roots 指向的直接对象,这一步关系到后面遍历的准确性,所以需要STW,同时虽然GC算法一般有写屏障可以感知对象是否被修改,但是GC roots 一般不是对象,而是活跃的引用,所以并不是很可靠
并发标记不用是因为它的结果并不作为最终结果,只是最大限度的提高性能,后续会通过重新标记进行改正,重新标记过程又是STW的
(¥)JVM有哪些垃圾回收算法?
标记-清除

标记-清除算法就是经过前面的标记之后,知道哪些对象可以被清理掉,直接清理,不复制,不移动,在原来的位置直接删除
缺点:产生很多的内存碎片
优点:速度快
标记-复制

标记-复制算法就是使用到两片内存空间,来回切换使用,每次创建新对象都放在其它的一块区域,然后快满的时候就触发标记,并将对象存活对象复制到另一块区域紧密排序
好处:不会产生内存碎片
缺点:多耗费一块内存且复制过程较耗时
标记-整理



它是前面两种的一个结合
阶段1:遍历GC roots ,标记所有存活对象
阶段2:将所有死掉的对象移除掉
阶段3:将存活的对象紧密的从内存其实位置开始排放
整个过程是在一块内存内完成的,并且没有造成内存碎片
优点:节省空间,不造成内存碎片
缺点:耗时高
单纯的从时间长短上面来看:标记-清除 < 标记-复制 < 标记-整理。
单纯从结果来看:标记-整理 > 标记-复制 >= 标记-清除
(¥)JVM中一次完整的GC流程是怎样的?
这里还是按照分代的结构进行解析
当一个对象创建的时候会根据它的大小确定进入新生代还是老年代。
可以通过参数:-XX:PretenureSizeThreshold,如果这个参数配置不为0,那么当一个对象大于这个参数的时候,就会直接进入老年代
这个参数默认情况下是0,表示不会提前进入老年代
首先就是在Eden区创建对象,当Eden区满了之后会触发YoungGC(会在YoungGC前做一次空间分配担保,如果失败可能直接触发FullGC)
年轻代采用的是标记复制算法,主要分为,标记、复制、清除三个步骤,会从GC Root开始进行存活对象的标记,然后把Eden区和Survivor区复制到另外一个Survivor区。然后再把Eden和From Survivor区的对象清理掉。
这个过程,可能会发生两件事情,第一个就是Survivor有可能存不下这些存活的对象,这时候就会进行空间分配担保。如果担保成功了,那么就没什么事儿,正常进行Young GC就行了。但是如果担保失败了,说明老年代可能也不够了,这时候就会触发一次FullGC了。(Survivor不够会塞到老年代)
还会发生第二件事情就是,在这个过程中,会进行对象的年龄判断,如果他经过一定次数的GC之后,还没有被回收,那么这个对象就会被放到老年代当中去。
而老年代如果不够了,或者担保失败了,那么就会触发老年代的GC,一般来说,现在用的比较多的老年代的垃圾收集器是CMS或者G1,他们采用的都是三色标记法。
也就是分为四个阶段:初始标记、并发标记、重新标记、及并发清理。
老年代在做FullGC之后,如果空间还是不够,那就要触发OOM了。
(¥)YoungGC和FullGC的触发条件是什么?
YoungGC 就是当Eden满了,不够给对象分配空间时会触发
FullGC 触发条件比较多:(担保失败,老年代空间不足,永久代空间不足)
- 第一个就是担保失败的时候:
-
- 当准备要触发一次YoungGC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查 老年代最大可用的 连续 空间 小于新生代所有对象的总空间,但是HandlePromotionFailure=false,那么就会触发一次FullGC(HandlePromotionFailure 这个配置,在JDK 7中并不在支持了,这一步骤在该版本已取消)
- 当准备要触发一次YoungGC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查老年代最大可用的连续空间小于新生代所有对象的总空间,但是 HandlePromotionFailure=true,继续检查发现老年代最大可用 连续 空间 小于 历次晋升到老年代的对象的 平均 大小时,会触发一次FullGC
- 第二个就是老年代空间不足,会触发FullGC
- 第三个就是如果有永久代,永久代空间不足,同样FullGC
担保逻辑:(两步走)
- 第一步:老年代最大的可用连续空间是否小于新生代所有对象总和
- 如果第一步判定小于就进入第二步,否则担保成功
- 第二步:老年代最大的可用连续空间是否小于历次晋升到老年代的对象的平均大小
- 如果第二步判定为小于说明担保失败,否则成功
注意:jdk1.6中第二步不一定能执行,需要通过设置参数 HandlePromotionFailure为true才会执行第二步
(¥)类的生命周期是怎么样的?

加载------》链接(验证、准备、解析)------》初始化------》使用------》卸载
类加载阶段 类使用阶段 类卸载阶段
类加载阶段: (¥)Java中类加载的过程是怎么样的?(5个步骤做什么)
**类使用阶段:**就是在完成类加载之后会有代码段引用该类,可能是初始化该类的对象,或者反射获取该类的元数据
类卸载过程:
- 该类所有的实例都已被GC回收。
- 该类的ClassLoader已经被GC回收。
那么该类会在FULLGC期间从方法区被回收掉。
所以JDK自带的基础类是一定不会被回收掉的,那些自定义类加载器一些场景的类会被回收掉,如tomcat,SPI,JSP等临时类,是存活不久的,所以需要来不断回收
(¥)内存泄漏和内存溢出的区别是什么?
内存泄露是指用完的内存未及时的回收,导致一直占用着资源,影响系统的整体运行效率,久而久之就会造成内存溢出
内存溢出是指OOM,也就是系统没有那么多内存可以进行分配,导致出现的异常OutOfMemoryError,也就是JVM自己的内存不够分配了
内存溢出分为栈溢出和堆溢出,内存泄露虽然不会造成OOM,但是随着系统运行过程中的不断积累,最终就会导致OOM,例如我们使用ThreadLocal的时候如果不及时的进行清理,久了就会造成OOM
(¥)OutOfMemory和StackOverflow的区别是什么?
OutOfMemory 一般是发生在堆上,当分配对象的时候,堆中的内存不够进行分配就会出现这个报错,也可能是对象太多导致存储不下
OOM其实也有很多分类,比如是堆内存不够,还是直接内存不够等等
StackOverflow 是发生在栈上的,一般是因为我们的递归调用比较深,可能退出递归条件不完善导致的,每一层递归调用都会产生一定的栈帧开销
有些语言是支持自动动态调整栈空间的,但是Java不行,Java的栈空间是固定的,用超了就会直接报错,这里栈大小是针对线程来说的,我们只能指定一个线程的栈空间容量大小
堆内存就可以,会在我们设置的空间范围内自动的扩缩容,堆内存是针对整个jvm来说的
(¥)请分别写出一个Java堆、栈、元空间溢出的代码?
堆溢出:
堆溢出一般是因为创建的内存太多了,堆存储不下
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ArrayList<Object> list = new ArrayList<>();
while (true) {
list.add(new Object());
}
}
}
// Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3512)
at java.base/java.util.Arrays.copyOf(Arrays.java:3481)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:244)
at java.base/java.util.ArrayList.add(ArrayList.java:454)
at java.base/java.util.ArrayList.add(ArrayList.java:467)
at main.Main.main(Main.java:13)
栈溢出:
栈溢出通常是因为递归深度太深导致的
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
while (true) {
main(args);
}
}
}
// Exception in thread "main" java.lang.StackOverflowError
at main.Main.main(Main.java:11)
at main.Main.main(Main.java:11)
at main.Main.main(Main.java:11)
at main.Main.main(Main.java:11)
at main.Main.main(Main.java:11)
元空间溢出:
元空间存储的是类的元数据,元空间溢出一般就是因为加载的类过多或是类的元数据过大
元数据是存储在直接内存,也就是jvm之外的内存,和系统的内存大小直接挂钩,如果没通过参数限制其大小的话,很难溢出
public class MetaspaceOverflow {
public static void main(String[] args) {
ClassPool classPool = ClassPool.getDefault();
for (int i = 0; ; i++) {
classPool.makeClass("Class" + i).toClass(); // 动态创建大量的类
}
}
}
(¥)JVM为什么要把堆和栈区分出来呢?
栈主要是用来存储私有的局部变量信息和线程调用,是线程私有的区域,主要是存储这个线程执行过程中产生的一些临时数据
堆主要是用来存储对象,属于线程共享区域,堆的大小可以根据我们配置的参数动态的进行调整,并且可以受到GC算法管理
那么分开的原因就是因为两个区域存储的数据是不一致的,分开可以更加高效和精细化的进行管理
(¥)如何判断JVM中类和其他类是不是同一个类?
类的唯一性需要由类本身以及它的类加载器共同来确定的
每一个类加载器都有一个独立的类名称空间(就是有一个唯一标识)
那么比较两个类是否属于同一个类,需要看是否来自同一份 .class 文件 和加载它们的类加载器是否是同一个
如果不是同一个,即使是同一份 .class文件,也不算是同一个类
一定要记住:是否属于同一个类的前提 是加载它们的类加载器是否是同一个
(¥)什么情况会导致JVM退出?
正常退出:
当非守护线程都执行完成的守护,jvm会正常的退出,这是最正常和理想的退出方式
主动退出:
public class Main {
public static void main(String[] args) throws InterruptedException {
// 0表示正常退出,非0表示异常退出
Runtime.getRuntime().halt(0);
// 0表示正常退出,非0表示异常退出
System.exit(0);
}
}
两种方式都行
它们之间的区别就是:使用通过Runtime.getRuntime().halt(0);退出不会执行钩子方法;而使用System.exit(0);会执行钩子方法
钩子方法就是像下面这种
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("程序退出了");
}));
非正常退出:
如果是操作系统层面执行了 kill -9 指令的话也会导致jvm直接强制退出
或者遇到一些无法处理的错误也会导致退出,比如资源严重不足
(¥)Java发生了OOM一定会导致JVM退出吗?
不一定。
原因就是,Java做到线程之间互相独立,出现OOM或是栈溢出时,本质上只会杀死出现异常的线程,不会影响其它线程的正常运行,并且像OOM其实是可以catch之后进行处理的
实验代码:
public class Main {
static CountDownLatch latch = new CountDownLatch(1);
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
ArrayList<Object> list = new ArrayList<>();
while (true) {
list.add(new Object());
}
}
}

可以发现程序依旧健在
(¥)什么是safepoint,有啥用?
safepoint 其实就是程序执行中的一些特殊位置,当线程执行到这个位置的时候,可以被认为处于**"安全状态"** ,如果有需要,可以在这里暂停 ,在这里暂停是安全的
所谓的安全其实就是不会破坏内存结构,不会导致一些共享数据的错乱问题
什么操作需要等到安全点再执行?
当JVM需要对线程进行挂起的时候,会等到安全点在执行。
比较常见的有:STW
(¥)什么是编译和反编译?
编译就是把程序员能看懂的语言翻译成机器能读懂的语言
比如使用Javac将源代码编译成 .class 文件,就是编译,不过 .class 并不能直接运行,还要让虚拟机继续编译成机器码,这个也是编译(前端编译和后端编译)
反编译就是反过来,常见的就是把 .class 文件反编译成 Java源代码,
可以使用的工具就是 javap -c [要反编译的字节码文件](jdk自带)
(¥)什么是堆外内存?如何使用堆外内存?
堆外内存其实就是一块本地内存,是操作系统直接对应的,不属于jvm管理的一块区域
堆外内存 不受Java垃圾回收机制的管理 。在不再需要堆外内存时,务必手动释放内存资源
想要使用堆外内存,我了解的有两种方法:UnSafe类和NIO
UnSafe:
public class Main {
// 1. 获取 Unsafe 实例
private static Unsafe getUnsafe() throws Exception {
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
return (Unsafe) unsafeField.get(null);
}
public static void main(String[] args) throws Exception {
Unsafe unsafe = getUnsafe();
long size = 1024 * 1024; // 1MB 直接内存
long address = 0;
try {
// 2. 分配直接内存
address = unsafe.allocateMemory(size);
System.out.println("分配直接内存地址:" + address);
// 3. 写入数据到直接内存
String content = "Hello Direct Memory";
byte[] bytes = content.getBytes();
for (int i = 0; i < bytes.length; i++) {
// putByte(内存地址, 偏移量, 字节数据)
unsafe.putByte(address + i, bytes[i]);
}
// 4. 从直接内存读取数据
StringBuilder sb = new StringBuilder();
for (int i = 0; i < bytes.length; i++) {
// getByte(内存地址 + 偏移量)
sb.append((char) unsafe.getByte(address + i));
}
System.out.println("读取直接内存数据:" + sb);
// 5. 重新分配/调整直接内存大小(可选)
long newSize = 2 * 1024 * 1024; // 2MB
address = unsafe.reallocateMemory(address, newSize);
System.out.println("重新分配内存地址:" + address);
} finally {
// 6. 释放直接内存(必须手动释放,否则内存泄漏)
if (address != 0) {
unsafe.freeMemory(address);
System.out.println("释放直接内存完成");
}
}
}
}
NIO:
public class Main {
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
// 写入
byte[] content = "test Str".getBytes();
byteBuffer.put(content);
// 切换读取模式
byteBuffer.flip();
// 读取
byte[] bytes = new byte[content.length];
byteBuffer.get(bytes);
System.out.println(new String(bytes)); // test Str
}
}
Java堆内数据想要和内核态进行交互,第一步需要先拷贝到堆外内存,然后才能拷贝给内核态,而堆外内存就少了一次拷贝
当然,现在操作系统支持mmap,可以做到"零拷贝"(只拷贝一些记录参数)
堆外内存的优势:
- 减少拷贝次数,减低拷贝开销;
- 无GC干扰,避免被STW
- 容量比较大
(¥)什么是强引用、软引用、弱引用和虚引用?
引用强度排名:强软弱虚
强引用:
强引用是默认的形式,无需特殊定义,当出现内存不足的时候也不会被回收,会直接抛出OOM
String[] arr = new String[]{"a", "b", "c"};
软引用:
软引用就是使用SoftReference
软引用在出现内存不足的时候会被回收,不过只有内存不足时才会被回收,主要就是避免出现OOM
SoftReference<String[]> softReference = new SoftReference<>(new String[]{"a", "b", "c"});
弱引用:
弱引用就是使用WeakReference
在触发GC算法的时候一定会被清除掉,即使内存还没满
WeakReference<String[]> weakReference = new WeakReference<>(new String[]{"a", "b", "c"});
主要的应用场景就是ThreadLocalMap中的Entry类
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
虚引用:
虚引用就是通过PhantomReference
虚引用绑定的对象,它的生命周期同样是在触发GC算法的时候一定会被回收,但是通过它的get() 方法压根关联不到对象,有点像是对象的附属品,专门用来追踪对象是否被GC回收的,当对象被GC回收的时候,它也会被塞入ReferenceQueue中,我们可以通过ReferenceQueue感知到
在GC时被塞入队列这一点弱引用也同样有这种操作,不过允许不指定塞入队列的模式
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
PhantomReference<String> phantomRef = new PhantomReference<>(new String(), referenceQueue);
虚引用主要用来处理一些善后的清理工作,比如我们申请了一块堆外内存,那么可以把这个对象和虚引用进行绑定,这样子等这个直接内存变量被GC清理掉的时候,我们可以可以感知到并通过UnSafe方法中的clear() 方法进行清理
(¥)什么是双亲委派?如何破坏?
双亲委派就是要求每一个类(除非是顶类)都应该有一个父类加载器
那么类的加载任务就不再是当前类去执行,而是按照优先级顺序,优先交给父类,一层层交给顶级类,要是加载不了才一层层的退回给下一级去加载
双亲委派的好处:
这样的好处就是保证类的唯一性 和避免核心代码被篡改,
假设我们自己编写了一个Object类,类名和jvm层面的类重名,包名也和jvm层面定义的一致,如果没有双亲委派,那就会加载用户自己定义的java.lang.Object,也会加载jvm自带的java.lang.Object,造成有很多个不同的Object类实例,导致非常的混乱,也会出现调用出错的情况
而使用双亲委派,就会将类加载请求委派给最上层的类加载器去加载,避免出现重复加载以及错乱加载的情况,此外也能避免一些核心API被篡改
双亲委派的工作过程:
首先判断到没加载过且父类加载器不为null,会将类加载任务上报给父类加载器,通过parent.loadClass() 完成类加载任务上报,如果父类加载失败,会退回给当前类加载器,通过findClass() 完成加载
打破双亲委派:
如果想要打破双亲委派,就需要重写loadClass() 方法,改变逻辑,跳过调用父类的loadClass() 方法,而是直接通过findClass() 完成
常见的打破双亲委派的案例:Tomcat、SPI实现
SPI:
SPI就是提供给开发人员去进行拓展自己的一些框架实现,但是如果是默认的双亲委派模式的话,是自下而上去委托,但是因为这些拓展是开发人员自己定义的,框架上层并不认识,所以无法加载,只能打破这种,让一些类优先从下层去加载,这也是很多驱动底层的SPI加载的方式,例如JDBC
那么对于这种问题,Java开发人员开放了一个后门:线程上下文类加载器(Thread Context ClassLoader)
就是说我们可以通过 new Thread().setContextClassLoader(); 的方式来设置线程的上下文类加载器,要是我们不主动去设置,默认就是 App类加载器 ,也就是当前的应用类加载器
System.out.println(new Thread().getContextClassLoader());
jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b
使用的方式:
public class DriverManager {
static {
loadInitialDrivers();
}
private static void loadInitialDrivers() {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);
}
}
不过我们平时开发的时候往往不需要这么的复杂,因为底层其实也封装了一下,可以看一下我们平时使用SPI加载自定义类的时候是如何实现的
public void init() {
ServiceLoader<Listener> serviceLoader = ServiceLoader.load(Listener.class);
}
源码:帮我们完成了获取线程上下文这一步
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
基本是实现了SPI机制的,大多都是破坏双亲委派
(¥)破坏双亲委派之后,能重写String类吗?
不能。破坏双亲委派之后确实能绕过向上层层委托的逻辑,但是我们没办法直接替换java包下的类
并且底层其实真正的逻辑是defineClass()方法,也就是说你除非能修改jvm底层源码,否则不可能的,也没必要这样做
(¥)新生代和老年代的GC算法?
首先我们知道常见的垃圾回收算法有三种:标记-清除、标记-复制、标记-整理
但是标记-清除因为会产生内存碎片,所以基本没使用
新生代使用的是 标记-复制,因为首先就是避免内存碎片,但是标记-复制算法会浪费一定的内存,因为需要两块空间来回切换着用,所以新手代的做法就是定义 一个Eden+两个Survivor,每次工作的时候就是一个Eden+其中一个Survivor,另外一个Survivor用于下一次复制。只要按照比例调整Survivor大小就能减少内存浪费问题
老年代一般使用 标记-整理,因为首先就是GC的频率比新生代会低很多,其次就是减少内存浪费和避免出现内存碎片问题,所以使用标记-整理反而比较合适
不过有些垃圾回收器为了效率,是采用标记-清除 作为老年代的回收算法,比如CMS,这样能大大的降低STW的时间
(¥)Java的堆是如何分代的?为什么分代?
Java的堆由新生代和老年代组成

新生代占了1/3 主要是存储新创建的对象,而老年代占用2/3 主要是存储经过几轮GC都还存活的那些对象
新生代划分成 Eden 和 Survivor,新创建的对象是存储在Eden中,当Eden满了就会触发GC,存活下来的对象被移动到Survivor
如果Survivor也用完的话就会把还存活的对象迁移到老年代中
老年代一般就是经过了好几轮GC之后还或者的对象
分代的目的 就是因为对象的声明周期不同,根据存储不同生命周期对象来划分不同的代,这样性能会更高,同时也更好管理
对象的分代晋升:
新创建的对象会尝试在Eden分配空间,当Eden的空间耗尽的时候会触发YoungGC,YoungGC之后还能存活的对象就会被迁移到Survivor,
同理,当对象满足以下条件中的一条的时候就会从Survivor迁移到老年代
- 躲过一定次数的GC之后还能存活,默认情况下是15次,可以通过参数-XX:MaxTenuringThreshold 设置
- 动态年龄判断。年龄其实就是躲过的GC次数。当Survivor中以某个年龄为界限进行判断,当小于这个年龄的对象的总内存占用超过Survivor的一半或是一半以上,那么这个年龄网上的对象都会迁移到老年区
- 当对象太大是,年轻代存储不下,也会直接塞到老年代中(老年代空间比较大)。默认情况下这个规则不启动,需要通过参数-XX:PretenureSizeThreshold配置才生效,超过这个参数就会塞入老年代
(¥)永久代是什么?存储什么东西的?存储在什么位置?为什么后面改成了元空间?两者有什么区别?
在jdk7以前,堆被分成了三个部分:年轻代、老年代、永久代
永久代就是专门存储**Class类信息、常量池以及静态变量等,**是存储在堆中的一块区域
永久代同样也是有GC算法的,并且它的大小是固定的,可能会出现OOM
jdk8之所以要重构成元空间就是为了解决大小不变,且和JVM绑定比较深的问题,会导致其它的虚拟机不好拓展
所以元空间不是存储在堆中,而是本地内存,它的大小也不再是固定不变,而是和机器内存绑定
区别:大小不固定,存储空间从jvm堆改到本地内存
(¥)新生代如果只有一个Eden+一个Survivor可以吗?
不可以。
新生代的GC算法是采用标记-复制法,这种算法要求必需有一块空间是空着的
Eden 和 Survivor 一般是八二分,其中Survivor会被分成两份(8:1:1)
那么假设只有一个Eden+一个Survivor
- 同样Eden负责存储新对象,那么可以发现第二次GC的时候,Eden和Survivor都有对象,扫描的时候要扫两个不说,还用不了标记-复制(因为标记-复制要求一定有一块区域是空的),只能使用标记-清除,造成的结果就是带来内存碎片
- 那假设两个区域都接收新对象,然后来回搬,那么就要将两个区域都设置成50%才是效率最好的,但是造成的结果是原本只浪费10%,现在浪费50%
总结一下,一个Eden+一个Survivor情况下,要么造成内存碎片,要么浪费50%内存,所以一个Eden+两个Survivor才是最好的,只浪费10%内存,且避免内存碎片
在执行完YoungGC 后,如果存活的对象多于Survivor能存储的空间,就会将对象移动到老年代
**要是老年代也放不下呢?**所以这里在YoungGC前会判断
- 如果老年代最大连续空间能存储下新生代所有对象,说明安全,继续YoungGC
- 如果上一步判断失败,会判断最大可用连续空间是否大于历次晋升得平均大小,
-
- 大于,冒险做一次YoungGC
- 小于,触发FullGC
(¥)新生代和老年代的垃圾回收器有何区别?

常见的垃圾回收器如下:
- 串行垃圾回收器(Serial Garbage Collector) 如:Serial GC, Serial Old
- 并行垃圾回收器(Parallel Garbage Collector) 如:Parallel Scavenge,Parallel Old,ParNew
- 并发标记扫描垃圾回收器(CMS Garbage Collector)
- G1垃圾回收器(G1 Garbage Collector,JDK 7中推出,JDK 9中设置为默认)
- ZGC垃圾回收器(The Z Garbage Collector,JDK 11 推出)
新生代收集器有Serial、ParNew、Parallel Scavenge;
老年代收集器有Serial Old、Parallel Old、CMS。
整堆收集器有G1、ZGC
设计目标不同:
- 新生代的设计目标是空间换时间,追求高性能,一般使用标记-复制
- 老年代的设计目标是时间换空间或者追求高吞吐,如果是时间换空间的话就是使用标记-整理,追求高吞吐就是标记-清理
串行垃圾回收器:
- 新生代:Serial GC,采用标记-复制,单线程,执行的时候必需STW
- 老年代:Serial Old,采用标记-整理,单线程,执行的时候需要STW
单线程的好处就是没有切换开销,但是会比较慢
并行垃圾回收器:
- 新生代:
-
- ParNew其实就是Serial的多线程版本,在参数、回收算法上,和Serial是完全一样的,使用标记-复制 ,执行的时候需要STW,但是因为是多线程 版本,STW时长短
- Parallel Scavenge 和 ParNew 类似,同样是标记-复制 ,但是Parallel Scavenge更加关注吞吐量 ,追求缩短垃圾收集时间,适合跑一些后台任务
- 老年代:
-
- Parallel 是 Parallel Scavenge的老年代版本,同样是一个关注吞吐量 的并行垃圾收集器,他采用的是 标记-整理算法进行垃圾回收的。
并发垃圾回收器:
CMS是用于老年代 的,它是并发执行 的,采用的是标记-清除 算法,更加关注如何缩短STW时长问题
(¥)说一说JVM的并发回收和并行回收?
这里的并行和并发和操作系统层面的概念类似,但是不一样
并行回收:
方式:多个垃圾回收线程同时执行 ,执行时全程STW所有用户线程 ,主要追求高吞吐
例如:Parallel Scavenge,Parallel Old,ParNew
它们的设计目标就是追求最短的时间完成垃圾回收这一整个操作
适合场景:适合需要高吞吐量,但是能接受卡顿的场景
并发执行:
方式:执行的适合不会全程STW所有用户线程的执行,只在初始标记和重新标记时STW,相当于用户线程和垃圾回收线程同时或是交替执行,最大化缩短STW时长
例如:CMS、G1
它们的设计目标时为了优化用户体验,追求最低延迟完成垃圾回收操作
适合场景:对延迟要求极高,不希望让用户感受到明显的卡顿的系统
(¥)JDK11中新出的ZGC有什么特点?
**低停顿:**ZGC追求的是几乎0停顿,STW几乎是10毫秒以内的级别,而G1是追求STW可控
**高吞吐:**因为ZGC是一个并发的垃圾收集器,所以并不会全程阻塞用户线程的执行,并且由于极低的停顿时长,所以几乎不会影响到用户线程的业务执行
**兼容性:**对现有的Java应用同样兼容,且适配
**支持大堆:**能兼容MB到TB级别的内存
(¥)ZGC的内存模型是怎么样的?相较于G1有什么不同?为什么ZGC就适合大内存,而G1在内存较大的时候,性能就上不去?
内存模型:
ZGC直接抛弃了G1的分代和固定大小Region设计
里面同样是分成一个一个的Region(或者叫page)。
page 有三个维度
- 小页面:2MB,存储小对象
- 中页面:32MB,存储中等对象
- 大页面:N*2MB,存储超大对象(超过32MB)
此外使用着色指针替代RSet,就是说对象的GC状态不用记录到内存中,而是存储在指针空闲位,使用的时候直接读取即可全速完成对对象状态的判断
也就是
- 每个 Page 标记为 "可用 / 正在回收 / 已回收" 等状态,状态直接存在对象指针的空闲位(而非 Page 内部);
- 无需像 G1 那样维护 "新生代 / 老年代" 的逻辑划分,所有 Page 混在一起管理,不分代、无固定角色。
动态分配:ZGC会把一批page划分为分配区,新对象来的时候就是写入到分配区,用完就回收,避免分代带来的频繁YoungGC
为什么 ZGC 适合大内存,G1 大堆性能拉胯?
- G1的RSet的个数是和Region的数量对应的,所以当内存增加的时候,RSet的数量也急速上升,扫描RSet的时间也会急速上升;而ZGC使用空闲为记录着色指针,查询性能更好
- 分代设计会导致频繁的YoungGC;ZGC不分代,避免这个问题
- Region大小固定,没法很好的适配;page多粒度,能适配更多场景
(¥)介绍下CMS的垃圾回收过程?
CMS是一个并发垃圾回收处理器,用来处理老年代的。
它的设计目标就是缩短STW的时长。
它的处理过程主要是四个阶段:
初始标记:从GC roots 触发,标记所有直接可达对象,需要STW
并发标记:从灰色对象触发,标记整个对象图,初次遍历标记为灰色,遍历过的标记为黑色,无需STW
重新标记:修正并发标记过程中产生的更改,需要STW
并发清除:将不可达对象清除掉,无需STW
并发标记阶段如果出现新的对象加入,会记录引用,在重新标记阶段进行处理,避免误删
优点:
只有初始标记和重新标记两个阶段需要STW,所以整体的延迟是比较低的;此外就是这个过程,用户线程不会被全程卡住,可以提高并发性能
缺点:
CMS会出现浮动垃圾,在因为有些过程是允许用户线程和GC线程同时执行,会出现边清理边产生的情况
CMS的清理过程采用的清除算法是 标记-清除法,所以会造成内存碎片问题
此外就是多线程并发处理会比较的吃cpu的资源
因为需要遍历整个堆,所以只适合小堆的情况
(¥)介绍一下G1算法的原理?

其实G1打破了原本堆的一个布局,它将整个堆划分成一个个小的Region,每一个Region的大小是在(1M~32M)之间,没设置大小的话,默认按照堆内存除以2048计算
每个Region都是不同的角色,相当于物理上分散,但是逻辑上凑成了年轻代、老年代,并且引进了大对象区域(对象大小超过Region的一半)
G1 的回收分为 "YoungGC(新生代回收)" 和 "Mixed GC(混合回收,含新生代 + 部分老年代)",FullGC 仅作为兜底,频率极低。
- 新创建的对象会存储在Eden区。当G1判断年轻区不足(max默认60%),无法分配对象是会触发YoungGC
- 标记出Eden和Survivor区域中存活的对象
- 将这些存活对象复制到一个新的Survivor中
- 后续的YoungGC和之前是类似的,只不过是从Survivor到Survivor
- 当某个对象躲过15轮GC就被放入老年代
- 部分对象太大(超过Region的一半)也会放到老年代,不过是大对象区域
- 多次GC之后会出现对象很多占用在老年代区域,超过一定的阈值(45%)就会触发MixedGC,它是混合回收,会同时回收年轻代和部分老年代的区域,主要采用标记-复制算法实现
清理的时候如何高效的判断对象能都删除?(RS的作用)
那么现在堆被分成了很多个小的Region,在对某个Region做清理工作的时候,需要知道外部有没有Region指向了当前对象,所以每一个Region会维护一个**RememberSet(RS)**记录外部的哪个对象引用了当前Region中的哪个对象
如果没有RS,就需要扫描整个堆,大堆情况下直接卡死
总结一下RS的作用:
- 每个 Region 都维护一个 RS,记录 "哪些外部 Region 引用了我这个 Region 的对象";
- 回收时只查当前 Region 的 RS,不用扫全堆 → 快速定位引用关系,大幅缩短耗时。
回收策略是怎么样的?
G1 会计算每个 Region 的 "回收性价比":垃圾占比越高,性价比越高(比如一个 Region 里 90% 是垃圾,回收它能快速腾出大量空间)。
- 每次 GC 时,G1 会优先选 "性价比最高的一批 Region"(叫 CSet,收集集合)进行回收;
- 结合参数
-XX:MaxGCPauseMillis(默认 200ms),G1 会保证 "回收这批 Region 的总耗时不超过设定的停顿阈值" → 实现可预测的低延迟。
如何避免漏标现象?
主要是通过原始快照的思路,当在并发标记的过程中,如果出现用户线程对引用进行修改,就会记录下来,后续进行补偿,
具体的步骤就是当用户线程尝试移除灰色对象对白色对象的引用的时候会将这个白色对象记录下来,在重新标记的时候会直接将这个白色对象标记为存活
(¥)G1算法的FullGC是什么?什么时候会执行?
G1 的 Full GC 本质是单线程的 Serial Old 收集,扫描整个堆,会长时间的STW
它和 G1 正常的「Mixed GC」(混合回收年轻代 + 部分老年代)完全不同,Mixed GC 是并发 / 并行的,而 Full GC 是单线程、全堆扫描的 "兜底方案"。
触发FullGC的场景:
- 老年代资源耗尽,可以通过参数 -XX:InitiatingHeapOccupancyPercent 配置只剩下多少就会触发,默认45%,这个时候会触发并发标记老年代垃圾并准备Mixed GC,但是如果在并发标记的时候就彻底满了就会直接紧急进入FullGC
- 并发标记超时或是被中断,可能就是用户现成一直占用着cpu,导致老年代使用率持续增高,也会紧急触发FullGC
- 当然你可以手动调用System.gc()
总的来说,FullGC的触发条件是很苛刻的,要尽量避免
(¥)G1和CMS有什么区别?
G1算法和CMS算法都是使用三色标记法,所以前面的过程基本是一样的,主要的区别是在清理的过程,G1做了一些优化
|-------------|---------------------|---------------------|
| 特性 | CMS | G1 |
| 回收位置 | 老年代 | 整堆 |
| GC算法 | 标记-清除算法 | 标记-复制 标记-整理 |
| 垃圾识别算法 | 三色标记法------增量更新解决漏标 | 三色标记法------原始快照解决漏标 |
| 碎片产生 | 存在内存碎片 | 可防止内存碎片产生 |
| 可预测性 | 无法预测 | G1的STW时长可预测 |
| 堆内存基本要求 | 一般要求不高 | 4G以上 |
| 自适应调优 | 不支持 | 支持 |
主要的几点区别就是,作用范围G1是整个堆的(包括年轻代和老年代)
CMS的清除步骤用的算法是标记-清除 ,G1为了避免内存碎片,改成了使用标记-复制和标记-整理的 混合算法,那同时也要求G1需要的运行内存会高一点
整体上看:标记-整理
局部上看:标记-复制
(¥)为什么G1从JDK9之后成为默认的垃圾回收器?
jdk9以前默认的 垃圾回收器 是 Parallel GC(吞吐量优先)+ CMS(低延迟备选)
并发回收:(极短的STW且可控,同时支持GC线程和用户线程一起执行,避免卡顿)
- Parallel GC 虽然是并行的,在多核cpu下速度快,但是因为是全程STW,会造成很明显的线程卡顿现象
- 而CMS虽然和G1一样并发,支持用户线程和GC线程一起跑,但是因为CMS的STW不可控,而G1提供了参数可以配置,可控性更好
适用整个堆:(一个GC搞定整个堆,不用搭配其它的垃圾收集器)
- 之前的做法是年轻代使用ParNew,老年代使用CMS,但是两个GC结合工作的时候任意出现兼容性问题,现在用G1就能搞定一整个,更加的优雅
避免内存碎片:
- 之前的CMS因为清理算法使用的是标记-清除 ,这种算法虽然快,但是会造成大量的内存碎片,而G1算法直接重新定义了堆的结构,将堆划分成很多个Region,不同的Region角色不同,并且清除的时候采用标记-复制或是标记-整理,避免内存碎片
停顿时间更可控:
- G1 支持通过
-XX:MaxGCPauseMillis指定 "单次 GC 最多卡多久",然后G1算法清除的时候会根据这个参数去选出最具性价比的删除方案
适配大内存情景:
- CMS因为需要扫描整个对象区,所以只适合小堆的情况,而G1因为每次只扫描垃圾对象占比最多的那些Region,所以性能会更高
参数多:
- G1因为算是支持参数最多的一个垃圾回收器,能适配我们各种动态调整的需求
(¥)Java8和Java11的GC有什么区别?
- Java 8 中默认的Parallel Scavenge GC+Parallel Old GC的 ,分别用来做新生代和老年代的垃圾回收。而在Java 11中默认采用的是G1进行整堆回收的
- 另外,Java 11中还新增了一种垃圾收集器,那就是ZGC,他可以在保证高吞吐量的同时保证最短的暂停时间。
- Java 8的是可达性分析,而Java 11中的G1采用的是三色标记法,可以大大降低STW的时长。
- G1的内存划分是自适应的,它会根据堆的大小和使用情况来动态调整各个区域的大小和比例。而Parallel Scavenge GC+Parallel Old GC都是固定分配的策略。
(¥)虚拟机中的堆一定是线程共享的吗?
不一定。
JVM为了兼顾线程安全以及对象创建效率两个因素,会给每个线程分配一个TLAB,就是一个缓冲区
TLAB是在堆上的
TLAB虽然是线程私有,但是其它线程同样可以访问到这个区域,但是不能在上面创建对象
所以,读取这方面,堆内存确实是线程共享的,但是创建对象的话,不是所有的堆内存都线程共享,所以不一定
TLAB的区域很小,创建对象的时候会尝试先在TLAB进行创建,如果对象太多或是现有空间已经很小了,就会直接在堆上的公共区域创建,但是这个TLAB的内存就相当于浪费掉了
(¥)一个对象的结构是什么样的?
对象头+实例数据+对齐填充
主要结构:
- 对象头包含:
-
- Mark World :存储锁状态、GC分代年龄、哈希码
- 类元数据指针:指向方法区中当前对象所属类的 instanceKlass 对象(instanceKlass 就是描述当前类的元数据)【说人话就是指向一个标识当前对象属于什么类的指针】
- 实例数据:对象的成员变量(非静态)
- 对齐填充:Java要保证对象大小为8的倍数,这样能提高效率,所以为了确保一定是8,会填充到够位置(无实际含义)
(¥)有哪些常用的JVM启动参数?
- 堆设置:
-
- -Xms:设置堆的初始大小。
- -Xmx:设置堆的最大大小。
- 栈设置:
-
- -Xss:设置每个线程的栈大小。
- 垃圾回收器设置:
-
- -XX:+UseG1GC:使用 G1 垃圾回收器。
- -XX:+UseParallelGC:使用并行垃圾回收器。
- 性能调优:
-
- -XX:PermSize 和 -XX:MaxPermSize:在 Java 8 之前设置永久代的初始大小和最大大小。
- -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize:在 Java 8 及以上版本设置 Metaspace 的初始大小和最大大小。
- -XX:+PrintGCDetails:打印垃圾回收的详细信息。
- 调试和分析:
-
- -verbose:gc:输出垃圾回收的详细信息。
- -XX:+HeapDumpOnOutOfMemoryError:在内存溢出时生成堆转储。