Java内存区域划分、内存分配原理(深入理解JVM一)
Java虚拟机在执行Java的过程中会把管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,而有的区域则依赖线程的启动和结束而创建和销毁。
Java虚拟机包括下面几个运行时数据区域:(堆和方法区线程共享,会抛出OutOfMemoryError异常;PC、虚拟机栈和本地方法栈线程私有。两个栈会抛出StackOverflowError和OutOfMemoryError;PC是唯一不会抛出OutOfMemoryError异常的区域。)
记住一句话:堆主要用来存放对象,栈主要用来执行程序。
1. 程序计数器
程序计数器是一块较小的区域,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的模型里,字节码指示器就是通过改变程序计数器的值来指定下一条需要执行的指令。分支,循环等基础功能就是依赖程序计数器来完成的。
由于java虚拟机的多线程是通过轮流切换并分配处理器执行时间来完成,一个处理器同一时间只会执行一条线程中的指令。为了线程恢复后能够恢复正确的执行位置,每条线程都需要一个独立的程序计数器,以确保线程之间互不影响。所以程序计数器是“线程私有”的内存。
如果虚拟机正在执行的是一个Java方法,则计数器指定的是字节码指令对应的地址,如果正在执行的是一个本地(Native)方法,则计数器值为空(undefined)。程序计数器区域是Java虚拟机中唯一没有定义OutOfMemory异常的区域。
2. Java虚拟机栈
和程序计数器一样也是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。(也就是每个方法都对应一个帧帧,方法的开始于结束是栈帧的压栈与弹栈。栈帧中的本地变量表存的是本地变量(方法内部的变量),栈帧中的操作数栈是方法中进行运算的实际栈空间。)
通常所说的虚拟机运行时分为栈和堆,这里的栈指的就是虚拟机栈或者说虚拟机栈中的局部变量表部分。
局部变量表存放了编译期的可知的8中基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。局部变量表所需的内存空间在编 译器完成分配,当进入一个方法时这个方法需要在帧中分配多大的内存空间是完全确定的,运行期间不会改变局部变量表的大小。(64为长度的long和 double会占用两个局部变量空间,其他的数据类型占用一个)
Java虚拟机栈可能出现两种类型的异常:1. 线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。2.虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。
例如:下面的例子深入理解栈中的结构:
/** * @Author: qlq * @Description * @Date: 22:44 2018/9/25 */ public class MathTest { public static void main(String a[]){ MathTest mathTest = new MathTest(); System.out.println(mathTest.math()); } public int math(){ int a=20; int b=20; int c = (a+b)*10; return c; } }
反汇编查看信息:
$ javap -c -l ./MathTest.class Compiled from "MathTest.java" public class MathTest { public MathTest(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 6: 0 public static void main(java.lang.String[]); Code: 0: new #2 // class MathTest 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; 11: aload_1 12: invokevirtual #5 // Method math:()I 15: invokevirtual #6 // Method java/io/PrintStream.println:(I)V 18: return LineNumberTable: line 8: 0 line 9: 8 line 11: 18 public int math(); Code: 0: bipush 20 2: istore_1 3: bipush 20 5: istore_2 6: iload_1 7: iload_2 8: iadd 9: bipush 10 11: imul 12: istore_3 13: iload_3 14: ireturn LineNumberTable: line 13: 0 line 14: 3 line 15: 6 line 16: 13 }
画图解释上面:线程先执行main()方法,所以先创建main()栈帧并压栈(每个栈帧包括局部变量表,操作数栈,动态链接,返回地址等信息),在main()中调用math()所以创建math()栈帧,math()栈帧的返回地址存的是main()方法执行完math()方法的下一条指令,也就是执行完math之后main应该执行的操作。当math()执行完之后根据弹出栈,也就是math()栈帧消失,返回地址根据此栈帧的返回地址进行确定。
当执行完(a+b)之后的栈图如下:(本地变量表存的是a=20,b=20,操作数栈存的是40)
当执行到(a+b)*10之后的栈图如下:(本地变量表存的是a=20,b=20,操作数栈存的是40,10)
执行(a+b)*10之后的栈图如下:(本地变量表存的是a=20,b=20),将操作数栈的10和40弹栈之后进行乘法运算:=>运算结果写回操作数栈=》从操作数栈加载到本地变量表3(c=400)=>返回结果=》math()栈帧弹栈
3. 本地方法栈
本地方法栈和虚拟机栈基本类似,只不过Java虚拟机栈执行的是Java代码(字节码),本地方法栈中执行的是本地方法(native)的服务。由于很多Native方法都是用C语言实现的,所以也被叫做C栈。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(比如Sun HotSpot)直接把本地方法栈和虚拟机栈合二为一。本地方法栈中也会抛出StackOverflowError和OutOfMemory异常。
本地方法其实执行的是native方法,native其实没有实现,非常像抽象方法,实际是在执行的时候由执行引擎调用本地方法栈中的方法(一般是C语言写的,比如dll动态链接库等):常见的native方法有:Thread类中开启线程的start()方法中调用了本地的start0()方法,String的intern()方法。如下:
public synchronized void start() { if (threadStatus != 0) throw new IllegalThreadStateException(); group.add(this); boolean started = false; try { start0(); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { } } } private native void start0();
public native String intern();
4. 堆
堆是Java虚拟机所管理的内存中最大的一块。堆是所有线程共享的一块区域,在虚拟机启动时创建。堆的唯一目的是存放对象实例,几乎所有的对象实例以及数组都在这里分配,不过随着JIT编译器的发展和逃逸技术的成熟,栈上分配和标量替换技术使得这种情况发生着微妙的变化,对上分配正变得不那么绝对。
附:在Java编程语言和环境中,即时编译器(JIT compiler,just-in-time compiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。当你写好一个Java程序后,源语 言的语句将由Java编译器编译成字节码,而不是编译成与某个特定的处理器硬件平台对应的指令代码(比如,Intel的Pentium微处理器或IBM的 System/390处理器)。字节码是可以发送给任何平台并且能在那个平台上运行的独立于平台的代码。
Java堆是垃圾收集器管理的主要区域,所以也称为 “GC堆”(Garbage Collected Heap)。由于现在的垃圾收集器基本上都是采用分代收集算法,所以Java堆还可细分为:新生代和老生代。在细致一点可分为Eden(伊甸区)空间,From Survivor空间,To Survivor空间(幸存者区),Old Generation(老年带)。如果从内存分配的角度看,线程共享的Java堆可划分出多个线程私有的分配缓冲区。不过无论如何划分,都与存放内容无关,无论哪个 区域,都是用来存放对象实例。细分的目的是为了更好的回收内存或者更快的分配内存。
Java堆可以是物理上不连续的空间,只要逻辑上连续即可,主流的虚拟机都是按照可扩展的方式来实现的。如果当前对中没有内存完成对象实例的创建,并且不能在进行内存扩展,则会抛出OutOfMemoryError异常。
5. 方法区(HotSpot上也被叫做永久带,Perm Generation)
方法区也是线程共享的区域,用于存储已经被虚拟机加载的类信息,常量,静态变量和即时编译器(JIT)编译后的代码等数据。Java虚拟机把方法区描述为堆的一个逻辑分区,不过方法区有一个别名Non-Heap(非堆),用于区别于Java堆区。
Java虚拟机规范对这个区域的限制也非常宽松,除了可以是物理不连续的空间外,也允许固定大小和扩展性,还可以不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的(所以常量和静态变量的定义要多注意)。方法区的内存收集还是会出现,不过这个区域的内存收集主要是针对常量池的回收和对类型的卸载。
一般来说方法区的内存回收比较难以令人满意。当方法区无法满足内存分配需求时将抛出OutOfMemoryError异常。
(今天才知道方法区,也就是非堆占用的内存实际是堆内存的一部分,也可以称之为Java堆中的永久区)。
6. 运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等信息以外,还有一项信息是常量池用于存储编译器生成的各种字面量和符号引用,这部分信息将在类加载后存放到方法区的运行时常量池中。Java虚拟机对类的每一部分(包括常量池)都有严格的规定,每个字节用于存储哪种数据都必须有规范上的要求,这样才能够被虚拟机认可,装载和执行。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java虚拟机并不要求常量只能在编译期产生,也就是并非预置入Class文件常量池的内容才能进入方法区的运行时常量池中,运行期间也可将新的常量放入常量池中。
常量池是方法区的一部分,所以受到内存的限制,当无法申请到足够内存时会抛出OutOfMemoryError异常。
7. 直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
机直接内存的分配不会受到Java 堆大小的限制,但是,既然是内存肯定会受到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统的限制),从而导致动态扩展时出现OutOfMemoryError异常。
补充: 对象访问
对象访问在Java语言中无处不在,即使是最简单的访问,也会涉及到Java栈,java堆,方法区这三个最重要的内存区域之间的关联关系。如下面的代码:
Object obj = new Object();
假设这段代码出现在方法体中,那么“Object obj”部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型的数据存在。而“new Object();”部分的语义将会反应到Java堆中,形成一块存储Object类型所有实例数据值(Instance Data)的结构化内存,根据具体类型以及虚拟机实现的对象分布的不同,这块内存的长度是不固定的。另外,在JAVA堆中还必须包含能查找到此对象内存数 据的地址信息,这些类型数据则存储在方法区中。
由于reference类型在Java虚拟机中之规定了指向对象的引用,并没有规定这个引用要通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此虚拟机实现的对象访问方式会有所不同。主流的访问方式有两种:句柄访问方式和直接指针。
1. 如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
2. 如果通过直接指针方式访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象的地址。
两种方式各有优势,句柄访问方式最大的好处是reference中存放的是稳定的句柄地址,在对象被移动时,只会改变句柄中的实例数据指针,而 reference本身不需要被修改。而指针访问的最大优势是速度快,它节省了一次指针定位的开销,由于对象访问在Java中非常频繁,一次这类开销积少 成多后也是一项非常可观的成本。
具体的访问方式都是有虚拟机指定的,虚拟机Sun HotSpot使用的是直接指针方式,不过从整个软件开发的范围来看,各种语言和框架使用句柄访问方式的情况十分常见
补充:新生代和老年代的比例以及比例设置
堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
新生代和老年代的比例默认是1:2,也就是新生代占堆的1/3,老年代占堆的2/3(–XX:NewRatio可以调节新生代和老年代比例)。新生代Eden和两个Survivor的比例是8:1:1。(–XX:SurvivorRatio可以调节E区和两个S区比例)