第二章:垃圾回收
:::info
什么是垃圾回收
:::
垃圾回收是一种自动管理内存的机制,用于检测和释放程序中不再被引用的内存对象,以避免内存泄漏和提高程序性能。
- 对象的生命周期:在程序中,对象在被创建时分配内存空间,然后在不再被引用时,这部分内存应该被释放。对象的生命周期包括创建、使用和不再被引用的阶段。
- 引用:引用是指对象的访问或指向对象的指针。当没有引用指向一个对象时,该对象就成为垃圾。
- 垃圾回收器:垃圾回收器时负责检测和回收不再被引用的内存对象的组件。它周期性地运行,找到并释放那些不再被引用的对象所占用的内存空间。
- 垃圾回收算法:垃圾回收器可以使用不同的垃圾回收算法来确定哪些对象是可达的(仍然被引用),哪些是不可达的(成为垃圾)。常见的回收算法包括标记-清楚算法、复制算法、标记-清理算法等。
垃圾回收的目标是优化内存使用,减少内存泄露的可能性,提高程序的性能和稳定性。开发人员通常无需手动释放对象,而是依赖于垃圾回收器来管理内存。这使得开发更加方便,同时避免了一些与手动内存管理相关的常见错误。
一.如何判断对象可以回收
1.1 引用计数法
引用计数法是一种垃圾回收算法,主要思想就是为每个对象维护一个引用计数,每当有一个引用指向对象时,计数加一,当引用失效时,计数减一,当计数为零时,表示对象不再被引用,可以被回收。
但是,引用计数法有一些问题,其中最主要的问题就是无法解决循环引用的情况。比如A对象引用B对象,B对象又引用A对象,形成了一个环,这就是循环引用。即使这组对象不再被其他对象引用,它们之间的引用计数也永远不会变为零,导致这组对象无法被垃圾回收。
由于Java中存在复杂的数据结构和对象引用关系,引用计数法无法很好地处理这些情况。因此,Java使用可达性分析算法。
1.2 可达性分析算法
这个算法的核心思想是通过根集合(包括类变量、本地变量等)作为起点,通过一系列的引用关系追踪对象的可达性,确定哪些对象是可达的,哪些是不可达的,然后回收不可达对象。
基本原理和步骤:
- 根集合:可达性分析的起点是一组称为根集合的引用对象,包括类变量(static变量)、本地变量以及一些特殊的引用。这些对象是程序执行的起点,它们始终被认为是可达的。
- 跟踪引用关系:从根集合开始,通过引用关系逐步跟踪对象的引用,形成一个引用链。如果一个对象可以通过一系列的引用关系连接到跟集合中的任何一个对象,那么这个对象就是可达的。
- 标记阶段:在追踪引用关系的过程中,标记所有被访问到的对象为"活跃"或"可达"。这个阶段确保只有可达的对象被保留,而不可达的对象被标记为"非活跃"或"不可达"。
- 清除阶段:在标记阶段结束后,清除所有未被标记为"活跃"的对 象,即不可达对象。这些不可达对象的内存空间将被释放,成为可用于存储新对象的空间。
这就好像是BFS算法,只要是从根节点搜索到的都保留,没搜索到的都舍去。
为啥解决了循环引用问题呢?
一组对象之间是循环引用,那么只要外界没有对象引用它们其中任意一个,那么这组对象就永远不会被跟踪到,那么就会被认为是不可达的,在清除阶段就会被回收。
1.3 四种引用
:::info
强引用
:::
强引用时最常见、最普遍的引用类型,当一个对象具有强引用时,垃圾回收器不会回收这个对象,即使系统中内存不足。只有当不再有任何强引用指向对象时,才有可能被垃圾回收器回收。
举个例子:
public class StrongReferenceExample {
public static void main(String[] args) {
// 创建一个对象并赋予强引用
Object obj = new Object();
// obj 强引用仍然存在,对象不会被垃圾回收
System.out.println("Object is still reachable.");
// 将 obj 引用置为 null,此时没有强引用指向对象
obj = null;
// 此时对象变为不可达,但不一定会立即被回收
// 垃圾回收器会在适当的时候回收不可达的对象
// 一旦垃圾回收器决定回收对象,它的 finalize() 方法(如果有)将被调用
// 最终,对象的内存将被释放
System.gc(); // 不建议显式调用 System.gc(),这里仅作示例
}
}
在这个示例中,obj是一个强引用,它持有对一个Object对象的引用。只有当obj不再引用对象时,该对象才变为不可达,从而变得可以被垃圾回收。强引用通常在程序中广泛使用,因为它们提供了最直观的对象引用方式,但也需要开发者注意确保及时释放不再需要的对象引用,以便让垃圾回收器更好地管理内存。
:::info
软引用
:::
软引用用于描述有用但非必须的对象。在系统将要发生内存溢出之前,会尽可能地保留软引用指向的对象。如果垃圾回收器确定内存不足,就会回收这些对象。使用SoftReference
类创建软引用。
使用软引用的主要目的是允许在内存不足时释放一些可有可无的对象,从而避免内存溢出。软引用通常用于实现缓存、允许在系统内存不足时自动释放不必要的缓存对象。
import java.lang.ref.SoftReference;
public class SoftReferenceExample {
public static void main(String[] args) {
// 创建一个对象并赋予软引用
SoftReference<Object> softRef = new SoftReference<>(new Object());
// 获取软引用指向的对象,obj现在是强引用
Object obj = softRef.get();
// obj 引用仍然存在,对象不会被垃圾回收
System.out.println("Object is still reachable.");
// 将 obj 引用置为 null,此时没有强引用指向对象
// 当系统内存不足时就会释放掉软引用的对象
obj = null;
// 此时对象变为不可达,但不一定会立即被回收
// 垃圾回收器会在适当的时候回收不可达的对象
System.gc(); // 不建议显式调用 System.gc(),这里仅作示例
// 获取软引用指向的对象
Object objAfterGC = softRef.get();
// 如果对象被回收,objAfterGC 将为 null
if (objAfterGC == null) {
System.out.println("Object has been garbage collected.");
} else {
System.out.println("Object is still reachable after garbage collection.");
}
}
}
举个例子:
我们在读取一些文件时,文件大小可能会非常大,而我们如果使用强引用就会导致内存溢出。比如下面代码,将每一个new出来的对象加入到list集合中,这样每一个对象都是强引用,导致不能被垃圾回收。
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
我们可以使用软引用,当内存不足时把那些对象给回收掉。
public static void soft() {
// list --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
我们只是清除掉了软引用指向的对象,软引用本身还没有处理。
我们还需要使用引用队列来回收软引用对象。将软引用对象关联引用队列,当软引用的引用对象被回收时,就会将软引用对象加入到引用队列中,我们在把这些软引用对象删除。
/**
* 演示软引用, 配合引用队列
*/
public class Demo2_4 {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i++) {
// 关联了引用队列, 当软引用所关联的 byte[]被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while( poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}
}
}
:::info
弱引用
:::
弱引用是一种比软引用更弱的引用类型。与软引用一样,当一个对象只被弱引用指向时,它在垃圾回收时可能会被回收。即使系统内存充足,只要没有强引用指向对象,垃圾回收器就有可能回收被弱引用指向的对象。
import java.lang.ref.WeakReference;
public class WeakReferenceExample {
public static void main(String[] args) {
// 创建一个对象并赋予弱引用
Object object = new Object();
WeakReference<Object> weakRef = new WeakReference<>(object);
// 获取弱引用指向的对象
Object referencedObject = weakRef.get();
// 输出对象是否还存在
System.out.println("Object exists: " + (referencedObject != null));
// 解除强引用
object = null;
// 弱引用指向的对象可能在下一次垃圾回收时被回收
System.gc(); // 不建议显式调用 System.gc(),这里仅作示例
// 获取弱引用指向的对象
referencedObject = weakRef.get();
// 输出对象是否还存在
System.out.println("Object exists after garbage collection: " + (referencedObject != null));
}
}
:::info
虚引用
:::
虚引用是最弱的一种引用关系,几乎没有直接的作用。虚引用的存在主要是为了在对象被垃圾回收时收到系统通知。如果一个对象经持有虚引用,那么他就和没有引用一样,随时可能会被垃圾回收。
虚引用通常用于监控对象被垃圾回收的时机,以便在对象被回收之前执行一些清理或资源释放的操作。虚引用是通过 PhantomReference 类实现的。
以下是虚引用的主要特性和使用方法:
- 不影响对象生命周期: 虚引用并不会影响对象的生命周期。即使一个对象只被虚引用引用,垃圾回收时也会回收该对象。
- 系统通知: 当对象被垃圾回收时,与之关联的虚引用会被放入一个关联的引用队列(ReferenceQueue)中,开发者可以通过监控引用队列来获取虚引用并执行一些清理操作。
import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
public class PhantomReferenceExample {
public static void main(String[] args) {
Object object = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomReference = new PhantomReference<>(object, referenceQueue);
// 解除强引用
object = null;
// 虚引用被创建后,将会被立即加入到 referenceQueue 中
Reference<? extends Object> reference = referenceQueue.poll();
if (reference != null) {
System.out.println("PhantomReference enqueued.");
}
// 垃圾回收器运行时,虚引用将被回收并加入 referenceQueue 中
System.gc(); // 不建议显式调用 System.gc(),这里仅作示例
// 从 referenceQueue 中获取被回收的虚引用
Reference<? extends Object> clearedReference = referenceQueue.poll();
// 如果 clearedReference 不为 null,则表示虚引用已经被垃圾回收
if (clearedReference != null) {
System.out.println("PhantomReference has been cleared.");
} else {
System.out.println("PhantomReference is still reachable.");
}
}
}
直接学的直接内存回收使用的就是虚引用,当ByteBuffer对象被回收时,引用队列就会得到通知然后通过Unsafe类释放内存。
:::info
终结器引用
:::
终结器引用指的是对象的中介方法即finalize()
方法被调用的引用。在Java中,每个对象都可以有一个与之相关联的终结器方法,这个方法会在对象被垃圾回收之前被调用。
当垃圾回收器准备回收一个对象时,首先会调用该对象的 finalize() 方法,如果该对象的 finalize() 方法内部没有重新引用对象(通过将自身赋值给某个类变量或实例变量),那么这个对象会被认为是可回收的。
public class FinalizerReferenceExample {
// 一个类带有终结器方法 finalize()
static class MyObjectWithFinalizer {
@Override
protected void finalize() throws Throwable {
System.out.println("Finalize method called.");
}
}
public static void main(String[] args) {
// 创建一个带有终结器方法的对象
MyObjectWithFinalizer myObject = new MyObjectWithFinalizer();
// 将对象设置为 null,让它变为不可达
myObject = null;
// 强制调用垃圾回收
System.gc(); // 不建议显式调用 System.gc(),这里仅作示例
// 等待一段时间以确保 finalize 方法执行
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
二、垃圾回收算法
2.1 标记-清除算法
标记-清除算法:是一种最基本的垃圾回收算法。用于在程序中找到不再被引用的对象并释放其占用的内存。
- 标记阶段:垃圾回收器会从根节点出发,遍历可达的对象,并在这些对象上打上标记,表示它们是活跃的对象。
- 清除阶段:垃圾回收器会便利整个堆,将没有被标记的对象认定为垃圾,然后将其回收,释放内存,以便为后续的对象分配提供足够的空间。但是清理后的内存区域可能会出现不连续的碎片。
优点和缺点:
- 优点:相对简单,实现较为直观,不需要格外的空间用于存储对象的引用关系
- 缺点
- 产生内存碎片:清除阶段可能会导致内存碎片,影响后续对象的分配。
它的清除是直接记录下来被清除对象的地址和大小,这些回收的内存块都是断断续续的,如果有更大的对象要进内存的话很可能会找不到合适大小的内存块。
2.2 标记整理算法
它比标记清除算法多了一个操作,就是标记完可达对象后,将所有可达对象向一端移动,然后清理掉不可达对象,从而在另一端形成连续的内存块可以使用。解决了内存碎片的问题。
- 优点:解决内存碎片问题,提高内存利用率
- 缺点:整理阶段开销更大,改变了对象的位置,需要多做一些工作
2.3 复制算法
复制算法将堆内存分为两个区域,通常称为From区和To区。在垃圾回收过程中,所有存活的对象会从From空间被复制到To空间,然后清理From空间,最终实现内存的紧凑排列。
主要步骤:
- 标记阶段:从根节点出发,标记所有可达的对象。
- 复制阶段:将所有标记为存活的对象从From空间复制到To空间。在复制过程中,对象在内存中的相对位置可能会发生改变,但是对象之间的相对关系保持不变。
- 切换空间:将From和To空间的角色进行切换。
- 清理空间:清理之前From空间中的所有对象,这样From空间就成了一个新的可用空间,等待下一次的垃圾回收。
- 优点:
- 内存紧凑:复制算法通过将存活对象复制到新的空间,避免了内存碎片的产生,保证了内存的紧凑排列。
- 缺点:
- 内存利用率降低:这属于典型的空间换时间,堆内存的一半空间不可使用。
三、分代垃圾回收
分代垃圾回收是一种垃圾回收策略,根据对象的生命周期将堆内存划分为不同的代,不同代采用不同的垃圾回收算法。通常分为三代:新生代、老年代、永久代/元空间。
- 新生代:大多数对象在创建后很快就不在被引用。因此将新创建的对象划分到新生代,采用一种高效的垃圾回收算法,通常是复制算法。这有助于在新生代实现垃圾回收,同时减少存活对象的复制成本。
- 老年代:存活时间较长的对象被划分到老年代。由于老年代的对象存活时间较长,采用标记-整理算法,以及一些优化的算法,来减小老年代的垃圾回收开销。
- 永久代/元空间:存放类的元数据信息,例如类的结构、方法等。元空间没有垃圾回收机制,只会堆元数据进行动态分配管理。
优点:
- 针对对象生命周期:分代垃圾回收根据对象的生命周期采用不同的垃圾回收算法,使得垃圾回收更加针对性和高效。
- 减少停顿时间:针对新生代的短生命周期对象采用复制算法,能够快速回收垃圾,减少垃圾回收的停顿时间。
- 降低老年代垃圾回收开销:由于老年代的对象存活时间较长,采用标记-整理算法等更为复杂但相对较低开销的算法。
3.1 新生代的垃圾回收过程
新手代的垃圾回收主要采用复制算法,这是因为新生代的对象生命周期较短,大部分对象在创建后很快就不再被引用。
- 对象的创建:当新的对象被创建时,它们被分配在新生代的Eden区。
- Eden区垃圾回收(Minor GC):当Eden区满时,会触发一次新生代的垃圾回收,也称为Minor GC。在这个过程中,垃圾回收器会标记并复制所有存活的对象到另一个幸存区To中。未被标记的对象被认为是垃圾,会被清理。
- 幸存区的使用:幸存区分为From区和To区。在一次 Minor GC 后,存活的对象会被复制到 To 区。在下一次 Minor GC 时,From 区和 To 区的角色会交换。这样反复进行,使得新生代的垃圾回收可以重复利用两个幸存区,减少内存复制的成本。
- 晋升:对象在 Eden 区经历一次 Minor GC 后仍然存活的,会被移动到幸存区。如果对象在幸存区经历了一定次数的 Minor GC 仍然存活,它将会被晋升到老年代。因为老年代的对象生命周期比较长,适合存放长时间存活的对象。
3.2 老年代的垃圾回收过程
Full GC 是一种全堆垃圾回收,它不仅包括老年代的回收,还会对新生代进行回收。 Full GC 通常是一种比较耗时的操作,因为它需要扫描整个堆内存,并清理所有的存活对象,包括新生代和老年代。
老年代的垃圾回收通常使用的是标记清理算法或者标记整理算法。
Full GC 通常发生在一下情况之一:
- 老年代空间不足:当老年代的空间不足时,触发 Full GC 以清理老年代的垃圾。这通常发生在老年代的对象无法晋升到老年代,导致老年代空间被占满。
- 手动触发:在某些情况下,开发者可以通过代码手动触发 Full GC。这通常是为了在系统空闲时进行一次完整的垃圾回收,以确保系统中的垃圾被彻底清理。
3.3 Stop the world
“Stop the world” 是指垃圾回收过程中,应用程序的所有线程都被暂停,即停止运行,一边垃圾回收器可以安全地执行工作而不受应用程序地干扰。还有就是垃圾回收过程中对象地内存地址会发生变化,如果程序继续执行,可能会报错。 我们之前所说的停顿时间就是只指应用程序被暂停执行的时间。
在Java中,垃圾回收过程中的停顿时间是由于某些垃圾回收算法需要在整个堆内存上执行操作,而这个操作需要停止所有应用程序线程。主要的停顿时间发生在老年代的垃圾回收,特别是在进行Full GC
时。
停顿时间会影响应用程序的性能和响应时间,尤其是对于对实时性要求高的应用。
3.4 演示垃圾回收
/**
* 演示内存的分配策略
*/
public class Demo2_1 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
public static void main(String[] args) throws InterruptedException {
}
}
我们设置内存大小为20M,新生代大小为10M
Heap
def new generation total 9216K, used 2228K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 27% used [0x00000000fec00000, 0x00000000fee2d0f8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3319K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 361K, capacity 388K, committed 512K, reserved 1048576K
此时Eden区已经不到7M了,我们现在添加一个7M的对象到堆中,肯定会触发垃圾回收。
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
然后我们看打印信息:
出发了垃圾回收,新手代中的一些对象被回收了,有些对象被加入到From区了,7M对象被加入到了Eden区。
[GC (Allocation Failure) [DefNew: 2064K->692K(9216K), 0.0008704 secs] 2064K->692K(19456K), 0.0009013 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 8106K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 90% used [0x00000000fec00000, 0x00000000ff33d8c0, 0x00000000ff400000)
from space 1024K, 67% used [0x00000000ff500000, 0x00000000ff5ad330, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3320K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 361K, capacity 388K, committed 512K, reserved 1048576K
当添加的对象为8M时,会直接加入到老年代中,因为新生代的Eden区只有8M,但是新添加的对象比8M多,新生代就放不下了,所以直接放到老年代中。
Heap
def new generation total 9216K, used 2228K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 27% used [0x00000000fec00000, 0x00000000fee2d0f8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 80% used [0x00000000ff600000, 0x00000000ffe00010, 0x00000000ffe00200, 0x0000000100000000)
Metaspace used 3319K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 361K, capacity 388K, committed 512K, reserved 1048576K
当我们在添加一个8M的对象时,我们发现都放不下了了,就会出现堆内存溢出。
[GC (Allocation Failure) [DefNew: 2064K->692K(9216K), 0.0015750 secs][Tenured: 8192K->8883K(10240K), 0.0015420 secs] 10256K->8883K(19456K), [Metaspace: 3313K->3313K(1056768K)], 0.0031908 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 8883K->8866K(10240K), 0.0017135 secs] 8883K->8866K(19456K), [Metaspace: 3313K->3313K(1056768K)], 0.0017266 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 246K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 3% used [0x00000000fec00000, 0x00000000fec3d890, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 8866K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 86% used [0x00000000ff600000, 0x00000000ffea8938, 0x00000000ffea8a00, 0x0000000100000000)
Metaspace used 3345K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 364K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at cn.itcast.jvm.t2.Demo2_1.main(Demo2_1.java:27)
:::info
线程出现堆内存溢出会导致整个线程结束吗
:::
答案是不会
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();
System.out.println("sleep....");
Thread.sleep(1000000L);
}
四、垃圾回收器
4.1 分类标准
- 串行
- 单线程
- 堆内存较小,适合单核处理器或小型应用。
- 吞吐量优先
- 多线程
- 适合堆内存较大,多核处理器
- 让单位时间内,STW的时间最短, 0.2 0.2
- 响应时间优先
- 多线程
- 适合堆内存较大、多核处理器
- 尽可能让单次STW的时间最短 0.1 0.1 0.1 0.1 0.1
4.2 Serial 收集器
Serial(串行) 收集器是一个单线程收集器,它的单线程不仅仅意味着它只会使用一条垃圾回收线程去完成垃圾回收工作,而且它在进行垃圾回收的时候必须暂停其他工作线程(STW),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法
4.3 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,其他完全一样。
4.4 Parallel Scavenge 收集器
Parallel Scavenge 收集器 关注点是吞吐量(高效率的利用CPU),所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。
-XX:+UseParallelGC
使用 Parallel 收集器+ 老年代串行
-XX:+UseParallelOldGC
使用 Parallel 收集器+ 老年代并行
新生代采用标记-复制算法,老年代采用标记-整理算法
JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old
4.5 CMS回收器
CMS收集器重点关注响应时间。主要目标是减少应用程序的停顿时间,并实现了让垃圾回收线程和用户线程同时工作。对新生代和老生代使用的都是 标记-清除算法。
步骤:
- 初始标记:暂停所用的其他线程,并只标记与 GCRoots 直接相连的对象,并不需要扫描整个引用链,因此速度很快。(无STW)
- 并发标记:同时开启垃圾回收线程和用户线程,然后对初始标记阶段标记的对象进行整个引用链的扫描(STW)
- 因为是并发的,可以降低垃圾回收的时间,从而降低系统响应时间。这也是 CMS 能极大降低 GC 停顿时间的核心原因,但也带来了一些问题:并发标记的时候,引用可能发生变化 ,因此可能会发生漏标或者多标。
- 重新标记:指的是对并发标记阶段出现的问题进行纠正(STW)
- 并发标记阶段可能会出现漏标或者多标的问题,所以需要花一些时间解决这个问题。
- 并发清除:将标记为垃圾的对象清除(无STW)
- 这个阶段,垃圾回收线程和用户线程可以并发执行,因为并不影响用户的响应时间。
从上面的步骤我们可以看出: CMS 之所以能极大地降低 GC 停顿时间,本质上是将原本冗长的引用链扫描进行切分。通过 GC 线程与用户线程并发执行,加上重新标记校正的方式,减少了垃圾回收的时间。
缺点:
- 对CPU消耗资源较大:在并发标记和并发清楚阶段需要开启多个线程,所以需要占用CPU资源。
- 无法处理浮动垃圾:
- 浮动垃圾是指在并发标记和清理过程中产生的垃圾,在标记阶段,可能会创建新的对象,而新的对象不会再标记阶段被标记,因为会被认为是活跃的对象。
- 在清理阶段,也可能会创建新的对象,也不会被回收。
- CMS 有重新标记可以阶段并发标记阶段出现的新对象,但无法解决清理阶段的新对象。
- 产生空间碎片:标记-清除算法会导致产生空间碎片。
4.6 G1回收器
G1回收器是一种面向服务端应用的回收器,适用于需要较短停顿时间和高吞吐量的场景。
- G1回收注重吞吐量和低延迟
- 超大堆内存,将堆划分为多个大小相等的区域
- 整体上是标记-整理算法,两个区域之间是复制算法
G1 对于 Java 堆的划分
G1回收器对于Java堆的划分不同于以往,之前都会划分为:新生代和老年代,新生代又划分为Eden区和Survivor区,Survivor区又分为from去和to区。
但是现在,G1不在坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆空间划分为多个大小相等的独立区域(Region),每个Region都可以称为 Eden空间 ,Survivor空间,老年代空间。
这种思想上的转变和设计,使得G1可以面向堆内存任何部分来组成回首集来进行回收,衡量标准不再是它属于哪个代,二十哪块内存存放的垃圾最多,回收收益最大,这就是G1收集器的Mixed GC模式。
Region还有一类特殊的 Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果是那些超过了整个Region容量的超大对象,将会放在连续 N 个 Humongous Region区域。
:::info
G1 回收器垃圾回收过程
:::
G1 GC 的垃圾回收过程主要包括以下三个环节:
- 新生代GC(Young GC)
- 新生代GC + 老年代并发标记过程
- 混合回收
(如果需要,单线程 、独占式、高强度的 Full GC 还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收)
顺时针young gc -> young gc + concurrent mark-> Mixed GC顺序,进行垃圾回收
- 应用程序分配内存,当新手代的Eden区用尽时开始新生代回收过程;G1 的新生代回收阶段是一个并行(多个垃圾回收线程)的独占式回收器。在新生代回收器,G1 GC暂停所有应用程序线程,启动多线程执行新手代回收。然后从新生代区间移动存活对象到幸存区或者老年代区,也有可能两个区域都会设计。这个阶段执行的是复制算法。
- 当堆内存使用达到一定值*默认45%)时,来时执行老年代并发标记过程。
- 标记完成后马上开始混合回收过程,混合回收先执行重新标记,解决并发标记过程中出现的错误标记问题。然后对新生代、幸存区、老年代进行全面回收,新生代移动到幸存区或者老年,老年代区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。而且,G1的老年代回收器不需要整个老年代都被回收,只需要回收一部分老年代的区间即可,因为需要控制停顿时间,不能太长。
:::info
记忆集和写屏障
:::
一个对象被不同区域引用的问题:
- 一个Region不可能是孤立的,一个Region中的对象可能被任意Region中的对象引用。如新生代引用了老年代,如果新手代垃圾回收,那么也会扫描老年代。
- 判断对象存活时,是否需要扫描整个Java堆才能保证准确?
- 在其他的分代回收器,也存在这样的问题(G1更突出)
- 回收新手代也不得不同时扫描老年代?(降低回收效率)
解决办法
- 不管是G1 还是其他分代收集器,JVM都是使用 记忆集(Remembered Set)来避免全局扫描。
- 每个Region都有一个对应的记忆集
- 每次Reference类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时去终止操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)
- 如果不同,通过 卡表(Card Table)把相关引用信息记录到引用指向对象
:::info
卡表
:::
卡表是在分代垃圾回收算法中用于跟踪对象引用裱花的数据结构之一,其中包括G1回收器。卡表主要用于优化老年代与新生代之间的引用关系的追踪。
基本原理如下:
- 划分堆内存:G1 垃圾回收器将堆内存划分为多个固定大小的区域,称为卡,每个卡通常对应着一小块内存,其大小通常是 512 字节。
- 记录引用变化:当新生代对象发生写操作时,G1 写屏障会记录相应卡的状态,以表示卡中的引用关系发生了变化。
- 提高并发标记效率:在并发标记阶段,垃圾回收器只需要扫描被标记的卡,而不是整个老年代。这样可以大大减小扫描的范围,提高并发标记的效率。
:::info
为啥要扫描老年代,而不是根据引用地址直接去找
:::
在垃圾回收的过程中,为了确保对象的存活状态,垃圾回收需要便利并标记所有存活的对象。直接根据引用地址去找对象的存活状态是可能的,但这种方式通常会导致一些问题,特别是在并发场景下。
在并发垃圾回收过程中,应用程序可能在垃圾回收的同时进行,如果直接根据引用地址去找对象,可能会出现并发修改对象状态的问题。通过扫描整个区域,垃圾回收器可以在不干扰应用程序执行的情况下获取一致的快照。
:::info
记忆集为啥记录老年代指向新手代的对象的引用,而不是新生代指向老年代
:::
在分代垃圾回收中,新手代的对象比老年代的对象更容易被回收。如果在对新生代执行垃圾回收时,我们还需要去老年代执行扫描,看有没有老年代的对象引用了新生代对象,给新生代对象标记为存活。所以说,为了提高效率,不让每次对新生代回收时都要扫描老年代,我们可以使用记忆集来记录哪些老年代对于引用了新生代对象。这样就可以不用去扫描老年代,而直接去扫描记忆集,从而提高了垃圾回收的效率。
:::info
Remark
:::
三色标记法:
- 黑色:该对象已经被标记过了,且该对象下的属性也全都被标记过了(程序所需要的对象)
- 灰色:该对象已经被标记过了,但该对象下的属性还没有全被标记过(GC需要从此对象中去寻找垃圾)
- 白色:该对象没有被标记过
:::info
写屏障
:::
G1垃圾回收器在其并发标记阶段使用了一种称为卡表的数据结构来实现写屏障。G1的写屏障的作用实在对象引用发生写操作时,通知垃圾回收器有关对象引用的变化,一边保持垃圾回收器的内部数据结构的一致性。
分为两种情况:
- 普通对象引用的写入:当普通对象引用发生写入时,G1垃圾回收器使用卡表来追踪引用的变化。卡表是一个字节数组,每个字节对应一小块内存(称为卡),卡的大小通常是512/写屏障将更新卡表中对应卡的标记,已通知垃圾回收器这个卡中的引用发生了变化。这使得在并发标记过程中,垃圾回收器只需扫描标记过的卡,而不是扫描整个堆。
- 引用类型为指针的写入:对于引用类型为指针的写入,G1 垃圾回收器使用了一种称为“SATB”的写屏障。在这种情况下,写屏障将在写操作时记录对象引用的状态,并在下一次垃圾回收时,将这些记录的引用用于确保一致的标记。这有助于处理跨代的引用关系,以确保不会漏掉任何引用。
:::info
普通引用对象的写入和引用类型为指针的写入的区别是什么?
:::
目前还不知道
:::info
G1 回收器有哪些优化?
:::
字符串去重:
- 优点:节省大量内存
- 缺点:略微多占用了 CPU 时间,新生代回收时间略微增加
String s1 = new String("hello"); char[]{'h','e','l','l','o'}
String s2 = new String("hello"); char[]{'h','e','l','l','o'}
垃圾回收器会将所有新分配的字符串放入一个队列中,当新生代回收时,G1 并发检查是否有字符串重复,如果它们值一样,让它们引用同一个 char[],这样另一个就可以被回收了。
注意,与 String.intern() 不一样
- String.intern() 关注的是字符串对象,而字符串去重关注的是 char[]
- 在 JVM 内部,使用了不同的字符串表。
并发标记类卸载:
所有对象都经过并发标记后,就能知道那些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。
五、垃圾回收调优
垃圾回收调优是一个重要的人物,它可以对应用程序的性能和内存利用率产生显著影响。在Java 中,有多种垃圾回收器和调优选项可供选择。以下是一些常见的 GC 调优策略。
- 选择合适的垃圾回收器:Java 虚拟机提供了不同类型的垃圾回收及,如 Serial、Parallel、CMS、G1 等。选择适合应用场景的垃圾回收器是调优的第一步。
- 调整堆大小:通过-Xms 和 -Xmx 选项可以设置 Java 堆的初始花销和最大大小。合理调整堆大小有助于避免内存不足或浪费,提高垃圾回收的效率。
- 选择合适的垃圾回收策略:对于长时间运行的应用程序,选择更适合并发执行的垃圾回收策略(如 CMS 或 G1)可能会更好。对于短暂的命令行应用程序,Serial 回收器可能是一个合适的选择。
- 设置垃圾回收器的参数:针对选定的垃圾回收器,可以通过调整相应的参数进行优化。例如,对于 G1 回收其,可以通过过设置区域大小或者 GC 停顿时间等参数进行调优。
- 监控和分析 GC 日志:启用 GC 日志,并使用工具分析 GC 日志,以了解 GC 行为和性能瓶颈。
- 使用内存分析工具:使用内存分析工具(如 Eclipse Memory Analyzer)检查内存泄漏和不必要的对象引用,优化内存使用。
- 减少全局暂停时间:通过调整 GC 策略或参数,尽量减小垃圾回收导致的全局暂停时间,提高应用程序的响应性。
- 适当的并发设置:对于某些垃圾回收器,可以通过调整相关的并发参数来改善垃圾回收的性能。
- 实施分代策略:利用分代回收的优势,将新生代和老年代分别进行调优,根据应用的特征选择合适的 GC 策略和参数。
:::info
最快的 GC 是不发生 GC
:::
查看 FullGC 前后的内存占用,考虑下面几个问题。
- 数据是不是太多?
- resultSet = statement.executeQuery(“select * from 大表 limit n”) 如果直接查询一个数据量很大的表到 Java 中,会导致内存泄漏
- 数据表示是否太臃肿?
- 查询的很多数据有很多是用不大的,可以只筛选需要用到的数据到内存中
- 是否存在内存泄漏?
- 尽量不适用 Java 中的容器做缓存,可以使用第三方(比如 Redis)
- 也可以使用软引用或者弱引用来适当的删除内存数据。
新生代是 Java 堆中的一部分,主要用于存放刚刚被创建的对象。
特点如下:
- 短命对象:新生代中的对象通常具有短暂的生命周期,着是因为大多数情况下,刚被创建的对象很快就会变成垃圾,只有少数对象会存活更长时间。
- 频繁的垃圾回收:由于新生代的对象生命周期较短,垃圾回收器会频繁地对新生代进行垃圾回收,以及时释放不再使用的内存空间。这种垃圾回收称为 Minor GC。
:::info
TLAB
:::
TLAB 是 Java 虚拟机中一种用于优化对象分配地技术。它在多线程环境下提高了对象分配的效率。
在 Java 中,对象地分配通常是线程共享的,所有线程都可以访问共享的堆内存区域。然而,为了减少线程之间的竞争和提高分配效率,Java 虚拟机引入了 Thread-Local Allocation Buffer
。
主要特点和作用包括:
- 线程本地:每个线程都会拥有自己的 TLAB 区域,这个区域是在堆内存中分配的。因此,每个线程都可以在自己的 TLAB 区域上进行对象分配,这样减少了线程之间的竞争。
- 减少同步:在没有 TLAB 的情况下,多个线程需要在堆上的共享内存区域上进行对象分配,可能需要进行同步操作,导致竞争和性能瓶颈。使用 TLAB 可以减少这种竞争,因为每个线程都在自己的 TLAB 上进行分配。
:::info
新生代越大越好吗?
:::
如果新生代太小,那么就会导致 Minor GC
次数更加频繁,导致吞吐量变小。
如果新生代太大,那么就会导致老年代越小,老年代越小,那么触发 Full GC
的次数就越多,导致回收的时间就会越长,吞吐量变小。
Oracle 建议新生代大小在 25%-50%之间。
:::info
幸存区大小如何设置
:::
幸存区的大小会影响对象晋升到老年代的频率,如果幸存区过小,可能导致对象频繁晋升到老年代,增加 Full GC
的频率。也延长了对象的生存时间,本应该在新生代中就被回收的对于却等在老年代触发 Full GC
的时候回收。
一方面我们需要存活时间短的对象留在新生代以便下次垃圾回收,一方面我们也希望存活时间长的对象尽快晋升到老年代中。
这些存活时间长的对象会占用新生代的空间,在新生代垃圾回收时也要将这些对象进行复制,消耗了更多的资源。
:::info
老年代大小如何设置
:::
老年代是Java堆中的一部分,主要用于存放经过多次垃圾回收仍然存活的对象。老年代的调优涉及到一些关键的因素,包括垃圾回收器的选择、老年代大小的设置、Full GC的频率等。以下是一些建议的老年代调优策略:
- 选择合适的垃圾回收器: Java虚拟机提供了不同类型的垃圾回收器,包括Serial、Parallel、CMS、G1等。根据应用的特性和性能需求选择合适的垃圾回收器,因为不同的回收器对老年代的管理方式和效果有所不同。
- 调整老年代的大小: 通过
-Xms
、-Xmx
等选项,可以设置Java堆的初始大小和最大大小,从而影响老年代的大小。合理设置老年代的大小有助于避免OutOfMemoryError和提高垃圾回收的效率。 - 避免过度晋升: 如果对象过早晋升到老年代,可能导致老年代的空间不足,增加Full GC的频率。可以通过调整新生代和Survivor区的大小,以及调整晋升的年龄阈值等来避免过度晋升。
- 设置长时间回收的阈值: 对于CMS(Concurrent Mark-Sweep)和G1(Garbage-First)等垃圾回收器,可以通过
-XX:MaxTenuringThreshold
选项来设置对象晋升到老年代的年龄阈值。增大年龄阈值有助于减少对象频繁晋升到老年代。 - 监控老年代的使用情况: 使用GC日志和监控工具(如VisualVM、JConsole等)来监控老年代的使用情况,包括老年代的空间利用率、Full GC的频率、老年代垃圾回收的时间等,以便及时发现和解决问题。
- 使用分代策略: 利用分代垃圾回收的特点,通过调整新生代和老年代的比例、优化GC策略等,使得不同代的垃圾回收更为高效。
- 选择合适的GC策略: 不同的GC策略对老年代的影响也不同。例如,CMS适用于需要短暂停顿的应用,而G1适用于更复杂的内存分配模式。选择合适的GC策略有助于提高老年代的性能。
我们来看几个案例:
Full GC 和 Minor GC 频繁是为什么?
因为新生代的内存设置太小,导致新手代发生 Minor GC 就频繁,新生代垃圾回收频繁就会导致有更多的短生命周期对象晋升到老年代,老年代中内存一多,就会触发 Full GC。所以我们需要先调整新生代的大小,来逐步改善。