java内存管理原理

作为java开发人员,很少会去关心内存是如何分配与回收,java虚拟机为我们做好了一切,但并不代表开发人员可以对java内存管理的原理一无所知,java应用程序是很消耗内存的,特别是对Sever模式的应用程序,当并发高,运行时间长的时候,代码中内存的浪费也会导致应用程序的停顿、事务执行的失败。因此,了解java内存的管理对于编码的习惯,jvm垃圾回收器的选择等有助于应用程序效率的提高有很大的帮助。
基础知识—java内存块简述
Java虚拟机的内存主要分为以下几块
一、 堆内存
堆内存是java内存块中最大的一块内存,也是java虚拟机内存管理的主要内存块,它存放对象的实例,线程共享。想想看,我们应用程序中有成千上万个对象,每个对象可能被实例化成几十个乃至上百个,可能还更多。
我们来做一个简单的计算:一个bean对象有7个string型属性,对象是简单的getter/setter方法,大小是187byte。通过如下代码可以判断一个对象的大小:
public void testSize() {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(new OOMObject());
byte[] bs = baos.toByteArray();
int size = bs.length - 4;// 对象大小
System.out.println(size);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

}


如果一个应用程序有5W个这样的对象,实例化100个。那么它占据堆内存大小为:
(187*50000*100)/(1024*1024)=891.69M
这个数据可以简单的想象下,当创建大量string对象,那么内存的消耗相当可观,因此在程序当中,要注意对象的创建,能重用的就重用,如String的对象最好用stringbuffer来代替等等。
那么堆内存是如何管理的呢,先从堆内存的结构说起:
(一) 结构:
堆内存可以分为新生代和老年代,新生代又可以分为Eden区、From Survivor区、To Survivor区。
(二) 异常:
会抛出OutOfMemoryError并进一步提示Java heap space.抛出异常原理及例子请看第四部分.
(三) 管理与回收原理:
Jvm对新生代和老年代有不同的管理方式和算法,也有不同的垃圾回收器进行回收。
1. 新生代
这个区的大小可以通过-XX:NewRatio来设置,例如:-XX:NewRatio=4表示新生代和老代的比例是1:4。新生代的垃圾回收管理一般采用复制算法。这个算法是将内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
基于这种算法,JVM把新生代分为一块较大的Eden空间和两块较小的Survivor空间.
1) 实例化一个对象时,首先会优先在Eden区域分配内存,但当这个对象很大的时候则直接进入老年代。
a) 对象大到什么程度才会进入老年代呢?默认的大小是Eden的大小,但是可以通过-XX:PretenureSizeThreshold参数设置,如 -XX:PretenureSizeThreshold=3145728,这个参数不能与-Xmx之类的参数一样直接写3MB.看如下代码.
/**
* 参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:MaxTenuringThreshold=15 -XX:SurvivorRatio=8 -XX:+PrintTenuringDistribution -XX:PretenureSizeThreshold=3145728B
*/
private void bigObject(){
int _1M=1024*1024;
byte[] allo1;
//分配3M,直接进入老年代.
allo1=new byte[3*_1M];

}

b) 如何设置Eden的大小?通过比例Eden:Survivor的比例来设置,默认是8:1.例如-XX:SurvivorRatio=8,表示1个Survivor和Eden的比值为1:8
2) 在Eden分配内存,首先要判断空间是否足够,如果不够则会进行一次Minor GC,以释放内存。释放内存后还是不够分配,则直接进入老年代。Minor GC中内存的调配,发生了以下动作:
a) JVM会进行一次复制算法,把不能回收的对象,也就是还有引用的对象复制到第二块Survivor,这时要注意当第二块Survivor空间不足以存放复制对象时,则复制对象会晋升到老年代。请看如下代码:
/**
* 新生代不够分配,就直接分配到老年代,老年代还是不够直接报错
* 参数:-Xms30M -Xmx30M -Xmn10M -XX:+PrintGCDetails
*/
private void notEnoughEdenAndOld(){
int _1M=1024*1024;
//1:分配6M到新生代:Eden和一个Survivor
byte[] _6M=new byte[6*_1M];

//2:欲分配7M空间。
//3:发现新生代空间不够,触发一次Minor GC,
//先前分配的6M空间对象还有引用,故不能GC掉,基于复制的算法,
//把6M的对象复制到第二块Survivor,第二块Survivor只有1M空间,不够存放6M的内容,
//则6M对象直接进入老年代。
//4:此时新生代够存放7M对象,7M对象分配给新生代
//5:这时候新生代7M,老年代6M
byte[] _7M=new byte[7*_1M];
_6M=null;
//6:欲在新生代分配6M空间,不够(见第3点描述)。
//7:发生一次MinorGC。
//8:在GC的过程中,7M的空间晋升老年代,6M分配给新生代。
//9:这时候新生代6M,老年代7+6=13M,其中6M可以回收。
byte[] _6M2=new byte[6*_1M];
_7M=null;
//10:欲在新生代分配8M空间,不够(见第3点描述)
//11:发生一次MinorGC
//12:在GC的过程中,6M的空间晋升老年代。老年代6+13=19M,其中6+7=13M可以回收
//13:此时新生代没有对象,老年代8M+7M=15M
byte[] _8=new byte[8*_1M];
}

