您的位置:首页 > 其它

JVM内存调优之GC算法

2017-01-15 19:21 309 查看

初识JVM

JVM简介

虽然理解JVM不是开发或运行Java程序的必要条件,但是多了解一些JVM知识,那么就可以避免很多性能上的问题。

Java虚拟机(JVM)指的是Java应用的运行环境,从一般意义上来讲,JVM是通过规范来定义的一个虚拟的计算机,被设计用来解释执行从Java源码编译而来的字节码。更通俗地说,JVM是指对这个规范的具体实现。这种实现基于严格的指令集和全面的内存模型。另外,JVM也通常被形容为对软件运行时环境的实现。通常JVM实现主要指的是HotSpot。

JVM规范保证任何的实现都能够以同样的方式解释执行字节码。其实现可以多样化,包括进程、独立的Java操作系统或者直接执行字节码的处理器芯片。我们了解最多的JVM是作为软件实现,运行在流行的操作系统平台上(包括Windows、Linux和Solaris等)。

JVM的结构允许对一个Java应用进行更细微的控制。这些应用运行在沙箱(Sandbox)环境中。确保在没有恰当的许可时,无法访问到本地文件系统、处理器和网络连接。远程执行时,代码还需要进行证书认证。

除了解释执行Java字节码,大多数的JVM实现还包含一个JIT(just-in-time 即时)编译器,用于为常用的方法生成机器码。机器码使用的是CPU的本地语言,相比字节码有着更快的运行速度。

JVM物理结构

JVM物理结构如下,我们来看一下:



方法区

也称”永久代” 、“非堆”,用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。

运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。

java栈

描述的是Java 方法执行的内存模型:每个方法被执行的时候 都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。声明周期与线程相同,是线程私有的。

局部变量表存放了编译器可知的各种基本数据类型、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。

本地方法栈

与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务。

java 堆

也叫做堆、GC堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建。该内存区域存放了对象实例及数组(所有new的对象)。其大小通过-Xms(最小值)和-Xmx(最大值)参数设置,-Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G,默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列;当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation=来指定这个比列,对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。

由于现在收集器都是采用分代收集算法,堆被划分为新生代和老年代。新生代主要存储新创建的对象和尚未进入老年代的对象。老年代存储经过多次新生代GC(Minor GC)任然存活的对象。

内存模型

Java内存模型建立在自动内存管理的概念之上。当一个对象不再被一个应用所引用,垃圾回收器就会回收它,从而释放相应的内存。这一点和其他很多需要自行释放内存的语言有很大不同。

JVM从底层操作系统中分配内存,并将它们分为以下几个区域:

堆空间(Heap Space):这是共享的内存区域,用于存储可以被垃圾回收器回收的对象。

方法区(Method Area):这块区域以前被称作“永生代”(permanent

generation),用于存储被加载的类。这块区域最近被JVM取消了。现在,被加载的类作为元数据加载到底层操作系统的本地内存区。

本地区(Native Area):这个区域用于存储基本类型的引用和变量。

一个有效的管理内存方法是把对空间划分为不同代,这样垃圾回收器就不用扫描整个堆区。大多数的对象的生命周期都很段短暂,那些生命周期较长的对象往往直到应用退出才需要被清除。

当一个Java应用创建了一个对象,这个对象是被存储到“初生池”(eden pool)。一旦初生池存储满了,就会在新生代触发一次minor gc(小范围的垃圾回收)。首先,垃圾回收器会标记出那些“死对象”(不再被应用所引用的对象),同时延长所有保留对象的生命周期(这个生命周期长度是用数字来描述,代表了期所经历过的垃圾回收的次数)。然后,垃圾回收器会回收这些死对象,并把剩余的活着的对象移动到“幸存池”(survivor pool),从而清空初生池。

当一个对象存活达到一定的周期后,它就会被移动到堆中的老生代:“终身代”(tenured pool)。最后,当终身代被填满时,就会触发一次full gc或major gc(完全的垃圾回收),以清理终身代。

一般我们把初生池和幸存池所在的区域合并成为新生代,由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成,可通过-Xmn参数来指定新生代的大小,也可以通过-XX:SurvivorRation来调整Eden Space及Survivor Space的大小。

把终身代所在的区域成为老生代,用于存放经过多次新生代GC任然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:①.大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。②.大的数组对象,切数组中无引用外部对象。

对应的,在新生代上产生的gc称为minor gc,在老生代上产生的gc称为full gc。希望这样大家在其他地方看到对应的术语时能更好理解。

