深入理解JAVA虚拟机 第二章--JAVA内存区域与内存溢出异常

时间:2022-11-06 00:42:36

这一章主要讲的是java的内存分配机制,挺重要的。大家记好笔记呦~

概述

在内存管理方面,C++的开发人员相当于“皇帝”但却又是最基础工作的“劳动人民”——因为他们拥有对每一个对象进行处理操作的权利,但也要对其负责到底。比如从一开始的构造函数到最后的析构函数;而JAVA却不同。Java把对对象和内存的管理移交给了Java虚拟机,这样子就不容易出现因自己的疏忽导致的内存泄漏和内存溢出问题。但万一要是出现了,如果不了解虚拟机的运作机制,解决起来就是千难万难。

运行时数据区域

Java在运行过程中把内存分为好几个功能不同的区域。

程序计数器(Program Counter Register)

大家如果学过操作系统就不会对这个感到陌生。PC(Program Counter Register)也就是我们说的程序计数器,用来寻址。具体的我在这里也不多说了,这个在我们实际Java编程中用的不多,大概用处是比如:一段程序有好多好多指令,然后把这些指令放到一个栈里面,PC就是指向这些指令的首地址。每个线程都有自己独立的PC,而且互不影响(即线程私有)。
按书上的说法:如果正在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行一个Native方法,则改计数器为空。
注意一点:这个内存区域是唯一一个在Java虚拟机里面不会OOM(OutOfMemoryError,内存溢出)的区域。
特点:线程私有,没有OOM

Java虚拟机栈(Java Virtual Machine Stacks)

每个方法在执行的同时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。调用则进栈,执行完则出栈。Java栈里面有一块比较重要的叫做:局部变量表。里面存放了编译器可知的各种基本数据类型(8个基本类型)、对象的引用(并不是对象本身)和returnAddress类型(原话:指向了一条字节码指令的地址。我并不是很懂耶)。
long和double需要占用两个局部变量空间(Slot),其他只要占一个。局部变量表所需的内存空间在编译期间就完成分配,进入一个方法时,这个方法需要分配多少局部变量空间已经被完全确定了,在方法运行时这个不会改变大小。此外规定了两种异常状况:如果线程请求的栈深度大于虚拟机则抛出*Error(栈溢出);Java虚拟机扩展内存时无法申请到足够内存时会抛出OutOfMemoryError(内存溢出)
特点:线程私有,oom,栈溢出

本地方法栈(Native Method Stack)

作用与Java虚拟机栈比较类似,只是这里面放的是Native方法。

特点:oom,栈溢出

Java堆(Java Heap)

内存里面最大的一块,且被所有线程共享。在虚拟机启动时创建,唯一目的的就是存放对象实例。即几乎所有对象以及数组都要在堆里分配。
Java堆同时也是垃圾回收器主要管理的地方。所以Java堆有时又称为GC堆(Garbage Collected Heap)。Java堆还可根据收集器的收集算法往下细分:新生代,老年代。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(TLAB)。划分内存的目的是:更好的回收内存或者更快的分配内存。
Java堆可以分配在不连续的内存空间里面,只要逻辑是连续的就可以。如果在堆里面没有足够的内存来分配实例,并且堆也无法扩展时,会OOM。
特点:线程共享,会oom

方法区(Method Area)

与Java堆一样也是线程共享的,方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区还有一个名字叫做Non-Heap(非堆),目的就是一用于区分开Java堆。
Java虚拟机对方法区的限制非常宽松:可以不连续的内存也可以不实现垃圾收集(但我感觉最后还是要收集的,如果不收集的话不是会导致内存泄漏吗???)。
特点:线程共享,会oom,可以不垃圾回收。

运行时常量池(!=常量池)

我在标题里面特意指出来运行时常量池和常量池是不一样的。
常量池(Constant Pool Table):是在Class文件中,用来存放编译期生成的各种字面量和符号引用。常量池在类加载以后会放进运行时常量池。Java虚拟机对于常量池有着严格的规定:每一个字节用来放什么类型的数据。
运行时常量池(Runtime Constant Pool):是方法区的一部分。Java虚拟机对运行时常量池没有严格的规定。所以各个厂商提供的虚拟机可以有不同的实现。还有一个特点是动态性:运行时常量池里面的东西不一定要是从常量池里面转移进来的,也可以在代码运行期间放入。
特点:线程共享,oom,动态性。

直接内存(Direct Memory)

直接内存并不是Java虚拟机规范中定义的内存区域,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中 的DirectByteBuffer对象作为这块内存的引用进行操作,作用类似于缓存区,来避免Java堆和Native堆来回复制数据。

**特点:**oom

HotSpot虚拟机关于对象的知识

对象的创建

如 A a = new A();

  1. 当虚拟机遇到new指令时,会去检查A这个类是否已经被加载进来。如果没有则会执行一个类的加载过程。
  2. 虚拟机会为新生对象分配内存,所要分配内存的大小已经在类加载的时候确定了。分配内存相当于在堆里面划出一块合适的空间。那么根据Java堆的规整程度需要考虑两种情况:如果在Java堆里面分配好的内存放在一边,没分配好的内存放在另一边(即我们说的规整),那么会有一个指针,我们放进去一个对象指针就往空闲区域移一块;如果不规整,那么需要一张表来维护内存,记录哪些内存块是可以用的。还有一个问题是考虑并发,因为有可能在A线程分配对象,指针还没移动的时候,B线程对象也要分配内存,那么就会出现错误。解决方法有两种:1、保证分配内存空间这个操作的原子性(即不能被打断);2、把不同的线程放在不同的内存里面操作。比如先预留5MB给A操作,5MB给B操作,这样子的话就不会发生因并发引起的问题。
  3. 虚拟机把分配好的内存空间初始化零值。如果使用TLAB,那么初始化工作可以提前到TLAB分配时进行。正是因为这一步才保证对象的实例字段没有初始化就可以使用。
  4. 虚拟机对对象进行一些必要的设置。如哈希码,这个对象是哪个类的实例等信息存放在对象头(Object Header)里面。
  5. 从虚拟机的视角,一个对象已经创建完成了。但从Java代码来看,现在还并没有执行构造函数,也就是初始化方法(init())。所以这一步就是执行init()。

