概念
在JVM架构中,堆内存和垃圾回收器这两个部分和垃圾回收相关。堆内存是运行时用来存储实例对象的数据空间,垃圾回收器运行在堆内存上。
在Java的内存模型中,最重要的是要了解堆内存的概念。运行时的Java实例对象存储在堆内存空间中。当一个对象不再被引用了,它变成可被从堆内存中回收空间。在垃圾回收的过程中,这些对象将被从堆内存中清除,同时它们的空间也就被回收了。
Java堆内存中的对象分代存储(依据对象本身的存活时限):
- 年轻代
- a-Eden区(所有实例在运行时最初都分配到eden区中)
- b-S0 Survivor Space(老一些的对象被从eden区移动到S0区,其实是eden区中的对象经过一次对eden区的Young GC还存活的对象被移动到S0)
- c,-S1 Survivor Space(再老一些的对象被从S0区移动到S1区,其实是在Young GC过程中S0区已满,则会将eden区中还存活的对象和S0区中的存活对象移动到S1区中)
- 老年代
经过S0,S1中几轮迭代后还存活的对象被提升到老年代 - 永久代
包含一些元数据像类、方法等等(永久代空间在JDK8特性中已经被移除)
Java垃圾回收是一个自动运行的管理程序运行时使用的内存的进程。通过GC的自动执行JVM将程序员从申请和释放内存的繁重操作中解放出来。作为一个自动执行的进程,程序员不需要在代码中主动初始化GC。Java提供了System.gc()和Runtime.gc()这两个hook来请求JVM调用GC进程
尽管要求系统机制给程序员提供调用GC的机会,但是实际上这是由JVM负责决定的。JVM可以选择拒绝启动GC的请求,因此并不保证这些请求会真的调用垃圾回收。这是JVM基于内存堆空间的Eden区(年轻代)的使用情况做出的决定。
JVM规范将这个选择权利留给了各个JVM的具体实现,因此实际上JVM是如何选择的视不同JVM的实现而定(但应该始终记住的是,不能依赖于这两个方法的调用,它们是不被保证执行的)
垃圾回收的流程
Eden Space:当一个实例被创建的时候,它最初被存放在堆内存空间的年轻代的Eden区中
Survivor Space(S0 和S1):作为minor回收周期的一部分,还活着的对象(还有引用指向它)被从eden区中移动到survivor空间S0。同样的,垃圾回收器扫描S0并将活着的实例移动到S1
无用的对象被标记并回收。垃圾回收器决定这些被标记的实例是在扫描的过程中移出内存还是在另外独立的迁移进程中执行
Old Generation:老年代或者永久代是堆内存的第二个逻辑部分。当垃圾回收器在做minor GC周期中,S1 survivor区中还活着的实例会被提升到老年代中。S1区中不再被引用的对象被标记并清除
Major GC:在Java垃圾回收过程中实例生命周期的最后一个阶段。Major GC在垃圾回收过程中扫描属于Old Generation部分的堆内存。如果实例没有被任何引用关联,它们将被标记、清除;如果它们还被引用关联着,则将继续存留在old generation。
从上述过程可以看出:生存时限越长的对象,其被垃圾回收处理机制扫描的频率就越低。
在回收过程中,会产生一些问题,比如说碎片。
一旦实例从堆内存中删除了,它们原来的位置将空出来给以后分配实例使用。显然这些空闲空间很容易在内存空间中产生碎片。为了能够更快地分配实例地址,需要对内存做去碎片化操作。根据不同垃圾回收器的策略,被回收的内存将在回收的过程同时或者在GC另外独立的过程中压缩整合。
四种类型的垃圾回收器
Java中存在四种类型的垃圾回收器:
- Serial Garbage Collector
- Parallel Garbage Collector
- CMS Garbage Collector
- G1 Garbage Collector
这四种类型的垃圾回收器都有各自的优点和缺点。最重要的是我们可以选择JVM使用哪种类型的垃圾回收器。我们可以通过传递不同的JVM参数来设置使用哪一个。各个垃圾回收器在不同应用场景下的效率会有很大的差异。因此了解各种不同类型的垃圾回收器以及它们的应用场景是非常重要的。
Serial Garbage Collector:
串行垃圾回收器控制所有的应用线程。它是为单线程场景设计的,只使用一个线程来执行垃圾回收工作。它暂停所有应用线程来执行垃圾回收工作的方式不适用于服务器的应用环境。它最适用的是简单的命令行程序
使用-XX:+UseSerialGC JVM参数来开启使用串行垃圾回收器Parallel Garbage Collector:
并行垃圾回收器也称作基于吞吐量的回收器。它是JVM的默认垃圾回收器。与Serial不同的是,它使用多个线程来执行垃圾回收工作。和Serial回收器一样,它在执行垃圾回收工作是也需要暂停所有应用线程CMS Garbage Collector:
并发标记清除(Concurrent Mark Sweep,CMS)垃圾回收器,使用多个线程来扫描堆内存并标记可被清除的对象,然后清除标记的对象。CMS垃圾回收器只在下面这两种情形下暂停工作线程:
- 在老年代中标记引用对象的时候
- 在做垃圾回收的过程中堆内存中有变化发生
对比与并行垃圾回收器,CMS回收器使用更多的CPU来保证更高的吞吐量。如果我们可以有更多的CPU用来提升性能,那么CMS垃圾回收器是比并行回收器更好的选择
使用-XX:+UseParNewGC JVM参数来开启使用CMS垃圾回收器。G1 Garbage Collector:
G1垃圾回收器应用于大的堆内存空间。它将堆内存空间划分为不同的区域,对各个区域并行地做回收工作。G1在回收内存空间后还立即对堆空闲空间做整合工作以减少碎片。CMS却是在全部停止(stop the world,STW)时执行内存整合工作。对于不同的区域G1根据垃圾的数量决定优先级
使用-XX:UseG1GC JVM参数来开启使用G1垃圾回收器
在使用G1垃圾回收器时,开启使用-XX:+UseStringDeduplacaton JVM参数。它会通过把重复的String值移动到同一个char[]数组来优化堆内存占用。这是Java 8 u 20引入的选项
Java中的垃圾判定与回收托管特征
我们知道,GC主要处理的是对象的回收操作,那么什么时候会触发一个对象的回收的呢:
1、对象没有引用
2、作用域发生未捕获异常
3、程序在作用域正常执行完毕
4、程序执行了System.exit()
5、程序发生意外终止(被杀进程等)
接下来将介绍几种JVM使用的判定算法:
- 计数器算法
在JDK1.2之前,使用的是引用计数器算法,即当类被加载到内存以后,就会产生方法区,堆栈、程序计数器等一系列信息,当创建对象的时候,为这个对象在堆栈空间中分配对象,同时会产生一个引用计数器,同时引用计数器+1,当有新的引用的时候,引用计数器继续+1,而当其中一个引用销毁的时候,引用计数器-1,当引用计数器被减为零的时候,标志着这个对象已经没有引用了,可以回收了。
引用计数算法在JDK1.2之前的版本被广泛使用,但是随着业务的发展,很快出现了一个问题:
当我们的代码出现下面的情形时,该算法将无法适应
这样的代码会产生如下引用情形 objA指向objB,而objB又指向objA,这样当其他所有的引用都消失了之后,objA和objB还有一个相互的引用,也就是说两个对象的引用计数器各为1,而实际上这两个对象都已经没有额外的引用,已经是垃圾了。
- 根搜索算法
根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张有向图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点
目前java中可作为GC Root的对象有
虚拟机栈中引用的对象(本地变量表)
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈中引用的对象(Native对象)[C/C++使用native接口编写的方法中的对象]
JAVA内存泄漏
Java语言的一个关键的优势就是它的内存管理机制。你只管创建对象,Java的垃圾回收器帮你分配以及回收内存。然而,实际的情况并没有那么简单,因为内存泄漏在Java应用程序中还是时有发生的
事实上,在讲解Java中垃圾判定时,我们就看到了在JDK1.2之前,Java中的内存泄露甚至是一个比较常见的现象。
内存泄漏的定义:对象已经没有被应用程序使用,但是垃圾回收器没办法移除它们,因为还在被引用着
要想理解这个定义,我们需要先了解一下对象在内存中的状态。下面的这张图就解释了什么是无用对象以及什么是未被引用对象
从刚才的图里面可以看出,里面有被引用对象和未被引用对象。未被引用对象会被垃圾回收器回收,而被引用的对象却不会。
未被引用的对象当然是不再被使用的对象,因为没有对象再引用它。然而无用对象却不全是未被引用对象。其中还有被引用的。就是这种情况导致了内存泄漏
上例中,A对象引用B对象,A对象的生命周期(t1-t4)比B对象的生命周期(t2-t3)长的多。当B对象没有被应用程序使用之后,A对象仍然在引用着B对象。这样,垃圾回收器就没办法将B对象从内存中移除,从而导致内存问题,因为如果A引用更多这样的对象,那将有更多的未被引用对象存在,并消耗内存空间
B对象也可能会持有许多其他的对象,那这些对象同样也不会被垃圾回收器回收。所有这些没在使用的对象将持续的消耗之前分配的内存空间。
- 下面是几条容易上手的建议,来帮助开发人员防止内存泄漏的发生:
- 不再使用的对象将指向其的引用置空指向null
- 特别注意一些像HashMap、ArrayList的集合对象,它们经常会引发内存泄漏。当它们被声明为static时,它们的生命周期就会和应用程序一样长
- 同样是集合,当原有对象的属性发生改变(hashCode变化),remove()方法可能会失效,导致内存泄露
- 特别注意系统中各种事件监听和回调。当一个监听器在使用的时候被注册,但不再使用之后却未被反注册
- “如果一个类自己管理内存,那开发人员就得小心内存泄漏问题了。” 通常一些成员变量引用其他对象,初始化的时候需要置空
事实上,对于一些常用API的使用如果不了解其实现方式的话也很容易引起内存泄露。例如,String类常用的截取字串的方法substring()在JDK1.6中如果滥用就会导致比较严重的内存泄露
如果研究过Java的源代码,就会发现,Java中的字符串由char[]作为数据结构支持,String类中包含了3个成员:char[] value,int offset,int count,它们分别用于存放实际的字符序列,本字符串对象的第一个字符在字符数组中的位置以及字符串对象包含多少个字符。
在JDK6中,substring()方法创建了一个新的String对象,但是新的String对象中的value成员指向了和源字符串相同的字符数组,只不过offset和count的取值发生了改变。
这种情况下,如果你有一个非常长的字符串,但是只使用了substring()方法截取了很短的一部分字串来使用,根据JDK6的实现方式,虽然只是用了很短的字串,但是仍然保留了整个长字符串的所有字符,会引发大量的内存浪费并影响性能
因此,在JDK6中,如果需要使用substring()方法,建议使用如下的方式,它将明确构建一个新的字符串(包括用于支持内容的字符数组)
x = x.substring(m, n) + “”;
在JDK7中,substring()方法已经做出了改进,会在对内存中为新的字符串对象创建一个新的字符数组:
finalize方法
Object中包含了一个叫做finalize()的方法,提供在对象被回收时调用以释放资源,默认情况下其不执行任何动作
由于Object是Java继承体系的根,因此事实上所有的Java类都具备finalize方法
当垃圾回收器确定了一个对象没有任何引用时,其会调用finalize()方法。但是,finalize方法并不保证调用时机,因此也不建议重写finalize()方法
如果必须要重写finalize()方法,请记住使用super.finalize()调用父类的清除方法,否则对象清理的过程可能不完整
每个对象只能被GC自动调用finalize( )方法一次。如果在finalize( )方法执行时产生异常(exception),则该对象仍可以被垃圾收集器收集
Java语言允许程序员为任何方法添加finalize( )方法,该方法会在垃圾收集器交换回收对象之前被调用。但不要过分依赖该方法对系统资源进行回收和再利用,因为该方法调用后的执行结果是不可预知的
当finalize()方法尚未被调用时,System.runFinalization()方法可以用来调用finalize()方法,并实现相同的效果,对无用对象进行垃圾收集
还有一个理由让我们需要更加谨慎对待finalize方法,那就是它其实有可能会阻断垃圾回收器对本对象的回收,我们称为对象复活,造成逻辑混乱和内存泄露
垃圾收集器跟踪每一个对象,收集那些不可到达的对象(即该对象没有被程序的任何“活的部分”所调用),回收其占有的内存空间。但在进行垃圾回收的时候,垃圾回收器会调用finalize( )方法,通过让其他对象知道它的存在,而使不可到达的对象再次“复活”为可到达的对象
既然每个对象只能调用一次finalize( )方法,所以每个对象也只可能“复活”一次
对象复活案例
强引用、软引用、弱引用及虚引用
在JDK1.2以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可触及状态,程序才能使用它
这就像在日常生活中,从商店购买了某样物品后,如果有用,就一直保留它,否则就把它扔到垃圾箱,由清洁工人收走。一般说来,如果物品已经被扔到垃圾箱,想再 把它捡回来使用就不可能了
但有时候情况并不这么简单,你可能会遇到类似鸡肋一样的物品,食之无味,弃之可惜。这种物品现在已经无用了,保留它会占空间,但是立刻扔掉它也不划算,因为也许将来还会派用场。对于这样的可有可无的物品,一种折衷的处理办法是:如果家里空间足够,就先把它保留在家里,如果家里空间不够,即使把家里所有的垃圾清除,还是无法容纳那些必不可少的生活用品,那么再扔掉这些可有可无的物品。
从JDK1.2版本开始,为了解决上述问题,把对象的引用分为四种级别,从而使程序能更加灵活的控制对象的生命周期。这四种级别由高到低依次为:
- 强引用
- 软引用
- 弱引用
- 虚引用
下图描述了不同引用类型的垃圾回收特性:
归纳一下
- 强应用
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用:
ClassName object = new ClassName();
如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题
- 软引用:
如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存
String str=new String("abc");
SoftReference<String> softRef=new SoftReference<String>(str);
- 弱引用:
如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象
String str=new String("abc");
WeakReference<String> weakRef=new WeakReference<String>(str);
- 虚引用:
顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收
虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
String str = new String("abc");
ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> phantomRef = new PhantomReference<String>(str, queue);
由于虚引用的特点,绑定应用队列后是finalize()方法的理想替代品,一旦虚引用被加入引用队列,就没有任何办法获取虚引用指向的对象,因此不存在对象复活的隐患
正如之前说的,软引用、弱引用、虚引用均可以和一个引用队列绑定使用
ReferenceQueue是作为 JVM GC与上层Reference对象管理之间的一个消息传递方式,它使得我们可以对所监听的对象引用可达发生变化时做一些处理
我们希望当一个对象被gc掉的时候通知用户线程,进行额外的处理时,就需要使用引用队列了。ReferenceQueue即这样的一个对象,当一个obj被gc掉之后,其相应的引用对象(软引用、弱引用、虚引用),即ref对象会被放入queue中。我们可以从queue中获取到相应的对象信息,同时进行额外的处理。比如反向操作,数据清理
引用队列实现了一个队列的入队(enqueue)和出队(poll还有remove)操作,内部元素就是泛型的Reference,并且Queue的实现,是由Reference自身的链表结构所实现的
引用队列的入队操作是由垃圾回收器完成的,当其发现回收的对象具备软引用、弱引用或虚引用时,会自动将对象的引用对象入队
我们只需要在必要时执行出队操作即可监控到有哪些对象被回收并执行相关的资源操作
实现对象内存缓存的方法
刚才提到的引用类型刚好可以满足我们对内存敏感缓存的需要:
软引用适用于保存数据相对较多,对象声明周期相对较长的缓存
弱引用适用于数据交换较为频繁的高速缓存体系,在JDK中为我们提供了WeakHashtable实现了这种功能。
- 利用软引用实现内存缓存:
1.自行构建软引用的子类,在其中添加作为标识存在的变量
2.构建缓存使用的引用队列
3.创建加入缓存方法,为提供的对象参数构建软引用并和引用队列绑定,将引用队列的标识作为键、引用对象本身作为值存入散列表,在加入缓存的方法中对现有缓存数据进行刷新,即循环对引用队列进行出队操作,捕获队列中引用对象的标识,并将该标志对应的引用对象从散列表中移出
4.创建从缓存中获取对象的方法(也要先刷新缓存),即通过标识在散列表中获取引用对象,再通过引用对象的get方法获取实际的数据对象