JVM之Java内存区域与内存溢出异常(二)

      对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们拥有每一个对象的“所有权”,又担负着对象整个生命周期的维护责任,即维护对象从创建到结束的内存管理;
而对于Java程序员来说,有虚拟机自动内存管理机制帮助,不需要为每一个new 操作去写delete/free代码(释放内存),不容易出现内存泄漏和内存溢出问题(虚拟机自动内存管理机制来管理),正是Java程序员把内存控制的权利交给Java虚拟机,所以一旦出现内存泄漏和溢出问题,就必须了解虚拟机怎样使用内存,才能更好地排查错误。也就是几乎丧失了内存控制权;
即内存控制权存在巨大区别
    1、Java虚拟机运行时数据区
这里写图片描述
    JVM在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域(如上图),这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
    (1)、程序计数器
    程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选去下一条需要执行的字节码指令,分支、循环、跳转、异常处理和线程恢复等基础功能都是依赖这个计数器来完成。
    程序计数器是线程私有的,因为JVM多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,即在任何一个确定的时刻,一个处理器(对多核处理器来说是一个内核)都只会执行线程中的一条指令。因此在线程切换后能恢复到正确的执行位置,需要每一个线程都有一个程序计数器。
    线程执行的是一个Java方法,则计数器指示需要执行的字节码指令地址;如果执行的是Native方法,这个计数器的值为空。这个内存区域是唯一一个在Java规范中没有规定任何OutOfMemoryError情况的区域。
    (2)、Java虚拟机栈
    JVM Stacks也是线程私有的,它的生命周期与线程相同。JVM Stacks描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(后面会有详细信息)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    局部变量表存放方法中定义的局部变量和方法中的参数,即编译期可知的各种基本数据类型、对象引用(可能是指向对象起始地址的引用指针,或者代表一个对象的句柄或与该对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
    操作数栈用来存放操作数,我们知道,Java 程序编译之后就变成了一条条字节码指令,其形式类似汇编,但和汇编有不同之处:汇编指令的操作数存放在数据段和寄存器中,可通过存储器或寄存器寻址找到需要的操作数;而 Java 字节码指令的操作数存放在操作数栈中,当执行某条带 n 个操作数的指令时,就从栈顶取 n 个操作数,然后把指令的计算结果(如果有的话)入栈。因此,当我们说 JVM 执行引擎是基于栈的时候,其中的“栈”指的就是操作数栈。
局部变量表所需的内存空间在编译期间就完成分配(确定大小),在方法运行期间就不会改变局部变量表大小。
    动态链接是将符号引用解析为直接引用的过程,JVM在执行字节码时,如果遇到操作码第一次使用一个指向另一个类的符号引用,那么虚拟机就必须解析这个符号引用:
    查找被引用的类,如果必要就装载它
    将符号引用替换为直接引用,这样下次就可以直接使用直接引用。
    方法出口,当一个方法被执行后,有两种方法退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或者遇到了异常,并且该异常没有在方法体内得到处理,无论采用何种退出方式,方法退出后,都需要返回方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说方法正常退出时,调用者的pc计数器值就可以作为返回地址,栈帧中可能保存了这个计数器值(?),而方法异常退出时,返回地址是要通过异常处理器来确定,栈帧中一般不会保存这部分信息。
    方法退出的过程实际上等同于把当前帧出栈,因此退出时间可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的后面的一条指令。
    两种异常情况:
        1》线程请求的栈深度大于虚拟机允许的深度就会抛出stackoverfllowerror;
        2》如果虚拟机栈可以动态扩展(大部分JVM都可以),如果无法申请足够的内存,就会抛出OutOfMemoryError异常。
    (3)、本地方法栈
    本地方法栈与虚拟机栈所发挥的作用非常相似,只不过JVM Stacks是为虚拟机执行Java方法(字节码)服务的,而本地方法栈则是为JVM使用Native方法服务的。甚至有的虚拟机(Sun hotspot虚拟机)直接把本地方法栈和虚拟机栈合二为一,虚拟机栈一样,也会抛出StackOverFlowError和OutOfMemoryError异常。
    (4)、Java堆
    一般来说Java Heap是Java虚拟机所管理的内存中最大的一块,被所有的线程共享,在虚拟机启动的时候创建。几乎所有的对象都放在这里分配内存(Java规范里描述的是所有对象实例以及数组都要在堆上分配,但随着JIT与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将导致一些微妙的变化发生,所以都在堆上分配内存也不是那么绝对)。
    Java堆是垃圾收集器管理的主要区域,也被称为“GC”堆,由于现在收集器基本采用分代收集算法,堆还可以分为:新生代和老生代;细致一点划分:Eden空间、From Survivor空间、To Survivor空间等。
    (4)、方法区
    方法区与Java堆一样是各个线程共享内存区域,用于存储已经被虚拟机加载的类的信息、常量、静态变量、即时编译器编译的静态代码等数据。很多人把方法区称为“永久代“,本质上两者并不等价,仅仅是因为Hotspot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者用永久代来实现方法区而已。这样垃圾收集器就可以像管理Java堆一样管理这部分内存,可以省去专门为方法区编写内存管理代码工作(Java8以后放弃永久代开始使用native Memory,字符串常量池也移出永久代(Java7))。垃圾收集行为在该区域比较少出现,主要回收目标是常量池的回收和对类型的卸载,一般来说回收成绩都不令人满意,尤其是类型卸载,条件相当苛刻。
    运行时常量池:是方法区的一部分,Class文件中除了有类的版本字段、方法、接口等描述信息外,还有一项信息时常量池,用于存放编译期存放的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性Java语言并不要求常量一定只有编译期才产生,也是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,被开发人员利用的最多的就是String类的intern()方法。
    (5)、直接内存
    直接内存并不是Java虚拟机定义的内存区域,但是却被频繁使用,也会出现OutOfMemoryError异常。jdk1.4中的NIO(new Input/Output)类,引入了一种基于通道和缓冲区的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样在一些场景中显著提高性能,因为避免了Java堆和Native堆中来回复制数据。
    (6)HotSpot对象虚拟机探秘
探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程
    1>对象的创建(普通Java对象,除数组和Class对象等)
    类加载检查:当虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、和初始化过。如果没有,那必须先执行相应的类加载过程。
    分配内存:对象所需的内存的大小在类加载完成后便可完全确定。分配内存的任务等同于把一块确定大小的内存从堆中划分出来,分配方案有两种:
        指针碰撞:如果Java堆中的内存是规整的,即所有用过的内存都放在一边,空闲的内存放在另一边,中间分界点有一个指针作为指示器。分配内存就是把指针向空闲区域那边挪动一块与对象大小相等的距离。
        空闲列表:如果Java堆中内存不是规整的,即空闲去和已使用内存去互相交错,则虚拟机需要维护一张列表,记录那些可用,从空闲去找到一块足够大的空闲区划分给对象实例,同时更新列表。
Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定:使用Serial、ParNew等带Compact过程的收集器时,系统采用指针碰撞分配算法,而使用CMS这种基于Mark-Sweep算法收集器时,采用空闲列表法。
    除了分配内存,还有一个要考虑的问题是:对象创建在虚拟机中是非常频繁的行为,即使是修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存情况。
    解决的方案有两种:
    对分配内存空间的动作进行同步处理:虚拟机采用CAS配上失败重试的方式保证操作的原子性。
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程需要分配内存就在该线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁。
    把分配的内存空间(初始化对象的实例字段零值或默认值 如int 为0)初始化为零值(不包括对象头):这一步操作保证了对象的实例字段在java代码中可以不赋初始值就直接使用,程序能访问这些字段的数据类型所对应的零值。
    虚拟机对对象进行必要的设置:比如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的gc分代年龄等信息。这些信息存储在对象的对象头中。
从虚拟机的角度看,一个新的对象已经产生了,但从java程序的角度看,对象创建才刚刚开始–《init》方法还没有执行,所有的字段还都是零值(前面内存初始化)。一般来说(有字节码中是否跟随invokespecial指令所决定),执行new指令后执行《init》方法:把对象按照程序员的意愿初始化,这样一个真正可用的对象下完全生产出来。
    2>对象的内存布局
    在HotSpot中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据、对齐填充。
    在HotSpot虚拟机中对象的头部分为两部分:
第一部分是存储对象自身的运行时数据:哈希码、GC分代年龄、锁状态标制、线程池有锁、偏向线程ID、偏向时间戳等。这部分信息的长度在32位和64位的虚拟机中分别为32bit和64bit。官方称之为“Mark Word”,由于对象需要存储的运行时数据很多,已经超出了32位、64位位图所能表示的限度,综合其他原因,Mark word 被设计成非固定的数据结构,以便在极小的空间存储尽量多的信息,根据对象那个的状态复用自己的空间 。比如对象未被锁定的状态下,32位空间25位存储哈希码,4位存储对象分代年龄、2位用于存储标志位,1bit固定位0。其他状态下就不一样的,比如可能就不是25位表示哈希码值。
第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并一定要经过对象本身,另外一个对象如果是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为普通java对象的元数据可以确定java对象的大小,但是从数组的元数据中却无法确定数组的大小。
    实例数据部分:
    这部分是对象真正存储的有效信息,也就是在程序中所定义的各种类型的字段内容。无论是从父类继承下来,还是在子类中定义,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
    对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用,由于hotspot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。对象头部分正好是8字节的倍数(1倍或者两倍),因此实例数据没有对齐时,需要对齐填充来补全。
    3>对象的访问定位
    建立了对象是为了使用对象,我们Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位,访问堆中的对象的具体位置,所以对象的访问方式取决于虚拟机实现而定的。
主流的方式有两种:使用句柄和直接指针:
使用句柄:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据类型数据各自的具体地址。
这里写图片描述
从图片中可以看出很多信息,句柄池的组成,java堆的划分、本地变量表等。
使用直接指针:使用直接指针,那么指针中的地址就是Java实例对象在堆中的地址,而实例对象要必须指向其对象类型,因此在实例对象所在的地址中数据就必须包含指向方法区中的类型对象数据的指针。
这里写图片描述
区别:
使用句柄时当对象移动时(垃圾收集时)只需要修改句柄中的实例数据指针,而reference不需要修改;
使用直接指针好处是速度快,节省一次指针定位时间,由于java访问对象十分频繁,因此积少成多也是客观的执行成本;
Sun HotSpot使用的是直接指针方式
    4、虚拟机栈和本地方法栈溢出
描述两种异常:
线程请求的栈的深度大于虚拟机所允许的最大深度,就抛出StackOverflowError异常;
虚拟机在扩展栈时无法申请足够的内存空间,则抛出OutOfMemoryError异常。
当栈空间无法分配时,到底是内存太小还是已经使用的栈空间太大(栈帧太大)?
由于系统分配内存资源有限,总容量-最大堆容量-最大方法区容量=所有线程可使用的容量,而这些容量是有限的,所以在建立线程过多的情况下导致的内存溢出,且不能减少线程数或更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。(方法区不能减少?猜想:方法区中数据比较稳定,且垃圾回收比较少(主要是废弃的常量和无用的类),所以一般只会增加,动态改变少)

猜你喜欢

转载自blog.csdn.net/qq_26564827/article/details/79921812