Java虚拟机(JVM)垃圾回收机制

垃圾回收机制是由垃圾收集器Garbage Collection GC来实现的,GC是后台的守护进程。它的特别之处是它是一个低优先级进程,但是可以根据内存的使用情况动态的调整他的优先级。因此,它是在内存中低到一定限度时才会自动运行,从而实现对内存的回收。*这就是垃圾回收的时间不确定的原因。*

为何要这样设计:因为GC也是进程,也要消耗CPU等资源,如果GC执行过于频繁会对java的程序的执行产生较大的影响(java解释器本来就不快),因此JVM的设计者们选着了不定期的gc。

GC有关的是: runtime data area 中的 heap(对象实例会存储在这里) 和 gabage collector方法。
程序运行期间,所有对象实例存储在运行时数据区域的heap中,当一个对象不再被引用(使用),它就需要被收回。在GC过程中,这些不再被使用的对象从heap中收回,这样就会有空间被循环利用。
GC为内存中不再使用的对象进行回收,GC中调用回收的方法收集器garbage collector. 由于GC要消耗一些资源和时间,Java 在对对象的生命周期特征(eden or survivor)进行分析之后,采用了分代的方式进行对象的收集,以缩短GC对应用造成的暂停。

虚拟机中的对象

image-20210703214455740

对象的分配

虚拟机遇到一条new指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。

类加载就是把class加载到JVM的运行时数据区的过程

检查加载

首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用(符号引用 符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。

分配内存

接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

指针碰撞

如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。

image-20210703214939138

空闲列表

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

image-20210703215121137

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

如果是Serial、ParNew等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。

如果是使用CMS这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

并发安全

除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

CAS机制

解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;

分配缓冲

另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。

TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。

参数:

-XX:+UseTLAB

允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用TLAB,请指定-XX:-UseTLAB。

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

内存空间初始化

(注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes在Java hotspot VM内部表示为类元数据)、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。

对象初始化

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。

对象的内存布局

image-20210703220014236

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

如果对象是一个java数组,那么在对象头中还有一块用于记录数组长度的数据。

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。

句柄

如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针

如果使用直接指针访问, reference中存储的直接就是对象地址。

对比

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问的。

判断对象的存活

在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(死去代表着不可能再被任何途径使用得对象了)

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效时,计数器减1.

Python在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Isalive {
public Object instance =null;
//占据内存,便于判断分析GC
private byte[] bigSize = new byte[10*1024*1024];

public static void main(String[] args) {
Isalive objectA = new Isalive();//objectA 局部变量表 GCRoots
Isalive objectB = new Isalive();//objectB 局部变量表
//相互引用
objectA.instance = objectB;
objectB.instance = objectA;
//切断可达
objectA =null;
objectB =null;
//强制垃圾回收
System.gc();
}
}

image-20210703221112887

在代码中看到,只保留相互引用的对象还是被回收掉了,说明JVM中采用的不是引用计数法。

可达性分析

来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

作为GC Roots的对象包括下面几种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
* 虚拟机栈(栈帧中的本地变量表)中引用的对象。

* 方法区中类静态属性引用的对象。

* 方法区中常量引用的对象。

* 本地方法栈中JNI(即一般说的Native方法)引用的对象。

* JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。

* 所有被同步锁(synchronized关键)持有的对象。

* JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等

* JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代时)

以上的回收都是对象,类的回收条件:

注意如果Class要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

1、 **该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。

2、 加载该类的ClassLoader已经被回收。

3、 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

4、 参数控制:

img

Finalize方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class FinalizeGC {
public static FinalizeGC instance = null;
public void isAlive(){
System.out.println("I am still alive!");
}
@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("finalize method executed");
FinalizeGC.instance = this;
}
public static void main(String[] args) throws Throwable {
instance = new FinalizeGC();
//对象进行第1次GC
instance =null;
System.gc();
Thread.sleep(1000);//Finalizer方法优先级很低,需要等待
if(instance !=null){
instance.isAlive();
}else{
System.out.println("I am dead!");
}
//对象进行第2次GC
instance =null;
System.gc();
Thread.sleep(1000);
if(instance !=null){
instance.isAlive();
}else{
System.out.println("I am dead!");
}
}
}

运行结果:

img

可以看到,对象可以被拯救一次(finalize执行第一次,但是不会执行第二次)

注释掉代码17和26行,再来一次。

运行结果:

img

