深入理解Java虚拟机(一):Java内存区域与内存溢出异常

1.Java 虚拟机运行时数据区域

        Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。


1.1 程序计数器

        在Java虚拟机中,程序计数器是线程私有的。程序计数器是一块较少的内存区域,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机中的概念模型中,字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要这个依赖这个计数器来完成。

        由于Java虚拟机的多线程是通过线程轮流切换并分配处理器的执行时间的方式来实现的,在任何一个时刻,一个处理器都只会执行一条线程中的指令。因此为了线程切换后,能够恢复到正确的执行位置,每条线程都需要有一个独立的计数器,各条线程之间互不干扰、互不影响,独立存储。我们称这一类内存存储区域为“线程私有”的内存。

1.2 Java虚拟机栈

栈帧是方法运行期的基础数据结构 栈容量可由-Xss参数设定

        与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程一样。虚拟机栈的描述的就是Java方法执行的内存模型:每个方法在执行的过程中都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法除开等信息。每一个方法从调用至完成的过程。

        局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、double、long)、对象引用和returnAddress地址信息。局部变量表所存储的内存空间是在编译期间内完成分配的,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

        在Java虚拟机栈中,这个区域规定了两种异常规范:如果线程请求的栈深度大于虚拟机的栈深度,将抛出StackOverFlowError异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemory异常。

1.3 本地方法栈

栈容量可由-Xss参数设定

        本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常类似的,它们之间的区别就是虚拟机栈是为虚拟机执行Java方法服务,而本地方法栈是为虚拟机执行Native方法服务。

1.4 Java堆

可通过参数 -Xms 和-Xmx设置   。

        Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存;Java堆是垃圾收集器管理的主要区域,也叫GC堆;java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

1.5、方法区

参数-XX:MaxPermSize可设置 .

    方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

1.6 运行时常量池

可以通过-XX:PermSize和-XX:MaxPermSize设置

    运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

1.7 直接内存

可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆的最大值(-Xmx指定)一样

    直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常。在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在java堆和Navtive堆中来回复制数据。

2. HoptSpot虚拟机对象探秘

2.1 对象的创建

        当虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

        1. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来,其中有“指针碰撞”和“空闲列表”两种方式。除开如何划分可用空间之外,还有另外一个需要考虑的问题就是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现在正给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理--实际上虚拟机采用CAS配上失败重试的方法保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。哪个线程需要分配内存,就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

      2.  内存分配完毕后,虚拟机需要将分配到内存的空间都初始化为零值(不包括对象头),以保证对象实例字段在java代码中可以不赋初值就直接使用。

      3. 接下来就是对对象进行必要的设置,如这个对象是哪个类的实例、如何找到类的元数据、对象的哈希码、独享的GC分代年龄等信息。

       4.从虚拟机角度来看,一个新的对象已经产生,但从程序的角度来讲,对象的创建才开始----<init>方法还没开始执行,所有字段都还为0。所以,一般来说,执行new指令后会接着执行<init>方法把对象按照程序员的意愿进行初始化,这才算一个真正可用的对象完全产生出来。

2.2 对象的内存布局

        在HotSpot虚拟机中,独享在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

        1. 对象头:HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称他为“Mark Word”。另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。

       2. 实例数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各个类型的字段内容。这部分内容的存储顺序会受到虚拟机分配策略参数和字段在java源代码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles 、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Points),从分配策略可以看出,相同宽度的字段总是被分配到一起。

       3.填充对齐:这一部分并非必须存在,它仅仅是起占位符的作用,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全8位字节。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说对象的大小必须是8字节的整数倍,而对象头的部分的大小正好是8字节的倍数,因此,当对象实例数据没有对齐时候,就需要填充对齐来对齐。

2.3 对象的访问定位

        建立对象是为了使用对象,我们的java程序中需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机中只规范了一个指向对象的引用,并没有去定义这个引用应该以何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄直接指针两种。

        句柄访问方式:在java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象实例数据与类型数据各自的具体地址信息,如图:


    直接指针访问:在java堆中的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象的地址,如图:


   这两种对象在访问时各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象移动(GC中)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针对位的时间开销,由于对象的访问在java中非常频繁,因此这类开销积少成多后也是一种非常可观的执行成本。Sun HotSpot中采用第二种。

3 OutOfMemoryError 异常

3.1 Java堆溢出

        java堆用于存储对象实例,只要不停地创建对象,病情保证GC Roots到对象之间有可达的路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大的容量限制后就会产生内存溢出异常。eg:

        java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现java堆内存溢出情况,异常堆栈信息“java.lang.OutOfMemaryError”会跟着进一步提示“Java heap space”。要解决这个区域异常,一般是先通过内存映像分析工具对Dump出来的堆转储快照进行分析,区分是内存泄露还是内存溢出。

        如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。

        如果是不存在泄露,换句话说内存中的对象确实还活着,那就应当检查虚拟机的堆参数与机器物理内存对比是否还可以调大,从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

3.2 虚拟机栈和本地方法栈溢出

关于虚拟机和本地方法栈,在java虚拟机规范中描述了两种异常:

1)如果把线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOutflowError异常。

2)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

        这里把异常分成两种,看似更加严谨,但却存在着一些相互重叠的地方,当栈空间无法继续分配时,到底是内存太小还是已使用的栈空间太大,其本质上只是对同一件事的两种描述。对于HotSpot虚拟机来说,并不区别本地方法栈和虚拟机栈。

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


3.4本机直接内存溢出

猜你喜欢

转载自blog.csdn.net/qq_21125183/article/details/80885107
今日推荐