jvm虚拟机特性之内存分配与回收
java与c系语言最大的区别之一就是内存的回收,在C或C++中需要程序员主动释放掉不再需要的内存空间,而在java中这一切都交由jvm处理。虚拟机是如何管理这些对象的生命周期的呢?下面将进行介绍。
一,堆详述
GC主要发生在堆内存中,这部分我们会对堆内存进行比较详细的描述。
我们知道对象是在堆上分配的,一直到垃圾收集器把这些对象回收掉之前,他们会一直占据堆内存空间。堆是应用程序在运行期请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的内存区域。用一句话总结堆的作用:程序运行时动态申请某个大小的内存空间。
而java堆内存又根据作用及保存对象的不同分为新生代和老年代。
JVM堆
(1) 新域:存储所有新成生的对象
(2) 旧域:新域中的对象,经过了一定次数的GC循环后,被移入旧域
(3)永久域:存储类和方法对象,从配置的角度看,这个域是独立的,不包括在JVM堆内。默认为4M。
Gc 流程 :
[older generation][survivor 1][survivor 2][eden]
*young generation=eden + survivor
1.当eden满了,触发young GC;
2.young GC做2件事:一,去掉一部分没用的object;二,把老的还被引用的object发到survior里面,等下几次GC以后,survivor再放到old里面。
3.当old满了,触发full GC。full GC很消耗内存,把old,young里面大部分垃圾回收掉。这个时候用户线程都会被block。
GC在各个年代触发的情况
GC名称 | 介绍 |
---|---|
Minor GC | 也称发生在新生代,频率高,速度快(大部分对象活不过一次Minor GC,eden满了,触发young GC) |
Major GC | 发生在老年代,速度慢 |
–Full GC | 清理整个堆空间(老年代满时促发) |
不过实际运行中,Major GC会伴随至少一次 Minor GC,因此也不必过多纠结于到底是哪种GC(在有些资料中看到把full GC和Minor GC等价的说法)。
内存回收会带来碎片化带来的风险是极大的,严重影响JAVA程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存,在Java的内存回收机制中如何是处理的,这就涉及到新生代回收的关键问题。
新生代
新创建的对象分配的新生代中,经历多次GC之后进入老年代。新生代具体细分有分为Eden区和survivor区,survivor区又包含S0和S1两个区域。一般来说,我们创建了一个对象,它首先被放到新生代的Eden区。当Eden填满时执行一次Minor GC,此时存活下来的对象进入survivor区第一块survivor space S0,并且年龄加1。Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
上述机制最大的好处就是,整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片。
老年代
如果某个对象经历了几次垃圾回收之后还存活并且到达一定的年龄(默认15,可以通过-XX:MaxTenuringThreshold设置)则进入老年代。当老年代的空间不足时则执行一次full GC,以腾出空间给新创建的对象。老年代的空间一般比新生代大。
如果Major GC之后还是老年代不足,那苍天也救不了了,JVM会抛出内存不足的异常。
以上说明的是一般情况,但是虚拟机在执行对象的分配与回收时会根据不同情况做出一些动态调整的策略。
1,大对象可以直接进入老年代(通过虚拟机参数可以设置具体的大小阈值);
2,对象的年龄并一定等到15岁才会进入老年代,虚拟机若发现survivor空间中某个年龄的所有对象的总大小超过了整个survivor空间的一半,则大于这个年龄的对象可以提前进入老年代。
3,当新生代的存活下来的对象太多而容纳不了的时候,对象会提前进入老年代。
JVM的GC触发原理
对象可达判断方法
判断对象是否为垃圾对象的标准
-
引用计数算法(Reference Counting)
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。
优点:引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法。
缺点:但是Java语言中没有选用引用计数算法来管理内存,其中最主要的一个原因是它很难解决对象之间相互循环引用的问题。 -
GC Roots Analysis可达性分析:主流用这个判断
基本思路就是通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(即意味着直接回收)
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。
GC Root 引用点
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即一般说的Native方法)引用的对象;
总结就是,方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。
JVM的GC主要是对堆内存的回收过程
一般把新生代的GC称为minor GC ,把老年代的GC成为 full GC,所谓full gc会先出发一次minor gc,然后在进行老年代的GC。
首先想eden区申请分配空间,如果空间够,就直接进行分配,否则进行一次Minor GC。
minor GC 首先会对Eden区的对象进行标记,标记出来存活的对象。然后把存活的对象copy到From空间。
如果From空间足够,则回收eden区可回收的对象。
如果from内存空间不够,则把From空间存活的对象复制到To区,
如果TO区的内存空间也不够的话,则把To区存活的对象复制到老年代。
如果老年代空间也不够(或者达到触发老年年垃圾回收条件的话)则触发一次full GC。
简单方向就是Eden->From->To->Old,如下图所示:
默认是不会对持久带(方法区)进行垃圾回收的,设置参数可回收:-XX:+CMSClassUnloadingEnabled
二,垃圾收集算法
1,标记清除算法
标记清除算法分为两个阶段,标记和清除。标记阶段标记所有需要回收的对象,然后在释放掉有标记对象的空间,回收前后的内存空间分布图如下所示:
如图中所示,标记清除算法一个很明显的缺点就是会产生很多内存碎片。后果就是在需要一大块连续内存空间时很可能内存的总空间是够用的,但是却不能直接分配出可用的空间。
2,复制算法
复制算法将内存空间分成两份,对象总是在其中一块内存中分配,当空间不足时将需要存活的对象复制到另一块内存中。回收前后的内存空间分布图如下所示:
复制算法有效的避免了内存碎片的问题,当分配对象时只需要在堆顶移动指针就好,执行高效。但是他将内存分成了两份,一份用来分配对象,一份用来存放才存活下来的对象,这样相当于内存空间变成了原来的一半,空间开销比较大,很明显的以空间换时间。所以复制算法使用于存活率比较低的新生代。新生代分成了较大的一块Eden区,和两块相对较小的survivor区。当Eden区装满时,将Eden区和分配对象的一个survivor区中需要存活的对象复制到另一个survivor区。这样内存的浪费率不至于太高,而且获得了可观的性能。刚刚说到大对象可以直接进入老年代,这样做的好处是,如果大对象一直有效则减少了大对象在新生代中来回的复制。
3,标记整理算法
对于存活率比较高的对象采用复制算法不仅浪费空间,而且来回复制对象也将导致性能下降。所以对于老年代这样的区域的回收有人提出了标记整理算法。类似于标记清除算法,它开始也是标记阶段,标记出需要存活的对象。然后将所有需要存活的对象移动到内存的一端,占用一片连续的空间,而空闲出来的内存也是连续。回收前后的内存空间分布图如下所示:
相对于标记清除算法,标记整理算法看起来回收的性能并不是很高,因为整理时需要在内存移动对象的位置,而的确老年代的full GC确实是很耗时的。但是由于整理后内存空间是连续的,对于内存空间的利用率和大对象的分配都是有好处的。
三,垃圾收集器
1,Serial与Serial Old收集器
顾名思义这两个垃圾收集器是串行执行的,一个用于新生代一个用于老年代。他们的工作机制是,停止所有线程,然后创建一个线程进行垃圾收集。当前服务的基本上都配备了多核的CPU,这种垃圾收集器几乎已经没有用武之地,应为它不能发挥出多核CPU的优势。同时,收集垃圾停止所有线程也使得一些应用无法接受。
2,ParNew收集器
ParNew收集器对Serial的改进就是使用了多线程,它仍然要停止所有其他线程的执行。
3,Parallel Scavenge和Parallel Old收集器
这两个垃圾收集器类似于ParNew收集器,一个用于新生代一个用于老年代。但是它比ParNew更强大一些。Parallel
收集器可以让我们设置自己关心的吞吐量和GC停顿时间,虚拟机会尽量保证设置的参数。并且,他可以根据运行情况动态调整Eden区比例大小等参数(存在一个开关)。
4,并发收集器和CMS收集器
并发收集器GC时GC线程和应用线程大部分时间是并发执行,只是在初始标记(initial mark)和二次标记(remark)时需要stop-the-world,这可以大大缩短停顿时间(pause time),所以适用于响应时间优先的应用,减少用户等待时间。由于GC是和应用线程并发执行,只有在多CPU场景下才能发挥其价值,在一个N个处理器的系统上,并发收集部分使用K/N个可用处理器进行回收,一般情况下1<=K<=N/4。 在执行过程中还会产生新的垃圾floating garbage(浮动垃圾),如果等空间满了再开始GC,那这些新产生的垃圾就没地方放了(并发收集器一般需要20%的预留空间用于这些浮动垃圾),这时就会启动一次串行GC,等待时间将会很长,所以要在空间还未满时就要启动GC。mark和sweep操作会引起很多碎片,所以间隔一段时间需要整理整个空间,否则遇到大对象,没有连续空间也会启动一次串行GC。采用此收集器,收集频率不能大,否则会影响到cpu的利用率,进而影响吞吐量。
CMS收集器是一款优秀的垃圾收集器,因为他支持垃圾回收线程与虚拟机其他线程并发执行。它的整个垃圾回收过程分为了四个阶段:初始标记、并发标记、重新标记和并发清除。其中初始标记和重新标记需要stop the world,而耗时的并发标记和并发清除则是并发执行的。
由于CMS收集器使用的标记清除算法,所以会引入内存碎片的问题。为了解决这个问题它提了参数可以设置多少次full GC之后进行一次整理。
垃圾收集器对比图
5.JVM支持的GC收集器
JVM采用的是分代回收,不同代有不同的垃圾收集器
如图所示:连线的是可以组合使用
感兴趣的可以试读,试读地址http://book.51cto.com/art/201107/278857.htm,有前三章的内容。
回收器 | 概述 | 年轻代 | 老年代 |
---|---|---|---|
串行回收器(serial collector) | 客户端模式的默认回收器,所谓的串行,指的就是单线程回收,回收时将会暂停所有应用线程的执行 | 参见本文第三部分 | serial old回收器标记-清除-合并。标记所有存活对象,从头遍历堆,清除所有死亡对象,最后把存活对象移动到堆的前端,堆的后端就空了 |
并行回收器 | 服务器模式的默认回收器,利用多个线程进行垃圾回收,充分利用CPU,回收期间暂停所有应用线程 | Parallel Scavenge回收器,关注可控制的吞吐量(吞吐量=代码运行时间/(代码运行时间加垃圾回收时间)。吞吐量越大,垃圾回收时间越短,可以充分利用CPU。但是 | parrellel old回收器,多线程,同样采取“标记-清除-合并”。特点是“吞吐量优先” |
CMS回收器 | 停顿时间最短,分为以下步骤:1初始标记;2并发标记;3重新标记;4并发清除。优点是停顿时间短,并发回收,缺点是无法处理浮动垃圾,而且会导致空间碎片产生 | X | 适用 |
G1回收器 | 新技术,将堆内存划分为多个等大的区域,按照每个区域进行回收。工作过程是1初始标记;2并发标记;3最终标记;4筛选回收。特点是并行并发,分代收集,不会导致空间碎片,也可以由编程者自主确定停顿时间上限 | 适用 | 适用 |
附-转载的GC参数汇总以及一个使用实例,转载来源是
JVM垃圾回收器工作原理及使用实例介绍
- 与串行回收器相关的参数
-XX:+UseSerialGC:在新生代和老年代使用串行回收器。
-XX:+SuivivorRatio:设置 eden 区大小和 survivor 区大小的比例。
-XX:+PretenureSizeThreshold:设置大对象直接进入老年代的阈值。当对象的大小超过这个值时,将直接在老年代分配。
-XX:MaxTenuringThreshold:设置对象进入老年代的年龄的最大值。每一次 Minor GC 后,对象年龄就加 1。任何大于这个年龄的对象,一定会进入老年代。 - 与并行 GC 相关的参数
-XX:+UseParNewGC: 在新生代使用并行收集器。
-XX:+UseParallelOldGC: 老年代使用并行回收收集器。
-XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
-XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
-XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。
-XX:+UseAdaptiveSizePolicy:打开自适应 GC 策略。在这种模式下,新生代的大小,eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。 - 与 CMS 回收器相关的参数
-XX:+UseConcMarkSweepGC: 新生代使用并行收集器,老年代使用 CMS+串行收集器。
-XX:+ParallelCMSThreads: 设定 CMS 的线程数量。
-XX:+CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发,默认为 68%。
-XX:+UseFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。
-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收。
-XX:+CMSParallelRemarkEndable:启用并行重标记。
-XX:CMSInitatingPermOccupancyFraction:当永久区占用率达到这一百分比后,启动 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。
-XX:UseCMSInitatingOccupancyOnly:表示只在到达阈值的时候,才进行 CMS 回收。
-XX:+CMSIncrementalMode:使用增量模式,比较适合单 CPU。 - 与 G1 回收器相关的参数
-XX:+UseG1GC:使用 G1 回收器。
-XX:+UnlockExperimentalVMOptions:允许使用实验性参数。
-XX:+MaxGCPauseMills:设置最大垃圾收集停顿时间。
-XX:+GCPauseIntervalMills:设置停顿间隔时间。 - 其他参数
-XX:+DisableExplicitGC: 禁用显示 GC。
常用参数如下
[外链图片转存失败(img-smKCrKDa-1569224521619)(https://img-blog.csdn.net/20170519235016703?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvYW50b255OTExOA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)]
五、JAVA性能优化及调优实例
大多说针对内存的调优,都是针对于特定情况的。但是实际中,调优很难与JAVA运行动态特性的实际情况和工作负载保持一致。也就是说,几乎不可能通过单纯的调优来达到消除GC的目的。
真正影响JAVA程序性能的,就是碎片化。碎片是JAVA堆内存中的空闲空间,可能是TLAB剩余空间,也可能是被释放掉的具有较长生命周期的小对象占用的空间。
下面是一些在实际写程序的过程中应该注意的点,养成这些习惯可以在一定程度上减少内存的无谓消耗,进一步就可以减少因为内存不足导致GC不断。类似的这种经验可以多积累交流:
- 减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存;
- 多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是在堆内存;
- 避免使用finalize,该方法会给GC增添很大的负担;
- 如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用HashMap,就不要用HashTable。同理,尽量减少使用synchronized
- 用移位符号替代乘除号。eg:a*8应该写作a<<3
- 对于经常反复使用的对象使用缓存;
- 尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组;
- 尽量使用final修饰符,final表示不可修改,访问效率高
- 单线程情况下(或者是针对于局部变量),字符串尽量使用StringBuilder,比StringBuffer要快;
- 尽量使用StringBuffer来连接字符串。这里需要注意的是,StringBuffer的默认缓存容量是16个字符,如果超过16,apend方法调用私有的expandCapacity()方法,来保证足够的缓存容量。因此,如果可以预设StringBuffer的容量,避免append再去扩展容量
示例
import java.util.HashMap;
public class GCTimeTest {
static HashMap map = new HashMap();
public static void main(String[] args){
long begintime = System.currentTimeMillis();
for(int i=0;i<10000;i++){
if(map.size()*512/1024/1024>=400){
map.clear();//保护内存不溢出
System.out.println("clean map");
}
byte[] b1;
for(int j=0;j<100;j++){
b1 = new byte[512];
map.put(System.nanoTime(), b1);//不断消耗内存
}
}
long endtime = System.currentTimeMillis();
System.out.println(endtime-begintime);
}
}
通过上面的代码运行 1 万次循环,每次分配 512*100B 空间,采用不同的垃圾回收器,输出程序运行所消耗的时间。
使用参数-Xmx512M -Xms512M -XX:+UseParNewGC 运行代码,输出如下:
clean map 8565
cost time=1655
使用参数-Xmx512M -Xms512M -XX:+UseParallelOldGC –XX:ParallelGCThreads=8 运行代码,输出如下:
clean map 8798
cost time=1998
如果可以预设StringBuffer的容量,避免append再去扩展容量,示例下面两个示例·:
示例一:
StringBuffer st = new StringBuffer(50);
st.append("let us cook");
st.append(" ");
st.append("a matcha cake for our dinner");
String s = st.toString();
示例二:
public String toString() {
return new StringBuilder().append("[").append(name).append("]")
.append("[").append(Message).append("]")
.append("[").append(salary).append("]").toString();
}