JVM内存分配、垃圾回收、类加载浅析

来源:互联网 发布:中国科技数据统计 编辑:程序博客网 时间:2024/06/06 18:26

JVM内存分配

JVM将内存主要划分为:方法区、虚拟机栈、本地方法栈、堆、程序计数器。如下图所示:

其中,虚拟机栈和程序计数器是每个线程独立拥有,方法区、本地方法栈、堆是该进程内的所有线程共享。

1、程序计数器
程序计数器占用内存空间小,可以把它看成是当前线程所执行的字节码的行号指示器,每个线程都自己的计数器,记录当前执行到哪个个指令。如果线程在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果执行的是Native方法,这个计数器的值为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2、虚拟机栈
Java虚拟机栈的生命周期与线程相同,线程销毁时,该栈也就结束了。它里面存放的是执行的函数的一些数据,比如局部变量表、局部基本类型变量值、操作数栈、方法出口等等。执行引擎每调用一个函数时,就为这个函数创建一个栈帧,并加入虚拟机栈。函数从调用到执行结束,其实是对应一个栈帧的入栈和出栈的过程。

这个区域可能出现两种异常:一种是StackOverflowError,一种是OutOfMemoryError。

对于StackOverflowError,当执行栈的深度大于一定值时,就会抛出该异常,也就是函数嵌套太深,比如一个函数递归调用自己。

而对于OutOfMemoryError异常,并不是当前这个线程占用的内存太大导致(一些基本类型的局部变量占用不了太多内存),主要是因为创建了太多线程,每创建一个新的线程就要申请一块栈区,当无法申请到足够的内存时,就会出现OutOfMemoryError。

3、堆
堆可是虚拟机中最大的一块内存,所有线程所共享的内存区域,几乎所有的实例对象、数组都放在这块区域中。GC主要针对的就是该区域,该区域也是最容易出现OOM的。

4、方法区
方法区存放的是类信息、静态变量(static修饰的)、常量(final修饰的变量和String)池等。虚拟机很难推测那个类信息不再使用,因此这块区域很难被回收,这也是为什么使用静态变量要特别谨慎的原因,所以这块区域的垃圾回收主要是常量池回收,值得注意的是JDK1.7已经把常量池转移到堆里面了。同样,这个区域也会会抛出OutOfMemoryError异常。

对于常量池中的一些特性,下面以字符串常量为例子说明一下。

String s1 = “abc”
编译的时候就在常量池中生成了”abc”对象,把变量s1的指针指向常量池中”abc”的地址。

String s2 = “a”+”bc”
由于”a”和”bc”都是确定的常量,所以在编译阶段就会优化为”abc”,同时在常量池中会生成”a”和”bc”两个对象。

String s3 = new String(“abc”)
先看常量池中有没有”abc”,如果有,就拷贝一份到堆中;如果没有,就在常量池中生成”abc”,并拷贝一份到堆中。

String s4 = “a” + new String(“bc”)
由于new对象是不确定其堆地址的,所以编译时不会优化为常量池中的”abc”,而是在堆中生成一个”abc”对象。

5、本地方法栈
本地方法栈与虚拟机栈所发挥的作用很相似,他们的区别在于虚拟机栈为执行Java代码方法服务,而本地方法栈是为Native方法服务。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

垃圾回收GC

垃圾回收分为两个过程:标记垃圾、清除垃圾。每个过程都有一些算法,下面介绍几种常用的。

垃圾标记算法

标记垃圾主要有两种算法:

1、引用计数法(Reference Counting Collector)
堆中每个对象实例都有一个引用计数,当一个对象被创建时,会用一个计数器来记录当前对象被引用数。每当这个对象被“==”赋值给引用变量时,计数器加1;但当这个对象的某个引用变量超过了生命周期或者被赋新值时,计数器减1。计数器为0的对象就被标记为垃圾。当一个对象被回收时,这个对象所引用(持有)的所有对象的计数器减1。

这种算法的优点是,计数器可以很快执行,交织在程序运行中,几乎不需要额外的性能开销。缺点是无法检测出循环引用,比如下面代码:

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;    }}

互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

2、根搜索算法
java中的对象,都是被一层一层引用的,A持有B,B持有C,C又持有D和E。。。,这就构成了树结构。JVM中判断一个对象是否死亡,就是通过这棵树的根节点开始,达不了的对象,就是可回收的。如下图:

这里写图片描述

那么那些对象可作为GC Roots呢?主要有以下几种:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象。2.方法区中类静态属性引用的对象。3.方法区中常量引用的对象4.本地方法栈中JNI(即一般说的Native方法)引用的对象。


垃圾清除算法

清除垃圾主要有下面四种算法:

