一、JVM的内存结构(体系结构)
JVM是Java程序得以运行的平台,也是Java程序可以跨平台的底层支撑,从整体上来看,JVM的主要功能可以分为加载和执行两大块。其中类加载器负责.class文件的寻址与加载,执行引擎负责字节码指令执行及内存的管理等等。下面是JVM一个经典的体系结构图:
1、Class Loader类加载器:将.class文件加载进内存;class文件在文件的开头会有特定的文件标识,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定;
2、Execution Engine 执行引擎:负责解释命令,提交给操作系统执行
3、Native Interface本地方法接口:调用C/C++语言编写的方法;本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序;
JAVA诞生的时候是C/C++的天下,要想立足,必须有调用C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码;
它的具体做法是:在Native Method Stack本地方方法栈中登记native方法,在Execution Engine执行时加载native libraies(本地方法库);
目前该方法的使用越来越少,除非是与硬件有关的应用,比如通过Java程序驱动打印机,或者Java系统管理生产设备,在企业级应用中已经比较少见了;
因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,直接调服务,这里不多作介绍;
4、**Runtime Date Area运行时数据区:核心重点,所谓的JVM调优,也就是调这一块内容:
-
Method Area 方法区:方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法(如构造函数,接口代码)也在此定义。
简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;
静态变量+常量+类信息+运行时常量池====>存在方法区中
*实例变量存在====>堆内存中*
-
Program Count Register 程序计数器:
每个线程都有一个程序计数器,也就是一个指针,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计;
-
Native Method Stack 本地方法栈:它的具体做法是在 Native Method Stack中登记native方法,在Execution Engine执行时加载native libraies;(这个在上面讲解Native Interface时候已经说过)
注意:JVM优化,永远指的是优化共享区域,而且几乎99%的优化都是优化堆,而剩下的1%就是优化方法区!
栈管运行,堆管存储
JVM优化,永远指的是优化共享区域,而且99%的优化都是优化堆,而剩下的1%是优化方法区,栈是不需要优化的
5、Stack 栈是什么?
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命周期是跟随线程的生命周期的,线程结束栈内存也就释放,对于栈来说,不存在垃圾回收问题;
只要线程一结束,它的栈内存也就Over,生命周期和线程一致,是线程私有的。
基本类型的变量和对象的引用变量都是在函数的栈内存中分配。
5.1、栈存储什么?
栈帧中主要保存3类数据:
本地变量(Local Variables):输入参数和输出参数,以及方法内的变量;
栈操作(Operand Stack):记录出栈、入栈的操作;
栈帧数据(Frame Data):包括类文件、方法等等;
形象比喻:栈就好比是弹夹,每一个方法,就是它的子弹;————后进先出,先进后出
5.2、栈运行原理
栈中的数据,都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行时数据的数据集;
当一个方法A被调用时就会产生一个栈帧F1,并被压入到栈中;
方法A又调用了方法B,浴室又产生了栈帧F2,也被压入到栈中;
方法B又调用了方法C,浴室又产生了栈帧F3,也被压入到栈中;
……
执行完毕后,先弹出F3栈帧,在弹出F2栈帧,再弹出F1栈帧……
遵循“先进后出”/“后进先出”原则。
二、JVM优化是指优化哪儿?
根据上面部分的内容,我们已经知道JVM优化其实就是优化“线程共享的数据区”,即下图中圈出的部分——堆(99%)+方法区(1%)
1、JVM(Java)有3种:
-
Sun公司的HotSpot(默认的,最多在用的)———— 被Oracle收购了
-
BEA公司的JRocket ———— 也被Oracle收购了,一山不容二虎,没干的过HotSpot
-
IBM公司的J9 VM ————太贵,Oracle买不起了
2、正式开始JVM优化——堆
熟悉三区结构后方可学习——JVM垃圾收集
2.1、是什么?
一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了class文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行;
堆内存分为三部分:
-
Young Generation Space 新生区 Young
-
Tenure Generation Space 养老区 Old
-
Permanent Space 永久区 Perm
?问题:new 一个对象是new在哪儿?
new在堆内存中的新生区的“伊甸园”,在这个区里面,对象基本上是伴随着对象的一生,就这么结束了,朝生夕死(因为GC回收机制);
2.2、新生区(Young)
新生区是类的诞生、成长、消亡的区域(个别会幸存);一个类在这里产生、应用、最后被垃圾回收器收集、结束生命。
新生区又分为两部分:
-
伊甸园区(Eden Space):
-
幸存者区(Survivor Space):幸存者区有两个0区(Survivor 0 Space)和1区(Survivor 1 Space)。
当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存者0区;
当幸存者0区也满了的时候,再对该区进行垃圾回收(Minor GC),将剩余对象移动到幸存者1区;
那幸存者1区也满了的时候呢?再移动到养老区;
如果养老区也满了,那么这个时候将产生一次大的垃圾回收Major GC(FullGC),进行养老区的内存清理;
若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常(OutOfMemory Error 内存溢出错误)
如果出现了java.lang.OutOfMemoryError:Java heap space异常,说明Java虚拟机的堆内存不够,原因有二:
|
注意:垃圾回收(GC)有两个:
|
代码创建一个大对象,制造OOM错误;
使用 System.out.println(Runtime.getRuntime().maxMemory()/1024/1024); 查看本机JVM的最大内存;
使用byte[] byteArray = new byte[1024*1024*1024*4];就可以创建一个4G的大对象;
2.3、养老区(Old)
养老区用于保存从新生区筛选出来的JAVA对象,一般池对象都在这个区域活跃;
2.4、永久区
永久存储区是一个常驻内存区域,用于存放JDK自身所携带的Class、Interface的元数据,
也就是说,它存储的是运行环境所必须的类信息(如Object类等),被装载进此区域的数据时不会被垃圾回收器回收掉的,只有关闭JVM才会释放此区域所占用的内存。
如果出现java.lang.OutOfMemoryError:PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。
一般出现这种情况,都是程序启动需要加载大量的第三方jar包。
例如,在一个Tomcat下部署了太多的应用,或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。
JDK1.6及之前: 有永久代,常量池1.6在方法区;
JDK1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆;
JDK1.8及之后: 无永久代,常量池1.8在元空间
Java7和Java8最大的一个特点(区别):Java7叫永久代,Java8叫元空间 |
重申一遍:只有熟悉了三区结构后,才可以学习JVM垃圾收集GC!!!
三、做一个小练习——程序内存划分小总结
代码如下:
package com.jiguiquan.www; /** ** 程序内存划分小总结 * @author jiguiquan * */ public class MemoryTest { public static void main(String[] args) { //Line1 int i = 1; //Line2 Object obj = new Object(); //Line3 MemoryTest mem = new MemoryTest(); //Line4 mem.foo(obj); //Line5 } //Line9 private void foo(Object param) { //Line6 String str = param.toString(); //Line7 System.out.println(str); } //Line9 }
划分效果图:
注意:为什么这里会有一个String Pool(字符串池)?
答案:这就跟JDK的版本有关系了,我们知道:JDK6、7、8三个版本都一样,只需要记住 String对象是在字符串常量池中(String Pool)中,而:
-
JDK6中,常量池在方法区;
-
JDK7中,常量池在堆;
-
JDK8中,常量池在元空间;
严格来说,堆(Heap)和方法区(Method Area)是分开的,所以在JDK6中的图示如下:
程序内存划分小总结:
实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等;
虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要将它和堆区分开;
对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Permanent Gen)”,但严格本质上来说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,JDK1.7的版本中,已经将原本放在永久代的字符串常量池移走(即JDK7中,常量池已经不再方法区了,而是移到了堆中)。
常量池(Constant Pool)是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,这部分内容将在“类加载器加载后进入方法区的运行时常量池”中存放;
四、正式开始垃圾回收GC
在JDK1.7中:
堆实际而言只是从“新生区”到“养老区”,而右边是“永久代”;
Minor GC(轻量级GC)发生在“伊甸园区”,而Major GC(重量级GC)发生在“养老区”;
在JDK1.8中:
JDK1.8之后将最初的“永久代”取消了,由“元空间”取代;
目的:将HotSpot与JRockit两个虚拟机标准合2为1,诞生了新的Java规范——JDK1.8;
如果遇到这样的问题:Java7和Java8有什么区别? 第一点,最重要的一点:Java7有“永久代”,Java8没有,由永久代改为了“元空间”; |
五、堆内存调优简介
-Xms | 设置初始分配大小,默认为物理内存的“1/64” |
-Xmx |
最大分配内存,默认为物理内存的“1/4” |
-XX:+PrintGCDetails | 输出详细的GC处理日志 |
首先使用以下代码,打印MAX_MEMORY和TOTAL_MEMORY:
package com.jiguiquan.www; public class MemoryCheck { public static void main(String[] args) { long maxMemory = Runtime.getRuntime().maxMemory(); //返回Java虚拟机试图使用的最大内存量, 约等于1/4 = 4000M long totalMemory = Runtime.getRuntime().totalMemory(); //返回Java虚拟机中的内存总量,约等于1/64 = 250M System.out.println("MAX_MEMORY = "+maxMemory+"(字节)、"+(maxMemory/(double)1024/1024)+"MB"); System.out.println("TOTAL_MEMORY = "+totalMemory+"(字节)、"+(totalMemory/(double)1024/1024)+"MB"); } }
我们来运行查看结果:
MAX_MEMORY = 3790077952(字节)、3614.5MB TOTAL_MEMORY = 257425408(字节)、245.5MB
和我们预期的相差不大;
使用 -Xmx1024m -Xms1024m -XX:PrintGCDetails 对JVM进行重新配置:
修改完设置后再次运行的结果为:(由于没有虚拟机,所以使用的是别人的截图)
而且可以很容易的计算出, 堆内存大小 = PSYoungGen新生区 + ParOldGen养老区,注意在JDK8中“PSPermGen永久代”会变为“Metaspace元空间”;
六、模拟自动触发垃圾回收
注意:
只有在养老区才会包OOM,因为上面有一句话:“若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常(OutOfMemory Error 内存溢出错误)”;
所以,只要报OOM错误,正常上一行一定是Full GC;
七、常见面试题
1、StackOverFlowError和OutOfMemoryError,谈谈你的理解?
2、一般上面时候回发生GC?如何处理?
答:Java中的GC会有两种回收:年轻代的Minor GC,和老年代的Full GC;
新对象创建时如果伊甸园空间不足会触发MinorGC,如果此时老年代的内存空间不足,会触发FullGC;如果FullGC之后,还是空间不足,那么就会抛出OutOfMemoryError错误。
3、GC回收策略,谈谈你的理解?
答:年轻代(伊甸园区+2个幸存区),GC回收策略为“复制”;
老年代的保存空间一般较大,GC的回收策略为“整理-压缩”;
频繁收集 Young区
较少收集 Old区
基本不动 Perm区
八、GC的三大算法(其实是4大)
JVM在进行GC回收时,并非每次都对上面的三个内存区域进行一起回收的,大部分时候回收的都是指“新生代”;
因此,GC按照回收的区域又分为了两种类型,一种是普通GC(Minor GC),一种是全局GC(Major GC或者Full GC);
普通GC(MinorGC):只针对新生代区域的GC;——较小收集
全局GC(MajorGC or FullGC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC;——较大收集
1、GC总共有4大算法,但是其中的引用计数法已经被淘汰了
-
引用计数法:因为解决不了 双端循环引用 的问题,所以已经被淘汰了
-
复制算法:年轻代中使用的GC是MinorGC,这种GC使用的算法就是复制算法(Copying)
-
标记清除;
-
标记整理/压缩
2、复制算法详解(新生代):
复制之后要交换,谁空谁是TO
Minor GC会把Eden中的所有(剩下的)活的对象都移动到Survivor区域中,如果Survivor区中放不下,那么剩下的活的对象就被移到Old Generation中,也即一旦收集后,Eden是就变成空的了(死了的就死了,没死的也得搬家)。 当对象在Eden(包括一个Survivor区域,这里假设是from区域)出生后,在经过一次Minor GC后,如果对象还存活,并且能够被另一块Survivor区域所容纳(上面已经假设为from区域,这里应为to区域,即to区域有足够的内存空间来存储Eden和from区域中存活的对象),则使用 复制算法 将这些仍然还存活的对象复制到另外一块Survivor区域(即TO区),然后清理所使用过的Eden以及Suivivor区域(即FROM区域),并且将这些对象的年龄设置为1,以后对象在Survivor区每熬过一次Minor GC,就将对象的年龄 +1,当对象的年龄达到某个值时(默认是15岁,通过 -XX:MaxTenuringThreshold 来设定参数),这些对象就会成为老年代。 两个Survivor区,轮流充当FROM区和TO区。 |
复制算法的优点是:不会产生内存碎片,缺点是浪费了一点点内存空间!
HotSpot JVM把年轻代分为了三部分:1个Eden和2个Survivor区(分别叫from和to),三者默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Suivivor区,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当年龄达到一定程度时(默认为15岁),就会被移动到老年代中。 因为年轻代中的对象基本都是朝生夕死(80%以上),所以在年轻代中的垃圾回收算法使用的是复制算法,复制算法不会产生内存碎片; |
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的,紧接着进行GC,Eden中所有存活下来的对象都将会被复制到“To”区: 而在“From”区中,仍然存活的对象,会根据他们的年龄值来决定他们的去向:年龄值达到一定值(年龄阈值,可以通过 -XX:MaxTenuringThreshold来设置)的对象将会被移动到老年代中,没有达到阈值的对象将会被复制到“To”区域。 经过这次GC,Eden区和From区已经被清空。这个时候“From”和“To”区会交换他们的角色,也就是新的“To”就是上次GC的“From”,新的“From”就是上次GC的“To”。 不管怎样,都会保证名为“To”的Survivor区域是空的。 Minor GC会一直重复这样的过程,直到“To”区也被填满,“To”区被填满之后,会将所有对象移动到老年代中。 |
下图中:黄色的是已经被GC回收(杀死的),红色是存活的,绿色是剩余空间(因为不可能Eden区达到100%才进行GC的,所以肯定回味剩余空间(绿色))
进行了一次GC复制算法后,很显然,Eden区和刚刚的“From”区就空了(绿色),那么自然的,此From区就会变成下一次GC过程中的“To”区;————这就是所谓的“复制之后要交换,谁空谁是To”
因为Den区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。
一旦发生GC,将10%的活动区间与另外80%中存活的对象转移到10%的空闲区间。接下来,将之前90%的内存全部释放,以此类推!
(Eden:From:To = 8:1:1)
重申复制算法的优缺点:
优点:不会产生内存碎片,完整度极高;
缺点:浪费了那10%的内存空间,因为总要有10%的空区间是作为“To”区的必备条件。
复制算法弥补了标记/清除算法中,内存布局混乱的缺点,不过与此同时,它的缺点也是相当的明显: 1、它浪费了一般的内存,这太要命了(其实是8:1:1中的Survivor); 2、如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有的对象都复制一遍,并将所有的引用地址重复一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变得不可忽视。 所以从以上的描述中,不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是:我们必须要克服50%内存的浪费(是指Survivor区)。 |
3、标记清除(Mark-Sweep)和标记整理(Mark-Compact)
老年代 一般是由标记清除或者是标记清除与标记整理的混合实现
注意:标记 是指对 活着的对象进行标记,回收哪些没被标记的对象。
优点:不需要额外的空间。
缺点:两次扫描,耗时严重;会产生内存碎片,
1、首先,它的缺点就是效率比较低(递归与全堆对象遍历——一次标记+一次清除),而且在进行GC的时候,需要停止应用程序,这会导致用户体验感非常差劲! 2、其次,主要的缺点就是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随机的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。 |
为了解决内存碎片的问题,自然而然地就引出了第3中算法(标记/整理算法)
标记/整理算法的唯一缺点就是效率也不高;不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
4、3中算法的对比:
-
内存效率:复制算法 > 标记清除算法 > 标记整理算法(此处的效率只是简单地对比时间复杂度,实际情况不一定如此);
-
内存整齐度:复制算法 = 标记整理算法 > 标记清除算法;
-
内存利用率:标记整理算法 = 标记清除算法 > 复制算法;
结合上面3中算法的优缺点,是不是应该出现一个完美的算法(解决办法)?
答:无,没有最好的算法,只有最合适的算法————>分代收集算法!!! |
九、思考一下几个问题(百度面试原题)
1、JVM内存模型以及分区,需要详细到每个区放什么?
2、堆连的分区:Eden,Survivor,from,to,老年代,各自的特点?
3、GC的三种收集方法:标记清除、标记整理、复制算法的原理与特点?
4、Minor GC和Full GC分别在什么发生?
2 Comments
大神写的太好了,受益匪浅,嘤嘤嘤
呀,欢迎大神光临,盆币生辉!