对象没有被拯救,这个就是finalize方法执行缓慢,还没有完成拯救,垃圾回收器就已经回收掉了。

所以建议大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了finalize方法!因为在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好

各种引用

强引用

一般的Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。

软引用SoftReference

一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。参见代码:

VM参数 -Xms10m -Xmx10m -XX:+PrintGC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) {
User u = new User(1,"King"); //new是强引用
SoftReference<User> userSoft = new SoftReference<User>(u);//软引用
u = null;//干掉强引用,确保这个实例只有userSoft的软引用
System.out.println(userSoft.get()); //看一下这个对象是否还在
System.gc();//进行一次GC垃圾回收 千万不要写在业务代码中。
System.out.println("After gc");
System.out.println(userSoft.get());
//往堆中填充数据,导致OOM
List<byte[]> list = new LinkedList<>();
try {
for(int i=0;i<100;i++) {
//System.out.println("*************"+userSoft.get());
list.add(new byte[1024*1024*1]); //1M的对象 100m
}
} catch (Throwable e) {
//抛出了OOM异常时打印软引用对象
System.out.println("Exception*************"+userSoft.get());
}

}

运行结果

img

例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

弱引用WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

参看代码:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
User u = new User(1,"King");
WeakReference<User> userWeak = new WeakReference<User>(u);
u = null;//干掉强引用,确保这个实例只有userWeak的弱引用
System.out.println(userWeak.get());
System.gc();//进行一次GC垃圾回收,千万不要写在业务代码中。
System.out.println("After gc");
System.out.println(userWeak.get());
}

image-20210703223219530

注意:软引用 SoftReference和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。

实际运用(WeakHashMap、ThreadLocal)

虚引用PhantomReference

幽灵引用,最弱(随时会被回收掉)

垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

对象的分配策略

栈上分配

当方法中的对象没有发生逃逸时会使用栈上分配

逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用,比如:调用参数传递到其他方法中,这种称之为方法逃逸,甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EscapeAnalysisTest {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 50000000; i++) {//5千万的对象,为什么不会垃圾回收
allocate();
}
System.out.println((System.currentTimeMillis() - start) + " ms");
Thread.sleep(600000);
}

static void allocate() {//满足逃逸分析(不会逃逸出方法)
MyObject myObject = new MyObject(2020, 2020.6);
}

static class MyObject {
int a;
double b;

MyObject(int a, double b) {
this.a = a;
this.b = b;
}
}
}

这段代码在调用的过程中 myboject这个对象属于全局逃逸,JVM可以做栈上分配

然后通过开启和关闭DoEscapeAnalysis开关观察不同。

开启逃逸分析(JVM默认开启)

image-20210703223903951

输出

image-20210703224006011

关闭逃逸分析

img

查看执行速度

img

测试结果可见,开启逃逸分析对代码的执行性能有很大的影响!

如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。

采用了逃逸分析后,满足逃逸的对象在栈上分配

image-20210703224331315

没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢

image-20210703224428565

开启GC打印日志

-XX:+PrintGC

开启逃逸分析

image-20210703224006011

可以看到没有GC日志

关闭逃逸分析

img

可以看到关闭了逃逸分析,JVM在频繁的进行垃圾回收(GC),正是这一块的操作导致性能有较大的差别。

对象优先在Eden区分配

注意:新生代初始时就有大小

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

-XX:+PrintGCDetails

-XX:PretenureSizeThreshold=4m

-XX:+UseSerialGC

PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。

最典型的大对象是那种很长的字符串以及数组。这样做的目的:1.避免大量内存复制,2.避免提前进行垃圾回收,明明内存有空间进行分配。

长期存活的对象进入老年区

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor区中每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(并发的垃圾回收器默认为15),CMS是6时,就会被晋升到老年代中。

-XX:MaxTenuringThreshold调整

对象年龄动态判断

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

垃圾回收算法

垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的实现,我们重点讲解分代收集理论和3种算法的思想。

分代收集理论

当前商业虚拟机的垃圾收集器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:

1、 绝大部分的对象都是朝生夕死

2、 熬过多次垃圾回收的对象就越难回收。

根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

GC种类

市面上发生垃圾回收的叫法很多,我大体整理了一下:

1、 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。

2、 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有CMS垃圾回收器会有这个单独的收集老年代的行为。(Major GC定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)

3、 整堆收集(Full GC):收集整个Java堆和方法区(注意包含方法区)

img

img

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。

