Java虚拟机:内存模型

Java虚拟机是Java程序运行的真实环境,要深入理解Java是如何运行的,理解Java虚拟机是必不可少的。典型的例子:内存溢出。当我们理解java虚拟机之后,我们就会明白为何会出现这个问题,我们又当如何去定位并解决这个问题。

Java执行过程

1、JVM内存分布

通常我们说的JVM内存分布即指运行时数据区。下图为分布概念图:

运行时数据区

1.1 堆(Heap)

  • 被所有线程共享的一块内存区域,在虚拟机启动时创建
  • 用来存贮对象实例
  • 可以通过-Xmx和-Xms控制堆的大小
  • OutOfMemoryError异常:当在堆中没有内存完成实例分配,且堆也无法再扩展时

    Java堆是垃圾回收器的主要工作区域。由于现在垃圾收集器采用的基本都是分代收集算法,所以堆还可以细分为新生代(New/Young)老年代(Old/Tenured),再细致一点还有Eden区、From Survivior区、To Survivor区。

    新生代:新建的对象都由新生代分配内存。常常又被划分为Eden区和Survivor区,Eden空间不足时会把存活的对象转移到Survivor。新生代的大小可有-Xmn控制,也可用-XX:SurvivorRatio控制Eden和Survivor的比例。
    老年代:存放经过多次垃圾回收仍然存活的对象。

1.2 方法区(Method Area)

  • 线程间共享
  • 用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器变异后的代码等数据
  • OutOfMemoryError异常:当方法区无法满足内存的分配需求时

运行时常量池

  • 方法区的一部分
  • 用于存放编译期生成的各种字面量和符号引用,如String类型常量就存放在常量池
  • OutOfMemoryError异常:当常量池无法再申请到内存时

1.3 程序计数器(Program Counter Register)

  • 当前线程所执行的字节码的行号指示器
  • 当前线程私有
  • 不会出现OutOfMemoryError
  • 如果线程执行是一个Java方法的时候,计数器记录的是虚拟机字节码指令的地址;当执行的是Native的方法的时候,计数器指令为空

1.4 虚拟机栈(VM Stack)

  • 线程私有,生命周期与线程相同
  • 存储方法的局部变量表(局部变量、对象引用和返回地址等)、操作数栈、动态链接、方法出口等信息
  • Java方法执行的呢内存模型,每个方法执行的同时都会创建一个栈帧,每一个方法被调用直至完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • StackOverflowError异常:当线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError异常:如果栈的扩展时无法申请到足够的内存

    JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈。

    这里解释一下局部变量表,局部变量表存储方法相关的局部变量,包括基本数据,对象引用和返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

1.5 本地方法栈(Native Method Stack)

与虚拟机栈类似,唯一的区别就是虚拟机栈是执行Java方法的,本地方法栈是执行native方法的。

在HotSpot虚拟机中直接把本地方法栈与虚拟机栈二合一

1.6 直接内存(Direct Memory)

  • 直接内存并不是虚拟机运行的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用.
  • NIO可以使用Native函数库直接分配堆外内存,堆中的DirectByteBuffer对象作为这块内存的引用进行操作
  • 大小不受Java堆大小的限制,受本机(服务器)内存限制
  • OutOfMemoryError异常:系统内存不足时

1.7 永久代(PermGen)的发展

方法区是JVM的规范,是一个逻辑存储区域。永久代是JVM规范的一种实现,并且永久代是HotSpot特有的。

  • JDK 1.6
    永久代即方法区,存储着类信息、常量、静态变量、JIT编译的代码等
  • JDK 1.7
    符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap
  • JDK 1.8
    不存在永久代,取而代之的是元空间(MetaSpace)。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

那么,为什么要做出这些转变呢?有以下几点原因:

  1. 字符串存在永久代中,容易出现性能问题和内存溢出。
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  4. Oracle 可能会将HotSpot 与 JRockit 合二为一。

关于PermGen和Metaspace,可以看下liuxiaopeng的博客

1.8 小结

