900字范文,内容丰富有趣,生活中的好帮手!
900字范文 > 《深入理解Java虚拟机》之Java内存区域与内存溢出异常

《深入理解Java虚拟机》之Java内存区域与内存溢出异常

时间:2021-11-06 22:43:00

相关推荐

《深入理解Java虚拟机》之Java内存区域与内存溢出异常

阅读《深入理解Java虚拟机》第2版,结合JDK8的读书笔记。当前文章为书本的第2章节。

2.1.概述

从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作用,服务对象以及其中可能产生的问题。

2.2.运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。有:程序计数器,虚拟机栈,本地方法栈,堆,方法区。为了提升性能针对内存区域,分为线程共享区域(堆和方法区)和线程隔离区域(程序计数器,虚拟机栈和本地方法栈)。

2.2.1.程序计数器

程序计算器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。

如果线程正在执行的是一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域也是Java虚拟机中唯一一个没有规定任何OutOfMemoryError情况的区域。

2.2.2.虚拟机栈

虚拟机栈(VM Stack)描述的是Java执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用到执行完成,对应着一个栈帧在虚拟机栈的入栈到出栈。

在Java虚拟机规范中,对虚拟机栈规定了两种异常状况:1).如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;2).如果虚拟机栈可以的动态扩展,但扩展时无法申请到足够的内存,则会抛出OutOfMemoryError异常。

2.2.3.本地方法栈

本地方法栈(Native Method Stack)和虚拟机栈发挥的作用相似,虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈是为虚拟机使用到Native方法服务。

在虚拟机规范中没有强制规定本地方法栈中方法使用的语言,使用方式与数据结构并没有强制规定,可以自由实现。甚至有的虚拟机直接把本地方法栈和虚拟机栈合二为一。

2.2.4.堆

Java堆(Heap)是虚拟机中内存最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

逃逸分析(Escape Analysis)就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用(例如作为调用参数传递到其他方法中),称为方法逃逸。甚至还有可能被外部线程访问到(例如赋值给类变量),称为线程逃逸。

栈上分配(Stack Allocation)是依据逃逸分析技术得出的优化手段。如果能够确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的对象占比很大,如果能使用栈上分配,大量的对象会随着方法的结束而自动销毁,垃圾收集系统的压力将会小很多。

标量替换(Scalar Replacement)是依据逃逸分析技术得出的优化手段。标量是指一个数据已经无法再分解成更小的数据来表示了。例如Java虚拟机的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,可以称为标量。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散,那程序真正执行的时候可能不创建对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分之后,除了可以让对象的成员变量在栈上分配(栈上存储的数据,有很大的概率会被分配到物理机器的告诉寄存器中存储)和读写之外,还可以为后续进一步的优化手段创建条件。

从内存回收的角度来看,现在收集器基本都采用分代收集算法,所以堆还可以细分为:新生代和老年代,其中新生代还分为Eden空间、From Survivor空间、To Survivor空间。

Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可以实现成固定大小,也可以是可扩展的。当前主流的虚拟机都是按照可扩展实现的(通过-Xmx和-Xms控制)。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

2.2.5.方法区

方法区(Method Area)与堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

JDK8的虚拟机HotSpot针对方法区的实现由“永久代(Permanent Generation)”改为“元空间(Metaspace)”,使用元空间实现的方法区,只要内存没有触碰到进程可用的内存上限,就不存在内存溢出的问题。

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存,还可以选择不实现垃圾回收。不过这部分区域的回收还是有必要的,主要针对常量池的回收和对类型的卸载。

2.2.6.运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于Class文件常量池的另外一个重要特征时具备动态性,也就是运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

2.2.7.直接内存

不属于虚拟机内存中的一部分,在JDK1.4以后加入了NIO(New Input/Output)类,引入基于通道(Channel)与缓存区(Buffer)的I/O方式,它可以使用Native函数库直接分配对外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

2.3.HotSpot虚拟机对象密探

