JVM----Java虚拟机内存结构

Java和C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

1.JVM

Java摆脱了硬件平台的束缚,实现了“一次编写,到处运行”。

那是什么让Java实现了Java语言最重要的特性,即:平台无关性呢?那就是Java虚拟机(Java virtualmachine)。

平台无关性原理:编译后的 Java程序(.class文件)由 JVM执行。JVM屏蔽了与具体平台相关的信息,使程序可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。因此实现Java平台无关性。

2.Java内存区域

对Java程序员来说,JVM有自动内存管理机制,不需要自己手动进行内存分配和回收,也不容易出现内存泄露和内存溢出的问题。那么我们为什么还要了解JVM的内存结构呢?

这里需要说明,内存泄露(Memory Lesk)就是已经分配的内存没有办法回收,造成内存空间的浪费。

内存溢出(Memory Overflow)就是提供的可以使用的内存小于需要的内存,就是内存不够分配。

正是因为Java程序员把内存控制的权利交给了Java虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工作。因此了解JVM的内存结构还是很有必要的。

JVM在执行Java程序的过程中把它所管理的内存分为若干个不同的数据区域。JVM所管理的内存包括以下几个运行时数据区域。

JVM----Java虚拟机内存结构

下面分别讲解这些区域的作用、服务对象以及其中可能产生的问题。

(1)程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它是当前线程所执行的字节码的行号指示器,就是用于标记线程执行到哪一步了。在虚拟机的概念模型里,字节码解释器通过改变这个计数器的值获取下一条需要执行的字节码指令。

在多线程程序执行过程中需要轮流切换线程分配处理器时,为了线程切换后能恢复到正确的位置,每个线程也都需要一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储,它们是线程私有的。

此内存区域是唯一一个在Java虚拟机中没有规定任何OutOfMemoryError异常情况的区域。

(2)Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,生命周期和线程相同。虚拟机栈服务的对象是虚拟机执行的Java方法(也就是字节码)。每个方法在执行的同时会创建一个栈帧,每个方法从调用到执行完成的过程,就对应一个栈帧在虚拟机中入栈到出栈的过程。

通常把Java内存分为栈区和堆区,栈区就是指的虚拟机栈,或者是虚拟机栈中的局部变量表

局部变量表存放了编译期可知的常用数据类型。局部变量表中的这些常用数据类型所需的空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的内存是完全确定的,在方法运行期间不会改变局部变量表的大小。

在这个区域会出现两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,就会出现*异常;如果虚拟机栈可以动态扩展(大部分JVM是可以的),扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

(3)本地方法栈

本地方法栈(Native Method Stack)为虚拟机使用的native方法服务

在这里,需要指出:JAVA中有两种方法:JAVA方法和本地方法。

JAVA方法是由JAVA编写的,编译成字节码,存储在class文件中。本地方法是由其它语言编写的,编译成和处理器相关的机器代码。("A native method is a Java method whose implementation is provided by non-java code.")

JAVA方法是与平台无关的,但是本地方法不是。本地方法保存在动态链接库中,即.dll(windows系统)文件中,格式是各个平台专有的。

为什么要使用Native Method?

 java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

<1>有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因。

<2>使用本地方法,我们就可以用java实现jre的与底层系统的交互,甚至JVM的一些部分就是用C写的。另外,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

但是需要提醒的是,使用本地方法是有开销的,它丧失了java的很多好处。如果别无选择,我们可以选择使用本地方法。

更多本地方法的讲解,参考:JAVA本地方法详解,什么是JAVA本地方法?

和虚拟机栈一样,本地方法栈也会抛出*和OutOfMemoryError异常。

(4)Java堆

Java堆(Java Heap)是Java虚拟机管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。所有的对象实例以及数组都要在堆上分配。(The heap is the runtime data area from which memory for all class instance and arrays is allocated.)

Java堆是垃圾收集器管理的主要区域。Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是扩展的,当前主流虚拟机都是按照扩展来实现的(通过-Xmx和-Xms控制,-Xms:堆的最小值参数,-Xmx:堆的最大值参数)。

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

(5)方法区

方法区(Method Area)和Java堆一样,也是各个线程共享的内存区域,比如每个线程都可以访问同一个类的静态变量。该区域用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。比如,在程序中通过getName、isInterface等方法来获取信息时,这些数据就来源于方法区。方法区同样可以是不连续的内存空间,空间大小可以选择是固定或可扩展,还可以选择实现或不实现垃圾收集。

垃圾收集在这个区域很少见,该区域的内存回收目标主要是针对常量池的回收和类型的卸载,但是回收效果不是很满意。值得注意的是JDK1.7已经把常量池转移到堆里面了。

当方法区无法满足内存分配要求时,会抛出OutOfMemoryError异常。

(6)运行时常量池

