Java 语言与其余编程语言有一个很是突出的特色,自动化内存管理机制。而这种机制离不开高效率的垃圾收集器(Garbage Collection)与合理的内存分配策略,这也是本篇文章将要描述的两个核心点。java
引一句周志明老师对 Java 中的内存管理机制的描述:git
Java 与 C++ 之间有一堵有内存动态分配和垃圾收集技术所围成的「高墙」,墙外面的人想进去,墙里面的人却想出来。github
各有各的优点,没有谁会替代谁,只是应用在不一样的场合下,谁更适合而已。算法
Java 中使用「可达性分析算法」来断定堆中的垃圾,可是不少其余的编程语言都采用「引用计数算法」判断对象是否依然存活。例如,Python,C++ 以及一些游戏脚本语言就采用的「引用计数算法」来断定对象的存活与否。编程
引用计数算法:给每个引用对象增长一个计数器,每当有一个地方引用了该对象,就使该对象的计数器加一,每当一个引用失效时就使该计数器减一。当进行垃圾断定的时候,若是某个对象的计数器为零即说明了该对象无人引用,是垃圾。数组
这种算法设计简单,效率高,但 Java 里为何没有采用呢?安全
主要是引用计数算法存在一个很致命的问题,循环引用。咱们看一段代码:bash
public class A {
private B bRef;
public B getbRef() {
return bRef;
}
public void setbRef(B bRef) {
this.bRef = bRef;
}
}
复制代码
public class B {
private A aRef;
public A getaRef() {
return aRef;
}
public void setaRef(A aRef) {
this.aRef = aRef;
}
}
复制代码
产生循环引用:微信
public static void main(String[] args){
A obj1 = new A();
B obj2 = new B();
obj1.setbRef(obj2);
obj2.setaRef(obj1);
obj1 = null;
obj2 = null;
}
复制代码
他们的内存布局以下:数据结构
依照引用计数算法,栈中 obj1 对堆中 A 的对象有一个引用,所以计数器增一,obj2 对堆中 B 的对象有一个引用,计数器增一。而后这两个对象中的字段又互相引用了,各自的计数器增一。
而后咱们让 obj1 和 obj2 分别失去对堆中的引用,按照常理来讲,堆中的这两个对象已经无用了,应该被回收内存。可是你会发现,采用引用计数算法的程序语言不会回收这两个对象的内存空间,由于它们内部互相引用,计数器都不为零。
这就是「循环引用」问题,引用计数算法是没法辨别堆中的这两个对象已经无用了,因此程序中若是大量互相引用的代码,收集器将没法回收这部分无用的垃圾,即产生内存泄露问题。
可是,若是上述逻辑由 Java 语言实现,运行结果会告诉你,GC 回收了这部分垃圾。看看 GC 日志:
粗糙点来讲,原先堆中的两个对象加上堆中一些其余对象总共占用了 2302K 内存空间,通过 GC 后,显然这两个对象所占的内存空间被释放了。
既然如此,那么 Java 采用的「可达性分析算法」是如何避免这一类问题的呢?
可达性分析算法:从「GC Roots」为起始点,遍历引用链,全部可以直接或者间接被「GC Roots」引用的对象都断定为存活,其余全部对象都将在 GC 工做时被回收。
那么这些根结点(GC Roots)的如何选择将直接决定了 GC 收集效率的高低。Java 中,规定如下的对象能够做为 GC Roots:
总体上来看,这几种对象都是随时可能被使用的,不能轻易释放,或者说,这些对象的存活性极高,因此它们关联着的对象都不能被回收内存。
可达性分析的第一步就是枚举出全部的根结点(GC Roots),而后才能去遍历标记全部不可达对象。而实际上,HotSpot 的实现并无按序枚举全部的虚拟机栈,方法区等区域进行根结点查找,而是使用了 OopMap 这种数据结构来实现枚举操做的。
堆中的每一个对象在本身的类型信息中都保存有一个 OopMap 结构,记录了对象内引用类型的偏移量,也就是说,经过该对象能够获得该对象内部引用的全部其余对象的引用。
对于虚拟机栈来讲,编译器会在每一个方法的某些特殊位置使用 OopMap 记录当前时刻栈中哪些位置存放有引用。
因而 GC 在进行可达性分析的时候,无需遍历全部的栈和方法区,只须要遍历一下各个线程当前的 OopMap 便可完成根结点枚举操做,接着递归标记可达对象就好了。
理解了 HotSpot 是如何枚举根结点的,那么对于安全点这个概念就很好理解了,全部生成 OopMap 更新的位置就叫作安全点。当系统发起 GC 请求的时候,须要中断全部线程的活动,而并非线程的任何状态下都适合 GC 的,必须在停下来以前完成 OopMap 的更新,这样会方便 GC 枚举跟结点。
因此,咱们说线程收到中断请求的时候,须要「跑」到最近的安全点才能停下,这是由于安全点的位置会完成 OopMap 的更新,以保证各个位置的对象引用关系再也不改变。(你想啊,GC 根据 OopMap 进行根结点枚举,离上一次 OopMap 你已经作了一大堆事情了,改变了栈上不少对象的引用关系,难道你在停下来被 GC 以前不该该把你所作的这些操做记录下来吗?否则 GC 哪知道哪些对象已经不用了,哪些对象你又从新引用了?)
那安全区域又是一个什么样的概念呢?
安全区域是指,一段代码的执行不会更改引用关系,这段代码所处的范围能够理解为一个区域,某个线程在这个区域中执行的时候,只要标志本身进入了安全区域,就不用理会系统发起的 GC 请求而能够继续运行。
程序离开安全区域以前,会检查系统是否已经完成了 GC 过程,若是没有则等待,不然「走」出安全区域,继续执行后续指令。
安全区域其实是安全点的一个扩展,安全区域中运行的线程能够与 GC 垃圾收集线程并发工做,这是它最大的一个特色。
Java 里的引用本质上相似于 C 语言中的指针,变量中的值是内存中另外一块的地址,而并不是实际的数据。Java 中有四种引用,它们各自有不一样的生命范围。
除了强引用,其余的三类引用实际中不多使用,关于它们的测试代码,将随着本篇文章一块儿脱管在个人 GitHub 上,感兴趣的能够去 fork 回去运行一下,此处再也不赘述。
垃圾收集算法的实现是很复杂的,而且不一样平台的虚拟机也有着不一样的实现,可是单看收集算法自己而言,仍是相对容易理解的。
标记-清除算法
标记清除算法实现思路包含两个阶段,第一个阶段,根据可达性分析算法标记全部不可达的「垃圾」,第二阶段,直接释放这些对象所占用的内存空间。
可是,它的缺点也很明显,作一次清除操做至少要遍历两次堆,一次用于标记,一次用于清除。而且整个堆内存会存在大量的内存碎片,一旦遇到大对象,将没法提供连续的内存空间而不得不提早触发一次 Full GC。
复制算法
复制算法将内存划分为两份大小相等的块,每次只使用其中的一块,当系统发起 GC 收集动做时,将当前块中依然存活的对象所有复制到另外一块中,并整块的释放当前块所占内存空间。
这种算法不须要挨个去遍历清除,总体上释放内存,相对而言,效率是提升了,可是须要浪费一半的内存空间,有点浪费。
根据 IBM 公司的研究代表,「新生代」中的对象每每都是「朝生夕死」的,也就是说,咱们彻底没有必要舍掉一半的内存用于转移 GC 后存活的对象,由于活着的对象不多。
主流的商业虚拟机都采用复制算法对新生代进行垃圾收集,可是却将内存划分三个块,一块较大的 Eden 区和两块较小的 Survivor 区。
Eden 和 From 区域用于分配新生代对象的内存空间,当发生 Minor GC 的时候,虚拟机会将 Eden 和 From 中全部存活的对象所有移动到 To 区域并释放 Eden 和 From 的内存空间。
这样不只解决了效率问题,也解决了空间浪费的问题,可是存在的问题是,若是不巧,某次 Minor GC 后,活着的对象不少,To 区放不下怎么办?
虚拟机的作法是,将这些对象往老年代晋升,具体的后文详细介绍。
标记-整理算法
标记整理算法通常用在老年代,它在标记清除算法的基础上,增长了一个步骤用于对将全部存活着的对象往一端移动以解决内存碎片问题。这种算法适用于老年代的垃圾回收,由于老年代的对象存活性高,每次只须要移动不多的次数即能完成垃圾的清理。
从可达性分析算法断定哪些对象不可达,标记为「垃圾」,到回收算法实现内存的释放操做,这些都是理论,而垃圾收集器才是这些算法的实际实现。虚拟机中使用不一样的垃圾收集器收集不一样分代中的「垃圾」,每种垃圾收集器都具备各自的特色,也适用于不一样的场合,须要适时组合使用。但并非任意的两个收集器都能组合工做的:
能够看到,新生代主要有三款收集器,老年代也有三款收集器,G1(Garbage First)是一款号称能一统全部分代的收集器,固然还不成熟。
收集器不少,本文限于篇幅不可能每个都详细的介绍,只能简单的描述一下各个收集器的特色和优劣之处。
关于垃圾收集器的细节内容,不少,文章中不可能描述清楚,你们能够参阅相关书籍及论文进行学习。
Java 对象的内存都分配在堆中,准确来讲,新生的对象都分配在新生代的 Eden 区中,若是 Eden 区域不足以存放一些对象的时候,系统将发起一次 Minor GC 清除并复制依然存活的对象到 Survivor 区,一旦 Survivor 区域不够存放,将经过内存担保机制将这些对象移入老年代。下面咱们用代码具体看一看:
//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//限制了 10M 的堆内存,其中新生代和老年代分别占 5M
byte[] buffer = new byte[2 * 1024 * 1024];
复制代码
新生代收集器默认 Eden 与 Survivor 的比例为是 8:1。这里咱们看到新生代已使用空间 4032K,其中一部分是咱们两兆的字节数组,其他的是一些系统的对象内存分配。
若是咱们还要再分配一兆大小的内存空间呢?
//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
byte[] buffer = new byte[2 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
复制代码
虚拟机首先会检查一下新生代还能不能再分出一兆的内存空间出来,发现不能,因而发起 MinorGC 回收新生代堆空间,并将依然存活的对象复制到另外一块 Survivor 空间(to),发现 512K 根本放不下 buffer,因而经过担保机制将 buffer 送入老年代,接着为 buffer1 分配一兆的内存空间。
接着,咱们来看看这个担保机制是怎样的?
当实际发生 MinorGC 以前,虚拟机会查看老年代最大可用的连续空间是否能容纳新生代当前全部对象,由于它假设这次 MinorGC 后,新生代全部对象都可以存活下来。
若是条件可以成立,虚拟机认为这次 GC 毫无风险,将直接进行 MinorGC 对新生代进行垃圾回收,不然虚拟机会去查看 HandlePromotionFailure 参数设置的值是否容许「担保失败」。
若是容许,那么虚拟机将继续判断老年代最大可用连续空间是否大于历届晋升过来的新生代对象的平均大小。
若是大于,那么虚拟机将冒着风险去进行 MinorGC 操做,不然将改成一次 FullGC。
取平均值的这种几率方法能大几率的保证安全担保,但也不乏担保失败的状况出现,一旦担保失败,虚拟机将发起 FullGC 对整个堆进行扫描回收。看一段代码:
//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//系统对象占用大约 2M 堆空间
byte[] buffer = new byte[1 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
//此时新生代所剩下的空间大约 512K
byte[] buffer2 = new byte[1 * 1024 * 1024];
复制代码
当咱们的 buffer 和 buffer1 分配进 Eden 区以后,新生代剩下不足一兆的内存空间,可是当咱们分配一个一兆的字节数组时,系统查看老年代空间为 5M 可以容纳新生代全部存活对象(4M 左右),因而直接发起 MinorGC,回收了新生代中部分对象并尝试着将活着的对象复制到 to 区块中。
显然,to 区域不能容纳这么多对象,因而所有晋升进入老年代。
接着为 buffer2 分配 1M 内存空间在 Eden 区,GC 日志以下:
能够看到,buffer 和 buffer1 已经被担保进入老年代了,而 buffer2 则被分配在了新生代中。MinorGC 以前,新生代中大约 4M 的对象在 MinorGC 后只剩下 504K 了,其中 2M 左右的对象被担保进入了老年代,还有一部分则被回收了内存。
总结一下,本篇文章介绍了虚拟机断定垃圾的「可达性分析算法」,几种垃圾回收算法,还简单的描述不一样垃圾收集器各自的特色及应用场景。最后咱们经过一些代码了解了虚拟机是如何分配内存给新生对象的。
总的来讲,这只能算作一篇科普类文章,帮助你了解相关概念,其余的相关深刻细节之处,还有待深刻学习。
文章中的全部代码、图片、文件都云存储在个人 GitHub 上:
(https://github.com/SingleYam/overview_java)
欢迎关注微信公众号:扑在代码上的高尔基,全部文章都将同步在公众号上。