b) 长期存活的对象将进入老年代。长期存活的对象默认是15岁,也就是经过了15次Minor GC还存在的对象,可以通过-XX:MaxTenuringThreshold来设置,例如-XX:MaxTenuringThreshold=1,意思是当MinorGC一次后,就直接把改对象放入老年代。参考如下代码:
/**
* 参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:MaxTenuringThreshold=1 -XX:SurvivorRatio=8
*/
private void notEnoughEdenAndOld1(){
int _1M=1024*1024;
byte[] allo1,allo2,allo3;
//1:分配256到新生代:Eden和一个Survivor
allo1=new byte[_1M/4];
//2:欲分配4M空间。
//3:发现新生代空间足够,直接分配到新生代。
//4:这时候新生代4.25M。
allo2=new byte[4*_1M];
//5:欲分配5M空间。
//6:发现新生代空间不够,触发一次Minor GC,
//先前分配的4.25M空间对象还有引用,故不能GC掉,基于复制的算法,
//把4.25M的对象复制到第二块Survivor。
//7:此时新生代不够存放7M对象,7M对象分配给老年代
//8:这时候老年代4.25M,新生代5M,并且标记为1岁
allo3=new byte[5*_1M];
//9:欲分配4M空间。
//10:发现新生代空间不够,触发一次Minor GC,
//新生代中5M的对象已经被标记为1岁,直接进入老年代,5524K>0K(9216K)完美清0,如果是-XX:MaxTenuringThreshold=15的话则不会清0
//11:这时候老年代5+4=9M,新生代4.25M
allo3=new byte[4*_1M];
}

c) 动态年龄对象晋升到老年代:
描述如下:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄.参考如下代码:
/**
* 参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:MaxTenuringThreshold=15 -XX:SurvivorRatio=8
*/
private void notEnoughEdenAndOld2(){
int _1M=1024*1024;
byte[] allo1,allo2,allo3,allo4;
//1:分配256到新生代:Eden和一个Survivor
allo1=new byte[_1M/4];

allo2=new byte[_1M/4];

allo3=new byte[4*_1M];
//触发一次MinorGC,allo1、allo2、allo3被标记为1岁
//晋升到老年代的是allo3对象
//这时候老年代的大小差不多是4M-256*2K(剩余allo1、allo2和部分allo3对象在另一块Survivor空间),新生代的大小是4M+256*2K
allo4=new byte[4*_1M];
allo4=null;
//触发一次MinorGC,allo1、allo2的等于Survivor空间的一半,因为allo1、allo2和部分allo3晋升到老年代
//allo4被回收
//新生代大小是4M,老年代大小是4M-256*2M+256*2M+部分的allo3,结果是4M
allo4=new byte[4*_1M];
}

d) 什么样的对象是不能回收的对象呢?JVM是使用根搜索算法(GC RootsTracing)判断对象是否存活的。这个算法的基本思路是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
2. 老年代
老年代的垃圾回收与管理效率不高,大概是新生代的十分之一左右,这跟算法有关,老年代不能采用复制的算法,原因有2个:
1:复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低.而老年代存放的大部分是长久存活的对象,会倒是较多的复制操作.
2:当对象100%存活的情况下,第二块Survivor空间将很大可能放不下,需要额外的空间担保.老年代以外已经没有额外的空间担保了.
因此老年代一般采用采用标记-清除算法或者是标记整理算法.
1) 标记-清除:分为标记和清楚两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象.它有2个缺点:
a) 效率问题,标记和清除过程的效率都不高.
b) 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.
2) 标记-整理:该算法的标记过程和标记-清除的标记一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存.效率会比标记-清除算法来的高.
晋升到老年代的对象,当碰到如下情况会发生FULL GC:
1:老年代空间不足,看如下代码:
/**
* 参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
*/
private void old(){
int _1M=1024*1024;
byte[] allo1,allo2,allo3;
//大对象直接进入老年代
allo1=new byte[8*_1M];
//新生代足够存放2M
allo2=new byte[2*_1M];
//分配7M给新生代,但是不够分配,触发一次Minor GC,
//新生代的2M晋升到老年代,此时老年代空间不够,触发一次FULL GC,对象没有释放,空间不够,直接报错
allo3=new byte[7*_1M];

}

