java 的JVM内存详解和内存溢出异常

说明

更多关于JAVA虚拟机的知识,大家可以参考 《深入理解java虚拟机》 –周志明著 一书,下面的内容大部分都是总结自这本书中的内容。

java的内存管理

对于java 和C++来说,有这这样一个巨大的差别,这个差别就是由内存动态分配和垃圾回收技术所围成的高墙,墙里面的人想出来,墙外面的人想进去。对于java来说,JVM提供了自动管理内存机制,在该机制下,程序员不用向C或者C++一样再去写 delete、free去释放内存空间,因为内存管理的权力是由java虚拟机控制的,但是一旦发生了内存泄漏或者内存溢出等问题,排查错误将十分艰难,所以学习了解虚拟机是如何管理内存的,至关重要。

JVM

java最常用的虚拟机,一般是sun公司提供的hotSpot虚拟机, 是基于栈的虚拟机, 而对于Andriod来说,5.0以后默认使用 Art虚拟机,是基于寄存器的虚拟机。eclipse中使用JDK 1.7做开发时,使用的就是HotSpot虚拟机

java内存的几个模块: 运行时数据区

这里写图片描述

更加详细的一个内存布局,如图:
这里写图片描述
可以看到内存区域主要分为这样几块:

1. 程序计数器(线程私有)

程序计数器的作用:
我们知道,java虚拟机的多线程是是通过CPU时间分片来给每一个线程轮流分配时间片来进行线程的执行的,因此为了每一个线程切换之后能回到正确的执行位置,每个线程都需要一个独立的程序计数器,各个线程之间的程序计数器互相独立,互不影响。
异常:
这个内存区域是JVM规范唯一一个没有规定任何OutofMemoryError异常的区域

2. java虚拟机栈(线程私有,为每一个线程分配一个虚拟栈)