2.3.1.对象的创建

以下为普通Java对象的创建过程,也就是当虚拟机遇到new指令时的操作:

检查这个指令的参数是否能在常量池中定位到一个类的符号引用如果有,则检查这个符号引用代表的类是否被加载,解析和初始化(执行方法)过如果没有,则必须先执行相应的类加载过程在类加载检查通过后,需要为新生对象分配内存。需要考虑内存划分方法和内存分配动作 内存划分方法

对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。内存划分的方式有两种:指针碰撞和空闲列表。选择哪种分配方法由Java堆是否规整决定的,而Java堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定。

指针碰撞(Bump the Pointer):假设Java堆中内存是绝对规整的,已使用的内存一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存就是把指针向空闲空间那边挪动一段与对象大小相等的距离。

空闲列表(Free List):假设Java堆中内存并不规整,已使用的内存和空闲的内存是相互交错的,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

内存分配动作

对象创建在虚拟机中是非常频繁的行为,即使是修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决方案有两种:CAS加失败重试和本地线程分配缓冲。虚拟机是否使用本地线程分配缓冲,可以通过-XX:+/-UseTLAB参数来设定。

CAS加失败重试方案:CAS(Compare and Swap),即比较交换,一种无锁算法。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B,当且仅当A和V相同时,将V修改为B,否则认为是失败,失败之后再重试保证更新操作的原子性。

本地线程分配缓冲(Thread Local Allocation Buffer, 简称TLAB):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

内存分配完之后,虚拟机需要将分配到内存空间都初始化零值(不包含对象头)。该步骤保证类变量可以不赋值就直接使用。虚拟机要对对象进行必要的设置。在对象的对象头(Object Header)中存放对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息执行方法

方法和方法的区别:是instance实例构造器,对非静态变量解析初始化,而是class类构造器对静态变量,静态代码块进行初始化。Stack Overflow上的解释:init is the (or one of the) constructor(s) for the instance, and non-static field initialization. clinit are the static initialization blocks for the class, and static field initialization.

// 引用HankingHu的CSDN博客-深入理解jvm--Java中init和clinit区别完全解析class X {static Log log = LogFactory.getLog(); // <clinit>private int x = 1; // <init>X(){// <init>}static {// <clinit>}}

2.3.2.对象的内存布局

在HotSpot虚拟机中,对象内存布局分为对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

对象头

包含两部分信息,一部分为对象自身的运行时数据,如HashCode,GC分代年龄,锁状态标识,线程持有的锁,偏向线程ID,偏向时间戳等,这些数据的长度为32bit和64bit(32位系统为32bit,64位系统为64bit),官方称为Mark Word。

Mark Word会根据对象处于不同状态来存储不同的数据,以下以64bit介绍存放的内容

其中:hashcode为对象哈希码,age为对象年龄,lock为锁状态标记位,biased_lock为是否启用偏向锁标记(1为启用,0为未启用),thread为线程ID,epoch为偏向时间戳,ptr_to_lock_record为轻量级锁状态下指向栈中锁记录的指针,ptr_to_heavyweight_monitor为重量级锁状态下指向对象监视器Monitor的指针。

以下内容来自CSDN:阿珍爱上了阿强?-Java对象结构与锁实现原理及MarkWord详解

我们通常说的通过synchronized实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

初期锁对象刚创建时,还没有任何线程来竞争,对象处于无锁状态(无线程竞争它)当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线程的栈帧中的锁记录。如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。

另一部分为类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,需要有一块用于记录数组长度的数据,不然无法确定数组的大小。

并不是所有的虚拟机实现都必须在对象数据上保留类型指针。

实例数据

程序代码中所定义的各种类型的字段内容。无论是父类继承下来,还是子类中定义的,都需要记录起来。

这部分的存储顺序会受虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序影响。HotSpot虚拟机默认分配策略:longs/doubles,ints,short/chars,bytes/booleans,oop(Ordinary-Object-Pointers),也就是相同宽度的字段被分配到一起。