2:老年代空间足够,但是不允许担保失败,看如下代码:
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:-HandlePromotionFailure
*/
private void old(){
int _1M=1024*1024;
byte[] allo1,allo2,allo3,allo4,allo5,allo6,allo7;
//2M进入新生代
allo1=new byte[2*_1M];
//2M进入新生代,总共有4M
allo2=new byte[2*_1M];
//触发一次MinoGC,4M进入老年代
//这时候新生代4M,老年代4M
allo3=new byte[4*_1M];
//3M进入新生代
//这时候新生代7M,老年代4M
allo4=new byte[3*_1M];

allo3=null;
allo2=null;
//触发一次MinoGC,回收6M空间(allo3+allo2),另外2M空间进入老年代,参数设置不允许担保
//2M<老年代剩余空间,因此会触发一次FULL GC
allo5=new byte[3*_1M];
}

3:不够空间存放大对象,看如下代码:
/**
* 参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
*/
private void old(){
int _1M=1024*1024;
byte[] allo1,allo2,allo3;
//新生代和老年代都不够存放,触发FULL GC,并且报错
allo1=new byte[20*_1M];
}

3. 分配过程如下图:
[img]http://dl.iteye.com/upload/attachment/0070/5074/211907ef-f705-3b44-8d5b-bab4aaef1443.jpg[/img]

二、 方法区:
方法区内存块存放的是被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它是线程共享的。这块内存会导致OutOfMemoryError:PermGen space。
以下的操作为导致内存溢出:
1:使用CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的class可以加载到内存。
2:大量JSP或动态产生JSP文件的应用。
3:常量池的溢出。请看如下代码:
/**
* 常量池溢出模拟
* 参数-XX:PermSize=10M -XX:MaxPermSize=10M -XX:+PrintGCDetails
*/
private void changliangchi(){
List<String> list=new ArrayList<String>();
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}

}

