一、Java内存区域概览
JVM实现(前两者已被Oracle收购):HotSpot、JRockit(BEA)、J9(IBM)、AJDK(阿里)等。JVM运行在操作系统之上,与硬件没有直接交互。
一个Java类运行流程:1.我们运行javac命令编译后的class文件,被类装载器ClassLoader装载进入内存区域,ClassLoader在启动之初进行类的加载Load(验证)、链接Link(准备)、初始化Init(解析),使用溯源双亲委派模型,将字节码文件实例化成Class对象。2.执行命令java,提交给执行引擎解释,默认采用解释与编译混合执行的模式,然后提交给操作系统。Java关键字native修饰的方法,通过JNI走本地库接口,调用C/C++函数,如Thread的start函数内部实现调用的start0函数。
双亲委派模型:类加载器的等级制度,以组合的方式来复用父加载器的功能,低层次的类加载器不能覆盖更高层次加载器已经加载的类。第一层是Bootstrap ClassLoader加载核心Java类,第二层是Extension ClassLoader(JDK8。JDK11为Platform ClassLoader)加载扩展系统类,第三层为Application ClassLoader加载用户自定义CLASSPATH路径下的类。在启动时设置JVM参数-XX:+TraceClassLoading
可观察系统加载了哪个jar包中的哪个类,对于解决类冲突非常有用。
如图,内存区域分为线程独占区(虚拟机栈、本地方法栈、程序计数器)和线程共享区(方法区、堆、元空间),我们可对虚拟机进行调优,优化的是线程共享区:99%是在优化堆、1%是优化方法区。

程序计数器:记录当前线程所执行到的字节码行号,每个线程都有一个程序计数器,就是一个指针,指向方法区中方法字节码。
方法区:存在栈中(方法调用顺序返回值先进后出)。存储运行时常量池、已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(构如造函数+接口代码及定义+静态变量+常量+类信息)。
本地方法栈:主导JVM外,为所调用到的Native脚本方法服务。如JNI类本地方法(现在大多都是走服务如WebService),如大量使用JNI会丧失跨平台特性,如要求极高的执行效率的偏底层的跨进程操作时,可考虑JNI调用方式。
虚拟机栈:主导JVM内,存放方法运行时所需的数据,成为栈帧。一般我们说的内存使用栈都是虚拟机栈。
Java堆:存储对象实例、实例变量。可通过JVM参数参数设置其最小值和最大值,通常在生产环境中将两个值设置成相同,避免在GC后调整堆大小带来的额外压力。
二、栈管运行,堆管存储
1、栈
栈也叫栈内存,主管Java程序的运行,生命周期随线程创建而创建、线程结束而结束,对栈来说不存在垃圾回收问题,基本类型的变量和对象的引用变量都是在栈内存中分配。栈帧中主要保存三类数据:局部变量表(参数及方法内的变量)、操作栈(字节码指令集的定义基于栈类型)、栈帧数据(类文件和方法等)、动态连接、方法返回地址等,即八大基本数据类型+引用类型+方法。
方法定义及方法之间的调用关系,导致对象之间产生的互动,存在栈中。栈中的数据都是以栈帧的格式存在,栈帧是一个有关方法和运行时数据集的内存区块,待执行的方法按照执行顺序入栈,执行完后按先进后出顺序出栈。执行过程中如果出现异常会进行异常回溯,返回地址通过异常处理表确定。栈空间撑破报StackOverFlow异常,表示请求的栈溢出导致内存耗尽,通常出现在递归方法中。
如图,在一个栈中有两个栈帧,栈帧2是先入栈的方法,然后方法2又调用了方法1,栈帧1处于栈顶的位置,栈帧2处于栈底,线程结束栈释放。每执行一个方法都会产生一个栈帧入栈,顶部栈就是当前的方法,该方法执行完后会自动将此栈帧出栈。