在满足这个前提下,父类的变量会出现在子类之前。CompactFields参数值如果为true(默认为true),子类中较窄的变量也可能会插入父类变量的空隙之中。

对齐填充

不是必然存在,仅仅起占位符的作用。因为HotSpot VM的内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。因此当对象实例数据部分没有对齐时,需要通过对齐填充来补全。

2.3.3.对象的访问定位

Java程序需要通过栈(虚拟机栈)上的reference类型来操作堆上的具体对象。Java虚拟机规范中只规定了reference类型是一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置。目前主流的方式有使用句柄和直接指针两种。

使用句柄

在Java堆中划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体信息。

图片拍摄于周志明老师的《深入理解Java虚拟机 第2版》

直接指针

reference中存储的是Java堆中的对象实例地址,Java堆中的对象实例必须存放类型数据的相关信息(一般是存放在对象头中,参考2.3.2章节)。

图片拍摄于周志明老师的《深入理解Java虚拟机 第2版》

2.4.实战:OutOfMemoryError异常

本节主要是模拟各种内存溢出的情况。下列代码内容中涉及VM参数,都通过IDEA设置,如下图:

2.4.1.Java堆溢出

内存泄漏(Memory-Leak)和内存溢出(Memory-Overflow)都会导致堆溢出。

内存泄露

程序在申请内存后,无法释放已申请的内存空间,长期占用内存最终内存泄露导致堆溢出。

内存溢出

程序在申请内存时,没有足够的内存空间供其使用,导致堆溢出。

