JVM学习与问题总结——java内存区域与内存溢出异常
Posted chen-ying
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM学习与问题总结——java内存区域与内存溢出异常相关的知识,希望对你有一定的参考价值。
- java虚拟机将内存分为哪些区域?
根据Java SE7版本的Java虚拟机规范,虚拟机管理的内存包括5个运行时数据区域:
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 方法区
- 堆
运行时数据区各部分的作用?
程序计数器
一个线程所执行的字节码的行号指示器。
字节码解释器会通过改变计数器的值来选取下一条将要执行的指令,那么分支、循环、跳转、异常处理、线程恢复都需要依赖计数器来完成。而Java虚拟机当中的多线程是通过争取CPU时间片来切换线程执行任务,当一个线程重新获取CPU时间片的时候就需要恢复上一次任务执行的位置与状态,那么每一个线程就都有一个独立的程序计数器来负责记录虚拟机正在执行的字节码指令的地址。
Java虚拟机栈
虚拟机栈为虚拟机执行Java方法服务每一个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。在一个线程当中,每一个方法从调用到执行结束就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
本地方法栈
本地方法栈为虚拟机使用到Native方法服务,与虚拟机栈所发挥的作用相似。
Java堆
Java堆通常是Java虚拟机所管理的内存当中的最大的一块,在虚拟机启动时创建,所有线程共享,它可以处于物理上不连续的空间中,但是逻辑上一定是连续的,唯一的作用就是用于存放对象实例,即普通的对象实例以及数组都在堆上分配空间。
方法区
方法区用于存储已经被虚拟机加载的类信息、常量、静态变量与即时编译器编译后的代码等数据。它和Java堆一样,是各个线程共享的内存区域,也不需要连续的内存并且可以选择固定或可扩充的空间大小,与堆相似。
HotSpot虚拟机中对象是如何创建的?
当虚拟机遇到一条new指令的时候,首先会去检查方法区常量池中能不能定位到对应的一个类的符号引用,然后检查这个符号引用对应的类是否经过了加载解析和初始化,如果没有就先进行相应的类加载过程,然后就由虚拟机为新生对象分配内存了,而这个空间的大小是在类加载的过程就已经确定下来的,那么直接在堆中直接分配相应的空间。但是堆中空余的空间可能是规整的也可能是零碎的,这两种情况下的分配方式也是不同的:假如是规整的那就可以采用“指针碰撞”的方式分配内存,如果是零碎的就采用“空闲列表的方式分配”。
在虚拟机为对象分配内存结束后,将分配到的内存空间初始化为零值,这一步就保证了对象的实例字段在java代码中即使不赋初值也可以直接访问。
以及虚拟机需要对对象进行的必要设置,把一些重要信息放在对象的对象头之中,这些信息就是对象是哪个类的实例、如何找到类的元数据信息以及哈希码值与GC分代年龄等等。
那么通过以上的步骤,在虚拟机中一个新的对象就已经产生了。
但是一般来说,执行new指令之后,紧接着会进行< init>方法按照程序员的想法进行初始化,这样一个java程序层次上的对象才算真正创建出来了。
一个对象由哪几部分构成?
以HotSpot虚拟机为例子,对象在内存中存储的布局可以分为三个区域:
对象头
对象头主要存储两部分的内容,首先是对象自身的运行时数据,比如哈希码、GC分代年龄、锁状态标志和线程持有的锁等内容;另外一部分就是类型指针,就是对象指向它的类元数据的指针,虚拟机会通过这个对象指针来确定这个对象是属于哪个类的实例。
实例数据
实例数据也就是对象真正存储的有效信息,程序代码包括其父类的各种字段的内容都会存储在此。
对齐填充
这个部分也不是必然存在的,就是起一个占位符的作用,例如由于HotSpot虚拟机的自动内存管理系统要求对象大小必须是8个字节的整数倍,那么实例数据部分没有对齐的时候,就需要对齐填充来补全。
对象是如何访问定位的?
java程序通过栈上的引用数据操作堆上的对象,引用定位访问堆中对象的具体位置目前在主流虚拟机实现中主要是两种方式:使用句柄或者使用直接指针。
oom异常可能在哪些情况在哪块区域发送?
堆、虚拟机栈与本地方法栈、方法区和其中的运行时常量池、本机直接内存。
如何判断堆中的对象是否可以被回收?
不可能再被任何途径使用的对象,即“死亡”,也就可以被回收了。
要确定对象是“存活”还是死亡可以通过引用计数算法和可达性分析算法。
引用计数算法
给对象添加一个引用计数器,当对象被引用的时候计数器的值加1,当引用失效的时候计数器的值就减一,那么计数器为0的对象就是不可能再被使用的,即可以被回收的对象了。但是主流的虚拟机在进行内存管理时很少使用这种算法,这种方法虽然判定效率很高,但是很难解决对象之间相互循环引用的问题。
可达性分析算法
通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链的时候,也就是这个对象对GC Roots不可达的时候,这个对象就是不可用的,可以进行回收。
而在Java语言中可以作为GC Roots的对象包括虚拟机栈中所引用的对象、方法区中静态属性所引用的对象、方法区中常量引用的对象和本地方法栈中Native方法引用的对象。
但是即使是在可达性分析算法中判定的不可达对象也有逃避回收的机会,真正宣布一个对象死亡至少要经过两次标记过程,第一次是经过可达性分析被判定没有与GC Root相连接的引用链,进行一次标记与筛选,筛选的条件是是否必要执行finalize()方法,而当对象没有覆盖finalize()方法或者已经被执行过都视为没有必要执行。
因为任何一个对象的finalize()方法都只会被系统自动调用一次,对象要么在唯一一次finalize()方法调用当中进行最后的自救,可以通过
重新与引用链上任何一个对象关联的方式进行一次自救
,而在这个过程中也就是GC对这个对象进行的第二次标记,这个时候被移除出”即将被回收“的集合;如果对象面临的是下一次被回收,finalize()方法已经被执行过了,也就不会再一次执行,对象也没有办法摆脱被回收的命运。
常用的垃圾回收算法有哪些?
标记-清除算法
首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。但是这种方式标记与清除两个步骤的效率都不高,并且还会留下一片不连续的内存碎片,当之后要为较大的对象分配空间的时候可能因为没办法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
复制算法
把可用内存划分为容量相同的两块,每次只使用其中的一块。当着一块内存用完了,就将还存活着的对象复制到另一块内存上,然后把已经使用完的这一片内存空间一次性清理掉。这种方式不用再担心碎片内存的分配问题,简单高效,只需移动堆顶指针就可以为对象分配空间,但是每次可以使用的内存仅为原来的一半,代价比较高。但是当对象存活率较高时就要进行较多的复制操作,效率会较低,而由于新生代的对象声明周期大都不长,所以复制算法用于进行新生代的垃圾收集是很合适的。
标记-整理算法
标记-整理算法的标记过程与标记-清理算法一样,后续步骤则使所有存活对象都向一端移动,然后直接清理掉端边界的内存。标记-整理算法解决了标记-整理算法产生不连续内存的问题,相比复制算法在对象成活率高的情况下也有更高的收集效率,适合于老年代的特点对其进行垃圾收集。
分代收集算法
分代收集算法的思想也就是将对象按照存活周期的不同划分不同的内存,一般就是把java堆划分为新生代与老年代,对各个年代的特点采取最适合的垃圾收集算法,新生代对象存活率低就采用复制算法,那么只要付出较少的复制成本就能完成垃圾收集,而对老年代就可以采用标记-清理算法或者标记-整理算法进行回收。
引用有哪些类型,为什么要分这么多种类型?
在JDK1.2之前Java引用的定义很传统:如果reference类型存储的数值是代表另外一块内存的起始地址,就称这块内存代表着一个引用。所以对于JDK1.2之前引用的定义还是比较狭隘的,一个对象只有可及与不可及两种状态,当要在虚拟机内部描述状态稍微复杂的对象就无能为力了。JDK1.2之后,Java对引用的概念进行了扩充,将引用分为了强引用、软引用、弱引用、虚引用。
强引用
强引用就是指在程序代码中普遍存在的,类似"Object obj=new Object()"这一类的引用,可以通过引用访问对应的对象,只要强引用还存在,垃圾收集器绝对不会回收掉被引用的对象。
软引用
对于软引用关联的对象,在系统即将发生内存溢出异常之前,就会把这些对象列入回收范围内,进行第二次回收,如果这次回收后还是没有足够的内存,系统才会抛出内存溢出异常。代码实现可以用这样的方式:
Object obj=new Object(); SoftReference<Object> softReference=new SoftReference<>(obj); obj=null; System.out.println(softReference.get());
softReference是对obj的一个软引用,而当obj引用对象被标记为需要回收的对象时,则softReference.get()返回null,软引用主要对用户实现缓存的功能,当内存足够的时候,可以直接通过软引用获取对象,而当内存不够的时候,就会清除掉软引用对象,如果仍然没有足够的空间才会抛出内存溢出异常。
弱引用
弱引用的强度比软引用更弱,只有弱引用的对象的声明周期更为短暂,被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾收集进行时,无论内存是否足够,都将收回只被弱引用关联的对象。 提供了WeakReference类来实现弱引用。
虚引用
虚引用则是最弱的一种引用关系,虚引用的存在完全不会对对象的生存周期产生影响,也无法通过它获取对象实例,设置虚引用就是为了在对象被收集器回收的时候获得一个系统通知。可以用PhantomReference类来实现虚引用。
软引用、弱引用、虚引用都和引用队列联合使用,如果所引用的对象将要被垃圾收集器回收的时候就将这个引用添加到引用对列当中,那么之后程序可以通过判断引用队列当中是够存在对象的引用来判断对象是否将被回收,也就可以在对象被回收之前采取一些措施。
由此可见,四种引用的强度依次减小,并且与引用队列可联合使用,程序通过四个级别的引用更加灵活的控制对象的声明周期。
finalize()方法在垃圾回收的过程中有什么用?
- 首先一个对一个对象进行可达性分析确定是不可达对象它也不一定就会被回收,一个对象被回收的过程应该会至少经过两次标记,在可达性分析确定不可达的时候会被标记一次,而在finalize()方法被调用,即将被回收的时候又会被标记一次。而finalize()是否执行也是有条件的,如果finalize()方法没有被覆盖或者已经执行过了就不会再执行了,而一旦执行finalize()方法,要么就是在finalize()方法内部进行一次自救,通过重新与GC Root建立关联而避免被回收,而进行下一次垃圾回收的时候,这个对象就没有自救的机会了,这一次是一定会被回收的。也就是说finalize()方法在这个过程当中是起到了一个判定死亡与对象面对回收时的自救的作用。
- finalize()方法也被认为是进行外部资源释放的方式,用来做最后的资源回收,但是从它用在对象自救的过程中来看:如果一个对象被判定为有必要执行finalize()方法,那么这个对象就会被放入一个叫做F-Queue的队列之中,稍后由一个虚拟机自动创建的并且是低优先级的Finalizer线程去触发它的finalize()方法,但是同时为了避免finalize()方法内部执行缓慢或者说发生循环这样的问题的发生导致F-Queue这个队列当中其他对象处于永久等待状态,甚至导致系统的奔溃,finalize()方法不一定会被执行完,所以很明显这个方法的调用存在不确定性,加之运行代价较高,进行资源释回收恐怕也不够稳定,不如使用try-catch方法或者其他方式。
方法区有必要进行垃圾回收吗?为什么?
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定或者扩展的大小外,可以选择不实现垃圾回收, 它用于存储已被虚拟机加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等数据,所存放的数据生命周期较长,即使对这个区域进行回收效果也很难令人满意,但是根据Java虚拟机规范的规定, 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
所以方法区在虚拟机的具体实现中也是有必要进行垃圾回收的,方法区的垃圾回收主要是针对常量池的回收和对类型的卸载,回收废弃常量和无用的类,当常量不会继续被使用就会被清理出常量池,而回收类的条件会严苛许多,如果一个类的实例全部都已经被回收,相应的类加载器也被回收了,并且对应的Class对象没有被任何地方引用也就无法再任何地方通过反射访问这个类的方法,那这个类就可以被回收。
OopMap是什么?
Java虚拟机的GC实现可能会有不同的方式:如果过虚拟机选择不记录任何数据的类型,那就无法区分内存里某个位置的数据是引用类型还是其他类型,那么在这种情况下实现的GC就是保守式GC;JVM还可以选择在站上不记录类型信息,在对象上存储类型信息,这样实现的就是半保守式GC。
而如果是准确式GC,从外部不记录下类型信息,生成映射表,虚拟机就能知道某个位置的具体数据是什么类型,这在HotSpot VM的实现中就是使用一组称为OopMap的数据结构来达到目的,在类加载完成的时候,HotSpot就会把对象内什么偏移量是什么类型的数据计算出来,在JIT编译过程中在特定的位置记录下栈和寄存器中什么位置是引用,那么在GC扫描的时候就能直接得出这些信息了。不同的虚拟机可以通过不同的映射表实现。
(安全点和安全区是为了解决什么样的问题,有什么用?)
HotSpot中在OopMap的协助下可以完成GC Root的枚举完成垃圾回收之前的可达性分析,但是为了保证分析结果的准确性是需要在GC Root枚举的过程中暂停所有执行线程的,以免线程执行过程中出现引用关系的变化,但是如果为每一条指令都生成对应的OopMap,将占据大量的额外空间,所以只在特定的位置记录这些信息,每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在循环的末尾 ,方法临返回前或者调用方法的call指令后及可能抛异常的位置等,这个位置就称为安全点,在HotSpot中的GC就只能到安全点才能进入。GC发生时,线程就会执行到最近的安全点上停顿下来。
但是有的线程如果处于阻塞状态是无法走到安全点停顿下来的,对于这种情况就需要安全区域来解决,处于安全区域的代码片段中引用关系都不会发生变化,在这个区域的任何一个地方开始GC都是安全的。
常见的垃圾收集器有哪些,分别有什么用?
Serial串行收集器
Serial收集器是一个单线程的收集器,它只使用一个CPU即一条收集线程去完成垃圾收集的工作,在它进行垃圾收集时,必须暂停其他所有线程的工作,知道它收集结束。它在进行收集工作的时候回使其他线程停顿,但是这种实现方式简单高效,对于限定单个CPU的环境来说,由于它没有线程交互的开销,可以获得最高的单线程垃圾收集效率。由于Client模式下虚拟机管理的内存通常不大,Serial进行垃圾收集的时候其他线程停顿的时间不会太长,所以对于运行在Client模式下虚拟机还是很不错的。
ParNew 收集器
ParNew收集器其实就是Serial收集器的多线程版本, 使用多条线程进行垃圾收集,是许多运行在Server模式下的虚拟机首选新生代垃圾收集器,并且能够跟老年代CMS收集器配合工作。
Parallel Scavenge 收集器
Parallel Scavenge是一个使用复制算法的并行多线程新生代收集器,它关注于达到一个可控制的尽量高的吞吐量,这个吞吐量也就是代码运行时间在代码运行时间与垃圾收集时间的总和中的占比,在主要进行后台运算而没有太多交互任务的使用情景下,较高的高吞吐量可以更高效地利用CPU时间。
Serial Old 收集器
Serial Old收集器是Serial 收集器的老年代版本,是使用标记-整理算法的单线程收集器,主要也是用于Client模式下的虚拟机使用。
Parallel Old 收集器
Parallel Old是Parallel Scavenge收集器的老年代版本, 使用多线程和“标记-整理”算法。 在JDK1.6这个收集器出现之前,Parallel Scavenge收集器不能够与CMS收集器配合使用,只能选择Serial Old收集器进行老年代的垃圾收集,而由于Serial Old收集器在多CPU处理能力等较优的硬件条件下性能过低,以至于Parallel Scavenge收集器的保证的较高吞吐量的优点无法展现。Parallel Old收集器出现以后就能配合Parallel Scavenge收集器在注重吞吐量以及CPU资源敏感的场合使用了。
CMS 收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,采用标记清除-算法实现,并且它的内存回收总体上是和用户线程并发执行的,是一款并发低停顿收集器,但是对CPU资源很敏感,无法处理浮动垃圾,并且垃圾回收完成后会产生许多碎片空间容易触发下一次Full GC。
(G1 收集器)
G1收集器是基于标记整理算法实现的,不会产生空间碎片,可以精确地控制停顿,将堆划分为多个大小固定的独立区域,并跟踪这些区域的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(Garbage First)。
对象分配的策略有哪些?
空间分配担保是什么?
以上是关于JVM学习与问题总结——java内存区域与内存溢出异常的主要内容,如果未能解决你的问题,请参考以下文章
JVM 内存区域总结:方法区+堆内存+本地方法栈+元空间——JVM系列