运行时常量池(Runtime Constant Pool)在JDK1.7是方法区的一部分,在JDK1.7之后已经把常量池转移到堆里面了。常量池用于存放编译器生成的各种字面量和符号引用。Java语言并不要求常量一定只有在编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可将新的常量放入常量池(常用的有String.intern( )方法)。

其中,字面量比较接近于常量的概念,如文本字符串,声明为final的常量值等。符号引用是指类、接口的全限定名,字段和方法的名称以及属性;

下图是常量池里存储的内容:

JVM----Java虚拟机内存结构

当常量池无法再申请到内存时,会抛出OutOfMemoryError异常。

3.OutOfMemoryError异常实例

除了程序计数器之外,其他几个运行时数据区域都有发生OutOfMemoryError异常的可能。在工作中遇到实际的内存溢出时,怎样能根据异常信息判断是哪个区域的内存溢出,怎样才能知道是什么样的代码才会导致这些区域内存溢出,以及出现这些异常后应该如何处理?我们可以通过一些OutOfMemoryError异常的实例学习解决内存溢出问题。

每次出现内存溢出的情况,我们可能就需要查看和修改与虚拟机有关的一些参数。

(1)Java堆溢出

堆是用来存放对象实例的。因此如果是这个区域发生溢出,可能的原因就是对象数量到达最大堆的容量限制。比如不断在main( )方法中创建对象而不回收,就会出现堆溢出的问题。

当出现堆内存溢出时,堆栈信息“java.lang.OutOfMemory”会跟着进一步提示“Java heap space”。

要解决这个区域的异常可以使用内存映像分析工具,如Eclipse Memory Analyzer,对对存储快照进行分析,确认内存中的对象是否是有必要的,是发生了内存泄露还是内存溢出。

如果是内存泄露,根据泄露对象类型信息和使用工具查看到的GC root引用链的信息,就可以定位泄露代码的位置。(GC root是内存回收时用来判断一个要回收的对象到GC root的路径是否是可达的,从而判断该对象是否可以回收,GC root在垃圾收集时会有讲解。)

如果是内存溢出,要是这些对象确实都有存在的必要,可以检查虚拟机的堆参数(-Xms:堆的最小值参数,-Xmx:堆的最大值参数)与物理机器对比看是否可以调大。这两个堆参数可以在Eclipse的安装包所在的文件夹中eclipse.ini中设置。

JVM----Java虚拟机内存结构

eclipse.ini中的内容如下:

JVM----Java虚拟机内存结构

其中最后两个参数就是堆内存的最小和最大值。

另外和JVM相关的参数可以在Eclipse中的Debug/Run页签中设置。

JVM----Java虚拟机内存结构

解决堆内存溢出问题还可以从代码上检查是否存在某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期的消耗。 

(2)虚拟机栈和本地方法栈溢出

在JVM中,栈容量由-Xss参数设定,本地方法栈的容量由-Xoss参数设定。有些虚拟机中并不区分虚拟机栈和本地方法栈,因此设置-Xoss参数是无效的。在虚拟机栈和和本地方法栈溢出中,定义了两种异常:*和OutOfMemoryError。在实验中发现,单线程条件下,在栈中这两种异常经常出现的是*异常。

实验一:使用-Xss参数减少栈内存容量。结果:抛出*异常,因为栈内存容量设置的很小,异常出现时输出的堆栈深度相应缩小。(比如,设置的Xss参数值为128k)。

方法二:定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出*异常,异常出现时输出的堆栈深度相应缩小。

实验结果表明:在单个线程下,无论是栈帧太大还是虚拟机栈内存太小,当内存无法分配的时候,抛出的都是*异常。

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

在JDK1.7之前,运行时常量池是方法区的一部分,JDK1.7之后就放入了堆区,这两个区域的溢出测试可以放在一起进行。我们可以测试常量池位置的改变对常量池中执行的程序的影响。

String.intern( )是一个本地方法,它的作用是:如果一个String 对象的字符串已经包含在字符串常量池中,则返回常量池中的String对象;否则,把这个String 对象的字符串添加到常量池中,并返回该String对象。类似于缓存的原理。在JDK1.6及之前的版本中,运行时常量池是方法区的一部分,可以通过-XX:PermSize(方法区容量)和-XX:MaxPermSize(方法区最大容量)限制方法区的大小,从而间接限制常量池的容量。

方法区用于存放Class相关的信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。

在实际应用中,方法区的溢出也会经常发生:如<1>在Spring,Hibernate框架中,对类进行增强时,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载进内存,也就越容易遇到方法区溢出的问题。<2>大量JSP或动态产生的JSP文件的应用(JSP第一次运行需要编译成Java类)也有产生方法区溢出异常的可能性。

4.垃圾收集器与内存分配策略

参见:JVM----垃圾收集器与内存分配策略

参考:《深入理解Java虚拟机(第2版)》--周志明 Chapter2