public class HeapOOM {static class OObject {}public static void main(String[] args) {// VM Args:-Xmx20m -Xms20m -XX:HeapDumpOnOutOfMemoryError// 参数说明:-Xmx为堆最大内存,-Xms为堆最小内存// -XX:HeapDumpOnOutOfMemoryError当内存溢出时dump出当前的内存堆转储快照,便以分析List<OObject> list = new ArrayList<>();while (true) {list.add(new OObject());}}}// 执行结果如下java.lang.OutOfMemoryError: Java heap spaceDumping heap to java_pid19368.hprof ...Heap dump file created [28126471 bytes in 0.093 secs]Exception in thread "main" java.lang.OutOfMemoryError: Java heap spaceat java.util.Arrays.copyOf(Arrays.java:3210)at java.util.Arrays.copyOf(Arrays.java:3181)at java.util.ArrayList.grow(ArrayList.java:265)at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)at java.util.ArrayList.add(ArrayList.java:462)at HeapOOM.main(HeapOOM.java:19)

将导出的快照文件通过JDK自带工具jvisualvm来分析,可以知道是因为太多的实例导致的内存溢出。如果快照文件太大,可以尝试使用JProfile(专业分析工具)来分析。

2.4.2.虚拟机栈和本地方法栈溢出

HotSpot虚拟机不区分虚拟机栈和本地方法栈,所以栈容量由-Xss参数设置。在Java虚拟机规范中描述了两种异常:

StackOverflowError

线程请求的栈深度大于虚拟机所允许的最大深度。

OutOfMemoryError

虚拟机在扩展栈时无法申请到足够的内存空间

public class StackOF {private int stackLength = 1;public void stackLeak() {stackLength++;stackLeak();}public static void main(String[] args) {// VM Args:-Xss128kStackOF stackOF = new StackOF();try {stackOF.stackLeak();} catch (Throwable e) {System.out.println("stack length: " + stackOF.stackLength);throw e;}}}// 异常如下:stack length: 994Exception in thread "main" java.lang.StackOverflowError...... 后续异常信息省略

在单线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,抛出的都是StackOverflowError异常。如果测试时不限于单线程,可以通过不断地建立线程来让其内存溢出,抛出OutOfMemoryError异常。

例如32位的windows系统给一个进程的内存是2G,减去最大堆容量(Xmx),程序计数器因为很小可以忽略,剩下的内存由虚拟机栈和本地方法栈“瓜分”。每个线程分配的栈容量越大(可以通过-Xss来控制栈容量的分配),可以建立的线程数就越少。

我的是Window10x64位系统,貌似对进程分配的内存就是系统内存,所以没有具体模拟OutOfMemoryError异常。

2.4.3.方法区和运行时常量池溢出

运行时常量池是方法区的一部分。JDK8版本的HotSpot虚拟机关于方法区的实现方式为Metaspace。

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和-XX:MaxMetaspaceSize=N,MetaspaceSize是指初次Full GC的内存大小,MaxMetaspaceSize是元空间允许最大的内存大小。下列案例会分析

import net.sf.cglib.proxy.Enhancer;import net.sf.cglib.proxy.MethodInterceptor;import net.sf.cglib.proxy.MethodProxy;import java.lang.reflect.Method;/*** @author guoyu.huang* @version 1.0.0*/public class JavaMethodAreaOOM {static class OOMObject{}public static void main(String[] args) throws InterruptedException {// 借助CGLib在运行时产生大量的类去填满方法区,直到溢出// VM args :-Xmx20m -Xms20m -XX:MetaspaceSize=12M -XX:MaxMetaspaceSize=20M -XX:+PrintGCDetailswhile(true){Thread.sleep(10);Enhancer enhancer = new Enhancer();enhancer.setSuperclass(OOMObject.class);enhancer.setUseCache(false);enhancer.setCallback(new MethodInterceptor() {@Overridepublic Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {return methodProxy.invokeSuper(o, args);}});enhancer.create();}}}// 异常如下:Exception in thread "main" java.lang.OutOfMemoryError: Metaspace// 打印GC日志如下:......[GC (Metadata GC Threshold) [PSYoungGen: 2992K->224K(6144K)] 8427K->5766K(19968K), 0.0007869 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Metadata GC Threshold) [PSYoungGen: 224K->0K(6144K)] [ParOldGen: 5542K->3235K(13824K)] 5766K->3235K(19968K), [Metaspace: 11069K->11069K(1060864K)], 0.0261145 secs] [Times: user=0.11 sys=0.00, real=0.03 secs] [GC (Allocation Failure) [PSYoungGen: 5632K->224K(6144K)] 8867K->3459K(19968K), 0.0009194 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] ......HeapPSYoungGentotal 6144K, used 114K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)eden space 5632K, 2% used [0x00000000ff980000,0x00000000ff99cbf0,0x00000000fff00000)from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)ParOldGen total 13824K, used 3010K [0x00000000fec00000, 0x00000000ff980000, 0x00000000ff980000)object space 13824K, 21% used [0x00000000fec00000,0x00000000feef0ad8,0x00000000ff980000)Metaspace used 19266K, capacity 20374K, committed 20480K, reserved 1069056Kclass space used 1545K, capacity 1609K, committed 1664K, reserved 1048576K

通过GC日志可以得出以下结论:

设置堆最大内存20M,元空间最大内存20M,GC日志显示堆内存分配了20M,元空间内存使用了20M,可知堆内存和元空间内存分开VM参数MetaspaceSize设置的值是第一次Full GC时的内存值,也就是当元空间内存使用达到这值时会发生Full GCVM参数MaxMetaspaceSize设置的值是元空间的最大内存值

2.4.4.本机内存直接溢出

DirectMemory容量可以通过-XX:MaxDirectMemorySize指定,如果没有指定,则默认与Java堆最大值(-Xmx)一样。

由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查是不是这方面的原因。

2.5.本章小结

Java虚拟机在执行Java程序时,会把内存分为程序计算器,虚拟机栈,本地方法栈,堆,方法区,运行时常量池,直接内存。HotSpot虚拟机实现探秘,对象的创建,对象的内存布局,对象的访问定位。实战演练各种内存溢出。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。