前言:刚开始接触Java虚拟机的知识,参考的是周志明的《深入理解Java虚拟机》这本书。一方面整理思路,同时也为了方便以后查阅,所以整理了书中的内容。
本章首先介绍Java虚拟机的运行时数据区域,分为6个区域,主要从各个区域的作用、是否为线程共享、可能出现的异常进行描述。然后介绍了对象的创建过程、对象的内存布局以及如何访问对象,在对象的创建过程中要注意内存分配的两种方式(指针碰撞和空闲列表),在并发情况下如何做到线程安全(同步或者使用TLAB);对象的内存布局包括对象头,实例数据和对齐填充三个部分;对象的访问有两种方式,使用句柄访问或者使用直接指针访问。最后,是实战部分,模拟了Java堆溢出、栈溢出和方法区与运行时常量池的溢出,并简要介绍了遇到这些情况如何分析和解决。
一.运行时数据区域
Java虚拟机所管理的内存包括以下6个运行时数据区域,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁
1.程序计数器
1)如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是Native方法,这个计数器的值为空
2)线程私有
3)唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
2.Java虚拟机栈
1)描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法的出口信息等
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
其中,局部变量表存放了编译期可知的各种基本数据类型、对象引用类型和returnAddress类型(指向了一条字节码指令的地址);所需的内存空间在编译期间完成分配
2)线程私有
3)规定了两种异常状况:
如果线程请求的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
3.本地方法栈
1)本地方法栈为虚拟机使用到的Native方法服务;虚拟机栈为虚拟机执行Java方法(也就是字节码)服务
Sun HotSpot虚拟机直接把虚拟机栈和本地方法栈合二为一
2)线程私有
3)规定了两种异常状况:StackOverflowError异常与OutOfMemoryError异常
4.Java堆
1)此内存唯一的目的是存放对象实例,几乎所有的对象实例都在这里分配内存(不是所有的对象实例,因为随着JIT编译器的发展与逃逸分析计数逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化)
2)线程共享,在虚拟机启动时创建
3)如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
4)Java堆是Java虚拟机所管理的内存中最大的一块
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collection Heap)
5.方法区
1)存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
2)线程共享
3)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
4)这个区域的内存回收的目标主要是针对常量池的回收和对类型的卸载
5)对于HotSpot虚拟机,很多人把方法区称为“永久代”,本质上两者不等价,仅仅因为它的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是,在JDK1.7中,已经把原本放在永久代的字符串常量池移出
6.运行时常量池
1)它是方法区的一部分
Class文件中有一项是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量的概念(如文本字符串、声明为final的常量值等);
符号引用则属于编译原理方面的概念,包括了三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
2)线程共享
3)当常量池无法再申请到内存时会抛出OutOfMemoryError异常
4)并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,如String类的intern()方法
二.HotSpot虚拟机对象探秘
1.对象的创建
1)虚拟机遇到一条new指令时,首先将去检查new指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程
2)在类加载检查通过后,接下来虚拟机将为新生对象分配内存
对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
i)根据Java堆是否规整,有两种分配方式:
指针碰撞(Bump the Pointer),假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
空闲列表(Free List),假设Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定(如,Serial、ParNew等带整理过程,系统采用的分配算法是指针碰撞;CMS基于标记-清除,通常采用空闲列表)
ii)并发情况下的分配
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,解决这个问题有两个方案:
对分配内存空间的动作进行同步处理(虚拟机采用CAS配上失败重试的方式保证更新操作的原子性);
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓存(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。使用参数
-XX:+/-UseTLAB来设定虚拟机是否使用TLAB
3)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)
如果使用TLAB,这一工作过程可以提前至TLAB分配时进行。
此操作保证实例字段在Java中不赋初值就可以直接使用
4)虚拟机对对象进行必要的设置,如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码值、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中
5)此时,从虚拟机的视角看,一个新的对象已经产生了,但从Java程序员的视角看,对象创建才刚刚开始,方法还没执行,所有的字段都还为0.所以,执行new指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正的对象才算完全生产出来
2.对象的内存布局
在HotSpot虚拟机中,对象的内存布局可以分为:对象头、实例数据、对齐填充
1)对象头包括两部分信息:
第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁标志状态、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据长度在32位和64位的虚拟机中分别是32bit和64bit 官方称它为Mark Word
第二部分是类型指针,即对象指向它的类元素数据的指针,虚拟机通过指针来确定这个对象是哪个类的实例。(并不是所有的虚拟机都有类型指针)
如果对象是一个Java数组,在对象头中还必须有一块用于记录数组长度的数据。
2)实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
这部分的存储顺序会收到虚拟机分配策略(相同宽度的字段总是被分配到一起)参数和字段在Java源码中的定义顺序的影响。
3)对齐填充
起着占位符的作用(对象的大小必须是8字节的整数倍)
3.对象的访问定位
1)通过栈上的reference数据来操作堆上的具体对象
使用直接指针访问(HotSpot虚拟机),reference中存储的直接就是对象地址。优点:速度快
2)使用句柄访问,Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体信息。
优势:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改
三.实战部分:OutOfMemoryError异常
1.Java堆溢出
Java堆是用来存储对象实例的,如果不断地创建对象,并且通过GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到堆容量的上限时就会溢出异常。
1)如何模拟Java堆溢出呢?
设置JVM参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
限制堆的内存为20M,在那么中通过死循环创建对象,就会出现OutOfMemoryError。
异常堆栈信息: java.lang.OutOfMemoryError: Java heap space
2)如何分析呢?
上面JVM中的第三个参数是让虚拟机在出现内存溢出异常时Dump出当前的内存转储快照,可以通过内存映像分析工具(Eclipse Memory Analyzer)对Dump出来的转储快照进行分析,分析是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄露,通过工具查看泄露对象到GC Roots的引用链,掌握泄露对象的类型信息及GC Roots引用链的信息,定位出泄露代码的位置;
如果是内存溢出,即内存中的对象都必须存活着,那么检查虚拟机的堆参数是否可以调大,并检查代码总是否存在某些对象生命周期过长、持有状态时间过长的情况,减少程序运行期的内存消耗。
2.虚拟机栈和本地方法栈溢出
对于HotSpot虚拟机,栈容量由 -Xss参数设定,会出现两种异常。
1)如何模拟栈的溢出呢?
在单线程下,使用-Xss减少栈的容量或是定义大量的本地变量,增大此方法帧中本地变量表的长度,都会抛出StackOverflowError。
在多线程情况下,通过不断建立线程的方式可以产生内存溢出异常。出现这种异常后,如果是建立太多线程导致的内存溢出,而且又不能减少线程数或更换64位虚拟机,可以通过减少最大堆和减少栈容量来换取更多的线程(Xmx:最大堆容量+MaxPermSize:最大方法区容量+栈容量)。
3.方法区和运行时常量池溢出
在JDK1.6中,String.intern方法会把首次遇到的字符串实例复制到永久代中,返回的是永久代中这个字符串实例的引用;
在JDK1.7中,String.intern方法不再复制实例,只是在常量池中记录首次出现的实例的引用。
方法区的溢出,基本思路是运行时产生大量的类去填满方法区。