走进JAVA虚拟机(二)
-------Java虚拟机的内存管理
有这样一种说法:“C++认为内存管理太重要了,于是让程序猿们亲自管理,JAVA也认为内存管理很重要,于是不让程序员管理”。这句话反映出了Java和C++在内存管理实践方面有很大的区别-----Java是通过虚拟机自动管理内存,而C++需要显式管理内存的分配与回收,这样一来Java让程序员从内存管理中解脱了出来,不需要太关心内存的分配和回收问题,但这并不意味着你可以对Java内存管理一无所知,不然你对程序的理解永远都是一知半解。不入内存,焉知其貌,一起解开Java内存管理的面纱吧!
1.JVM的内存结构
Java虚拟机在运行Java程序时,按照运行时数据的存储结构把内存划分为若干块不同的数据区。这些区域各有各的用途以及不同的创建和销毁时间。运行时的数据包括Java程序本身的数据信息和JVM运行Java程序时需要的额外数据信息。Java虚拟机规范把虚拟机内存划分为如下5种:
A.程序计数器:程序计数器可以看做是当前线程所执行的字节码的行号指示器。字节码指示器工作时就是通过改变计数器的值来选取下一条要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器来完成。Java虚拟机的多线程是通过线程轮流切换并分配CPU时间片的方式的,在某一时刻,一个处理器只会执行一个线程。所以为了线程切换后可以回到原来的执行位置,每条线程都需要一个程序计数器来记录当前线程执行到哪个指令了。另外,各个线程中的计数器互不影响,独立存储。JVM规范只定义了线程执行一个java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址。至于执行本地方法时,计数器的值为空。
B.Java虚拟机栈:java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈,所以java虚拟机栈和程序计数器一样都是线程私有的,其生命周期和线程相同。每个方法在执行时都会创建一个栈帧用于存放局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从条用到执行完成的过程都对应着一个栈帧在虚拟机栈中从入栈到出栈的一个过程。
Java虚拟机栈对应着两种异常:当线程请求的栈深度大于虚拟机所允许的栈深度,将会抛出StackOverflowError异常。当虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。由于Java虚拟机栈是与线程对应起来的,这个数据不是共享的,所以不存在数据的一致性和同步锁的问题。
C.本地方法栈:本地方法栈与虚拟机栈的功能相似,只是虚拟机栈是为虚拟机执行java方法服务,而本地方法栈是为执行本地方法服务,服务对象不同而已。
D.Java堆:Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建,用来存放Java对象,也是垃圾回收器管理的主要区域。堆是线程共享的,所以对于它的访问要注意同步问题,方法和对应的属性都需要保持一致性。Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可,其大小可以是固定的,也可以是可扩展的。堆按照对象生命的长短又可以分为新生代和永久代。
E.方法区:方法区和堆一样,是各个线程共享的内存区域,用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
2.JVM内存分配策略
程序计数器、虚拟机栈、本地方法栈这三个内存区域都是随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出而对应执行出栈与入栈的操作。每个栈帧中要分配多少内存基本上在编译期,类结构确定下来后就是明确了的。而且这三个区域不需要过多考虑内存分配的问题,因为当方法结束或线程结束时,内存自然随之回收了。所以这三个区域内存的分配和回收具有确定性。
当Java堆和方法区则与上述三者有很大的不同,其内存的分配在运行时才确定下来,具有动态性。
栈中主要存放基本类型变量和引用等数据,其存取速度比堆快,仅次于寄存器。缺点是存在栈中的数据与生存期必须是确定的,这使得栈缺乏灵活性。
堆用来存放类的实例对象,其优势是可以动态分配内存大小,生存期不必事先告诉编译器,因为堆是在运行期分配内存的,Java的垃圾收集器会自动回收不再使用的实例对象。缺点是由于堆是在应用程序运行的时候请求操作系统给自己分配内存,操作系统管理内存分配,所以在分配和回收内存时要占用较多的时间,这导致堆的存取速度较慢。
3.对象生死的判断
一个对象该不该被回收以其有没有被引用为根据,若被引用则不回收,若不再被引用,则需回收。那如何判断一个对象有没有被引用呢?在Java中通过“可达性分析算法”来判断对象是否存活着。
可达性分析算法的思想是通过一系列的称为“GC Roots”的对象作为起始点,从这些结点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,即CG Roots结点到这个对象不可达时,则说明此对象没有被引用,可以被垃圾回收器回收。
GC Roots的对象有四种:虚拟机栈中引用的对象、方法区静态属性引用的对象、方法区常量引用的对象、本地栈中引用的对象。
4.垃圾收集算法
A. 标记-清除算法:算法分为“标记”和“清除”两个阶段,首先标记出需要回收的对象,在标记完成后统一回收所有被标记的对象。但其有两点不足之处:一是效率问题,标记和清除两个过程的效率都不高,二是空间问题,标记清除后会产生大量不连续的内存碎片。
B. 复制算法:将内存按容量划分为大小相等的两块,每次只是用其中一块。这一块的内存使用完了,就将还存活着的对象复制到另一块上,然后再把已经使用过的内存空间一次性清理掉。适用于新生代的对象回收。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM有一项研究表明,新生代中98%的对象都是朝生夕死的,所以不需要按照1:1的比例分配内存空间。而是将内存分为一块较大的新生代Eden区和两块较小的永久代Survivor区,每次使用Eden空间和其中一块Suvivor空间。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一个块未使用的Survivor空间中,然后清理掉已使用过的Eden和Survivor空间。HotSpot虚拟机默认的Eden和Survivor的大小比例为8:1。
C . 标记-整理算法:标记过程和标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是把所有活动的对象都向一段移动,然后直接清理掉边界以外的内存。
D. 分代收集算法:根据对象生存周期的不同把堆分为新生代和老年代,在新生代中每次垃圾收集时都有大量对象死去,只有少量对象存活,适用于复制算法。而老年代中对象存活率高,没有额外空间对它进行分配担保,适用于“标记-清除”或“标记-整理”算法进行回收。