浅谈JVM基本结构,内存分配与垃圾回收问题
JVM之于Java的重要性不再赘述,言归正传,接下来谈谈关于JVM内存的事.
在Oracle官方发布的<
从图中可以看出,JVM的基本结构可以分为4大部分.
一.类加载器(ClassLoader):其作用是在程序运行时,将编译好的.class字节码文件装载到JVM的内存区域中.如下图所示流程,Java源码被编译器编译为字节码文件,字节码文件被类加载器加载到数据运行时区域(其实就是内存空间当中),然后再由执行引擎执行.class文件中的字节码指令.
二.执行引擎:执行.class字节码文件中的指令集,如果想了解class中的字节码指令,可以参考<<深入分析Java Web技术内幕>>的第5章深入class文件结构.
三.本地库接口(本地方法库):我的理解这是JVM与本地操作系统交互的接口,调用一些由C语言等编写的本地方法,一般的开发者并不用细纠.
四.JVM内存区(运行时数据区):这是JVM中非常重要的一部分,是Java程序运行时JVM所分配的内存区域,绝大部分开发者关注的重点都在此.
JVM的内存区域分为5大块,如下图所示.
1.虚拟机栈(Stack):一般俗称栈区,是线程私有的.栈区一般与线程紧密相联,一旦有新的线程被创建,JVM就会为该线程分配一个对应的java栈区,在这个栈区中会有许多栈帧,每运行一个方法就创建一个栈帧,用于存储局部变量,方法返回值等.栈帧中存储的局部变量随着线程的结束而结束,其生命周期取决于线程的生命周期,所以讲java栈中的变量都是线程私有的.
2.堆(Heap):真正存储对象的区域,当进行Object obj = new Object()这样一个操作时,真正的obj对象实例就会在heap中.
3.方法区(Method Area):包含常量池,静态变量等,有人说常量池也属于heap的一部分,但是严格上讲方法区只是堆的逻辑部分,方法区还有个别名叫做非堆(non-heap),所以方法区和堆还是有不同的.
4.程序计数器(Program Couter Register):用于保存当前线程的执行的内存地址.因为JVM是支持多线程的,多线程同时执行的时候可能会轮流切换,为了保证线程切换回来后还能恢复到原先状态,就需要一个独立的计数器,记录之前中断的位置,由此可以看出程序计数器也是线程私有的.
5.本地方法栈(Native Method Stack):性质与虚拟机栈类似,是为了方便JVM去调用本地方法接口的栈区,此处开发者很少去关注,我也是了解有限,因此不深入探究其作用.
说完JVM的基本结构,接下来谈谈JVM的内存分配与垃圾回收问题,这是一个对于Java开发者而言老生常谈且面试经常都会被问到的点.
JVM的内存分配一般是先一次性分配出一个较大的空间,当有新对象被创建时都在该空间上进行资源分配,这种分配方式有利于节省开销( 相比于C来讲),但这块被一次性开辟出来的内存空间也是有限的,如何清理这块空间上的无用(垃圾)对象,这就与垃圾回收(GC)机制密切相关.内存申请一般分为静态内存和动态内存.静态内存比较容易理解,编译时就能确定的内存就是静态内存,即内存是固定的,系统可以直接进行分配,比如short,int类型的变量,其占用的内存大小是固定的.而动态内存分配是只有当程序运行时,才能知道所要分配的内存空间大小,在运行之前是不确定的,因此属于动态内存.
如前文所述,无论是虚拟机栈,程序计数器以及本地方法栈均属于线程私有,其生命周期与线程的生命周期一致,当线程执行完毕,其所占用的内存空间也就随之释放,因此这三部分是不存在垃圾回收问题的.Java开发者平常所说的垃圾回收,主要针对的是堆区和方法区而言的,对象实例在程序运行时被在存放在堆区.在堆区中,JVM为每个对象分配的内存空间大小并不确定,所以这部分存在垃圾回收问题.
现在我们得知堆区中存在垃圾回收问题,可是如何确定堆区中哪些对象是有用的,哪些对象是垃圾,这就又涉及到了垃圾检测问题.一般垃圾检测有以下方法:
1.引用计数器:为每一个对象添加一个引用计数器,当有地方引用该对象时,这个计数器+1,当引用失效是则-1,当计数器为0时则视该对象为垃圾对象.但这种检测方式存在问题,那就是两个对象互相访问,计数器不会为0,但实际上这两个对象已经无法访问,导致垃圾对象无法正常回收.
2可达性分析算法:针对于上述垃圾检测机制的问题,所以还有另一种检测方式.以根集对象(这里讲的根集对象,一般指的是虚拟机栈中引用的对象,方法区常量池引用的对象,本地方法中引用的对象)为起始点进行搜索,如果发现有对象不可达的话,即视为垃圾对象.一般在JVM进行垃圾回收的时候,会检索堆中的所有对象是否会被这些根集对象引用,如果发现不能被引用的对象,则该对象就会被垃圾回收器进行回收.
那么垃圾对象被检测到了,如何处理这些垃圾对象也是一门学问,这里就涉及到了一些回收算法.
1.标记-清除算法(Mark-sweep):先标记,后清除,标记所有需要进行回收的垃圾对象,然后进行统一回收,这是最基础的一种回收算法,后续的复制算法和标记整理算法都基于标记清除算法.但是该算法的缺点非常明显,就是效率低,且容易造成大量内存碎片.
2.复制算法(Copying):此算法将内存空间划分为两个相等的区域,当进行垃圾回收时,通过遍历将正在使用的对象复制到另一个区域,然后回收旧区域的垃圾对象.该算法的优点在于复制的成本降低,且复制过去之后还可以进行相应的内存整理,不会出现内存碎片问题,但缺点也是非常明显的,就是需要两倍的内存空间做支撑.
3.标记-整理算法(Mark-Compact):此算法结合了标记-清除算法和复制算法的优点,从根节点开始标记所有被引用的的对象,然后遍历整个堆,把存活的对象压缩到一块,按顺序排放.这样就避免了内存碎片问题和空间浪费问题.
以上介绍了3种垃圾回收算法,但是JVM在进行垃圾回收的时候必然不能只使用其中一种或几种算法,而是采用了分代的垃圾回收策略.
为什么要采用分代的垃圾回收策略呢?显而易见,java中不同的对象具有不同的生命周期,不能简单的一刀切,根据对象的生命周期不同采用不同的垃圾回收方式,可以提高回收效率,降低开销.举个例子,在一个java程序运行过程中,一定很产生很多的对象,每个对象的作用不同,如生命周期较长的HttpSession(一次会话)对象,又比如生命周期较短的StringBuffer(字符串)对象,如果每次进行垃圾回收都对整个堆空间进行扫描检测,可以想象系统会承担非常大的开销,因此将java对象进行分代垃圾回收,是非常必要的策略.
那么问题来了,究竟是如何分代的呢?按照生命周期不同,将对象划分为如下三代:
年轻代(Young):
在年轻代中,又划分为伊甸园区(Eden),幸存0区(Survivor0)和幸存1区(Survivor1).所有对象最初被创建的时候都会在Eden区,当Eden区被填满时会进行Minor GC,如果还有对象存活,那么就会被JVM转移到幸存区0区或幸存1区.一般地,幸存0区和幸存1区中总有一个是空的,当其中一个区被填满时就会再进行Minor GC,就这样还能存活的对象就会在幸存0区和幸存1区之间来回切换.在幸存区经过很多次GC后依然还能存活的对象会被转移到年老代(一般需要设定一个存活阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置),当超过这个临界值时,就将还依旧存活的对象转移到年老代当中.
年老代(Old):
处于该代的java对象都是在年轻代久经考验而存活的对象,一般都是生命周期较长的对象.当年老代的空间被占满时,则不会进行Minor GC,而会触发Major GC/Full GC,回收整个堆内存.
注意:在年轻代进行的都是Minor GC
幸存0区和幸存1区是对称的,不存在先后关系
关于Minor GC,Major GC,Full GC的不同:
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具
备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快,所有的Minor GC都会触发全世界的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。
老年代 GC(Major GC ):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC.MajorGC 的速度一般会比 Minor GC慢10倍以上。FULL GC:对整个堆空间进行一次GC,系统开销非常大,很有可能影响程序正常运行.
持久代(Perm):
一般存放Class、Method等元信息,与java对象的垃圾回收关系不大,一般情况下128M就够了.
值得一提的是刚才提到了垃圾回收算法在分代垃圾回收策略中也被采用:
年轻代:复制算法
年老代:标记-整理算法
关于JVM内存参数设置:
如上图所示是关于JVM内存参数调优的设置区间,这种参数设置一般不是固定的,需要根据生产环境的情况设定.如下为相关参数解释:
如下图是某个测试环境下某应用服务器的JVM参数配置(仅举例,不可用于真实生产环境.)