JVM内存空间
Linux进程的内存空间
JVM以一个进程(Process)的身份运行在Linux系统上,了解Linux与进程的内存关系,是理解JVM与Linux内存的关系的基础。
下图给出了硬件、系统、进程三个层面的内存之间的概要关系。
从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和SWAP(位于磁盘)。物理内存是Linux活动时使用的主要内存区域;当物理内存不够使用时,Linux会把一部分暂时不用的内存数据放到磁盘上的SWAP中去,以便腾出更多的可用内存空间;而当需要使用位于SWAP的数据时,必须先将其换回到内存中。
从Linux系统上看,除了引导系统的BIN区,整个内存空间主要被分成两个部分:内核内存(Kernel space)、用户内存(User space)。
- 内核内存是Linux自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。
- 用户内存是提供给各个进程主要空间,Linux给各个进程提供相同的虚拟内存空间;这使得进程之间相互独立,互不干扰。实现的方法是采用虚拟内存技术:给每一个进程一定虚拟内存空间,而只有当虚拟内存实 际被使用时,才分配物理内存。
如下图所示,对于32的Linux系统来说,一般将0~3G的虚拟内存空间分配做为用户空间,将3~4G的虚拟内存空间分配 为内核空间;64位系统的划分情况是类似的。
从进程的角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为5个部分:代码区、数据区、堆区、栈区、未使用区。
- 代码区中存放应用程序的机器代码,运行过程中代码不能被修改,具有只读和固定大小的特点。
- 数据区中存放了应用程序中的全局数据,静态数据和一些常量字符串等,其大小也是固定的。
- 堆是运行时程序动态申请的空间,属于程序运行时直接申请、释放的内存资源。
- 栈区用来存放函数的传入参数、临时变量,以及返回地址等数据。
- 未使用区是分配新内 存空间的预备区域。
进程与JVM内存空间
JVM本质就是一个进程,因此其内存空间(也称之为运行时数据区,注意与JMM的区别)也有进程的一般特点。
但是,JVM又不是一个普通的进程,其在内存空间上有许多崭新的特点,主要原因有两个:
- JVM将许多本来属于操作系统管理范畴的东西,移植到了JVM内部,目的在于减少系统调用的次数;
- Java NIO,目的在于减少用于读写IO的系统调用的开销。JVM进程与普通进程内存模型比较如下图:
需要说明的是,这个模型的并不是JVM内存使用的精确模型,更侧重于从操作系统的角度而省略了一些JVM的内部细节(尽管也很重要)。
下面从用户内存和内核内存两个方面讲解JVM进程的内存特点。
用户内存
上图特别强调了JVM进程模型的代码区和数据区指的是JVM自身的,而非Java程序的。普通进程栈区,在JVM一般仅仅用做线程栈。JVM的堆区和普通进程的差别是最大的,下面具体详细说明:
-
首先是永久代。永久代本质上是Java程序的代码区和数据区。Java程序中类(class),会被加载到整个区域的不同数据结构中去,包括常量池、域、方法数据、方法体、构造函数、以及类中的专用方法、实例初始化、接口初始化等。这个区域对于操作系统来说,是堆的一个部分;而对于Java程序来 说,这是容纳程序本身及静态资源的空间,使得JVM能够解释执行Java程序。
-
其次是新生代和老年代。新生代和老年代才是Java程序真正使用的堆空间,主要用于内存对象的存储;但是其管理方式和普通进程有本质的区别。
-
普通进程在运行时给内存对象分配空间时,比如C++执行new操作时,会触发一次分配内存空间的系统调用,由操作系统的线程根据对象的大小分配好空间后返回;同时,程序释放对象时,比如C++执行delete操作时,也会触发一次系统调用,通知操作系统对象所占用的空间已经可以回收。 JVM对内存的使用和一般进程不同。JVM向操作系统申请一整段内存区域(具体大小可以在JVM参数调节)作为Java程序的堆(分为新生代和老年代);当Java程序申请内存空间,比如执行new操作,JVM将在这段空间中按所需大小分配给Java程序,并且Java程序不负责通知JVM何时可以释放这个对象的空间,垃圾对象内存空间的回收由JVM进行。
JVM的内存管理方式的优点是显而易见的
- 第一,减少系统调用的次数,JVM在给Java程序分配内存空间时不需要操作系统干预,仅仅在 Java堆大小变化时需要向操作系统申请内存或通知回收,而普通程序每次内存空间的分配回收都需要系统调用参与;
- 第二,减少内存泄漏,普通程序没有(或者 没有及时)通知操作系统内存空间的释放是内存泄漏的重要原因之一,而由JVM统一管理,可以避免程序员带来的内存泄漏问题。
-
最后是未使用区,未使用区是分配新内存空间的预备区域。 对于普通进程来说,这个区域被可用于堆和栈空间的申请及释放,每次堆内存分配都会使用这个区域,因此大小变动频繁; 对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的大小,并且这个区域通常并没有被分配实际的物理内存,只是允许进程在这个区域申请堆或栈空间。
2. 内核内存
应用程序通常不直接和内核内存打交道,内核内存由操作系统进行管理和使用;不过随着Linux对性能的关注及改进,一些新的特性使得应用程序可以使用内核内存,或者是映射到内核空间。Java NIO正是在这种背景下诞生的,其充分利用了Linux系统的新特性,提升了Java程序的IO性能。
上图给出了Java NIO使用的内核内存在linux系统中的分布情况。
-
nio buffer主要包括:nio使用各种channel时所使用的ByteBuffer、Java程序主动使用 ByteBuffer.allocateDirector申请分配的Buffer。
-
在PageCache里面,nio使用的内存主要包括:FileChannel.map方式打开文件占用mapped、FileChannel.transferTo和 FileChannel.transferFrom所需要的Cache(图中标示 nio file)。
通过JMX可以监控到NIO Buffer和 mapped 的使用情况,如下图所示。不过,FileChannel的实现是通过系统调用使用原生的PageCache,过程对于Java是透明的,无法监控到这部分内存的使用大小。
Linux和Java NIO在内核内存上开辟空间给程序使用,主要是减少不要的复制,以减少IO操作系统调用的开销。例如,将磁盘文件的数据发送网卡,使用普通方法和NIO时,数据流动比较下图所示:
将数据在内核内存和用户内存之间拷贝是比较消耗资源和时间的事情,而从上图我们可以看到,通过NIO的方式减少了2次内核内存和用户内存之间的数据拷贝。这是Java NIO高性能的重要机制之一(另一个是异步非阻塞)。 从上面可以看出,内核内存对于Java程序性能也非常重要,因此,在划分系统内存使用时候,一定要给内核留出一定可用空间。
运行时数据区
橘色为线程共享,灰色为线程隔离
这就是jvm运行时数据区的示意图,可以看到,整个运行区可以按照线程共享和线程隔离分为两类:
- 线程共享区: 线程共享区是整个jvm数据共享的区域,不管是那个线程,都可以共享属于共享区的数据,例如存储在堆内存中的对象,存储在方法区中的已经被类加载器加载好的类信息等。
- 线程独享区: 这部分数据是只有本线程能够单独持有的,例如局部变量,该线程中程序计数器指向的当前线程运行的行号等。 我们经常说的线程安全,其本质就是为了防止线程共享区的数据被线程其它线程篡改,从而引发线程安全问题,而这种安全性问题在线程独享的数据区域则不会存在。
程序计数器
从上面的图可以看出,程序计数器是线程独享的,用来记录当前程序执行的指令,也可以看作程序当前执行的行号。
jvm中的程序计数器只记录java方法执行的字节码指令,不会记录native层方法的指令。其实也很好理解,jvm就是用来执行字节码指令的虚拟机,和native层的通信交流大部分都在外部处理好了。
程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域。
虚拟机栈
虚拟机栈是一个栈结构,是线程独享的,那么作为一种栈的数据结构,它存储的是栈帧。
每伴随着一个方法被调用,jvm就会创建一个栈帧,并且压入虚拟机栈,伴随着方法调用结束,该栈帧就会出栈,并且被销毁。也就是说一个栈帧的声明周期就是它对应的方法的执行周期。
一个线程的调用方法链可能很长,很多方法都同时出于执行状态,因此在活动的线程中,只有位于栈顶的栈帧才是有效的。
栈帧的生命周期就是对应的java方法执行的周期,所以栈帧需要存储方法执行时的一些数据。栈帧中存放的数据包括:局部变量表,操作数栈,动态连接和返回地址等
局部变量表
局部变量表是一组变量值的存储空间,用于存放方法的参数和方法内部定义的局部变量。 在java程序编译为class文件时,就在该方法的max——locals数据项中确定了该方法所需要分配的局部变量的最大容量。
局部变量表以变量槽为最小单位(Slot),存放了编译时期的各种基本数据类型(boolean byte char short int float long double),reference(指向对象的引用或者代表对象的句柄等)和returnAddress(指向了一条字节码指令的地址)。其中64位的long和double会占用2个局部变量空间,其余的数据类型只占一个。
**局部变量是不会像类变量一样被加载的,系统不会默认为局部变量赋初始值,所以一个局部变量如果不赋值是不能使用的。 **
操作数栈
操作数栈也常称为操作栈,同样是一个栈结构。同局部变量表一样,操作数栈的最大深度也在编译的时候就会写入到Class文件中Code属性的 max_stacks数据项中。
当一个方法刚开始执行的时候,操作数栈是空的,在方法执行的过程中,会有各种字节码在操作数栈上写入和提取内容,也就对应着入栈和出栈操作。
动态连接
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
- 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接 * 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令:
传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口。 - 方法执行过程中遇到异常:
无论是java虚拟机内部产生的异常还是代码中thtrow出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。
无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行,方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址
方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈,pc计数器的值指向调用该方法的后一条指令
示例
public static void main(String[] args) {
int a = 3;
int b = 4;
int c = 5;
int d = add(a, b) + add(a, c);
}
private static int add(int a, int b) {
return a + b;
}
按照我们之前的思路,开始对两个方法的栈帧及内部的数据开始进行分析。
首先,在main方法中,首先初始化局部变量表,依次给参数分配slot,然后在局部变量表中分配3个slot用来给变量a,b,c,d分配内存空间
然后两次调用add方法,传入不同的参数,伴随着两个add栈帧的创建,add栈帧内部局部变量表的初始化,add操作数栈对于参数的加法运算,运算完毕返回返回值给main方法
main方法先获得a+b的返回值,压入操作数栈,再获得a+c的返回值,压入操作数栈,最后碰到运算符”+”,两个操作数出栈进行运算,将运算结果再压入操作数栈,赋值给d(这里的计算机自动中缀转后缀)。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务 本地方法栈也有一个专门的程序计数器来记录本地方法的执行情况。
堆
Java堆是线程共享区域,在虚拟机启动的时候就会被创建。这块内存的目的就是存放对象实例,几乎所有的对象都在这里分配内存。根据java虚拟机规范:所有的对象实例和数组都要在堆上分配内存。
Java堆是GC回收的主要区域,Java语言通过可达型分析来判定对象是否存活的。算法的基本思想是通过一系列称为”GC Roots”的对象作为跟节点,从这些节点往下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链连接的话,则证明该对象不可用,将被判定为可回收的对象。
java堆内存可以细分为新生代、老年代和永久代。现在的垃圾收集器基本都基于分代收集算法,因此这样细分java堆内存空间也是很有必要的。同样的,作为线程共享区的Java堆也可以分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer),不管如何划分,都是为了更好的存储对象,合理的利用空间进行内存的分配与回收。
新生代
新生代分为:Eden区和两块Survivor区(From Survivor区和To Survivor区)。这三个区的内存占比一般为8:1:1
对象的分配一般是主要是在新生代的Eden区的,我们平时写代码的时候通过 “new”关键字或者反射、动态代理等等,总之就是创建新对象的方式,这种方式创建一个新的对象大部分情况下都是在Eden区分配内存的。如果启动了本地线程分配缓冲,将按照线程优先级在TLAB(线程私有分配缓冲区)上分配,少数情况下可能直接分配在老年代中。分配的规则取决于垃圾收集器和内存参数配置等。
当Eden没有足够空间分配的时候,虚拟机会触发一次GC,虚拟机GC完成后会将Eden区和已经分配的Survivor区中还存活的对象一次性复制另一个 Survivor区,如果这块Survivor空间不够用,则会在老年代中进行分配担保。
老年代
在新生代的Eden区不够分配且gc后剩余的Survivor区也不够分配时,会在老年代触发分配担保进行内存分配。除了这种情况,如果对象在Eden区分配的内存,并经过第一次GC后移动到Survivor空间,又经过多次GC后仍然在Survivor区中没有被回收(默认15次),就会被晋升到老年代中。
值得注意的是,并不一定非要达到系统默认GC次数对象才会被晋升到老年代,如果Survivor空间中相同年龄对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就会直接进入老年代,不需要达到最大GC次数。
永久代
关于永久代,其实只针对Java8之前的HotSpot虚拟机,在别的虚拟机上没有这个概念。这里的永久代实际上是方法区在HotSpot虚拟机上的内存实现位置。而在不同的虚拟机中,方法区的实现可以放在不同的位置。在Java8中HotSpot也废除了永久区,方法区存放在一个与堆不相连的本地区域——元空间。
方法区
方法区与Java堆一样,是线程共享的区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 。Java虚拟机规范把方法区放在堆中作为堆的物理部分,但是伴随着最新的Java8到来HotSpot虚拟机也把方法去放在了堆外的本地内存空间,而且方法区和堆,这两者本身逻辑上并没有联系。
方法区的内存也需要回收,但是一般这个区域的内存回收比较难,因为之前对类信息进行装载完毕后才能进入方法区,但是后面对于类型的卸载和常量池的回收,条件相当的苛刻。
运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载之后进入方法区的运行时常量池中存放。 一般来说,运行时常量池除了保存Class文件中描述的符号引用之外,还会把翻译出来的直接引用也存储在运行时常量池中。
直接内存
直接内存并不属于java虚拟机的运行时数据区,但是这部分内存会被频繁地使用。在JDK1.4中新加入了NIO,是基于通道与缓冲区的IO方式,可以使用native函数直接在堆外分配内存,通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用来进行操作,这样可以显著的提升性能,避免在java堆和native堆中来回复制数据对机器性能的损耗。
这是一种堆外分配很好的方式,但是既然是内存,还是会受到机器的总内存大小和处理器寻址空间的限制。