引言
如果你被问及Android内存泄露,而你只能背出几种常见的内存泄露的场景以及解决方案,却不能从更深层次的原理上去解释它,那么是时候补一补Java虚拟机的基础知识了。文章较长,请耐心阅读,相信会有所收获。
Java内存区域划分
许多程序员将Java内存简单粗暴地分为栈内存和堆内存,事实上,它至少可以分为堆、栈、方法区、本地方法栈等部分。借用《深入理解Java虚拟机》一书中的一张图,看图说话。
栈(Stack):
图中称作虚拟机栈(VM Stack),这里简称栈。栈内存用作在方法执行时创建栈帧(Stack Frame)存放局部变量表、操作数栈、动态链接、方法出口等。而局部变量表存放基本数据类型、对象引用、returnAddress类型的数据。方法调用时栈帧进栈,执行结束时栈帧出栈,局部变量自动销毁,不需要GC。想一想递归调用的“栈溢出”就很好理解了,以及Debug时查看的调用栈。栈内存是线程私有的,即每条线程都有独立的存储,各条线程之间互不影响。栈内存的特点是分配效率高但是容量有限。堆(Heap):
主要存放对象实例和数组,是线程共享的。堆内存中的对象不会随着方法执行结束而销毁,需要GC。它的特点是容量大,是Java内存管理中最大的一块,是GC主要区域,也是内存泄露的主要区域。方法区(Mehtod Area):
很多人习惯称之为静态区,它存放的是类信息、常量、静态变量、JIT编译后的代码数据等。方法区是线程共享的,需要GC。本地方法栈(Native Mehtod Stack):
类似栈,线程私有,不需要GC,区别是它服务于JNI。程序计数器(Computer Counter Register):
当前线程所执行的字节码的行号指示器,线程私有,不需要GC。
由以上划分可知,当我们编写的一个类被加载后,类信息、静态变量、常量等放在方法区;代码中的A a=new A( ),A a部分放在栈中,new A( )部分放在堆中;int b这样的变量也放在栈中。本地方法栈和程序计数器我们不做过多讨论,重点关注栈、堆、和方法区。
对象的创建过程
稍微了解一下对象的创建过程。它大致分为4个步骤:
- 检查类是否被加载,如果未加载则先加载
- 在堆上为新生对象分配内存
- 设置对象Header,如对象HashCode、GC分代年龄、类型指针等
- 执行《init》方法初始化
Java内存回收机制
内存区域划分好了,对象和变量创建好了,程序运行着,部分方法已经执行完了,那么哪些内存需要回收?何时回收?如何回收?已知所有的对象实例和数组都要在堆上分配,那我们重点关注这块最大的内存是如何回收的。
堆的回收
C++中,程序员在一个对象使用完毕时手动free/delete并赋值为null来回收对象占用的内存。Java使用众所周知的垃圾回收器来回收对象,那么它如何确定一个对象已经完成使命,可以被回收了呢?
引用计数算法
最简单的方法是给每个对象维护一个引用计数器,每当有一个地方引用它时,对应的计数器加1;引用失效时,计数器减1;当计数器小于等于0时,对象就可以被回收了。
引用计数算法简单高效,但它最大的问题是无法解决对象之间的循环引用问题。比如以下代码:
A a =new A();
B b =new B();
a.x=b;
b.x=a;
a=null;
b=null;
如上图右半部分,“A的实例”持有“B的实例”的引用,“B的实例”持有“A的实例”的引用。但是没有其他任何地方引用这两个实例。如果采用引用计数法,它们的引用计数器都不为0,垃圾回收器一直都不会回收它们;但实际上系统中并不需要再次使用这两个对象,这势必造成内存泄露——占着茅坑不拉屎。假如系统中有大量的这种循环引用的对象存在,并且每个这样的对象占用的内存都较大,那么系统很快就会因为内存溢出而崩溃。
可达性分析算法
Java虚拟机采用可达性分析算法来判定对象的存活与否。它应用图论的思想,堆中的每个对象实例都是图的一个顶点,一个对象对另一个对象的引用是一条有向边。在图的所有顶点中,以一系列称为“GC Roots”的顶点作为起始点,沿着有向边向下搜索,走过的路径成为引用链(Reference Chain)。当起始点集合到某个顶点不可达,即GC Roots到某个对象不存任何一条引用链时,则证明此对象是不可用的,可以回收。如下图右半部分,可达性分析算法可以解决对象间循环引用的问题。
Java中可作为GC Roots的对象包括以下几种:
- 栈中引用的对象
- 方法区中静态熟悉引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
到这里,我们已经可以简单解释内存泄露了。原本应该正常回收的对象,如上图右半部分,如果因为某种原因,变成上图左半部分一样,变成GC Roots可达了,那么这些对象就不会被及时回收,造成资源浪费,是为内存泄露。好比是小明用过的厕所,本来应该把门打开,其他人可以接着使用,但是小明不再继续使用,却把门锁了,别人用不了。小明是GC Roots或者和GC Roots连通的对象,厕所是应该回收的对象所占的内存资源。
实际的可达性分析算法比以上分析复杂地多,需要结合Java的强、软、弱、虚四种引用来解释。内存泄露的防治也离不开软引用和弱引用的使用。这个话题将在其他博文中详细阐述。
方法区的回收
方法区的回收极少被人提及,更不会像堆内存的回收一样被大众讨论,但它确实是客观存在。回看方法区存放的内容:类信息、常量、静态变量、JIT编译后的代码数据等,后两者的生命周期贯穿整个应用程序,因此方法区内存的回收内容主要是废弃常量和无用的类。
废弃常量
回收废弃常量与回收堆中的对象非常类似,不展开论述。
无用的类
回收无用的类即类的卸载,对应于类的加载。满足以下条件的类才可算作无用的类:
- 该类所有实例都已被回收,即Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法
以上只谈到需要回收的内存区域(堆和方法区)中如何判定一个对象可以回收,即内存中哪些是垃圾(Garbage),并未涉及如何进行垃圾回收(Garbage Collection,GC)。这部分内容非常类似《操作系统》或《计算机组成原理》中的存储单元的分配、寻址、清除、替换等操作。不外乎是几种常见的算法,由易到难,最终的实现因厂家而异。一般是难易搭配,集各种算法的有点于一身,但又有所折衷,以实现某几大因素(如时间、空间)的平衡。
标记 - 清除法
- 先标记所有需要回收的对象,然后统一清除。
- 优点:算法简单
- 缺点:
- 时间上:标记和清除的效率都不高
- 空间上:标记清除后会产生大量不连续的内存碎片
复制算法
- 将内存分为大小相等的两块,每次只使用其中的一块,用完后将还存活的对象复制到另外一块,然后把使用过的那块一次清理掉。
- 优点:每次回收整个半区,算法简单;时间上快速高效,空间上不会产生碎片。
- 缺点:
- 空间上,内存容量减半,利用率低。
- 对象存活率高时复制次数增加,效率降低。
标记 - 整理法
- 标记 - 整理 - 清除:先标记所有需要回收的对象,将所有存活对象移到一端,然后清除端边界以外的内存。
- 优缺点都类似标记 - 清除法,外加移动对象的开销。
分代回收算法
- 根据对象存活周期讲内存划分新生代、老年代等几块,根据不同年代的特点采用适当的算法。
- 新生代对象存活率低,采用复制算法;老年代对象存活率高,采用标记 - 清理算法或者标记 - 整理算法。
垃圾回收器种类
计算机中常把完成某类功能的程序叫做某某器,英语中就是XXX-er。比如完成编译功能的程序叫编译器Compiler,完成调试功能的程序叫调试器Debugger,以及此处完成垃圾回收功能的Garbage Collector。
Serial
- 最基本、历史最悠久
- 单线程收集器
- 使用一个CPU或一条线程进行垃圾回收
- 它进行垃圾回收时,必须暂停其他所有工作线程,Stop The World,直到本次回收结束。
- Stop The World,保洁阿姨在帮你打扫办公室时,你需要暂停一切工作离开房间,并且不继续乱扔垃圾,直到打扫结束。
ParNew
- Serial回收器的多线程版
Parallel Scavenge
- 新生代收集器,多线程
- 高吞吐量,高CPU利用率
Serial Old
- Serial回收器的老年代版本,单线程。
Parallel Old
- Parallel Scavenge的老年代版本,多线程。
CMS
- 回收停顿时间最短,并发
G1
- 较新较牛逼
总结
Java内存划分为栈、堆、方法区等区域,其中栈保存的是方法的局部变量,随方法起随方法灭,不需要GC;堆保存所有对象的实例和数组,是GC和泄露的重点区;方法区保存的是类信息、常量、静态变量等静态信息,也需要GC。
堆内存的回收中,判断对象存活的算法有引用计数算法和可达性分析算法,引用计数算法无法解决对象间循环引用的问题,虚拟机通常采用可达性分析算法。
常见的垃圾回收算法有:标记 - 清除法、复制算法、标记 - 整理法、分代回收算法。常见的垃圾回收器种类有:Serial、ParNew、Parallel Scavenge等。
以下内容请听下回分解:
- Java强、软、弱、虚四种引用详解
- 内存泄露的定义、与内存溢出的区别和联系、内存泄露产生的原因
- Java和Android常见的内存泄露的情景和解决方案
- Android内存泄露检测
- 防止内存泄露和内存优化的最佳实践等等