垃圾回收(Garbage Collection, 简写为GC) , Java开发者不需要手动释放对象的内存,JVM中的垃圾回收器(Garbage Collection)会自动回收。
代价: 这种自动化机制一旦出错,开发者就不得不去深入理解GC回收机制,甚至需要对这些“自动化”的技术实施必要的监控和调节。
Java运行时区域中的:程序计数器、虚拟机栈、本地方法栈 这3个区域与线程生命周期同步,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域不需要过多考虑回收的问题。
而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建那些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。
什么是垃圾
所谓垃圾就是内存中已经没有用的对象。Java虚拟机中使用一个叫作“可达性分析”的算法来决定对象是否可以被回收。
可达性分析
JVM把内存中所有对象之间的引用关系看作一张图,通过一组名为“GC Root”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。
如图,对象A/B/C/DE与GC Root之间都存在一条直接或间接的引用链,这表明它们与GC Root之间是可达的,因此它们不能被GC回收。而对象M和J虽然被对象J引用,但并不存在一条引用链连与GC Root连接,所以当GC进行垃圾回收时,只要遍历到J/K/M这3个对象就会将它们回收。
注意:上图代表的是些对象在内存中的引用。包括GC Root也是一组引用而并非对象。
GC Root对象
在Java中,有以下几种对象可以作为GC Root:
- Java虚拟机(局部变量表)中引用的对象
- 方法区中静态引用指向的对象
- 仍处于存活状态中的线程对象
- Native方法中JNI引用的对象
什么时候回收
不同的虚拟机有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。
- Allocation Failure: 在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败。
- System.gc(): 在应用层,Java开发工程师可以主动调用此API来请求一次GC。
代码验证GC Root的几种情况
Java命令参数
-Xms 初始分配JVM运行时内存大小;默认为物理内存的1/64。
#分配200M内存空间给JVM
java -Xms200m HelloWorld
验证虚拟机栈(局部变量)中引用的对象
public class GCRootLocalVariable{
private int _10MB = 10 * 1024 * 1024;
private byte[] memroy = new byte[8 * _10MB];
public static void main(String[] args) {
System.out.println("开始时:");
printMemory();
method();
System.gc();
System.out.println("第二次GC完成");
printMemory();
}
public static void method() {
GCRootLocalVariable g = new GCRootLocalVariable();
System.gc();
System.out.println("第一次GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.print("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
Log:
开始时
free is 242 M, total is 245 M,
第一次GC完成
free is 163 M, total is 245 M,
第二次GC完成
free is 243 M, total is 245 M,
- 第一次GC时,G作为局部变量,引用了new出的对象,且它被视为GC Roots,所以在GC后不会被回收
- 第二次GC时,Method()方法执行完后,局部变量g跟随方法消失,不再有引用指向该对象,所以第二次GC后此对象也会被回收。
注意:上面日志包括后面的实例中,因为有中间变量,所以会有1M左右的误差。
静态变量引用的对象
public class GCRootStaticVariable{
private static int _10MB = 10 * 1024 * 1024;
private byte[] memroy;
private static GCRootStaticVariable staticVariable;
public GCRootStaticVariable(int size) {
memory = new byte[size];
}
public static void main(String[] args) {
System.out.println("程序开始:");
printMemory();
GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
g.staticVariable = new GCRootStaticVariable(8 * _10MB);
g = null;
System.gc();
System.out.println("GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.print("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
Log:
程序开始:
free is 242 M, total is 245 M,
GC完成
free is 163 M, total is 245 M,
当调用GC时,只有g
对象的40M被GC回收掉,而静态变量staticVariable
作为GC Root,它引用的80M并不会被回收。
活跃线程作为GC Root
public class GCRootThread{
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];
public static void main(String[] args) throw Exception {
System.out.println("开始前内存情况:");
printMemory();
AsyncTasky at = new AsyncTasky(new GCRootThread());
Thread thread = new Thread(at);
thread.start();
System.gc();
System.out.println("main方法执行完毕,完成GC");
printMemory();
thread.join();
at = null;
System.gc();
System.out.println("线程代码执行完毕,完成GC");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.print("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
private static class AsyncTask implements Runnable {
private GCRootThread gcRootThread;
public AsyncTask(GCRootThread gcRootThread) {
this.gcRootThread = gcRootThread;
}
@Override
public void run() {
try {
Thread.sleep(500);
} catch(Exception e) {}
}
}
}
Log:
开始前内存情况:
free is 242 M, total is 245 M,
main方法执行完毕,完成GC
free is 163 M, total is 245 M,
线程代码执行完毕,完成GC
free is 243 M, total is 245 M,
当第一次调用GC时,线程没有结束它被作为GC Root,所以它所引用的对象gcRootThread
不会被GC回收;当第二次执行GC时,线程已执行完毕并被置为null
,这时线程已经被销毁,所以它引用的对象gcRootThread
也会被GC回收。
全局变量不能作为GC Root
public class GCRootClassVariable {
private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;
private GCRootClassVariable classVariable;
public GCRootClassVariable(int size) {
memory = new byte[size];
}
public static void main(String[] args) {
System.out.println("程序开始:");
printMemory();
GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
g.classVariable = new GCRootClassVariable(8 * _10MB);
g = null;
System.gc();
System.out.println("GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.print("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
log:
程序开始:
free is 242 M, total is 245 M,
GC完成
free is 243 M, total is 245 M,
从Log中可以看出,当g
被置空后,全局变量classVariable
也再被GC Root所引用。所以当调用GC时,对象g
和对象classVariable都会被回收。因此全局变量与静态变量不同,它不会被当作GC Root。
如何回收垃圾
几种垃圾收集算法的思想以及优缺点。
标记清除算法(Mark and Sweep GC)
从“GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾被回收。
- Mark标记阶段:找到内存中所有GC Root对象,只要和GC Root对象直接或间接相连则标记为灰色(也就是存活对象),否则标记为黑(也就是垃圾对象)。
- Sweep清除标记阶段:将标记为垃圾的对象直接清除。
- 优点:实现简单,不需要移动对象;
- 缺点:需要中断进程内其他执行的组件,并可能产生内存碎片,回收频率高
复制算法(Copying)
将现有内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
- 优点: 按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片
- 缺点:可用的内存大小为原来的一半,对象存活率高时会频繁进行复制。
标记-压缩算法(Mark-Compact)
需要先从根节点对所有可达对象做一次标记,之后将所有存活对象压缩到内存一端。最后,清理边界外所有的空间。
- Mark标记阶段:找到内存中的所有GC Root对象,只要是可达对象则标记为灰色,否则标记为黑色。
- Compact压缩阶段:将剩余存活对象按顺序压缩到内存某一端。
- 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间。性价比比较高。
- 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。
JVM分代回收策略
Java虚拟机对象存活的周期不同,把堆内存划分为几块:新生代、老年代。这就是JVM的内存分代策略。注意:在HosPot中除了新生代和老年代,还有永久代。
中心思想:
对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般比较短。如果经过多次回收仍然存活下来,则将它转移到老年代中。
新生代(Young Generation)
新生成的对象优先存放在新生代中,在新生代中常规应用进行一次垃圾回收一般可以回收70%~95%的内存空间,回收效率高。因为要进行一些复制操作,一般采用GC回收算法的复制算法。
新生代又细分为3部分:Eden
、Survivor0(简称S0)
、Survivor1(简称S1)
。这3部分按照8:1:1比例来划分新生代。
当Eden区第一次满时进行垃圾回收:清除垃圾对象,并将存活对象复制到S0
Eden区再满,再进行垃圾回收:Eden和S0区中的所有垃圾被清除,复制S0存活对到S1
如此反复多次(默认15次)
之后,如果还有存活对象,说明这些对象生命周期较长,移至老年代。
老年代(Old Generation)
老年代的内存一般比新生代大,能存放更多对象。如果对象比较大,并且新生代的剩余空间不足,则这个大对象直接被分配到老年代上。
-XX:PretenureSizeThreshold
设置老年代内存大小。老年代中的对象生命周期较长,不需要过多的复制操作,所以一般采用标记压缩算法
注意:老年代中的对象有时会引用到新生代中的对象。若执行GC可能需要查询整个老年代的对象引用情况,大大降低了效率。所以老年代中维护了一个
512byte
的card table,所有引用新生代对象的信息记录在这里。每次新生代发生GC时,只需要检查这个card table即可。大提高了性能。
GC Log分析
新生代和老年代打印日志的区别:
- 新生代GC:称Minor GC,非常频繁,回收速度快
- 老年代GC:称Major GC或Full GC,Major GC执行时至少会执行一次Minor GC。
注意:在有些虚拟机实现中,Major GC和Full GC存在着区别。Major GC只是代表回收老年代的内存,而Full GC则代表回收整个堆中的内存,即:新生代+老年代。
案例分析
Java命令参数
代码演示:
/**
* VM args: -Xms20M -Xmx20M -Xmn10M -XX:+printGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest{
private static final int _1MB = 1024 * 1024;
public static void testAllocation() {
byte[] a1, a2, a3, a4;
a1 = new byte[2 * _1MB];
a2 = new byte[2 * _1MB];
a3 = new byte[2 * _1MB];
a4 = new byte[1 * _1MB];
}
public static void main(String[] agrs) {
testAllocation();
}
}
执行代码打印日志如下:
Heap
PSYoungGen total9216K,used8003K[0x00000007bf600000,0x00000007c0000000,0x00000007c0000000)
eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdd0ed8,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
to space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 286K, capacity 386K, committed 512K, reserved 1048576K
日志字段意义:
从日志可得出:a1、a2、a3、a4
四个对象都被分配在了新生代(Eden)区。
将a4改为a4 = new byte[2 * _1MB]
日志如下:
[GC(AllocationFailure)[PSYoungGen:6815K->480K(9216K)]6815K->6632K(19456K),0.0067344secs][Times: user=0.04 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 9216K, used2130K[0x00000007bf600000,0x00000007c0000000,0x00000007c0000000)
eden space 8192K, 26% used [0x00000007bf600000,0x00000007bf814930,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
to space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen total 10240K, used 6420K [0x00000007bec00000,0x00000007bf600000,0x00000007bf600000)
object space 10240K, 62% used [0x00000007bec00000,0x00000007bf2450d0,0x00000007bf600000)
Metaspace used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 286K, capacity 386K, committed 512K, reserved 1048576K
在给a4
分配内存前,Eden
区已经被占6M内存。没有足够的内存来存储2M的a4
对象。所以执行一次Minor GC,并尝试将a1、a2、a3
复制到S1,但S1
只有1M空间,最终只能将a1、a2、a3
保存到Eden
区。所以Eden
区战胜2M(a4)
,老年代占用6M(a1、a2、a3)
。
这个案例也间接验证了JVM的内存分配和分代回收策略。
再谈引用
JVM中的引用关系强度由强到弱可以分成四种:强引用(Strong Reference)、软引用(Soft Reference)、 弱引用(Weak Reference)、虚引用(Phantom Reference)。
软件引用常规
代码如下:
public class SoftReferenceNormal{
static class SoftObject{
byte[] data = new byte[1024 * 1024 *1024];
}
public static void main(String[] args){
// 将缓存数据用软引用持有
SoftReference<SoftObject> cacheRef = new SoftReference<>(new SoftObject());
System.out.println("第一次GC前 软引用:" + cacheRef.get());
System.gc()
// 进行一次GC后查看对象的回收情况
System.out.println("第一次GC后 软引用: " + cacheRef.get());
// 再分配一个120M的对象,看看缓存对象的回收情况
SoftObject newSo = new SoftObject():
System.out.println("再次分配120M强引用对象之后 软引用:" + cacheRef.get());
}
}
执行并查看日志:
java -Xmx200m SoftReferenceNormal
第一次GC前 软件引用:SoftReferenceNormal$SoftObject@7852e922
第一次GC后 软件引用:SoftReferenceNormal$SoftObject@7852e922
再次分配120M强引用对象后 软引用: null
从日志得出:当第一次GC时,内存中还有剩余可用内存,所以软引用关联对象并不会被GC回收,但再次创建对象,JVM内存已经不够时软引用关联被回收掉了。
软件引用隐藏的问题
注意: 软件引用关联的对象自动会被GC回收,但软件引用对象本身也是一个强引用对象,因此不会自动被回收。如:
public class SoftReferenceTest{
public static class SoftObject{
byte[] data = new byte[1024];
}
public static int CACHE_INITIAL_CAPSCITY = 100 * 1024;
// 静态集合保存软引用,会导致这些引用对象无法被垃圾回收器回收
public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPSCITY);
public static void main(String args[]){
for(int i = 0; i < CACHE_INITIAL_APSCITY; i++){
SoftObject obj = new SoftObject();
cache.add(new SoftReference<>(obj));
if (i % 10000 == 0){
System.out.pringln("size of cache: " + cache.size());
}
}
System.out.pringln("end!");
}
}
执行并查看日志:
java --Xms4M -Xmx4M -Xmn2M SoftRefenceTest
size of cache: 1
size of cache: 10001
size of cache: 20001
size of cache: 30001
Exception int thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at SoftReferenceTest$SoftObject.<init>(SoftReferenceTest.java:9)
at SoftReferenceTest.main(SoftReferenceTest.java:18)
分析:GC overhead
异常是由于虚拟机不断回收软件引用,回收进行的速度过快,点用的CPU过大,且每次回收掉的内存过小,最终抛出了这个错误
优化:注册一个引用队列,每次循环后将引用队列中出现的软引用对象从cache中移除
public class SoftReferenceTest{
public static class MyBigObject{
byte[] data = new byte[1024];
}
public static int removedSoftRefs = 0;
public static CACHE_INITIAL_CAPACITY = 100 * 1024;
// 静态集合保存软引用,会导致这些引用对象本身无法被垃圾回收器回收
public static Set<SoftReference<MyBigObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
public static ReferenceQueue<MyBigObject> referenceQueue = new ReferenceQueue<>();
public static void main(String[] args) {
for (int i = 0; i < CACHE_INITIAL_CAPACITY; i++) {
MyBigObject obj = new MyBigObject();
cache.add(new SoftRefernece<>(obj, referenceQueue));
clearUselessReferences();
if (i % 10000 == 0){
System.out.pringln("size of cache: " + cache.size());
}
}
System.out.println("End, removed soft references = " + removedSoftRefs);
}
public static void clearUselessReferences() {
Reference<? extends MyBigObject> ref = referenceQueue.poll();
while (ref != null) {
if (cache.remove(ref)) {
removedSoftRefs ++;
}
ref = referenceQueue.poll();
}
}
}
执行并查看日志:
java -Xms4M -Xmx4M -Xmn2M SoftReferenceTest
size of cache: 1
size of cache: 1677
size of cache: 1262
size of cache: 847
size of cache: 432
size of cache: 1873
size of cache: 1685
size of cache: 1206
size of cache: 731
size of cache: 468
size of cache: 1924
End, removed soft references = 100718
执行过程中动态将集合中的软件引用删除后,程序正常运行。
总结
虚拟机垃圾回收机制是影响系统性能,并发能力的主要因素之一。对Android开发来说,有时垃圾回收会很大程序上影响UI线程,并造成卡顿现象。