注意:内存移动是必须实打实的移动(复制),不能使用指针玩。

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

Appel式回收

一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden区和两块较小的Survivor空间(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)

专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

回收效率不稳定,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率很低。

它的主要不足,空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。复制回收算法适用于新生代。

标记-整理算法(Mark-Compact)

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。

我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新。

所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

JVM中常见的垃圾收集器

分代收集的思想

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

请记住下图的垃圾收集器和之间的连线关系。

具体看官网*JVM参数:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

img

收集器 收集对象和算法 收集器类型 说明 适用场景
Serial 新生代,复制算法 单线程 简单高效; 适合内存不大的情况;
ParNew 新生代,复制算法 并行的多线程收集器 ParNew垃圾收集器是Serial收集器的多线程版本 搭配CMS垃圾回收器的首选
Parallel Scavenge 吞吐量优先收集器 新生代,复制算法 并行的多线程收集器 类似ParNew,更加关注吞吐量,达到一个可控制的吞吐量; 本身是Server级别多CPU机器上的默认GC方式,主要适合后台运算不需要太多交互的任务;
收集器 收集对象和算法 收集器类型 说明 适用场景
Serial Old 老年代,标记整理算法 单线程 Client模式下虚拟机使用
Parallel Old 老年代,标记整理算法 并行的多线程收集器 Parallel Scavenge收集器的老年代版本,为了配合Parallel Scavenge的面向吞吐量的特性而开发的对应组合; 在注重吞吐量以及CPU资源敏感的场合采用
CMS 老年代,标记清除算法 并行与并发收集器 尽可能的缩短垃圾收集时用户线程停止时间;缺点在于: 1.内存碎片 2.需要更多cpu资源 3.浮动垃圾问题,需要更大的堆空间 重视服务的响应速度、系统停顿时间和用户体验的互联网网站或者B/S系统。互联网后端目前cms是主流的垃圾回收器;
G1 跨新生代和老年代;标记整理 + 化整为零 并行与并发收集器 JDK1.7才正式引入,采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势; 面向服务端应用的垃圾回收器,目标为取代CMS

并行:垃圾收集的多线程的同时进行。

并发:垃圾收集的多线程和应用的多线程同时进行。

注:吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)

垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

Serial/Serial Old

最古老的,单线程,独占式,成熟,适合单CPU 服务器

-XX:+UseSerialGC 新生代和老年代都用串行收集器

-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

-XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old

ParNew

和Serial基本没区别,唯一的区别:多线程,多CPU的,停顿时间比Serial少

-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

Parallel Scavenge(ParallerGC)/Parallel Old

关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Concurrent Mark Sweep (CMS)

img

收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

l 初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

l 并发标记-和用户的应用程序同时进行,进行GC Roots追踪的过程,标记从GCRoots开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)

l 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

l 并发清除

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

-XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用CMS

CPU****敏感:CMS对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足4个时,CMS对用户的影响较大。

浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。

在1.6的版本中老年代空间使用率阈值(92%)

如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片

总体来说,CMS是JVM推出了第一款并发垃圾收集器,所以还是非常有代表性。

但是最大的问题是CMS采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS提供一个参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。
这个地方一般会使用Serial Old ,因为Serial Old是一个单线程,所以如果内存空间很大、且对象较多时,CMS发生这样情况会很卡。

Stop The World现象

任何的GC收集器都会进行业务线程的暂停,这个就是STW,Stop The World,所以我们GC调优的目标就是尽可能的减少STW的时间和次数。

G1

img

-XX:+UseG1GC

内存布局:在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。每一个区域可以通过参数-XX:G1HeapRegionSize=size 来设置。

img

Region中还有一块特殊区域Humongous区域,专门用于存储大对象,一般只要认为一个对象超过了Region容量的一般可认为是大对象,如果对象超级大,那么使用连续的N个Humongous区域来存储。

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

追求停顿时间:

-XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。

-XX:ParallerGCThreads:设置GC的工作线程数量。

一般在G1和CMS中间选择的话平衡点在6~8G,只有内存比较大G1才能发挥优势。

学习垃圾回收的意义

Java与C++等语言最大的技术区别:自动化的垃圾回收机制(GC)

为什么要了解GC和内存分配策略

1、面试需要

2、GC对应用的性能是有影响的;

3、写代码有好处

栈:栈中的生命周期是跟随线程,所以一般不需要关注

堆:堆中的对象是垃圾回收的重点

方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点