Java对象实例存放在堆中;常量存放在方法区的常量池;虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据放在方法区;以上区域是所有线程共享的。栈是线程私有的,存放该方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。

一个Java程序对应一个JVM,一个方法(线程)对应一个Java栈。

2、对象访问方式

Java对象这里指的是引用类型的对象,这里用Student stu=new Student()为例子访问,Student stu作为引用对象,存在与Java虚拟机栈上,new Student()保存在Java堆中,堆中记录Student类型的信息包括方法,接口,对象类型等地址,这些类型的执行的数据存储在方法区中。

2.1 句柄访问

Java堆中分配一块句柄池,虚拟机栈中存放句柄池中的地址,句柄池中包括对象示例数据和对象类型数据的地址。
句柄访问

2.2 直接指针访问

对象中存贮着对象实例数据和类数据的地址,Java栈的引用指向堆中的对象。

直接指针访问

2.3 小结

这两种访问方式各有优势。

句柄访问: reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身是不需要修改的。

直接指针访问:最大的好处是速度快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常的频繁,因此这类开销积少成多也是非常可观的执行成本。

就HotSpot虚拟机来说,它采用的是直接指针访问。但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也是十分常见的。

3、哪些对象需要回收?

Java运行时数据区的几大板块中,程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭,因此这几个区域的内存分配和回收都具备确定性,在这3个区域不需要过多的考虑GC问题。而堆和方法区则不一样,我们只有在程序运行期间才能知道会创建哪些对象、占用多少内存,这部分内存的分配和回收都是动态的,垃圾收集器关注的也正是这部分内存。

堆中存放着几乎所有的对象实例,在垃圾收集之前,需要判断哪些对象需要被除了,即哪些对象“存活”着,哪些对象已经“死去”。

3.1 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用该对象,计数器值就加1,当引用失效时,引用就减1。任何时刻计数器为0的对象就是不可能再被使用的。

缺陷:很难解决象之间互相循环引用的问题。

3.2 可达性算法

主流语言(Java、C#)的主流实现,都是通过可达性算法判断对象是否存活。

基本思路:通过一系列成为”GC Roots”的对象作为起始点,当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可达,所以它们就会被判定为可回收对象。
可达性算法

在Java中,可作为GC Roots的对象包括下面几种:

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

3.3 再谈引用

在JDK1.2以前,Java中的引用定义很传统:如果reference类型的数据中存储的数值代表着另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但很狭隘,在这种定义下,对象只存在被引用和没有被引用两种状态。我们希望描述这样一类对象:当空间内存足够时,则保留在内存中;如果内存在GC后还非常紧张,则可以抛弃这些对象。很多系统的缓存都符合这种引用场景。

在JDK1.之后,Java对引用概念进行了扩充,分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),强度依次递减。

  • 强引用:类似Object obj = new Object(),只要强引用还在,则不会被GC
  • 软引用:描述一些还有用但非必须的对象。在系统将要发生内存溢出异常前,将会把这些软引用对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:也是描述一些非必须对象,但强度比软引用更弱一些。这些对象只能存活到下一次GC之前。当开始GC时,无论内存是否足够,这些对象都会被回收。
  • 虚引用:不影响GC,唯一目的是被虚引用关联的对象被GC时能收到一个系统通知。

3.4 生存还是死亡

即使在可达性分析算法中不可达的对象,也并不是“非死不可”,这时候他们处于缓刑状态,要真正宣布一个对象死亡,至少需要经过两次标记过程:

  1. 可达性分析后没有与GC Roots相连的引用链,它会被第一次标记并且进行一次筛选。

    筛选条件是是否有必要执行finalize()方法。如果没有覆盖finalize()方法,或者finalize()已经被虚拟机调用过,这两种情况都被视为“没有必要执行”。
    如果判定为有必要执行finalize()方法,该对象被放置在一个F-Queue的队列中,并稍后由虚拟机建立的低优先级Finalizer线程去执行他,也就是触发对象的finalize()方法。finalize()是对象逃脱死亡的最后一次机会。

  2. 稍后GC将对F-Queue中的对象进行第二次小规模标记

    如果对象在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,那么第二次标记时它将被移出“即将回收”的集合;如果第二次标记时还没有逃脱,那它基本上就真的会被回收了。