虚拟机栈的作用:
java虚拟机栈描述的是java方法执行的内存模型, 里面存储的内容的基本单位是是一个个的栈帧每一个栈帧对应着一个方法,每一个方法的调用直至完成过程就对应这一个栈帧在虚拟机栈中的入栈出栈过程。
栈帧:
栈帧中存储的信息包括等:
1.、局部变量表: 存放了编译器可知的8种基本数据类型对象的引用等,其中64位的long和double会占据2个局部变量空间。主要用来存放方法的参数和方法内定义的局部变量, 在编译的时候就已经确定了局部变量表的大小,在垃圾回收的时候能否被回收的条件就是局部变量表中是否还存在着对对象的引用, 所以有一条推荐的规则是不使用的对象应该手动赋值为null(但是也会带来问题),不过从编码角度来将,以恰当的变量作用域来控制变量回收时间才是最优雅的解决办法。
2、 操作数栈 用来进行数据的操作的,对应着局部变量的出栈入栈过程
3、动态链接 每一个栈帧都包含着一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用的目的是为了支持方法调用过程中的动态链接,
在Class文件的常量池中存在着大量的符号引用,方法调用指令就以常量池中的符号引用(一切方法调用在class文件中存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址)作为参数
方法调用指令有5种:
invokestatic: 调用静态方法
invokespecial: 调用实例构造器方法,私有方法,父类方法
invokevirtual:调用所有的虚方法
invokeinterface: 调用接口方法
invokedynamic: 先在运行时动态解析出符号所引用的方法,再执行该方法,分派逻辑由用户设定的引导方法决定。
解析:方法的符号引用有的可以在类加载的阶段就直接转化为直接引用,这种转化为静态解析(方法在程序真正运行之前就已经有了一个可确定的版本,并且此版本在运行期是不可改变的,主要包括静态方法和私有方法,实例构造器,父类方法super这些方法也被称为非虚方法),这些方法在加载的时候被加载到方法区之后,地址就被确定了,称为为解析调用。另外final 修饰的方法也称为非虚方法,因为该方法不能被重写,没有其他版本,所以无需对其进行多态选择。
分派: 分派分为静态分派和动态分派。静态分派的典型应用是方法重载:根据参数的类型和参数的个数作为判定根据,而参数类型在编译期间的时候就已经确定了,; 动态分派的典型应用是方法重写,我们把这种,符号引用是在运行期间转化为直接引用,成为动态链接(在运行时期才确定调用哪个方法的分派过程称为动态分派
java虚拟机动态分派的过程:(invokevirtual指令的多态查找过程)
第一步: 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
第二步: 如果在C类中找到与常量池中的描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回方法的直接引用,查找结束,不通过,则抛出异常
第三步: 否则按照继承关系从下往上对C类的父类进行第2步的搜索和校验
第四步:如果始终没有找到相符的方法,抛出异常。
虚拟机动态分派的实现原理:
类会在方法区中建立一个虚方法表,该表一般在类加载的连接阶段进行初始化,初始化了类的变量值之后(为类的静态变量赋予默认值),虚拟机也会把该方法表初始化完毕。在这个虚方法表中存放的是各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类和父类虚方法表中该方法的地址是一致的,如果子类中重写了这个方法,则子类方法表中的地址将会替换为子类实现版本的入口地址
4、方法出口/方法返回地址 方法在调用执行完毕之后,肯定要退出,一种是正常退出,一种是异常退出,正常退出时返回地址可以是调用者的程序计数器的值,异常退出的时候,返回地址要通过异常处理器来确定。
方法退出的时候,相当于把当前栈帧出栈,因此如果是在A方法中调用了B方法,B调用结束时,要进行的操作有,恢复上层方法A的局部变量表和操作数栈如果B有返回值,返回值也要压入A操作数栈调整PC程序计数器指向下一条指令等

异常信息:两种
1. StackOverflowError 线程请求的栈深度大于虚拟机所允许的深度,将抛出此异常,
在单个线程下,无论是栈帧过大或者虚拟机栈容量过小,当内存无法分配时,抛出的也是StackOverflowError 信息
2. OurofMemoryError 虚拟机栈一般都可以进行动态扩展,如果在扩展的时候无法申请到足够的内存,则抛出此异常
一般出现在多线程编程之中,如果每一个栈空间过大,那么线程一多就容易发生内存不足, 可以通过减少最大堆(实际是增加栈的总内存空间)和减少栈大小(就是减少一个栈容量的大小,让每一个栈只能占据更少的空间)来换取更多的线程

3、 本地方法栈

主要为java中的native方法进行服务的,与虚拟机栈的功能类似,也会抛出StackOverflowError 和OurofMemoryError 两种异常信息。HotSpot中直接将本地方法栈和虚拟机栈合二为一了。

4. 堆(被所有线程共享的一块内存区域)

堆的作用:
是JVM内存管理的最大一块区域,在JVM启动时创建,主要用来存放对象的实例几乎所有的对象实例和数组都要在堆上分配空间,java堆是垃圾收集器回收垃圾的主要管理区域,现在的收集器基本上都采用的分代的收集方法(能够更好的回收内存,因为一般来讲刚生成的对象只有一小部分能够存活下来,所以分代之后,只需要频繁的对新生代进行垃圾回收,只有达到一定的去触发条件才进行一次full gc),所以堆还可以细分为:
新生代:分为三个区域,Eden,From Survivor,To Survivor,比例8:1:1, 新生成的对象一般都是放在Eden区域的,存活下来的放在From Survivor,From Survivor满了触发一次回收放到To Survivor, To Survivor满了触发一次回收,放到年老代,年老代满了触发一次full gc
年老代
从内存分配的角度来看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区,为了更好的分配内存
异常
OurofMemoryError 堆一般可以指定堆的大小,也可以进行动态扩展,(通过-Xmx和-Xms控制),一般都是可扩展的实现,当无法为新生成的对象分配内存空间时,就抛出OurofMemoryError 异常
小知识: 如何检查堆溢出出现的原因? 通过参数-XX:+HeapDumpOnOurofMemoryError 可以让虚拟机在内存溢出异常时Dump出当前的内存堆转储快照以便事后分析。
一般手段是通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的内存堆转储快照进行分析,先区分是发生了内存泄漏(Memory Leak: 内存中的对象已经可以销毁了,但是垃圾回收处理器无法销毁,如果是这样,可以进一步通过工具查看泄漏对象到GC root的引用链,于是就能清楚泄漏对象是如何与GC root关联以至于无法进行回收的)还是内存溢出(Memory Overflow,内存中对象都是存活的,但是没有内存空间了。可以调JVM参数,代码上检查减少生命周期过长的对象等)

5. 方法区(是各个线程共享的一块内存区域)

方法区作用:
用来存储已经被虚拟机加载的类信息(包括类名、访问修饰符、常量池、字段描述、方法描述等),常量(final),静态变量,即时编译后的代码等数据, 还有一个别名叫非堆,用来与java的堆区分开。 HotSpot将方法区成为永久代,是因为HotSpot将垃圾回收的区域扩展到了方法区,省去了专门为方法区内存编写专门的内存管理的diamante,对于其他虚拟机来讲是不存在永久代这个概念的, 针对方法区的垃圾回收主要包括针对常量池的回收和对类型的卸载。在JDK 1.7中的HotSpot中已经将原本放在永久代中的字符串常量池移出了
异常:
OurofMemoryError 方法区无法满足内存分配需求时, 在经常动态生成大量class的应用中,应该特别注意类的回收情况,比如 反射CGlib(开源项目,生成字节码文件),大量JSP或者动态产生JSP文件的应用(每一个JSP页面加载为一个类)基于OSGI的应用(即使是同一个类文件,被不同的类加载器加载被视为不同的类
运行时常量池:
运行时常量池是方法区的一部分。 Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项是常量池,存放的是编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区中的运行常量池中,一般来讲,除了符号引用之外,编译期间会将一部分能确定的符号引用转化为直接引用,直接引用也会放进运行时常量池中。
与Class文件常量池不同的另外一个特征是,运行时常量池具有动态性,运行期间也能将新的常量放进池中,用的比较多的是String的intern方法
附加一点复习的知识:

// 直接赋予字符串的方法,首先判断字符串常量池中是否有aaa, 如果存在,直接返回aaa的地址,如果不存在,会将字符串放在字符串常量池中; 
String str1 = "aaa";
String str2 = "aaa";
// new 的方法,首先检查字符串常量池,如果字符串常量池中存在aaa,则直接在堆上创建一个对象aaa,返回堆中此对象的地址; 如果字符串常量池中不存在aaa,则先将aaa放进字符串常量池中,然后再在堆上创建一个aaa对象,返回堆中此对象的地址
String str3 = new String("aaa");
// intern()方法,首先先检查字符串常量池中是否存在aaa,存在则直接返回池中aaa的地址,如果不存在,则将aaa放进字符串常量池中,不会在堆上创建对象
Strign str4 = str3.intern();
// 另外需要注意的一点是,常量池维护的常量都是由范围限制的,int型是 -128--- 127,超过范围之后,即使是int a = 128; 也会在堆上创建一个对象,而不是放置在常量池中

6. 直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是卷虚拟机规范中定义的内存区域, 在JDK 1.4之后新加入了NIO类,引入了一种基于通道的和缓冲区的I/O方式,非阻塞的,它可以使用native方法直接分配堆外内存,然后通过一个存储在堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了在java堆和Native堆中来回的复制数据,虽然不受java堆的大小的限制,但是也会受到本机内存的限制,因此也会出现OurofMemoryError异常。

JVM对象的创建的过程

  1. 当new一个对象时,首选检查这个指令的参数能否在方法区的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、连接(验证、初始化、解析)和初始化,如果没有加载,则先必须类执行加载过程;
  2. 为这个new的对象分配内存空间,一般空间大小在类加载的时候就已经确定了。 分配空间有两种策略: 第一如果堆中的内存空间绝对规整的,分配内存就是把指针向空闲内存区域移动一段要new的对象大小的距离,称为指针碰撞第二如果堆的内存空间不规整,使用的和未使用的交替存在,则必须要维护一个列表记录哪块内存地址没有被分配,从空闲出分得一块足够大小的内存空间放置对象,称为空闲列表。 内存是否规整要看JVM采用了哪种垃圾回收机制,是否带压缩整理功能, 标记-整理,复制就可以得到规整空间,标记-清除得到的是不规整的空间。
    另外还要考虑并发分配内存空间的问题
    如果对象的生成频繁,则有可能发生分配地址空间冲突,解决办法有二种: 一是对分配内存空间的操作进行同步处理,实际上虚拟机采用CAS原理(Compare and Swap)配上失败重试的方式保证分配的原子性另外一种是将内存分配的动作按照线程划分在不同的内存空间之中进行,即每个线程在java堆中预先分配一小块内存,成为本地线程分配缓冲TLAB,只有线程的TLAB分配满了之后才需要策略以来保证同步
  3. 内存分配完成之后,JVM为分配到的内存空间初始化为零值,对对象进行必要的设置,例如是哪个类的实例,hash码,对象的GC分代年龄等, 之后再执行构造函数init方法,完成一个对象的初始化工作。

对象的内存布局:包括

一、对象头第一部分存储自身的运行数据(如hash码,分代年龄,锁,偏向线程ID、时间戳等) 第二部分是类型指针(指向它的类元数据的指针,虚拟机通过这个指针确定这个独享是哪个类的实例,另外是数组的话,头里还包含记录数组长度的数据)
二、实例数据,对象存储的真正有效的数据
三、对齐填充,起占位符的作用,实例数据部分没有对齐就自动对齐

对象的访问定位

访问定位有两种方式:
1. 使用句柄
这里写图片描述
使用句柄的话,java会在堆中划分出一块区域单独存放句柄池, 使用句柄最大的优势是 当对象被移动时,只会改变句柄中的指针,而变量表中的reference不用修改。适合垃圾回收密集的场景
2. 使用直接指针(HotSpot使用的就是直接指针定位)
这里写图片描述
使用直接指针的最大优势就是速度更快,它节省了一次定位指针开销的时间,适合访问频繁的场景
上述两幅图片来自于博客:http://blog.csdn.net/zhaoyw2008/article/details/9286471

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章