2、堆
堆分成新生代和老年代,对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象。
新生区是类的诞生、成长、消亡的区域,一个类在这里产生、应用、最后被垃圾回收器收集,结束生命。新生区分为两部分:伊甸区(Eden space)和幸存者区(Survivor space)。绝大部分的类都在伊甸区被new出来;幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space)。
当伊甸区的空间用完时,程序又需创建对象,JVM的垃圾回收器将对伊甸区垃圾回收(Minor/Young GC、YGC)。先将伊甸区中的没有被引用的对象进行清除销毁,然后将依然存活的对象移动到幸存区0区;若幸存区0区满了,再对该区进行垃圾回收,然后移动到1区(当前空闲的那块Survivor区);若1区也满了则移动到养老区;若养老区满了,这个时候将产生Major GC(Full GC、FGC),进行养老区的内存清理;若养老区执行了Full GC之后依然无法进行对象的保存,就会产生OOM异常(Out Of Memory Error)。出错时的堆内存信息对解决问题非常有帮助,设置JVM参数-XX:+HeapDumpOnOutOfMemoryError在发送OOM异常时输出堆内信息。
新生区可设置对象存活在Survivor区的次数,达到次数后直接移至老年代。养老区并不会频繁的进行垃圾回收,一般设置成15或31(次回收操作后都没有被处理到的时候),如数据库连接池等对象就是放在养老区。

永久存储区常驻内存区域,加载一些构建系统不能够被回收、且保证系统稳定运行的元元素,运行环境所必须的类信息,关闭JVM才会释放此区域所占用内存。永久代是方法区(相当于是一个借口interface)的一个实现,与堆是一个逻辑概念,但却不保存在堆中。常量池是方法区的一部分,我们新生成的字符串都在常量池里面。
JDK1.6及之前,有永久代,常量池在方法区。
JDK1.7,有永久代,但已经逐步“去永久代”,常量池在堆。
JDK1.8,无永久代,常量池在元空间。

3、附
如果程序出现了java.lang.OutOfMemoryError:Java heap space异常,说明Java虚拟机内存不够,原因有二:(1)Java虚拟机堆内存设置不够,可以通过参数-Xms、-Xmx来调整。(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。
如果程序出现了java.lang.OutOfMemoryError:PermGen space异常,说明Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如,在一个Tomcat下部署了太多的应用,或者大量动态反射生成的类不被加载,最终导致Perm区被占满。发生OOM的JVM详细日志的上一行一定是养老区的FullGC操作。Maven的作用之一也是为了避免永久代溢出(主要作用是包管理防止jar包冲突)。
三、JVM垃圾收集
1、以JDK1.7、1.8 + HotSpot为例
JDK1.8及之后将最初的永久代(PSPermGen)取消了,由元空间(Metaspace)取代,目的是将HotSpot和JRockit两个虚拟机标准合二为一,都是对JVM规范中方法区的实现,解决了永久代在垃圾回收中存在的诸多问题,如图,堆内存大小不包含永久代/元空间。元空间并不在虚拟机中,而是在本地内存,默认情况下元空间大小仅受本地内存限制,在JDK8里,Perm区的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。
堆内存调优参数(同时设置多个参数用空格隔开):
示例:-Xms1024m -Xmx1024m -XX:+PrintGCDetails
其中:
-Xms:设置初始分配大小,默认为物理内存的“1/64”。
-Xmx:最大分配内存,默认为物理内存的“1/4”。
-XX:+PrintGCDetails:输出详细的GC处理日志。
Runtime.getRuntime().maxMemory(); //查看Java虚拟机试图使用的最大内存量
Runtime.getRuntime().totalMemory(); //查看Java虚拟机中的内存总量
其他:-XX: MaxPermSize=1280m,设置在JDK8之前的永久代固定加载的内存大小。
JDK1.7:

JDK1.8:

2、垃圾回收面试题
1)StackOverflowError和OutOfMemoryError,谈谈你的理解。
2)一般什么时候会发生GC?如何处理?
答:Java中的GC会有两种回收,年轻代的Minor GC和老年代的Major GC。新对象创建时,如果伊甸区空间不足会触发Minor GC,如果此时老年代的内存空间不足会触发Full GC,如果依旧空间不足会抛出OutOfMemoryError。
3)GC回收策略,谈谈你的理解。
答:年轻代(伊甸区+两个幸存区)的GC回收策略为“复制”;老年代的保存空间一般比较大,GC回收策略为“整理-压缩”。
四、GC算法应用原理
垃圾回收器是实现垃圾回收算法并应用在JVM环境中的内存管理模块,当前实现的垃圾回收器有数十种,如Serial、CMS、G1。JDK7推出的G1垃圾回收方式,通过-XX:+UseG1GC
参数启用,JDK11将G1作为默认垃圾回收器,在JDK11中还引入了实验性质(仅支持Linux环境下)的新GC算法ZGC,。
为了判断对象是否存活,JVM引入了GC Roots,如果一个对象与GC Roots之间没有直接或间接的引用关系,判决这些对象是可以回收的。JVM分代收集算法原则:频繁收集Young区,较少收集Old区,基本不动Perm区。
对象回收算法包括引用计数法和可达性分析算法。引用计数法简单高效,唯一缺点是当两个对象互相引用时无法被回收。可达性分析算法是通过一系列GC Roots的对象作为起点,搜索走过的路径形成引用链,当一个对象没有与任何引用链相连,表示对象不可达,可以内存回收。
垃圾回收算法包括标记、清理算法、复制算法、标记整理算法、分代回收算法。其中新生代一般采用复制算法,老生代采用标记整理算法。
相关设置参数:
-XX:MaxTenuringThreshold:设置对象在新生代中存活的次数,默认15。
1、引用计数法
主要用于判断对象是否存活(可达性分析算法完成同样的功能)。已经淘汰,因为都不好解决双端循环引用问题。判断是否存活还有可达性分析方法:强弱软虚引用。
2、复制算法Copying
简介:普通GC一般发生在年轻代,此区域用到的算法是轻量级的Minor GC,这种GC算法采用的是复制算法Copying。Minor GC会把Eden中所有活动的对象都移到Survivor区域中,如果Survivor放不下,剩下的活动对象被移到Old generation中,也即一旦收集后,Eden就变成空的了。
原理:一般新创建的对象都被分配在Eden区(一些大对象特殊处理:大对象、长期存活的对象直接进入老年代)。当对象在Eden(包括一个Survivor区域,这里假设是from区域)出生,经过一次Minor GC后如果对象还存活,并且能够被另外一个Survivor区域所容纳(前面已经假设为from区域,这里应为to区域,即to区域有足够的空间来存储Eden和from区域中存活的对象),则使用 复制算法 将这些仍然存活的对象复制到另一块Survivor区域(即to区),然后清理所使用过的Eden以及Survivor区域(即from区域),并且将这些对象的年龄设置为1,以后在Survivor区每熬过一次Minor GC,就将年龄+1,当对象达到某个值时(默认15岁,通过 -XX:MaxTenuringThreshold
来设定参数),这些对象就会成为老年代。(口诀:谁空谁是to、复制要交换:即s0和s1谁是from谁是to是一直在来回多次改变的)。如图,红色代表回收不了的需要进入老年代、黄色代表需要被清除、绿色的是仅存剩余空间。