1、标记-清除算法
首先,通过可达性分析将可回收的对象进行标记,标记后再统一回收所有被标记的对象。这种方法有2个不足点,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,清除之后会产生大量的不连续的内存碎片。

2、标记-整理算法
这种算法在上一种算法的基础上进行了优化,标记完成后,把所有存活的对象移动到内存的一端,然后清除垃圾对象。这样避免了内存碎片,但是效率问题依旧存在。

3、复制算法
为了解决效率问题,复制算法将内存分为大小相同的两块区域A、B,每次只使用其中一块。当区域A用完了,就将还存活的对象复制到区域B上,然后将A区域全部清除。这样每次只对半个区域进行垃圾回收,也不存在内存碎片的问题,缺点就是空间利用率太低。

由于大部分对象的生命周期都很短,所以每次清理垃圾时,只有极少数对象需要复制下来,大部分都要清除,所以有一种优化思路,将内存分为三块,区域A占80%、区域B和C分别占

4、分代回收算法
不同的对象,生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。分代回收算法将内存分为三块:新生代、老年代、持久代。每一块都有自己的回收算法,三块协调合作。

1、新生代

所有新生成的对象一开始都是放在新生代的。新生代的目标就是尽可能快速地回收那些生命周期短的对象。

新生代内存按照8:1:1的比例分为三个区域A、B1、B2,大部分对象在A区中生成。回收时先将A区存活对象复制到B1区,然后清空A区。当B1区存放满了,就将A区和B1区的存活对象复制到B2区,然后清空A、B1区,清空后,再将B2区中的对象复制到B1区,清空B2区。总之,原则就是用A区存放新生对象,B1区作为清理A区的“复制区”,B2区作为清理A、B1区的“备用复制区”。

在清理A、B1区时,将存活对象复制到B2中,如果装不下了,就说明对象的生命周期比较长,就将新生代中所有存活对象复制到老年代,然后清空新生代。

新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

2、老年代
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

3、持久代

用于存放静态文件,如Java类、方法等,持久代对垃圾回收没有显著影响。


GC的执行

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。

一般情况下,当新对象生成,并且在A区申请空间失败时,就会触发Scavenge GC,对新生代进行回收,具体回收过程上面已经讲过。因为大部分对象都是从A区开始的,同时A区不会分配的很大,所以A区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使A区能尽快空闲出来。

Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。下面这几种情况可能会发生Full GC:

1、老年代(Tenured)被写满
2、持久代(Perm)被写满
3、System.gc()被显示调用
4、上一次GC之后Heap的各域分配策略动态变化


类加载机制

类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括七个阶段:加载、验证、准备、解析、初始化、使用和卸载。

1、加载
加载过程主要做以下3件事:

1、通过一个类的全限定名称来获取此类的二进制流;
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口;

2、验证
这个阶段主要是为了确保Class文件字节流中包含信息符合当前虚拟机的要求,并且不会出现危害虚拟机自身的安全。

3、准备
正式为类变量分配内存,并设置类变量(属于类的变量,即static修饰的成员变量)初始值。这个时候只是初始化static修饰的引用变量,为这个引用变量设置该类型的初始值,而不是初始化“=”后面的部分。

比如public static int mNum = 123,此时只是初始化mNum,并赋值初始值为0。程序编译后,初始化阶段才会执行java方法,把mNum赋值为123。

4、解析
解析阶段是把虚拟机中常量池的符号引用替换为直接引用的过程。

5、初始化
初始化是最后一步,前面类加载过程中,除了加载阶段用户可以通过自定义类加载器参与以外,其余动作都是虚拟机主导和控制。到了初始化阶段,才是真正执行类中定义Java程序代码。

JVM规范明确规定,有且只有5种情况必须执行对类的初始化:

1、遇到new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:new新对象、读取静态变量、设置静态变量,调用静态函数;

2、使用java.lang.reflect包的方法对类进行反射调用时,如果类没初始化,则需要初始化;

3、当初始化一个类时,如果发现父类没有初始化,则需要先触发父类初始化;

4、当虚拟机启动时,用户需要指定一个执行的主类(包含main函数的类),虚拟机会先初始化这个类;

5、但是用JDK1.7启的动态语言支持时,如果一个MethodHandle实例最后解析的结果是REF_getStatic、REF_putStatic、Ref_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化。

准备阶段中,变量已经赋过一次系统要求的初始值,而在初始化阶段,才是真正执行代码中赋的值。初始化过程其实是执行类构造器clinit()方法的过程。

clinit()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。收集的顺序是按照语句在源文件中出现的顺序。静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量可以赋值,但不能访问。如下所示:

clinit()方法与类构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证子类的clinit()方法执行之前,父类的clinit()已经执行完毕。

1 0
原创粉丝点击