当垃圾回收(gc)执行的时候,所有应用线程都要被停止,系统产生一次暂停。minor gc非常频繁,所以被优化的能够快速的回收死对象,是新生代的内存的主要的回收方式。major gc运行起来就相对慢得多,因为要扫描非常多的活着的对象。垃圾回收器本身也有多种实现,有些垃圾回收器在一定情况下能更快的执行major gc。

堆的大小是动态的,只有堆需要扩张的时候才会从内存中分配。当堆被填满时,JVM会重新给堆分配更多的内存,直到达到堆大小的上限,这种重新分配同样会导致应用的短暂停止。

GC算法基础

GC简介

垃圾回收(Garbage Collection,GC),顾名思义,垃圾回收就是释放垃圾占用的空间,而在Java中,程序员不需要去关心内存动态分配和垃圾回收的问题,这一切都交给了JVM来处理。那么在Java中,什么样的对象会被认定为“垃圾”?那么当一些对象被确定为垃圾之后,采用什么样的策略来进行回收(释放空间)?在目前的商业虚拟机中,有哪些典型的垃圾收集器?

在深入GC算法的实现细节之前,我们最好先来了解下相关术语及背后的基本原理。不同回收器的实现细节各有不同,但总的来说基本所有的回收器都会关注如下两个方面:

找出所有的存活对象

清理掉所有的其它对象——被认为是废弃或无用的对象

标记可达对象

JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。

首先,垃圾回收器将某些特殊的对象定义为GC根对象。所谓的GC根对象包括:

当前执行方法中的所有本地变量及传入参数

活跃线程

已加载类中的静态变量

JNI引用

关于标记阶段有几个关键点是值得注意的:

开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(Safe

Point),这会触发一次Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。

暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。

当标记阶段完成后,GC开始进入下一阶段,删除不可达对象。

如何确定某个对象是无用的(即“垃圾”)

在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法。

这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。看下面这段代码:

public class Main {
public static void main(String[] args) {
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();

object1.object = object2;
object2.object = object1;

object1 = null;
object2 = null;
}
}

class MyObject{
public Object object = null;
}


最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。

为了解决这个问题,在Java中采取了 可达性分析法。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

再看一个例子:

String str = new String("hello");
SoftReference<String> sr = new SoftReference<String>(new String("java"));
WeakReference<String> wr = new WeakReference<String>(new String("world"));


这三句哪句会使得String对象成为可回收对象?第2句和第3句,第2句在内存不足的情况下会将String对象判定为可回收对象,第3句无论什么情况下String对象都会被判定为可回收对象。

最后总结一下平常遇到的比较常见的将对象判定为可回收对象的情况:

1.显示地将某个引用赋值为null或者将已经指向某个对象的引用指向新的对象,比如下面的代码:

Object obj = new Object();
obj = null;
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2;


2.局部引用所指向的对象,比如下面这段代码:

void fun() {

.....
for(int i=0;i<10;i++) {
Object obj = new Object();
System.out.println(obj.getClass());
}
}


循环每执行完一次,生成的Object对象都会成为可回收的对象。

3.只有弱引用与其关联的对象,比如:

WeakReference<String> wr = new WeakReference<String>(new String("world"));


清理垃圾的算法

在确定了哪些垃圾可以被回收后,垃圾收集器要做的就是是开始进行垃圾回收,所以接下来我们介绍几种常见的垃圾收集算法的核心思想。

Mark-Sweep(标记-清除)算法

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

标记后



标记前



从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

Copying(复制)算法

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

回收前



回收后



这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

Mark-Compact(标记-整理)算法

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

回收前



回收后



Generational Collection(分代收集)算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

最后

写这一篇文章算是纪念我学习的一周年,所以在最后我觉得我需要感谢引导我走上这一条学习之路的“老师”,他是我的师兄,很巧,同一个专业,同一个家乡,在我来到学校的第一天其实就认识了他,他就给我们几个人介绍了他正在学的Android开发。只不过那时候还不知道他的名字,而我也因为没有加入实验室而没有再想学习。直到寒假,也就是去年的这个时候,偶然在家乡群里又看见了师兄,当时师兄和我聊了很多,而我也就是从那一刻开始正式开始接触现在所学习的技术。我一直很感谢师兄,如果没有他,我不会是现在的我。这一年的学习,也是他让我懂得了更多,师兄经常会来实验室,每一次都是和我聊天,而我每一次都能从中获益良多。谢谢师兄,让我成为现在的我,让我喜欢上这样的学习和生活。

师兄的博客

Yinhuan_
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  jvm 算法