String.intern这个方法的意思是:如果池已经包含一个等于此 String 对象的字符串(用 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象的引用。如果不用intern这个方法上述代码将抛出堆内存溢出的错误。

垃圾回收分为2部分内容:
1:废弃常量。没有地方引用的常量.
2:无用的类。
1:该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
2:加载该类的ClassLoader已经被回收。
3:该类对于的java.lang.Class对象没有任何地方被引用,无法再任何地方通过反射访问该类的方法
三、 栈:
Java虚拟机栈是线程私有的,它的生命周期与线程相同。每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。所以不会出现栈帧内存溢出的异常。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(返回对象的类型)。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
栈会抛出2中异常:
1:StackOverflowError。
如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出异常。
2:OutOfMemoryError
如果虚拟机在扩展时无法申请到足够的内存空间,抛出异常。
可以通过-Xss参数来模拟这2中异常的抛出。
-Xss128K:减少栈内存大小,减少128K。
那么通过上面的描述可以知道,所有线程的栈是固定的,那么当每个线程的栈比较大(也就是说-Xss设置的比较小),那么能分配到的线程就比较少。这时候可能抛出第二种异常。如下代码:
/**
* 栈异常测试
* 参数-Xms1024M -Xmx1024M -XX:PermSize=256M -XX:MaxPermSize=256M -Xss83201K
*/
public static void main(String[] args) throws Throwable{
//不会打印12,因为栈内存设为0,则线程请求不到内存分配,直接抛出异常。
System.out.println(12);
JvmErrorTest jvmErrorTest=new JvmErrorTest();
try {
jvmErrorTest.methodStack();
} catch (Throwable e) {
System.out.println(jvmErrorTest.stackLength);
throw e;
}
}
private void methodStack(){
stackLength++;
methodStack();
}

如果-Xss设置的比较大,则每个线程栈内存就比较小,那么线程分配的多,这种时候就可以尽量避免第二种错误,但这种分配栈内存小,也就意味着每个栈的栈深度比较小,就有可能出现第一种情况异常,如下代码:
/**
* 栈异常测试
* 参数-Xms1024M -Xmx1024M -XX:PermSize=256M -XX:MaxPermSize=256M -Xss128K
*/
public static void main(String[] args) throws Throwable{
JvmErrorTest jvmErrorTest=new JvmErrorTest();
try {
jvmErrorTest.methodStack();
} catch (Throwable e) {
// TODO: handle exception
System.out.println(jvmErrorTest.stackLength);
throw e;
}
}
private void methodStack(){
stackLength++;
//递归调用,当达到最大栈深度,则报错
methodStack();
}

实际上第一种异常也可以看出是内存的溢出,只不过JVM把这种内存的溢出转化成栈深度的溢出。
程序计数器:
它是一块比较小的内存空间,作用是当前线程所执行的字节码的行号指示器。也是线程私有的。不会出现内存异常情况。
Java虚拟机垃圾回收器的选择:
新生代垃圾回收器和老年代垃圾回收器的搭配图:

单线程垃圾回收器:(单CPU环境下效果比较好,一般应用与Client模式)
新生代
Serial收集器
该收集器采用复制的算法,单线程的意义不仅仅意味着它只会使用一个CPU或者是一条线程去完成垃圾回收工作,更重要的是在它进行垃圾回收时,会暂停其他所有的工作线程,这样会造成应用程序的停顿,带给用户恶劣的体验,俗称“Stop The World”.
老年代
Serial Old收集器:
使用标记-整理算法,该收集器也可以用在Server模式下,一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用(Parallel Scavenge收集器本身有PS MarkSweep收集器来进行老年代收集,并没有直接使用Serial Old收集器,但是这个PS MarkSweep收集器是以Serial Old收集器为模板设计的,与Serial Old的实现非常接近);另一个是做为CMS收集器的后备方案,在并发手机发生Concurrent Mode Failure的时候使用。
多线程垃圾回收器:(大于1个的CPU效果比较好,一般应用与Server模式)
新生代
ParNew收集器
该收集器跟相差不大,唯一一个区别就是多线程,它是一个并行收集器。
并行:指多条垃圾收集线程并行工作,但用户线程仍然处于等待状态。
并发:指用户线程与垃圾收集线程同时执行。
Parallel Scavenge收集器
该收集器使用复制算法,也是并行的多线程收集器,它跟ParNew收集器有什么区别呢?关注点不同,Parallel Scavenge收集器关注吞吐量,所谓的吞吐量指的是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),如虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
老年代
Parellel Old收集器
该收集器是1.6版本才提供和Parallel Scavenge搭配使用
CMS收集器
该收集器是一种以获取最短回收停顿时间为目标的收集器。一般用在互联网或者B/S系统的服务端上。它是基于“标记-清楚”算法实现的。默认启动线程是(CPU数量+3)/4。当CPU在4个以上时,并发回收时垃圾收集线程最多占用不超过25%。如果CPU不足4个时,那么CMS对用户程序的影响就可能变得很大。
缺点:
1:无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,并且启用备用垃圾回收器Serial Old,这样停顿时间就很长了。
浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序的运行自然还会产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们。这一部分垃圾就称为“浮动垃圾”。
默认情况下,CMS收集器在老年代使用了68%的空间后就会被激活。
如果在应用中老年代增长的不是很快,可以适当的提高激活比例,通过参数设置:
-XX:CMSInitiatingOccupancyFraction。
2:收集过后,会产生大量的空间碎片。这种情况会对大对像的分配造成麻烦,往往会出现老年代还有很大的剩余空间,但是无法找到足够大的连续空间来分配大对象,不得不触发一次Full GC。
CMS收集器提供了两个参数来解决这个问题。
1:-XX:+UseCMSCompactAtFullCollection.用于在Full GC后进行一次碎片整理,但是无法并发,会造成停顿时间过长。
2:-XX:CMSFullGCsBeforeCompaction.这个参数用于设置在执行多少次压缩的Full GC后,跟着来一次带压缩的。

垃圾回收器组合的启用参数:
UseSerialGC:虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收。
UseParNewGC:打开此开关后,使用ParNew+Serial Old的收集器组合进行内存回收。
UseConcMarkSweepGC:打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。
UseParallelGC:虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge+Serial Old(PS MarkSweep)的收集器组合进行内存回收。
UseParallelOldGC:打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合进行内存回收。
综上所述对于垃圾回收器的选择,须根据CPU的个数或者逻辑核数来选择,
1个CPU用Serial+Serial Old的组合
2-3个CPU用Parallel Scavenge+Parallel Old的组合
4个或者4个以上CPU用ParNew+CMS+Serial Old的组合。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值