文章目录
- 引入
- 一、什么情况下JVM内存中的对象会被垃圾回收
- 1.1 哪些变量引用的对象不能被回收?
- 1.2 Java对象的引用类型
- 1.3拯救者finalize()方法
- 1.4 垃圾回收总结
- 二、分代模型
- 2.1 对象在JVM内存中的分配、流转
- 2.2 内存分配总结
- 三、垃圾回收算法
- 3.1 复制算法
- 3.2 标记整理算法
- 四、垃圾回收器
- 4.1 ParNew
- 4.2 CMS
- 4.3 灵活的G1
- 4.3.1 G1对垃圾回收造成的系统停顿,可控
- 4.3.2 Region 物理层面
- 4.3.3 新生代垃圾回收
- 4.3.4 何时触发新生代+老年代的混合垃圾回收?
引入
以下面代码为例:
public class Kafka {
public static void main(String[] args) {
loadReplicasFromDisk();
}
public void loadReplicasFromDisk(){
ReplicaManager replicaManager = new ReplicaManager();
replicaManager.load();
}
}
一旦loadReplicasFromDisk()方法执行完,就会将loadReplicasFromDisk()方法对应的栈帧,从Java虚拟机栈中“出栈”。一旦loadReplicasFromDisk()方法对应的栈帧出栈,栈帧中的局部变量也就没有了。自此以后,再也没有一个引用类型的局部变量可以指向Java堆内存中的ReplicaManager实例对象了。
但是就目前而言,Java堆内存里创建的对象还在,还是占用着内存空间。内存资源宝贵,这就需要JVM的垃圾回收机制。
只要启动一个JVM进程,就会有一个自带的后台自动运行的线程 ,专门在后台不断的检查JVM堆内存中的各个实例对象。如果Java堆内存中的某个实例对象,没有任何一个局部变量、类的静态变量、常量等指向它,它就成了“垃圾”,就会被后台垃圾回收线程清理掉。
一、什么情况下JVM内存中的对象会被垃圾回收
创建对象优先分配在新生代,内存空间打满就会触发Young GC来释放内存空间。一旦该对象没人引用,就会被回收掉
1.1 哪些变量引用的对象不能被回收?
JVM使用了可达性分析算法,判断哪些对象可以被回收。就是对每个对象,分析一下还有谁在引用它。然后一层层往上判断,看是否有一个GC Roots。只要一个对象被局部变量引用,就说明该对象有一个GC Roots,就不能被回收。这句话太绝对,因为还和对象的引用类型有关。
//在方法中创建对象,局部变量引用这个对象
public class Kafka {
public static void main(String[] args) {
loadReplicasFromDisk();
}
public void loadReplicasFromDisk(){
ReplicaManager replicaManager = new ReplicaManager();
}
}
两个方法的栈帧都入栈,局部变量保持对堆内存中对象的引用。如果此时触发Young GC,就会分析这个对象的可达性。经过分析发现,该对象正在被一个局部变量引用着(局部变量可以作为GC Roots,这说明该对象有一个GC Roots),不能被回收。
或者对象正在被一个静态变量引用,也会被判断为不可回收。
方法的局部变量和类的静态变量,都可以作为GC Roots。不严谨的说,只要对象被它们引用了,就不会被回收。
1.2 Java对象的引用类型
强引用(不会被回收),就是直接new出来的对象,被变量引用着:
ReplicaManager replicaManager = new ReplicaManager();
软引用(只要内存空间不足,即使正在被引用,也要被回收),对象被一个SoftReference软引用类型的对象包裹起来:
SoftReference<ReplicaManager> replicaManager = new SoftReference<ReplicaManager>(new ReplicaManager);
弱引用(类似没引用,直接被回收),对象被一个WakReference对象包裹:
WakReference<ReplicaManager> replicaManager = new WakReference<ReplicaManager>(new ReplicaManager);
虚引用,很少用,可以忽略。
1.3拯救者finalize()方法
讲道理,没有GC Roots引用的对象会被立刻回收。但是finalize()方法能拯救它。
public class ReplicaManager{
public static ReplicaManager instance;
@Override
protected void finalize() throws Throwable(){
RejplicaManager.instance = this;
}
}
类会将自己的实例对象交给静态变量(GC Roots),这样GC Roots就引用了对象,避免被回收
1.4 垃圾回收总结
创建的对象优先分配在新生代,放到Eden区。打满了就触发Young GC来释放内存。JVM的可达性分析算法,会判断:对象被方法的局部变量、类的静态变量引用,就不会被回收。但不绝对,因为还跟引用类型有关:
- 强引用:new出来的对象
- 软引用:对象被SoftReference包裹(如:SoftReference<对象>),Young GC时即使对象正在被引用,也得被强制回收
- 弱引用:对象被WeakReference包裹(如:WeakReference<对象>),等同于没引用,直接被回收
- 虚引用:没啥用
即使对象失去了GC Roots引用,但仍旧能通过finalize()方法拯救一下子。如果对象实例交给了某个GC Roots引用,就会让它重新被引用,不用被垃圾回收。
二、分代模型
JVM将Java堆内存划分为新生代(生存周期极短,创建和使用完后立马回收)、老年代(比如:用static修饰,长期存在)、永久代(就是方法区,存放类信息、常量池)。大部分的正常对象,都是优先新生代分配内存的。
public class Kafka {
//长期逗留内存,年轻代停留一会,最终进入老年代
private static ReplicaFetcher fetcher = new ReplicaFetcher();
public static void main(String[] args) {
loadReplicasFromDisk();
while(true){
fetchReplicasFromRemote();
Thread.sleep(1000);
}
}
//栈帧入栈,创建ReplicaManager对象,用完即收,放在年轻代,
//由栈帧中的局部变量引用
//方法执行完毕,栈帧出栈,年轻代中的ReplicaManager对象的下场就是被回收
private static void loadReplicasFromDisk(){
ReplicaManager replicaManager = new ReplicaManager();
replicaManager.load();
}
private static void fetchReplicasFromRemote(){
fetcher.fetch();
}
}
static修饰的ReplicaFetcher会长期逗留内存,进入年轻代不久,最终进入老年代。
main()方法栈帧入栈,创建的ReplicaManager对象用完即收,只是会被放在年轻代,由栈帧中的局部变量引用。
一旦方法执行完毕,栈帧出栈、对象不再被引用,年轻代中的对象就会被回收。但是ReplicaFetcher对象因为被静态变量fetcher引用,故长期存在于老年代中
2.1 对象在JVM内存中的分配、流转
优先分配在新生代,即使老年代中长期存在的对象,最初也是分配在新生代的。正常通过new创建的对象,也是分配在新生代的。
经过新生代Young GC后存活的对象太多,大批存活对象进入老年代。老年代中的“钉子户”也有乖乖被“拆迁”的一天。但是老年代的垃圾回收,速度很慢!
方法执行完毕,栈帧出栈,局部变量不再指向实例对象。但对象不会被立即回收,而是有触发条件。当新生代内存空间被挤满,JVM就会尝试进行一次新生代内存的垃圾回收(Minor GC也叫Young GC),将新生代中的垃圾对象回收。
但如果一个长期使用的对象能“躲过”15次的Young GC,就要被转移到老年代中去。
2.2 内存分配总结
- 对象优先分配在新生代
- 新生代内存满了,就触发Young GC回收垃圾对象
- 如果有对象能躲过十几次的Young GC,就转入老年代
- 如果老年代也满了,就会触发垃圾回收,清理垃圾对象
三、垃圾回收算法
3.1 复制算法
如果仅仅标记垃圾对象、回收,就会造成大量的内存碎片,造成内存浪费。为此,标记存活对象,将存活对象复制到另一块空白内存中。
这样,存活对象在“新家”就能紧密排列,新创建的对象也能很好的安置在“新家”。“老房子”直接铲平就好了。如此循环往复
缺点:
内存使用效率低,只能使用一半
为提高内存利用率,进行改进:
将新生代内存划分为:Eden区 80%、Survivor区 10%、Survivor区 10%
新创建的对象被分配到Eden区,触发Young GC后将存活对象复制到Survivor 1区、清空Eden区。等Eden区再次触发Young GC就将Eden区、Survivor 1区持有的存活对象,统统复制到Survivor 2区。这样就能保证始终都有一个Survivor区是空的
如此,就能将内存利用率提升至90%
3.2 标记整理算法
老年代中的垃圾对象、存活对象可能杂乱无章,因此需要标记存活对象。将存活对象都挪到一起,紧密排列,避免垃圾回收后出现太多的内存碎片。
之后再将老年代的垃圾对象一次性全回收了,老年代的垃圾回收算法比新生代的慢10倍。如果频繁Full GC就会影响系统性能,出现卡顿。
四、垃圾回收器
4.1 ParNew
针对新生代,使用复制算法。多线程并发机制,充分利用多核CPU资源。
它会将Eden区的存活对象标记,并全部转移到空Survivor区。暂停所有工作线程,禁止系统继续创建新对象,然后一举扫清Eden区内全部的垃圾对象。接着系统继续运行,新对象继续分配在Eden区。
设置JVM参数“-XX:+UseParNewGC”,就是启用ParNew垃圾回收器。默认的线程数 = CPU的核数。即16核CPU就代表ParNew有16个线程。可以通过“-XX:ParallelGCThreads”设置线程数,但是一般不动。
4.2 CMS
针对老年代,标记清除算法,但是会产生大量的内存碎片。进行一次垃圾回收,要经历4个阶段:
- 初始标记,标记出来所有GC Roots直接引用的对象
- 并发标记
- 重新标记
- 并发清理
① 初始标记,在此期间会让系统的所有工作线程统统stop the world。
//类静态变量才是GC Roots,不会管ReplicaFetcher对象。
//因为只有方法的局部变量、类的静态变量才是GC Roots,类的实例变量不是
public class Kafka{
private static ReplicaManager replicaManager = new ReplicaManager();
}
public class ReplicaManager{
private ReplicaFetcher replicaFetcher = new ReplicaFetcher();
}
初始标记过程:通过类静态变量replicaManager代表的GC Roots,标记出直接引用的ReplicaManager对象。
该过程速度极快,仅仅是标记GC Roots直接引用的对象。Stop The World影响并不大。
② 并发标记,允许系统的工作线程继续任意的创建新对象、继续运行。在此期间会新建大量存活对象,也可能原来的存活对象变成垃圾对象。垃圾回收线程会尽量的对已有的对象进行GC Roots追踪(间接引用对象)。但实在是被追踪的对象太多,导致耗时太高。
比如ReplicaFetcher对象被ReplicaManager对象的实例变量引用了,而ReplicaManager对象又被类静态变量引用了。那么就认定ReplicaFetcher对象是被GC Roots间接引用的,不能被回收掉。
这个阶段是最耗时的,因为要进行GC Roots追踪,需要看所有对象是否在根源上就被GC Roots引用了。
③ 重新标记,经过阶段②的创建新对象、标记存活对象和垃圾对象,肯定有很多存活对象和垃圾对象尚未被标记。所以此时还要将系统Stop The World,重新标记下在阶段②里创建的那些新对象、垃圾对象。工作量少,所以速度很快。
④并发清理,系统恢复运行,清理对象 和 系统程序并发执行,将垃圾对象从各种随机的内存位置清理掉,很耗时。
①③需要Stop The World,由于并发原因,影响不大。
②④最耗时,需要对GC Roots标记追踪和垃圾清理,并发原因影响也不大。但是②④期间并没有Stop The World,会导致有限的CPU资源被垃圾回收线程占用一部分。
缺点:
- 严重消耗CPU资源:CMS默认启动的垃圾回收线程数量 = (CPU核数 + 3)/4
- Concurrent Mode Failure问题:此时就会启动Serial Old代替CMS,强行Stop The World,重新进行长时间的GC Roots追踪,标记出全部的垃圾对象,不允许新对象产生。然后再将垃圾对象清理、恢复系统工作线程。
- 内存碎片:CMS使用标记清除,产生大量内存碎片。导致没有太多可用的连续内存空间,继而触发Full GC。所以CMS并非仅用标记清除算法。CMS有个参数:-XX:+UseCMSCompactAtFullCollection(Full GC后要Stop The World,进行碎片整理。把存活对象挪到一块,空出大片连续内存空间)和-XX:+CMFullGCsBeforeCompaction(默认0,即执行完0次Full GC后,再来一次内存碎片的清理)
Concurrent Mode Failure:发生④时,仅是回收标记的垃圾对象,但可能会有一些新创建对象进入老年代成为垃圾对象(浮动垃圾)。这些浮动垃圾尚未标记,本次不能被Full GC,只能等下次。-XX:CMSInitiatiingOccupancyFaction参数可以设置老年代触发CMS的内存占比,JDK1.6默认92%。预留8%给并发回收期间。如果这8%不够放,就会发生Concurrent Mode Failure。
4.3 灵活的G1
统一回收新生代、老年代。有更好的算法、设计机制。反观ParNew和CMS,最大的痛点就是Stop The Wordl!尽可能的减少、避免Stop The Word才是王道
G1会把Java堆内存拆分为多个大小相等的Region(新生代包括哪些Region、老年代包括哪些Region),整个过程都是动态的。
G1适用于超大内存的机器,如果内存太大、不用G1,会导致新生代每次GC回收垃圾太多,Stop The World时间太长。G1可以指定这个停顿时间,每次只回收部分Region即可
4.3.1 G1对垃圾回收造成的系统停顿,可控
可以让我们设置一个垃圾回收的预期停顿时间(Stop The World期间,系统停顿的时间不能超过多久)。这就要求对每个Region都进行追踪:每个Region里哪些对象是垃圾、回收这些垃圾的耗时。
比如Region 1有10MB垃圾,需要1s回收;Region 2有20MB垃圾,需要200ms回收。G1触发垃圾回收时,判断最近一段时间内(比如1小时内)因为垃圾回收造成了系统800ms的卡顿,现在又得再来一次回收,于是就回收掉那个耗时200ms的Region中的垃圾。
核心思路:G1会把内存拆分等大的Region、追踪这些Region中的垃圾和回收时间。将垃圾回收对系统造成的影响,交给我们控制。在我们指定的时间范围内,回收更对的垃圾
4.3.2 Region 物理层面
Region不归新生代、老年代,没有新生代给多少内存、老年代给多少内存一说。可能某个Region开始装的新建对象,触发垃圾回收后,就装老年代的长期存活对象。实际由G1控制,新生代、老年代的内存区域是不停变动的。
给堆内存设置大小,用**-XX:+UseG1GC指定G1垃圾回收器,用堆内存大小/2048=Region大小**。JVM最多有2048个Region,且Region的大小必须是2的倍数:1M、2M、4M。也能用**-XX:G1HeapRegionSize**手动指定Region大小
刚开始默认新生代占用堆内存5%,可通过**-XX:G1MaxNewSizePercent设置新生代初始占比**。最多占比不会超过60%,可通过**-XX:G1MaxNewSizePercent**设置。触发垃圾回收,Region的数量就会减少。
**在逻辑上,-XX:SurvivorRatio=8会划分出哪些Region属于Eden区,哪些Region属于Survivor。**只不过随着隶属新生代的Region的个数变化,Eden和Survivor名下的Region也会相应变化
4.3.3 新生代垃圾回收
如果新生代占据超过堆内存的60%,就触发垃圾回收。G1采用复制算法(不会产生内存碎片)来回收新生代的垃圾,进入Stop The World状态。将Eden对应的Region中的存活对象放到Survivor对应的Region中,回收掉Eden隶属的Region中的垃圾对象。可通过-XX:MaxGCPauseMills设置Stop The World的时间
进入老年代的条件:
- 对象在新生代躲过多次Young GC,-XX:MaxTenuringThreshold可设置这个年龄
- 动态年龄判断机制
G1提供了专门的Region来存放超大对象,而非让其直接进入老年代。判定规则:如果某个超大对象超过Region的50%,就会被放到专门的Region中。如果1个Region放不下这个超大对象,还能联合多个Region一起存放。
4.3.4 何时触发新生代+老年代的混合垃圾回收?
老年代如果占据了**堆内存的45%(-XX:InitiatingHeapOccupancyPercent)**的Region,就会触发一次混合回收。
G1的垃圾回收过程:
- 触发初始标记,需要进入Stop The World。仅仅是标记GC Roots能直接引用的对象,速度很快。比如标记类静态变量replicaManager
- 并发标记,允许系统程序运行的同时,并发的进行GC Roots的追踪(间接引用对象)。如replicaManager引用的ReplicaManager对象里有一个实例变量replicaFetcher引用了ReplicaFetcher对象。耗时,因为要追踪到所有的存活对象。还会监控对象的修改记录,如那个对象被新建了,哪个失去引用了
- 最终标记阶段,进入Stop The World。根据上一步监控的修改记录,最终标记一下哪些是垃圾对象、存活对象
- 混合回收,计算老年代中每个Region中的存活对象的数量、占比,和执行垃圾回收的预期性能、效率。然后Stop The World,全力进行垃圾回收。选择部分Region按照策略,使停顿时间控制在我们指定范围内。比如预测只能暂停200ms,那就回收符合200ms的Region
混合回收,就是从新生代、老年代、大对象里挑一些Region,保证在指定时间内最大化的回收垃圾。这个阶段可以执行多次。-XX:G1MixedGCCountTarget(默认8)可以控制最后的混合回收阶段执行几次的回收:先停止系统运行、再混合回收Region、再继续运行、再回收…循环8次完毕,才算完成一次混合回收。
8次循环回收过程中,一旦空闲下来的Region达到堆内存的5%(默认,-XX:G1HeapWastePercent),就立即提前结束整个回收过程
-XX:G1MixedGCLiveThresholdPercent默认85%,存活的对象必须低于这个值的Region才能参加混合回收。
混合回收失败的Full GC:
Mixed回收,新生代、老年代都是基于复制算法,将各个Region中的存活对象copy到其他Region中去。一旦copy过程中没有足够的空闲Region承载,就会立刻Stop The World,采用单线程进行标记、清理、压缩整理,空闲出一批Region。整个Full GC的过程极慢、极慢!