文章目录
JVM的串行GC应该是Java最古老的GC了。其基本原理是,用一个额外的单线程来完成垃圾收集动作,该线程与其他线程是互斥的,也就是说,在这个线程进行垃圾收集工作的时候,其他线程全部都需要停下来,等待这个线程工作完成,这也就是垃圾收集里面的“stop the world”现象。多数情况下,如果应用没有很高的内存需求和很低的延迟的话,整个收集过程偶尔会有一个几百ms的延迟,通常是可以接受的。但对于一些低延迟的系统,这个延迟通常不能接受,例如一些算法交易系统,算法算出结果通常也都是在ms级别,GC的时间达到几百ms,会极大破坏系统的有效性。
先来看一下串行GC的stop the world现象,以下代码参考了这里。
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package problme1;
import java.util.HashMap;
public class Problem1 {
public static class MyThread extends Thread {
HashMap<Long, byte []> map = new HashMap<Long, byte []>();
public void run (){
while (true ){
if (map.size()*512 /1024 /1024 >=400 ){
map.clear();
System.out.println("clean map" );
}
byte [] b1;
for (int i=0 ;i<100 ;i++){
b1 = new byte [512 ];
map.put(System.nanoTime(), b1);
}
try {
Thread.sleep(1 );
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static class PrintThread extends Thread {
public static final long start = System.currentTimeMillis();
public void run (){
while (true ){
long t = System.currentTimeMillis()-start;
System.out.println(t*1.0 /1000 );
try {
Thread.sleep(1000 );
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main (String args[]){
MyThread s1 = new MyThread();
PrintThread p1 = new PrintThread();
s1.start();
p1.start();
}
}
代码分2个线程,线程1每隔1ms就向一个HashMap中添加数据,当数据达到大概400M多的时候就清空这个HashMap,打印“clean map”,并重新开始添加数据;线程2每隔1000ms就输出一下系统运行的时间。为了确保看到stop the world现象,运行的时候要使用以下一些JVM参数,-Xmx512M -Xms512M -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails -Xmn1m -XX:PretenureSizeThreshold=50 -XX:MaxTenuringThreshold=1
。-Xmx512M -Xms512M
表示JVM的堆大小是512M,也就是比HashMap的400M大一些,确保不会发生溢出,也不宜过大,这个后面会分析;-XX:+UseSerialGC
是使用串行GC;-Xloggc:gc.log -XX:+PrintGCDetails
分别表示将gc的log写入gc.log文件,和打印GC的详细信息。输出如下,
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
34
0.0
1.01
2.014
3.014
4.014
5.015
6.015
7.019
8.022
9.022
10.022
11.023
12.023
13.023
14.024
clean map
15.024
16.396
17.396
18.397
19.402
20.403
21.403
22.403
23.403
24.404
25.408
26.408
27.408
clean map
28.409
29.409
30.41
31.411
可以看到,线程2每过大约1000ms就打印出当前的时间信息,直到发生需要清空HashMap的时候,线程1打印“clean map”,但是在清空HashMap后不久,发生了一个1400ms的停顿,比平常的停顿多了400ms,多出的这些时间就是串行GC造成的stop the world,此时GC专心收集HashMap的约400M垃圾内存,强制停止了所有线程。由于现在的计算机性能比较好,回收400M多内存有可能也比较快,尤其这些内存可能还是连续的,回收会更快,多运行几次,就有可能出现一次上述的情况。另外,测试程序使用了512M内存,其中HashMap占用了其中80%多的空间,使用这么大的内存的目的就是让GC的工作更多,速度变慢,使得现象更加明显。当然也可以使用更大的内存,只要控制好这个程序中HashMap所占空间的比例,这个现象的重现还是不困难的。
也可以看一下gc.log中的信息,应该在16s左右发生过一次比较长时间的GC动作,其中部分内容截取如下,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
......
16.502 : [GC (Allocation Failure) 16.502 : [DefNew: 960 K ->63 K (960 K ), 0.0064995 secs] 522869 K ->522854 K (524224 K ), 0.0065793 secs] [Times : user=0.01 sys=0.00 , real=0.01 secs]
16.530 : [GC (Allocation Failure) 16.530 : [DefNew: 959 K ->959 K (960 K ), 0.0000417 secs]16.530 : [Tenured: 522867 K ->52660 K (523264 K ), 0.4971022 secs] 523827 K ->52660 K (524224 K ), [Metaspace: 2462 K ->2462 K (1056768 K )], 0.4972655 secs] [Times : user=0.11 sys=0.00 , real=0.50 secs]
17.046 : [GC (Allocation Failure) 17.046 : [DefNew: 896 K ->63 K (960 K ), 0.0018982 secs] 53633 K ->53616 K (524224 K ), 0.0019434 secs] [Times : user=0.01 sys=0.00 , real=0.01 secs]
......
29.604 : [GC (Allocation Failure) 29.604 : [DefNew: 959 K ->64 K (960 K ), 0.0033689 secs] 522769 K ->522754 K (524224 K ), 0.0034256 secs] [Times : user=0.01 sys=0.00 , real=0.00 secs]
29.625 : [GC (Allocation Failure) 29.625 : [DefNew: 960 K ->960 K (960 K ), 0.0000176 secs]29.625 : [Tenured: 522717 K ->56730 K (523264 K ), 0.1138260 secs] 523677 K ->56730 K (524224 K ), [Metaspace: 2466 K ->2466 K (1056768 K )], 0.1139053 secs] [Times : user=0.11 sys=0.00 , real=0.11 secs]
29.756 : [GC (Allocation Failure) 29.756 : [DefNew: 896 K ->64 K (960 K ), 0.0017809 secs] 57652 K ->57637 K (524224 K ), 0.0018308 secs] [Times : user=0.00 sys=0.00 , real=0.00 secs]
......
32.104 : [GC (Allocation Failure) 32.104 : [DefNew: 959 K ->64 K (960 K ), 0.0062968 secs] 149350 K ->149336 K (524224 K ), 0.0063927 secs] [Times : user=0.01 sys=0.00 , real=0.01 secs]
Heap
def new generation total 960 K , used 766 K [0x00000000e0000000 , 0x00000000e0100000 , 0x00000000e0100000 )
eden space 896 K , 78 % used [0x00000000e0000000 , 0x00000000e00af9d8 , 0x00000000e00e0000 )
from space 64 K , 100 % used [0x00000000e00e0000 , 0x00000000e00f0000 , 0x00000000e00f0000 )
to space 64 K , 0 % used [0x00000000e00f0000 , 0x00000000e00f0000 , 0x00000000e0100000 )
tenured generation total 523264 K , used 149291 K [0x00000000e0100000 , 0x0000000100000000 , 0x0000000100000000 )
the space 523264 K , 28 % used [0x00000000e0100000 , 0x00000000e92cad38 , 0x00000000e92cae00 , 0x0000000100000000 )
Metaspace used 2474 K , capacity 4490 K , committed 4864 K , reserved 1056768 K
class space used 269 K , capacity 386 K , committed 512 K , reserved 1048576 K
可以看到,在16.530s的时候,发生了一次GC,使用了0.50s,与我们看到的现象基本吻合。同时,也可以看到,在29.625s的时候也发生了一次类似的GC,但是这一次就没有上一次的现象那么明显,也印证了这个现象并不是每次都能轻易重现的,取决于GC的效率。
既然串行GC有这个问题,那么为了减轻“stop the world”现象的影响,就需要引入新的GC。例如,并行GC,现在的服务器乃至个人PC都有多个CPU,一个CPU负责垃圾收集,其他CPU还可以运行程序代码,但是,由于内存中的对象是互斥的,比如,标记过程,因为一边标记一边运行程序的话,就有可能产生新的有用内存和垃圾内存,这样每一次gc都不能放心清除没有标记的内存,因为没有标记的内存可能是标记的过程中产生的有用内存;还有一种方法,就是分块收集,将内存分为多个块,每次收集的时候只收集其中一块,在标记的时候,不对标记所在的块做新内存分配,这样就不会清除掉有用内存,其他线程也可以在其他块中正常的工作。这两种GC的思想在JVM后续的版本中都有体现,分别是CMS收集器和G1收集器。
但是,这样的收集策略都是有潜在问题的,首先,垃圾收集带来的延迟影响是不能完全根除的,因为在标记的过程中,根对象是唯一的,也就是整个系统中的单点,所有线程都必须等待收集器访问根对象完成之后才能建立新对象,另外,即使是上述的分块收集策略,在进行收集的时候,等于内存突然少了一块,在分配内存的效率上必然会有影响;此外,上述的分块并行收集也会有破坏程序对象内存结构的问题,比如,一个大的HashMap,在分配的时候最好能集中分配在一起便于收集,但是分块管理的时候,就可能会将他们分配在不同的块上,这样,在HashMap清空的时候,可能要分块收集多次才能将所有内存收回,降低了内存的使用效率,程序可能要请求更大的内存才能正常工作;同时,不可避免的,引入更加复杂的收集器,会导致收集程序越来越复杂,例如,分块收集器就需要考虑多个块之间的对象如果有相互引用的情况等,万一发生bug极难定位问题。
如果是延迟要求极低的应用,GC的不可控收集时间,必然是不可接受的,此时还是建议不要使用Java这类动态语言,改为使用一些内存可控静态语言,如C/C++,来完成工作。