HotSpot把JVM分成三部分:1个Eden区和2个Survivor区(from和to),默认比例为 8:1:1。由于年轻代中的对象基本都是存活率较低的,所以在年轻代的回收算法使用该复制算法,回收后剩余的对象全部复制到to区,不会产生内存碎片,完整度高,缺点是浪费了Survivor的一半的存空间,所以该算法只适合对象存活率低的情况。
3、标记清除算法
该算法会从每个GC Roots出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。老年代一般是标记清除(Mark-Sweep),或标记清除与标记整理的混合实现。缺点:1、效率低(递归与全堆对象遍历),而且进行GC的时候,需要停止应用程序,用户体验差;2、清理出来的内存地址不连续,JVM需维持一内存空闲列表的开销;3、分配数组对象的时候,寻找连续内存空间不方便。

4、标记整理算法
标记整理(Mark-Compact),Full GC又叫Major GC(全局GC)。上一个算法会带来大量的空间碎片,导致要分配一个较大的连续空间容易触发FGC,标记整理算法类似计算机的磁盘整理,形成连续已使用的内存空间,未使用的清理掉。缺点:效率低于复制算法(要整理所有存活对象的引用地址)。

5、G1、CMS回收器算法
G1具备压缩功能,能避免碎片问题,G1的暂停程序时间更可控。G1将Java堆空间分割成了若干相同大小的区域,及region,包括Eden、Survivor、Old、Humongous四种region类型。
CMS过程:初始标记(STW initial mark)、并发标记(Concurrent marking)、并发预清理(Concurrent precleaning)
、重新标记(STW remark)、并发清理(Concurrent sweeping)、并发重置(Concurrent reset)。
6、总结
执行效率:复制算法>标记清除>标记整理
内存整齐度:复制算法=标记压缩>标记清除
内存利用率:标记清除=标记压缩>复制算法
在Java 9 默认采用了G1垃圾回收器,采用了时间复杂度和空间利用率都非常出色的算法。
7、相关命令行工具
jps:m(显示类入口参数)、l(显示进程包名类名)、v(显示VM参数信息)。
jstat -gcutil pid:显示pid进程的VM使用信息,后面可接两个数字参数:打印间隔时间、打印次数。
javap -verbose -p A.class:查看对象创建的字节码。