java与c++之间有一堵由动态分配和垃圾收集技术所围成的高墙,墙内的想出去,墙外的人却想进来。
然后言归正传,我这篇文章是想把垃圾收集和内存分配提出来讲一下的。重申下个人观点:我说的都是别人讲过的东西,老生常谈,然后会加一下自己的理解和看法,甚至有的时候有些东西也是自己的猜测。可能会有错的,欢迎指出,有异议的也欢迎交流。然后我之所以写出来一来是我觉得单独看一遍书其实理解的不深,自己打一遍,而且为了让文章言之有物我还要自己推敲琢磨,这样对我自己的理解本身是一种好处。另外就是如果读者看到了,觉得有帮助,那也算是一件好事。毕竟我是打在笔记本里自己看和打在简书里大家看,对我而言是差不多的!还有!我也是在学习阶段,我也是小白,刚刚也说了也会有错误。但是我不觉得一定要特别特别厉害才有资格写文章(最近有人问我整天写文章能挣钱咋的,所以针对这种看法我做一下解释。)
背景简介
因为我这次看的是书,所以介绍的比较全面,不再是视频那种单纯的知识点讲解。所以有一些背景简介,这里也和大家絮叨絮叨。
首先,垃圾收集(Garbage Collection,简称GC)其实并不是java的半生产物。1960年的Lisp就使用了。目前java的内存的动态分配和内存回收技术已经比较成熟,但是!!!当需要排查各种内存溢出,内存泄露的时候,当垃圾手机成为系统达到更高并发量的瓶颈的时候,我们还是要对内存是是必要的监控和调节。
上一篇文章我们说了内存分为:堆,栈,方法区,程序计数器。其中堆,方法区的随着jvm而生,所有线程共享。而虚拟机栈和本地栈统称为栈,和程序计数器是线程隔离的。随着线程而生,也随线程而灭。栈中的栈帧随着方法的进入和退出执行入栈和出栈。因为栈帧分配的内存是编译期就已知的,所以这个区域的回收具有确定性,我们不需要过多的考虑回收的问题。因为方法或者线程结束的时候,内存就自然跟着回收了。
而java堆则不一样,我们只有在程序运行时才知道会创建什么对象,这部分的内存分配和回收都是动态的,我们所说的垃圾收集器关注的也是这部分内存。
对象已死么?
java堆中存放的是所有对象的实例。垃圾收集器回收之前,要判断这个对象哪些还活着,哪些死了(就是不会再被使用的对象)。
引用计数法
通俗来讲,就是判断对象是否存活的一个算法:给对象中添加一个引用计数器(这里要区别于程序计数器)。每当有一个地方引用它时,计数器的值+1.引用失效时,计数器的值-1.如果说一个对象的计数器的值是0,就说明这个对象是不可用的。
客观来说这个即用计数算法实现简单,而且效果也不错,理解起来很容易。但是很多主流的jvm虚拟机并没有选用引用计数算法来管理内存。因为它有个弊端,或者说漏洞:那就是它很难解决对象之间的相互循环引用的问题。举个例子:两对象A,B互相引用,除此之外没有任何别的引用。实际上这个两个对象已经不可能再被访问了。但是计数器的值不是0,导致GC不能回收他们。
可达性分析算法
据说主流的商用程序语言是主流实现都是通过可达性分析来判断对象是否存活的。这个算法的基本思路就是通过一系列的成为”GC Roots“的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到”GC Roots“没有任何引用链相连,则这个对象是不可用的。(也叫这个根节点到对象不可达)。
这个根节点是可能是:栈中引用的对象。方法区中静态属性引用的对象。方法区中常量引用的对象等等。
其实我觉得这个也还算好理解。我们可以把所谓的根节点看作是一个树根,想判断一个叶子是不是属于这棵树的就是从树根开始顺着树干,树枝查找。最后发现能连上就说明这个树叶是这棵树的,但是要是发现树根到这个叶子连不上,则说明这个树叶不是这棵树的,也就是不可达。而在jvm虚拟机中这种不可达代表着不可用。(书上的图不错,我尽量画一下让大家可以直观的看下。)
再谈引用
无论是通过引用计数法,还是可达性分析,我们判断对象是否或者都和引用有关。在JDK1.2以后(之前的太遥远就不说了)java堆引用的概念进行了扩充:将引用分为强引用,软引用,弱引用,虚引用四种。这四种引用强度一次减弱。
强引用:类似Object o = new Object()这种,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用:描述一些还有用但是非必须的对象。在系统将要发生内存溢出之前,才会把软引用关联的对象列进回收范围之中进行二次回收。
弱引用:也是描述非必须的对象的。但是它比软引用更弱一些。当垃圾收集器工作时,会回收掉只被弱引用关联的对象。
虚引用:是最弱的一种引用关系。是否有虚引用完全不会对其生存时间有影响。也无法通过虚引用来获取一个对象实例。
其实我们可以用自己的手机来联想这几种引用关系。
虚引用就是内存垃圾,我们用智能机的小伙伴都知道,手机自己呆着呆着就总莫名其妙的出现垃圾,这个时候一般有提示:您的手机已经有XXg的垃圾,请及时清理。然后你点一个清理就没了!你都不一定知道系统都清理了什么玩意儿。
弱引用就是微信QQ的聊天记录啊,咋说呢,有时候显得没事了,觉得聊天记录也没大作用,删了就删了吧。然后一键清理好几G的内存,感觉自己萌萌哒。
软引用这个就比较有意思了,它属于你挺有用的,比如好几个G的电影。作为精神食粮,没事就看看,还是很喜欢滴!但是真的到了内存不足,干啥都提示没空间了的时候,也还是能下定决心把一些你懂得的资源删除的,这个就是软引用。关键时刻可以舍弃的东西。
至于强引用,差不多就是手机里的微信,qq,支付宝了吧。要手机的目的不就是这些么?删除是不可能删除的,换手机都不能删除的这种。
生存还是死亡
这是一个问题,比今天吃啥还不好解决。即使在可达性分析中不可达的对象,也不是立刻就杀死了,他们还有个“缓刑”的极端。这个就涉及到了对象的finalize()方法了,在effective java第三版中也有专门的一章讲解这个方法,这里知识简单的说一下,如果感兴趣的同学可以自己区搜索finalize()。
如果一个对象被判定不可达,这个时候我们就会调用它的finalize()方法(如果对象的finalize()方法没有覆盖或者finalize()已经被调用过了则系统判断没有必要调用了。然后finalize()方法只会被系统自动调用一次),finalize()方法是一个对昂逃脱死亡的最后一次机会。如果在这个方法里成功的和外界建立关联了。就是把自己变成可用了。就不会死了。如果调用finalize()方法还是没让自己变得有用,那么就真的被回收了。(我超级喜欢这段话,感觉就是电视剧里面演的,人被敌人抓住了,赶紧透漏情况,说的情报有用可以多活一会,啥也不知道直接就被灭口了)。
然后其实这个finalize()方法,建议大家避免使用它的。因为不确定的因素太多了。这个就不详细说明了,如果我有机会把effective java也写出来,那里会有详细的说明为啥不推荐使用终结,清理方法。
回收方法区
昨天已经说过了方法区也有人叫做“永久代”。但是这个不严谨。虽然java虚拟机规范中确实说过了可以不在方法区进行垃圾收集,而且在方法区进行垃圾收集的性价比比较低。但是该有还是要有的啊。
永久代的垃圾收集主要是两部分内容:废弃常量和无用的类。
废弃常量:这个很好理解,比如常量池有一个字符串”abc“,但是没有任何string对象引用指向这个”abc“,也没有地方用了或者字面量。那么这个“abc”就是废弃的。必要的话这个“abc”就会被系统清理出常量池。
无用的类:这个要同时满足三个条件:
- jaca堆中不存在这个类的实例
- 加载该类的ClassLoader已经被回收
- 无法在任何地方通过反射访问该类的方法。也就是该类对应的java.lang.Class对象也没有被引用。
虚拟机可以对满足上述三个条件的类进行回收。但是这里仅仅是”可以“。是否对类进行回收还是看要虚拟机的配置的。
垃圾收集算法
终于要讲到正题了,由于垃圾收集算法的实现比较复杂,而且各个平台的虚拟机操作内存的方法也不一样,所以这里就是介绍几种算法的思想。
标记-清除算法:这个是最基础的收集算法了。如它的名字一样,主要是两个阶段:标记,清除。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以说他基础,是因为后续的收集算法都是基于这种思路并对其不足进行改进得到的。它主要的问题有两个:
- 效率问题,标记和清除的效率都不高。
-
标记清除之后会产生大量的不连续的内存碎片。空间碎片太多导致内存空间利用率不高。
如上图做完一波清理操作,该清除的倒是清除了,但是如果这个时候我们要存入一个6个空间位大小的数据,内存是够用的,但是没有哪一块能放下,这不就是空间的浪费了么?
复制算法:复制算法是为了解决效率问题。他将可用内存划分为一样一样的两块。每次只是用一块。在一块内存用完了,就把其中活着的对象复制到另一块上面(从头开始连着放,不会产生空间碎片)。然后把之前满了那块空间都删除了。实现起来简单,运行起来也高效。但是缺点就是将内存缩小为原来的一半。代价不小啊。
据说现在流行的商用虚拟机都是采用这种算法的,而且有一点:新生代中的98%的对象朝生晚死,死的贼快,所以不需要1:1分配内存的。上一篇说到的Eden,Survivor就顺势而出了。一般默认Eden和Survivor的空间比8:1.。内存划分为两个Survivor和一个Eden。然后每次是Eden和使用中的Survivor中的活着的复制到另一个Survivor中,然后清理掉Eden和Survivor的空间。接下来使用Eden和刚刚被复制过来的Survivor。这样做相当于每次只有一个Survivor空间被浪费。按照默认比例:8:1:1.也就是每次只浪费百分之十的内存。
当然了,有时候Survivor可能空间不够了,可以暂时先放老年代的内存。(重点是暂时。反正就是有借要有还的那种。)
标记-整理算法:看名字都能看出来,就是标记清除算法的进阶版。标记就是标记清除中的标记过程,只不过这个不是标记完了直接清除就完事了,而是所有活着的对象都向一端移动。其实就是把活着的都聚在前面的一堆儿。最后把死了的都清除了。
分代收集算法:这个其实就是一个思想。跟咱们人似的,刚生出来的小孩儿容易出现各种意外,所以我们小心又全面的呵护着。但是青年中年一般很少莫名其妙无缘无故吃个饭噎死,洗个脸淹死啥的。就不用那么呵护了。我们对小孩子的照顾要是对成年人也那么照顾,人家愿不愿意不说,单说照顾的这个人都容易累死。所以代码里也是。你刚创建可以用完就没用了,这个时候容易朝生晚死,生命比较短。叫做新生代。但是很少你一直用着的东西突然就不用了。这种东西用的次数多了反而不太容易死了,就叫做老年代。一般波动就不大了。
上面讲的复制算法一般就是新生代的垃圾收集使用的。而标记-整理算法一般就是老年代使用的垃圾收集算法。其实仔细想想就能明白为什么。
垃圾收集器
其实刚刚说的那些算法都是内存回收的方法论,而垃圾收集器就是内存回收的具体实现。java虚拟机规范中没有明确规定垃圾收集器。所以不同厂商,不同版本的收集器是不同的。这里讨论的是比较常用的一些。
首先收集器是分代的,这个上文提到过。有的新生代收集器和老年代收集器可以共同工作,但是有的就不可以。而且没有最好的收集器。只有最合适。接下来具体介绍一下:
Serial收集器:它是最基本的,也是历史最老的收集器。他的两个特点:
- 单线程工作
- 垃圾收集的时候停止任何别的线程工作。
其实我看了书中介绍的这段真的很有意思,生动而且形象(下面的引用是书中的原话,我用键盘敲了出来而已)。
Serial收集器是JAVA虚拟机中最基本、历史最悠久的收集器,在JDK 1.3.1之前是JAVA虚拟机新生代收集的唯一选择。Serial收集器是一个,但它的“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是难以接受的。
对于这种恶劣的体验,虚拟机的设计者表示完全理解,但也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完吗?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质,但实际上可能肯定还要比打扫房间复杂得多啊。
其实它虽然存在自己的不足,但它依然是虚拟机运行在Client模式下但默认新生代收集器。它有着优于其他收集器的地方:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿还是可以接受的。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。
ParNew收集器:ParNew收集器其实就Serial的多线程版本。书中这里介绍了一下各个版本的配合和使用情况。但是因为这里是只是想简单的懂得收集器作用,原理。所以这一段就不写了。除了能多线程剩下与Serial收集器是差不多的。
注意两个概念:
- 在单核中ParNew收集器没有Serial收集器效率高,因为有线程切换的开销。
- 在双核中ParNew收集器和Serial收集器效率差不多。
- 在3核及以上ParNew收集器才比Serial收集器效率高。
Parallel Scavenge收集器”:它是一个新生代的收集器,也是使用复制算法的收集器。又是并行的多线程收集器。但是它不关注缩短垃圾收集的时间。更关注系统的吞吐量。
因此,Parallel Scavenge收集器也被称为“吞吐量优先”收集器。
吞吐量计算公式:
吞吐量 = cpu运行程序时间/cpu运行程序时间+GC时间
Serial Old收集器:Serial收集器的老年代版本。看名字就能看出来。使用的是标记-整理算法。
Parallel Old收集器:Parallel Scavenge收集器的老年代版本。是多线程和标记-整理算法。一般在注重吞吐量以及cpu资源敏感的场合,可以优先考虑Parallel Scavenge+Parallel Old。
CMS收集器:CMS垃圾回收器的全称是Concurrent Mark-Sweep Collector,从名字上可以看出两点,一个是使用的是并发收集,第二个是使用的收集算法是Mark-Sweep(标记-清除算法)。
它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为6个步骤,包括(我的书是第二版,只有四步。但是我查阅的资料都说六步,这里按六步写了):
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 并发预清理(CMS-concurrent-preclean)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
- 并发重置(CMS-concurrent-reset)。
因为这个介绍的篇幅比较多,我就用我个人的话来理解吧。
它只有初始标记和重新标记是需要暂停别的线程的。而且这两个行为的时间都很快。至于并发标记和并发清除都可以和用户线程一起工作,不用”stop the world“。这就让用户用起来很舒服。但是也有一些缺点:
- 对cpu资源敏感。
- 无法处理浮动垃圾。
- 采用标记-清除方法,会产生内存空间碎片。
G1收集器
这个是当今收集器技术发展最前沿的成果之一。反正听起来就高大上有木有?
G1的特点:
- 并行与并发。
- 分代收集(之前的收集器都是只针对年轻代或者老年代。G1是都可以使用)
- 空间整合性好。
-
可预测的停顿。
在G1之前,cms是很火的,所以这里好多都是G1和cms的对比。然后我看的书可能比较老,那时候还没有什么G1的实际使用数据(我估计现在有了吧,但是没有特意去查。)
内存分配与回收策略
我们之前一直说什么新生代,老年代的。这些到底什么区别?怎么划分的?
首先一般创建一个对象,在新生代Eden中分配内存。如果Eden中没有足够的空间,虚拟机将发起一次Minor GC(新生代GC,指发生在新生代的垃圾收集动作,所有的Minor GC都会触发全世界的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。)
如果这个对象需要大量连续内存空间,我们可以通过设置,让大对象直接进入老年代。这样做的目的是为了防止大对象在Eden和Survicor来回来去复制。
虚拟机采用了分代收集的思想来管理内存,那么内存回收的时候必须能分辨哪些是新生对象,哪些是老年对象。
为了做到这一点,虚拟机给每个对象定义了一个年龄计数器。活过一次Minor GC就涨一岁。当年龄到了一定程度(默认15岁,这个阈值可以改),就可以晋升到老年代了。
好了,这章关于垃圾收集器和内存分配就到这里了。一下午四十页书,而且感觉比自己单纯的看一遍效果要好得多。然后全文手打,我也是正在学习的过程中,有问题或者不同的意见欢迎指出共同交流学习。
全文手打不易,如果你觉得有帮到你或者有点用,别吝啬的点个喜欢和点个关注哦~~