3.5 回收方法区

方法区(或者HotSpot中的永久代)主要回收两部分:废弃常量和无用的类。

  1. 废弃常量

    如果没有任何一个地方引用常量,在GC时如果有必要的话,该常量会被清理出常量池。常量池中的类(接口)、方法、字段的符号引用也是类似的。

  2. 无用的类

    卸载类就很麻烦了,必须同时满足3个条件:

    • 该类所有实例都已被回收
    • 加载该类的ClassLoader已经被会后
    • 该类对应的Class对象没有在任何地方呗引用,无法通过反射访问该类的方法

    满足以上条件也只是可以被回收,是否被回收,HotSpot还有其他方法来控制。

4、何时回收?

GC经常发生的区域是堆区,堆区还可以细分为新生代、老年代,新生代还分为一个Eden区和两个Survivor区。

  1. 对象优先在Eden中分配,当Eden中没有足够空间时,虚拟机将发生一次Minor GC,Minor GC非常频繁,而且速度也很快;
  2. Full GC,发生在老年代的GC,当老年代没有足够的空间时即发生Full GC,发生Full GC一般都会有一次Minor GC。
  3. JDK 6 Update 24之后,HandlePromotionFailure无效。只要老年代剩余连续空间大于新生代对象总和或者历次晋升的平均大小就会进行Minor GC,否则Full GC。
    发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则进行一次Full GC,如果小于,则查看是否允许担保失败(HandlePromotionFailure=true/false),如果允许,那只会进行一次Minor GC,如果不允许,则改为进行一次Full GC。

5、如何回收?

简单描述几种算法的思路及演变过程

5.1 标记——清除算法

分为“标记”和“清除”两个阶段:

  • 标记:即前面提到过的,GC时两次标记的过程。
  • 清除:统一回收被标记的对象

缺点:

  • 效率不高:标记和清除的过程效率都不高
  • 产生大量内存碎片:导致以后分配较大内存对象时,由于没有足够大的连续内存从而提前触发一次GC动作。

标记——清除算法

5.2 复制算法

为了解决效率问题,出现了复制算法。将可用内存分为大小相等的两块,每次只是用其中一块。当这一块用完,将还存活的复制到另一块,然后再把使用过的内存空间一次清理掉,循环往复。

  • 优点:不用考虑内存碎片,实现简单,运行高效
  • 缺点:
    • 内存缩小为原来的一半,代价太大;
    • 需要额外的老年代空间进行分配担保

复制算法

5.3 标记——整理算法

分为“标记”和“整理”两个阶段:

  • 标记:即前面提到过的,GC时两次标记的过程。
  • 整理:让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

此处输入图片的描述

5.4 分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集算法”。

对于HotSpot虚拟机,堆内存分布如下图所示:

分代收集算法下的堆内存分布

新生代中使用 复制算法,老年代使用 标记——整理算法

默认情况下,eden与survivor的大小比例是8:1,可由 -XX:SurvivorRatio=8 设置。所以可用新生代大小为新生代的90%。

以下是算法的基本思路:

  1. 对象新建时,首先会在Eden区创建,年龄为0,直到GC时,若没有消亡,则放入servivor区,年龄为1
  2. 进入survivor区也不是安全的,当下一次Minor GC来的时候,会检查Eden区和使用的Survivor区,若有对象存活,则放入另一个Survivor区,年龄加1
  3. 当两个Survivor区切换几次后,对象年龄增长到15(默认,-XX:MaxTenuringThreshold=15),则进入老年代。
  4. 进入老年代也不是安全的,当老年代空间不足时,触发Major GC,已经消亡的对象还是会被干掉。

推荐一个这个写的很逗可以看下:http://blog.csdn.net/sd4015700/article/details/50109939

坚持原创技术分享,您的支持将鼓励我继续创作!
0%