Java 垃圾收集(GC)浅谈
为什么需要垃圾回收?
哪些内存需要回收?
什么时候回收?
如何回收?
为什么需要垃圾回收?
当需要排查各种内存溢出、内存泄露问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。在构建大型程序时,GC直接影响着内存优化和运行速度。
Java 内存区域
了解GC机制之前,需要先搞清楚Java程序在执行的时候,内存究竟是如何划分的。
gc_memo.jpg
私有内存区的区域名和相应的特性如下表所示:
区域名称 | 特性 |
---|---|
程序计数器 | 指示当前程序执行到了哪一行,执行Java方法时记录正在执行的虚拟机字节地址;执行本地方法时,计数器值为undefined |
虚拟机栈 | 用于执行Java方法。栈帧存储局部变量表、操作数栈、动态链接、方法返回一些额外的附加信息。程序执行时栈帧入栈;执行完成后栈帧出栈 |
本地方法栈 | 用于执行本地方法,其他和虚拟机栈类似 |
虚拟机栈中的局部变量表
里面存放了三个信息:
- 各种基本数据类型(boolean、byte、char、short、int、float、long、double)
- 对象引用(reference)
- returnAddress地址
这个returnAddress和程序计数器有什么区别?前者是指示JVM的指令执行到了哪一行,后者是指你的代码执行到哪一行。
哪些内存需要回收?
私有内存区伴随着线程的产生而产生,一旦线程中止,私有内存区也会自动消除,因此我们在本文中讨论的内存回收主要是针对共享内存区。
共享内存区:
区域名称 | 特性 |
---|---|
Java堆 | Java虚拟机管理的内存中最大的一块,所有线程共享,几乎所有的对象实例和数组都在这类分配内存。GC主要就是在Java堆中进行的。 |
方法区 | 用于存储已被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。但是已被最新的JVM取消了。现在,被加载的类作为元数据加载到底层操作系统的本地内存区。 |
Java 堆
堆内存是由存活和死亡的对象组成的。存活的对象是应用可以访问的,不会被垃圾回收。死亡的对象是应用不可访问尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些对象回收掉之前,他们会一直占据堆内存空间。堆是应用程序在运行期请求操作系统分配给自己的向搞地质扩展的数据结构,是不连续的内存区域。用一句话总结堆的作用:程序运行时动态申请某个大小的内存空间。
gc_heap_generation.jpg新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大都具备朝生夕灭的特性,所以Minor GC 非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上
新生代:刚刚新建的对象在Eden中,经历一次Minor GC, Eden中的存货对象就被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC, Eden和S0中的存活对象会被复制送入第二块survivor space S1。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16此,改对象就被送到老年代中。
至于为什么兴盛带要分出两个survivor区,参考博客为什么新生代内存需要有两个Sruvivor区
老年代:如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中。老年代的空间一般比新生代大。
对象创建后的内存分配
创建一个对象后,他会被放在堆内存的哪个部分呢?
gc_object_create.jpg什么时候回收?
Java并没有给我们提供明确的代码来标注一块内存并将其回收。或许你会说,我们可以将相关对象设为null或者用System.gc()。然而,后者将会严重影响代码的性能,因为每一次显示调用system.gc()都会停止所有响应,去检查内存中是否有可回收的对象,这回对程序的正常运行造成极大威胁。另外,调用该方法并不能保障JVM立即进行垃圾回收,仅仅是通知JVM要进行垃圾回收了,具体回收与否完全由JVM决定。
生存还是死亡
可达性算法
这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
gc_reachability.png在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
如何回收?
标记-清除(Mark-Sweep)算法
分为两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点:效率问题,标记和清除两个过程的效率都不高;空间问题,会产生很多碎片。
复制算法
将可用内存按容量划分为大小相等的两块,每次只用其中一块。当这一块用完了,就将还存活的对象复制到另外一块上面,然后把原始空间全部回收。高效、简单。
缺点:将内存缩小为原来的一半。
gc_copying.jpg
标记-整理(Mark-Compat)算法
gc_mark_compact.jpg标记过程与标记-清除算法过程一样,但后面不是简单的清除,而是让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
分代收集(Generational Collection)算法
gc_generation.jpg- 新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;
- 老年代中,其存活率较高、没有额外空间对它进行分配担保,就应该使用“标记-整理”或“标记-清除”算法进行回收。
一些收集器
Serial收集器
单线程收集器,表示在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。"Stop The World".
ParNew收集器
实际就是Serial收集器的多线程版本。
- 并发(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
- 并行(Concurrent):指用户线程与垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
Parallel Scavenge收集器
该收集器比较关注吞吐量(Throughout)(CPU用于用户代码的时间与CPU总消耗时间的比值),保证吞吐量在一个可控的范围内。
CMS(Concurrent Mark Sweep)收集器
CMS收集器是一种以获得最短停顿时间为目标的收集器。
G1(Garbage First)收集器
从JDK1.7 Update 14之后的HotSpot虚拟机正式提供了商用的G1收集器,与其他收集器相比,它具有如下优点:并行与并发;分代收集;空间整合;可预测的停顿等。
新生代收集器使用的收集器:Serial、ParNew、Parallel Scavenge
gc_sjq.jpg老年代收集器使用的收集器:Serial Old、Parallel Old、CMS
Java性能优化
大多数针对内存的调优,都是针对特定情况的。但是实际中,调优很难与Java运行动态特性的实际情况和工作负载保持一致。也就是说,几乎不可能通过单纯的调优来消除GC的目的。
写程序的时候应该注意的点:
- 减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存。
- 多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是存储在堆内存中。
- 避免使用finalize,该方法会给GC增添很大的负担
- 如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用HashMap,就不要使用HashTabl。同理,尽量减少使用synchronized。
- 用移位符号替代乘除号。比如:a*8应该写作a<<3
- 对于经常反复使用的对象使用缓存。
- 尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组
- 尽量使用final修饰符,final表示不可修改,访问效率高
- 单线程下(或者是针对于局部变量),字符串尽量使用StringBuilder,比StringBuffer要快
- 尽量使用StringBuffer来连接字符串。这里需要注意的是,StringBuffer的默认缓存容量是16个字符,如果超过16,append犯法调用私有的expandCapacity()方法,来保证足够的缓存容量。因此,如果可以预设StringBuffer的容量,避免append再去扩展容量。
参考资料:
1.《深入理解Java虚拟机-JVM高级特性与最佳实践》
2.橙子wj的博客 (强推)
网友评论