Java 内存模型和参数优化

## Java程序执行过程

1、 第一步,Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀)
2、 第二步,由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行
3、 第三步,在程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存
4、 因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)

JVM 内存模型

上图中可以看出,JVM的内存空间分为3大部分,分别是堆内存、方法区和栈内存。其中栈内存可以再细分为java虚拟机栈和本地方法栈。堆内存可以划分为新生代和老年代。新生代中还可以再次划分为Eden区、From Survivor区和To Survivor区。划分出来的各个区,分别保存不同的数据

堆内存: 用来存储对象本身的以及数组,是JVM内存模型中最大的一块区域,被所有线程共享,是在JVM启动时候进行创建的。几乎所有的对象的空间分配都是在堆内存上进行分配的。

考虑到JVM的内存回收机制,堆内存可以划分为新生代和老年代两个区域(默认新生代与老年代的空间大小为1:2)。新生代可以再划分为Eden区、From Survivor区和To Survivor区(三者比例为8:1:1)。几乎所有的新对象的创建都是在Eden区进行的。在垃圾回收(GC)过程中,Eden中的活跃对象会被转移到Survivor区,当再到达一定的年龄(经历过的Minor GC的次数),会被转移到老年代中。

堆可以处于物理上不连续的内存空间中,但是需要满足逻辑上的连续。在实现时,可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的

方法区:存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。(注:在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。)
方法区也叫作永久代,也是被所有的线程共享的。

JDK1.7中,已经把放在永久代的字符串常量池移到堆中。JDK1.8撤销永久代,引入元空间。

方法区不需要连续的内存,可以选择固定大小或者可扩展。并且还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

程序计数器:用于标识当前线程执行的字节码文件的行号指示器。多线程情况下,每个线程都具有各自独立的程序计数器,所以该区域是非线程共享的内存区域。它是CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令;(注:JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是逻辑作用上是等同的,在JVM中多线程是通过线程轮流切换来获得CPU执行时间的,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的)

当执行java方法时候,计数器中保存的是字节码文件的行号;当执行Native方法时,计数器的值为空。

Java栈:也叫作虚拟机栈,Java栈是Java方法执行的内存模型,Java栈中存放的是一个个的栈帧,每个栈帧(包括:局部变量表、操作数栈、运行时常量池(在下文中提到的方法区内)的引用、方法返回地址和一些额外的附加信息)对应一个被调用的方法,当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈(如果方法methodOne方法调用了methodTwo,那么methodOne就会先入栈创建一个栈桢,接着methodTwo再入栈成为栈顶(假设没有其他的方法执行),methodTwo执行完先出栈,接着methodOne执行完出栈)。由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

局部变量表中,可以存放的数据有8种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用和returnAddress类型。其中long和double因为是64位,会占用两个局部变量的空间。

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度(比如递归调用的时候),将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

本地方法栈:Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。它也是线程私有的内存区域,与java栈比较相似,不同之处在于该区域主要是保存Native方法相关的数据。Native方法是非Java语言编写的方法。

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

举例说明一下各个内存区域中保存的信息

上面的代码中:
1、 堆中进行对象的空间分配,比如Hashtable对象和String对象。
2、 方法区中保存类信息(TestJVM),方法(put方法,print方法,test方法)和静态变量(NUM)
3、 java栈中保存对象引用(score)

JVM内存参数设置

1、 新生代分三个区,一个Eden区,两个Survivor区(from和to区),可以通过-XXSurvivorRatio调整比例
作用:默认-XX:SurvivorRatio=8,表示Survivor区与Eden区的大小比值是1:1:8,在MinorGC过程,如果survivor空间不够大,不能够存储所有的从eden空间和from suvivor空间复制过来活动对象,溢出的对象会被复制到old代,溢出迁移到old代,会导致old代的空间快速增长

2、 大部分对象在先在Eden区中申请内存。
作用:可以通过设置-XX:PreTenureSizeThreShold大小,令大于这个值的对象直接保存到老年代,避免在Eden区与Survivor区之间频繁地通过复制算法回收内存

3、 当Eden区满时,无法为新的对象分配内存时,会进行Minor GC对其回收无用对象占用的内存,如果还有存活对象,则将存活的对象复制到Survivor From区(两个中Survivor对称);然后从Eden区存活下来的对象,就会被复制到From,当这个From区满时,此区的存活对象将被复制到To区,接下来Eden区存活下来的对象就会被复制到To区,经历一定的次数Minor GC后,还存活的对象,将被复制“老年代(Tenured)”。
作用:Minor默认15次,可通过-MaxTenuringThreshold参数调整新生代回收次数,防止对象过早进入老年代,降低老年代溢出的可能性

4、新生代和老年代的默认比例为1:2,即新生代占堆内存的1/3,老年代占2/3,可调整-XX:NewRatio的大小设置年轻和年老的比例。
作用:默认-XX:NewRatio=2,即young:tenured=1:2,适当调整新生代大小,可以一定层度上较少Full GC出现的概率

其他参数:

-Xms and -Xmx (or: -XX:InitialHeapSize and -XX:MaxHeapSize)指定JVM的初始和最大堆内存大小,两值可以设置相同,以避免每次垃圾回收完成后JVM重新分配内存。

-Xmn
设置新生代大小。
整个堆大小 = 新生代大小 + 老年代大小 + 持久代大小
所以增大新生代后,将会减小老年代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。

-Xss
设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

-XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath
让JVM在发生内存溢出时自动的生成堆内存快照(堆内存快照文件有可能很庞大,推荐将堆内存快照生成路径指定到一个拥有足够磁盘空间的地方。)

-XX:OnOutOfMemoryError
当内存溢发生时,我们甚至可以可以执行一些指令,比如发个E-mail通知管理员或者执行一些清理工作($ java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:OnOutOfMemoryError =”sh ~/cleanup.sh” MyApp)

-XX:PermSize and -XX:MaxPermSize
设置永久代大小的初始值和最大值(默认:最小值为物理内存的1/64,最大值为物理内存的1/16,永久代在堆内存中是一块独立的区域,这里设置的永久代大小并不会被包括在使用参数-XX:MaxHeapSize 设置的堆内存大小中)

-XX:PretenureSizeThreshold
令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制

总结:
JVM内存的系统级的调优主要的目的是减少Minor GC的频率和Full GC的次数,过多的Minor GC和Full GC是会占用很多的系统资源,影响系统的吞吐量。