Java
所承诺的自动内存管理主要是针对对象内存的回收和对象内存的分配.
在 Java
虚拟机的五块内存空间中,程序计数器、Java
虚拟机栈、本地方法栈内存的分配和回收都具有确定性,一半都在编译阶段就能确定下来需要分配的内存大小,并且由于都是线程私有.
因此它们的内存空间都随着线程的创建而创建,线程的结束而回收.也就是这三个区域的内存分配和回收都具有确定性.
而 Java
虚拟机中的方法区因为是用来存储类信息、常量静态变量,这些数据的变动性较小,因此不是 Java
内存管理重点需要关注的区域.
而对于堆,所有线程共享,所有的对象都需要在堆中创建和回收.虽然每个对象的大小在类加载的时候就能确定.
但对象的数量只有在程序运行期间才能确定,因此堆中内存的分配具有较大的不确定性.
此外,对象的生命周期长短不一,因此需要针对不同生命周期的对象采用不同的内存回收算法,增加了内存回收的复杂性.
综上所述:Java
自动内存管理最核心的功能是堆内存中对象的分配与回收.
1.1 对象优先在 Eden
区中分配
目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代.
在新生代中为了防止内存碎片问题,因此垃圾收集器一般都选用 "复制" 算法.因此,堆内存的新生代被进一步分为:Eden 区
Survior1 区 Survior2 区.
每次创建对象时,首先会在
Eden 区中分配.
若 Eden
区已满,则在 Survior1 区中分配.若 Eden 区 Survior1 区剩余内存太少,导致对象无法放入该区域时,就会启用
"分配担保",将当前 Eden 区 Survior1 区中的对象转移到老年代中,然后再将新对象存入 Eden
区.
1.2
大对象直接进入老年代
所谓 "大对象"
就是指一个占用大量连续存储空间的对象,如数组.
当发现一个大对象在
Eden 区 Survior1 区中存不下的时候就需要分配担保机制把当前 Eden 区 Survior1
区的所有对象都复制到老年代中去.
我们知道,一个大对象能够存入 Eden 区 Survior1
区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下.
因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作.
那么,什么样的对象才是
"大对象" 呢?
通过-XX:PretrnureSizeThreshold 参数设置大对象,该参数用于设置大小超过该参数的对象被认为是
"大对象",直接进入老年代.
注意:该参数只对
Serial 和 ParNew 收集器有效.
1.3
生命周期较长的对象进入老年代
老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?
新生代中的每个对象都有一个年龄计数器,当新生代发生一次 MinorGC
后,存活下来的对象的年龄就加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去.
使用-XXMaxTenuringThreshold
设置新生代的最大年龄,设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去.武
1.4
相同年龄的对象内存超过 Survior 内存一半的对象进入老年代
如果当前新生代的
Survior 中,年龄相同的对象的内存空间总和超过了 Survior
内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去.
无需等到对象的年龄超过
MaxTenuringThreshold 才被转移到老年代中去.
1.5 "分配担保"
策略详解
当垃圾收集器准备要在新生代发起一次 MinorGC 时,首先会检查 "老年代中最大的连续空闲区域的大小 是否大于
新生代中所有对象的大小?",也就是老年代中目前能够将新生代中所有对象全部装下?
若老年代能够装下新生代中所有的对象,那么此时进行 MinorGC 没有任何风险,然后就进行
MinorGC.
若老年代无法装下新生代中所有的对象,那么此时进行 MinorGC 是有风险的,垃圾收集器会进行一次预测:根据以往 MinorGC
过后存活对象的平均数来预测这次 MinorGC 后存活对象的平均数.
如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行 MinorGC,虽然此次 MinorGC
是有风险的.
如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次 Full
GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保.
这个过程就是分配担保.
注意:
分配担保是老年代为新生代作担保;
新生代中使用 "复制"
算法实现垃圾回收,老年代中使用 "标记-清除" 或 "标记-整理" 算法实现垃圾回收,只有使用 "复制"
算法的区域才需要分配担保,因此新生代需要分配担保,而老年代不需要分配担保.
四、了解 Java
虚拟机的垃圾回收算法
Java
虚拟机的内存模型分为五个部分,分别是:程序计数器、Java
虚拟机栈、本地方法栈、堆、方法区.
这五个区域既然是存储空间,那么为了避免 Java
虚拟机在运行期间内存存满的情况,就必须得有一个垃圾收集者的角色,不定期地回收一些无效内存,以保障 Java
虚拟机能够健康地持续运行.
这个垃圾收集者就是平常我们所说的
"垃圾收集器",那么垃圾收集器在何时清扫内存?清扫哪些数据?这就是接下来我们要解决的问题.
程序计数器、Java
虚拟机栈、本地方法栈都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁.
那么,垃圾收集器在何时清扫这三块区域的问题就解决了.
此外,Java
虚拟机栈、本地方法栈中的栈帧会随着方法的开始而入栈,方法的结束而出栈,并且每个栈帧中的本地变量表都是在类被加载的时候就确定的.
因此以上三个区域的垃圾收集工作具有确定性,垃圾收集器能够清楚地知道何时清扫这三块区域中的哪些数据.
然而,堆和方法区中的内存清理工作就没那么容易了.
堆和方法区所有线程共享,并且都在 JVM 启动时创建,一直得运行到 JVM
停止时.因此它们没办法根据线程的创建而创建、线程的结束而释放.
堆中存放 JVM
运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定.
方法区中存放类信息、静态成员变量、常量.类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类.因此,JVM
究竟要加载多少个类也需要在程序运行期间确定.
因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思.
1.1
堆内存的回收
1.1.1
如何判定哪些对象需要回收?
在对堆进行对象回收之前,首先要判断哪些是无效对象.我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收.一般有两种判别方式:
引用计数法:每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;若该引用失效则计数器减一.当计数器为 0
时,就认为该对象是无效对象.
可达性分析法:所有和
GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots
没有关联的对象就是无效对象.
GC Roots
是指:
Java
虚拟机栈所引用的对象 (栈帧中局部变量表中引用类型的变量所引用的对象);
方法区中静态属性引用的对象;
方法区中常量所引用的对象;
本地方法栈所引用的对象.
两者对比:
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题.
因此,目前主流语言均使用可达性分析方法来判断对象是否有效.
1.1.2
回收无效对象的过程
当 JVM
筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会,具体过程如下:
判断该对象是否覆盖了
finalize() 方法;
若已覆盖该方法,并该对象的 finalize() 方法还没有被执行过,那么就会将 finalize() 扔到 F-Queue
队列中;
若未覆盖该方法,则直接释放对象内存.
执行 F-Queue
队列中的 finalize() 方法;
虚拟机会以较低的优先级执行这些 finalize() 方法们,也不会确保所有的 finalize()
方法都会执行结束.
如果
finalize() 方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除.
对象重生或死亡;
如果在执行
finalize() 方法时,将 this
赋给了某一个引用,那么该对象就重生了.如果没有,那么就会被垃圾收集器清除.
注意:强烈不建议使用
finalize() 函数进行任何操作!如果需要释放资源,请使用 try-finally.因为 finalize()
不确定性大,开销大,无法保证顺利执行.
1.2
方法区的内存回收
我们知道,如果使用复制算法实现堆的内存回收,堆就会被分为新生代和老年代,新生代中的对象
"朝生夕死",每次垃圾回收都会清除掉大量的对象;而老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除掉.
由于方法区中存放生命周期较长的类信息、常量、静态变量,因此方法区就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉.
方法区中主要清除两种垃圾:
废弃常量;
废弃的类.
1.2.1
如何判定废弃常量?
清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉.
1.2.2
如何废弃废弃的类?
清除废弃类的条件较为苛刻:
该类的所有对象都已被清除;
该类的
java.lang.Class
对象没有被任何对象或变量引用;只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class.这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除;
加载该类的
ClassLoader 已经被回收.
1.3
垃圾收集算法
现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据.
1.3.1
标记-清除算法
首先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据.
分析:这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率.
1.3.2
复制算法
将内存分成两份,只将数据存储在其中一块上.当需要回收垃圾时,也是首先标记出废弃的数据,然后将有用的数据复制到另一块内存上,最后将第一块内存全部清除.
分析:这种算法避免了碎片空间,但内存被缩小了一半.而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高.
解决空间利用率:在新生代中,由于大量的对象都是
"朝生夕死",也就是一次垃圾收集后只有少量对象存活,因此我们可以将内存划分成三块:Eden、Survior1、Survior2,内存大小分别是
8:1:1.
分配内存时,只使用
Eden 和一块 Survior1.当发现 Eden Survior1 的内存即将满时,JVM 会发起一次
MinorGC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块 Survior2 中.那么,接下来就使用 Survior2
Eden 进行内存分配.
通过这种方式,只需要浪费
10% 的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题.
但是,当一个对象要申请内存空间时,发现 Eden Survior 中剩下的空间无法放置该对象,此时需要进行 Minor GC,如果
MinorGC 过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将对象转移到老年代中,这种方式叫做
"分配担保".
什么是分配担保?
当 JVM
准备为一个对象分配内存空间时,发现此时 Eden Survior 中空闲的区域无法装下该对象,那么就会触发
MinorGC,对该区域的废弃对象进行回收.
但如果 MinorGC
过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将 Eden Survior 中的所有对象都转移到老年代中,然后再将新对象存入
Eden 区.这个过程就是 "分配担保".
1.3.3
标记-整理算法
在回收垃圾前,首先将所有废弃的对象做上标记,然后将所有未被标记的对象移到一边,最后清空另一边区域即可.
分析:它是一种老年代的垃圾收集算法.
老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用 "复制"
算法,每次需要复制大量存活的对象,会导致效率很低.
而且,在新生代中使用
"复制" 算法,当 Eden Survior 中都装不下某个对象时,可以使用老年代的内存进行
"分配担保",而如果在老年代使用该算法,那么在老年代中如果出现 Eden Survior
装不下某个对象时,没有其他区域给他作分配担保.
因此,老年代中一般使用
"标记-整理" 算法.
1.3.4
分代收集算法
将内存划分为老年代和新生代.老年代中存放寿命较长的对象,新生代中存放 "朝生夕死"
的对象.然后在不同的区域使用不同的垃圾收集算法.
1.4 Java
中引用的种类
Java
中根据生命周期的长短,将引用分为 4 类.
1.4.1
强引用
我们平时所使用的引用就是强引用.
A a = new
A(); 也就是通过关键字 new 创建的对象所关联的引用就是强引用.
只要强引用存在,该对象永远也不会被回收.
1.4.2
软引用
只有当堆即将发生 OOM
异常时,JVM 才会回收软引用所指向的对象.
软引用通过
SoftReference 类实现.软引用的生命周期比强引用短一些.
1.4.3
弱引用
只要垃圾收集器运行,软引用所指向的对象就会被回收.
弱引用通过
WeakReference 类实现.弱引用的生命周期比软引用短.
1.4.4
虚引用
虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数.
一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知.虚引用通过 PhantomReference
类来实现.