Java等语言的一大特性就是能够自动管理内存——即能自动垃圾回收。它作为语言的一大特性,给予了开发者很大的便利,但在某些情景下也会成为我们程序的瓶颈,这里就以java的GC作为例子,我和大家一起分析下它的背景、优劣势分析、适用的场景,常见的回收算法,回收算法中的关键部分、原理和具体实现,最后还会以各个语言已有的实现为例来相互做个对比。
01 垃圾回收算法的背景
首先,自动垃圾回收这个概念最早是从Lisp语言出来的,创建的初衷是自动处理内存管理。那么为什么会需要程序来进行内存管理呢?
写过C的同学都知道,在写代码时,最常出现的一个问题就是内存问题,比如代码中申请了空间却忘了释放,导致内存泄漏。或是指针已经释放过一次了,结果再别处又被释放了一次,造成“double free";有一次调研显示,对于C程序,60%以上的bug都是程序员对内存操作不当的问题。可见人工管理内存会的确是一件容易出错的事儿。
02 tradeOff
垃圾回收诞生后,极大地降低了程序的bug率,程序员们可以更专注在业务功能的编写上,以往经常出现的这些问题都逐渐减弱了:
-
野指针问题 指针所对应的的内存空间已经被释放了,然而还有引用指向它,如果这块地址被重新分配后,再访问这个地址时,会发现这是一片乱数据。
-
double free问题 通常是发生在尝试多次释放同一块内存,即使那块内存已经被释放;
-
内存泄漏 通常发生于尝试去释放某块内存,但这块内存被一个无法访问的对象占用了。
但带来这些便利的同时也带来了弊端:
- 垃圾回收相比人工的释放内存方式,要消耗一些计算资源来决定释放哪些内存。
- 在性能上对程序有损耗。
- 在回收时会暂停程序,来进行回收。这对于实时性要求比较高的、事务性要求较高的系统,是一件无法容忍的事。
- 垃圾回收的时机会有延迟,如果回收的比较晚,可能会发生内存泄漏
当Java程序遇到并发比较大的情况,自动垃圾回收的速度没法及时跟上,可能会出现性能问题。所以在jvm调优时,垃圾回收也是调优的一大重点。
我们先看看一个对象是如何从创建到被回收的:
1.假设现在要new一个爸爸类的对象, new 类名:
class Father{
int action;
...
}
在业务代码中这么写:
public Father newFather(){
Father obj1= new Father();
}
2.在编译过后,jvm虚拟机会先去常量区去寻找这个类的符号引用。如果没有,说明这个类还没被加载,则开始这个类的解析和初始化
3.jvm在新生代新建一个对象 obj1
image.png3.在新生代中存活
随着新建的对象越来越多,Eden代已经放不下了,要发生一次回收和整理,于是发生了minor GC,jvm会从S0/S1中,选取一个空闲的,将存活下来的对象整整齐齐地拷过去,于是新生代又腾出了空间。
4.晋升老年代
如果我们的对象obj1还在被使用着,那么每一次新生代回收后都存活下来,年龄会不断+1,当他年龄增长到一定程度(默认是15次)还存活的话,就晋升到老年代
image.png
5.被回收
obj1已经没有人引用,成为一个待回收的“垃圾”,此时程序要再将一些大对象放入老年代,这会发现老年代内存不够,需要发生一次MajorGC,以便腾出内存来分配给对象。
说完了过程,我们来看看这里面的关键部分:
1.新建对象的内存分配
java将堆划分为新生代和老年代,而新生代里面又区分了Eden、FromSurvivor、ToSurvivor这几个区域,其中,toSurvivor的区域是空的。
图片来自https://blog.csdn.net/suifeng629/article/details/82462164
默认情况下,jvm采取的是一种动态分配的策略,根据生成对象的速率、Survivor区的使用情况动态调整Eden区和Survivor区的大小。前面的例子是在堆中分配了一个实例的空间。
当我们new一个实例的时候,其实是在新生代里面分配了一块内存。由于堆空间是线程共享的,所以进行空间的划分也需要进行同步,否则就容易出现两个对象共用一个内存的事故。极客时间的专栏中有个很好的比方:这就相当于两个司机(线程)要停在不同的停车位,不然都往一个地方停就很容易出现剐蹭事故。
java虚拟机给出了解决方案:预先为每个线程分配相应的堆空间,该线程只允许使用分给自己的堆空间。这项技术称为TLab,jvm默认开启了这选项。具体每个线程都可以申请一段属于自己的连续内存,线程需要维护两个指针,一个在TLab空闲内存的起始位置,一个在TLab尾部,接下来new操作时,就可以通过指针加法来实现,只要TLab的在完成指针加法后,空闲内存所在的指针依旧小于尾部,那么就分配成功,否则,则需要线程申请新的TLab。
当Eden区的空间逐渐被耗尽,就会发生一次Minor GC,回收新生代的垃圾对象,然后将存活的对象从From Survivor区拷贝到To Survivor区,交换from和to区的指针,以保证下次to区是空的。而当对象在新生代存活的年龄达到15,或是Survivor区的对象占用率为50%,较高复制次数的对象将会晋升到老年代里。
MinorGC有什么好处?好处就是不用对整个堆进行回收。但是,这也会有一个问题,老年代可能引用了新生代的对象,在标记存活对象时,我们需要扫描老年代的对象,如果该对象拥有对新对象的引用,那么这个引用也被视为GCRoot。这样一来,感觉又做了一次全堆扫描?
jvm对此给出了一个解决方案:卡表,具体大家可以去详细了解下。
2.jvm的堆内存是划分为新生代和老年代,那么为什么要进行分代?
其实这是基于一个假设而造就的:大部分java对象只存活一段时间,而少数对象则会存活一段时间。
Oracle的郑雨迪曾写过一个基准测试,验证了这一假设,结果如下图:
image.png
可看出短期存活的对象远远多于长期存活的对象。jvm根据对象存活的周期不同,对不同代采用不同的回收算法进行回收,从而提高回收效率。
对于新生代,我们可采用耗时较短的回收策略;而对于老年代,由于我们可以猜测这会在新生代的垃圾已经被回收了,或是堆本身的空间也被用完了,这会jvm将会进行一次全堆扫描,不计耗时成本。
在oracle官网上,你也可以看到采取分代的原因:Why Generational Garbage Collection?
了解完对象是如何在内存中创建和存活后,下一篇我们来看下如何实现无用对象的回收。
网友评论