对象的内存布局

我们下面讲的都是以HotSpot虚拟机为例。
对象在内存中存储的布局可以分为:对象头(Header),实例数据(Instance Data),对齐填充(Padding)。

  1. 对象头:包括两部分。第一部分官方称为“Mark Word”,用于存储自身的运行时数据,比如:哈希码,GC的分代年龄(在下一章会讲解到)等。。这部分数据长度在32位和64位虚拟机里面分别为32bit和64bit,但其实运行时数据量远比这个要大。所以Mark Word被设计成一个非固定的数据结构,以便在极小的空间里存放尽可能多的数据,它会根据对象的状态来复用自己的空间;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个来确定这个对象是哪个类的实例。但并不是所有虚拟机的实现都必须要通过对象本身来查找对象的元数据信息,即不一定要这个类型指针。另外如果对象是一个数组,还需要保存下来这个数组的长度。Java虚拟机不能从数组的元数据来确定数组的大小。
  2. 实例数据:对象真正储存的有效信息,也就是代码里面定义的那些东西(int,double什么什么的)。HotSpot的分配策略为相同宽度的字段放一起,比如long和double类型的数据放一起,short和char类型的数据放一起。在满足这个分配策略的前提下,父类的数据总是放在子类前面。(不同虚拟机的分配策略不一样,而且受到某些参数的影响,所以并不是千篇一律的哟~)
  3. 对齐填充:这个并不是一定存在的。左右么就是把一个对象空余的部分填填满。比如:HotSpot虚拟机要求每个对象大小必须是8字节的整数倍,那有些对象没有达到要求,这个时候就会放一些占位符来对齐填充空余的部分。

对象的访问定位

Java程序通过栈里面的reference数据(应该是引用的意思吧)来操作堆里面的具体对象。但Java虚拟机规范里面并没有规定这个引用通过何种方式去访问。所以看到这个大家也应该知道不同的虚拟机可以设计自己独特的方式。主流有两种:

  1. 句柄访问:在Java堆里面划分出一块内存专门用来放置句柄(也就是句柄池),那么引用里面存储的就是对象的句柄地址,句柄里面包含了指向对象实例数据与类型数据的指针。
    好处:因为在引用里面放地址,所以地址移动了以后只要在引用里改一下而不需要改引用。
  2. 直接指针访问:对象的实例数据直接放在引用里面,引用里面还有一个指向对象类型数据的指针。
    好处:速度快,因为节省了一次指针定位的时间开销。

oom异常

堆溢出

while(true)
{
list.add(new Onject());
}

这段代码就会导致堆溢出。(创建的对象都存放在list中,系统不会把这些对象当做垃圾清理掉,所以导致堆溢出)
我在自己试的时候电脑死机了。。
书上说运行结果应当为:java.lang.OutOfMemoryError: Java heap space

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

书中介绍了两种异常情况:

  1. 线程请求的栈深度大于虚拟机所允许的最大深度,则会*Error。
  2. 虚拟机在扩展栈的时候无法申请到足够的内存空间,则会OutOfMemoryError。

看下面的代码:

void a()
{
a();
}

那么一旦调用a()之后就会出现:java.lang.*Error(因为无限递归本身方法导致栈帧过多 溢出)。
书中说 实验表明:在单线程中无论是栈帧太大还是虚拟机容量太小都会抛出*Error。
下面是多线程的代码:

whiletrue
{
new Thread(new Runnable()
{
public void run()
{
whiletrue
{
}
}
}).start();
}

无限创建新的线程且每个线程都不会被销毁,会导致oom即上面说的OutOfMemoryError。由于操作系统为每个进程分配的内存是有限的,如果该进程内的线程分配到的内存过大就更容易出现oom。
线程数约等于(总内存-堆内存-方法区内存-PC-本地方法栈)/每个线程所分配的内存,所以如果要增大线程数就可以考虑减少堆内存和栈容量(我想这里应该是指本地方法栈,注:HotSpot里面不区分本地方法栈和虚拟机栈)。

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

先介绍一个方法String.intern(),:如果字符串常量池里面已经包含一个等于此对象的字符串,则返回这个字符串的String对象;否则将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

int i=0;
while(true)
{
list.add(String.valueOf(i++).intern());
}

会报如下异常:java.lang.OutOfMemoryError: PermGen space
这句话表示永久代(方法区)溢出。
注意:以上代码在jdk1.7之后就不会报异常,因为intern()方法的意思改了,具体的在这里就不细讲了额。。。

本机直接内存溢出

这个一般用不到吧。。也基本不会出现。书中介绍的情况我也基本没碰到过,我就偷个懒啦~嘻嘻

TIPS

  1. Native方法:http://blog.csdn.net/wike163/article/details/6635321