内存分配
Java中的内存分配都是由Java虚拟机来管理的,那么虚拟机是如何管理的呢?首先先了解一下Java虚拟机中将运行时的数据分为几个区域进行管理.
一 、运行数据管理
Java虚拟机在运行Java程序时将运行时加载的数据分为几个区域,分别是方法区,虚拟机栈区,本地方法栈,堆以及程序计数器.
1.程序计数器
(1)程序计数器是一块较小的内存空间,可以被看作是当前线程所执行的字节码的行号指示器.而字节码解释器在工作时通过改变程序计数器的值来获取下一行字节码指令,然后执行.
(2)在任何时间,cpu只会执行一个线程中的指令,但是由于java中多个线程之间会抢夺cpu执行权,而每次要保证在线程切换后程序依然能够从程序计数器的停止位置处继续执行,因此每个线程都会拥有一个独立程序计数器.
(3)如果线程执行的是一个java方法,则程序计数器记录的是当前虚拟机字节码指令的地址,如果执行的是一个native方法,则计数器值为空
(4)此内存区域是唯一一个java虚拟机规范中没有规定任何内存溢出情况的区域.
2.虚拟机栈
(1)虚拟机栈描述的是java方法执行的内存模型,每执行一个方法是,都会创建一个栈帧,其中存放方法中的局部变量表,操作数栈,动态连接以及方法出口等信息.方法执行完毕栈帧会从java虚拟机栈中出栈.
(2)局部变量表存放了编译期可知的基本数据类型(八种),对象引用(可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向一条字节码指令的地址).其中64为长度的long和double类型的数据会占2个局部变量空间,其余只占一个,编译期间为局部变量表分配空间.
(3)java虚拟机规范中规定了两种异常情况,一种是如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机可以动态扩展,在扩展时无法申请到足够的内存,会抛出OutOfMemory异常.
3.本地方法栈
类似于虚拟机栈,区别在于虚拟机栈为虚拟机执行java方法服务,而本地方法栈为虚拟机使用的native方法服务.
4.Java堆
(1)java堆是java虚拟机管理内存中最大的一块,并且被所有线程共享,在虚拟机启动时创建.java虚拟机规范中描述所有的对象实例和数组都要在堆上分配内存(原文:The heap is the runtime data area from which memory for all class instances and arrays is allocated)
(2)此区域是垃圾回收器的主要管理区域,从内存回收角度看,现在收集器基本上能够都采用分代收集算法,所以java堆可分为新生代和老生代;从内存分配角度看,线程共享的java堆可划分出多个线程私有的分配缓冲区.
(3)java堆可以处于物理上不连续的内存空间,只要是逻辑上连续即可
(4)如果无法在堆上给对象分配内存,并且堆也无法继续扩展时,将抛出OutOfMemory异常.
5.方法区
(1)线程共享区域,用于存储已被虚拟机加载的类信息、常量、访问修饰符、静态变量、字段描述、方法描述、即时编译器编译后的代码的数据等.
(2)该区域内存回收目标主要是针对常量池的回收和对类型的卸载.
(3)class文件中常量池主要存放的编译期生成的各种字面量(直接赋值,不声明变量存储)和符号引用将在类加载后进入方法区的运行时常量池中存放,运行时常量相对于class文件的常量池具备动态性,例如在运行期间也可加入新的常量,比如String类的intern()方法.
(4)方法区无法申请到足够的内存时会抛出内存溢出异常.
二、对象的创建
Java是面向对象语言,当一个java程序中通过new关键字创建一个新对象时,Java虚拟机是如何处理的呢?
创建一个新对象主要分为四步:
1.类加载判断
虚拟机遇到一个new关键字时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已被加载,解析和初始化过,如果没有则要进行相应的类加载过程.
2.为对象分配内存
当一个类被加载后就要为其分配内存,并且该内存大小已确定,由java堆内存是否规整(由所用的垃圾回收器是否带有压缩整理功能决定)分为两种分配方式:
(1)如果java堆中内存是规整的,所有已用内存和未用内存会被一个指针分开作为分界点的指示器,指针向未用内存偏移的距离与对象所占内存相等,这种分配方式为"指针碰撞".
(2)如果java堆内存是散乱的,java虚拟机就必须维护用于记录内存可用情况的一个列表,在分配时从列表中划分一块内存空间给对象,并更新列表记录,这种方式为"空闲列表".
3.对象初始化
分配好内存后,虚拟机要对分配的内存空间初始化为零值(不包括对象头),也可以在本地线程分配缓冲进行,即每个线程在java堆中预先分配的一小块内存,这样可以使得对象的字段不需要在java代码中赋初始值,但得到的为各数据类型所对应的零值(int为0,String为null).
4.对象信息设置
类的元数据信息,对象的哈希码,对象的GC分代年龄等都保存在对象头中.
三、对象在内存中的存储
在HotSpot虚拟机中,对象在内存中的存储可分为三个部分:对象头、实例数据和对齐填充。
1、对象头
对象中包含两部分信息。一部分是用于存储对象自身的运行时数据,如哈希码,GC分代年龄、线程持有锁、锁标志状态等,这部分内容的长度在32位虚拟机和64位虚拟机中分别是32bit和64bit,官方称为“Mark Word”;另一部分是类型指针,指对象指向其类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例,这样java虚拟机可以通过元数据来确定对象的大小,但如果对象时一个数组对象,那么在对象头中还必须额外提供一个记录数组长度的数据。
2、实例数据
实例数据是对象存储的有效信息,即程序中的字段信息(包括父类继承和子类定义),存储顺序依赖虚拟机分配策略参数和字段定义时的顺序,HotSpot虚拟机默认分配策略为:longs/doubles、ints、shorts/chars、bytes、booleans、oops(Ordinary Object Pointers),在此前提下,父类定义变量在子类之前,如果将虚拟机的CompactField参数设置为true,则子类长度较小的变量可能会插到父变量之间。
3、对齐填充
这部分内容是否存在是直接受对象实例数据内容大小影响的,其主要作用时充当占位符的角色。因为在虚拟机的自动内存管理系统中规定独享的起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,对象头刚好符合,但实例数据部分如果不符合则会进行对齐填充。
四、对对象的访问
java程序通过栈上的reference类型的引用来定位堆上的具体对象,而访问对象是由虚拟机来实现的,一般访问对象有两种方式:使用句柄和直接指针。
1、句柄访问
java堆会划分处一块内存作为句柄池,reference存储的就是句柄池的地址,在句柄池中存放着指向对象实例数据和类型数据的指针,这两个指针分别指向java堆中对应的对象实例和方法区中锁存储的该对象的类型信息。
使用该访问方式的好处是reference存储了稳定的句柄地址,如果对象位置发生变化(垃圾回收时)只是改变了句柄中的实例数据指针,reference本身不发生改变。
2、直接指针
reference存储的为对象在堆中的地址,所以该对象必须提供指向到对象类型的信息。
好处是直接访问对象,速度更快,而且少了一次指针定位所带来的时间开销。
内存回收
在Java的运行数据区的程序计数器、虚拟机栈和本地方法栈三个部分都是基于线程的,在执行字节码时,线程控制程序计数器记录行号,执行完毕,程序计数器停止工作;虚拟机栈和本地方法栈都是在执行方法和方法结束对应栈帧在栈中的入栈和出栈过程,而栈帧的内存分配大小在编译期已经确定,因此在这几个区域内存分配和回收都是基本确定的。
垃圾回收主要针对的是Java堆和方法区的内容,在Java堆中如果有不可用或已死亡的对象时,GC会自动回收这些对象。不过如何判定一个对象是否死亡?需要使用算法来进行判断。
一、判断对象是否存活的算法
1、引用计数算法
描述:给对象中添加一个引用计数器,每当该对象被引用一次,计数器加1;每当一个引用失效时,计数器减1,这样当任何时刻出现对象的引用计数器的值为0时,就表示该对象为不可用对象。
但是在Java中可能会存在循环引用的情况,所以循环引用的对象不可能被回收,引用计数法并不能解决这个问题。下面是一个循环引用的例子,创建两个CircleRef的引用分别赋给各自的静态成员变量,最后手动进行垃圾回收。
import org.junit.Test;
public class CircleRef {
public Object value = null;
private static final int _1MB = 1024 * 1024;
//为了使回收前后的内存有明显的变化,创建一个1MB的数组
private byte[] bs = new byte[2* _1MB];
@Test
public void test(){
CircleRef c1 = new CircleRef();
CircleRef c2 = new CircleRef();
c1.value = c2;
c2.value = c1;
c1 = null;
c2 = null;
//手动回收垃圾
System.gc();
}
}
[GC (System.gc()) [PSYoungGen: 11727K->2552K(18944K)] 11727K->3725K(62976K), 0.0085595 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[Full GC (System.gc()) [PSYoungGen: 2552K->0K(18944K)] [ParOldGen: 1173K->3653K(44032K)] 3725K->3653K(62976K), [Metaspace: 6242K->6242K(1056768K)], 0.0125583 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 18944K, used 477K [0x00000000eb380000, 0x00000000ec880000, 0x0000000100000000)
eden space 16384K, 2% used [0x00000000eb380000,0x00000000eb3f7730,0x00000000ec380000)
from space 2560K, 0% used [0x00000000ec380000,0x00000000ec380000,0x00000000ec600000)
to space 2560K, 0% used [0x00000000ec600000,0x00000000ec600000,0x00000000ec880000)
ParOldGen total 44032K, used 3653K [0x00000000c1a00000, 0x00000000c4500000, 0x00000000eb380000)
object space 44032K, 8% used [0x00000000c1a00000,0x00000000c1d914f8,0x00000000c4500000)
Metaspace used 6260K, capacity 6420K, committed 6656K, reserved 1056768K
class space used 716K, capacity 755K, committed 768K, reserved 1048576K
上面是输出的GC日志信息,打印GC日志一般要进行配置参数,有两种方法:第一种时在eclipse的run/debug下,在自变量选项卡中的VM自变量陪值虚拟机参数,这里只配置了
-XX:+PrintGCDetails ,指的是在程序运行后可以打印GC日志信息在控制台。
第二种方式是在eclipse的根目录下的.ini文件配置虚拟机参数
-verbose:gc
-Xloggc:eclipse_gc.log
-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails
重启eclipse后在eclipse的根目录下生成一个eclipse_gc文件,记录GC输出日志。
然后对日志信息进行分析:
第一行:GC (System.gc())指当前GC是minor GC,system.gc()指由函数触发,PCYoungGen指parallel Scavenge收集器,后面的方括号中代表 GC前新生代使用大小 -> GC后新生代使用大小(新生代总的可用大小),方括号外面代表 GC前heap的使用大小 -> GC后使用大小(整个heap总大小),时间为GC所用时间。
第二行:Full GC (System.gc())指调用了System.gc()函数所触发的回收,ParOldGen指parallel old收集器, Metaspace指元空间,用于存储类的元数据,jdk1.8出现,代替了之前的永生代(PermGen Space,HotSpot虚拟机专有)的概念。Full GC和GC是指垃圾回收的停顿类型,如果是”Full”,则说明GC发生了“Stop The World”。一般Minor GC指新生代收集动作,Full GC(或Major GC)指的是老年代收集动作。
第三行及以后:Java堆信息。其中Java堆的分布分为新生代和老生代,新生代中的Eden空间,还有from,to所在的survivor空间。
回到刚才的例子中,从日志中可以看出对象的确被回收过了,因此java虚拟机的垃圾回收并不是依赖引用计数算法,而是使用了可达性分析算法。
2、可达性分析算法
这种算法是如何判断对象是否死亡呢?该算法是通过一系列被 称为“GC Roots”的对象作为树的根节点,然后向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots之间没有任何的引用链时,代表这个对象已不可用,即是被回收的对象。如图中的G没有任何可达GC Roots的路径,则被视为回收对象。
Java中可做为GC Roots的对象有:
1、虚拟机栈(栈帧中的局部变量表)中引用的对象
2、方法区中类静态属性引用的对象
3、方法区中常量引用的对象
4、本地方法栈中JNI(Native方法)引用的对象
引用
由于对象都是依赖引用的,在垃圾回收时都要判断对象是否有对应的引用,那么引用是如何定义的?
在jdk1.2以后分为四种不停强度的引用:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
1、强引用在程序中普遍存在,如String str = new String(),只要强引用还在,垃圾收集器永远不会回收被引用的对象。
2、软引用来描述一些有用但并非必需的对象。这类对象在将发生内存溢出异常之前,会被列入回收范围内进行第二次回收。(SoftReference)
3、弱引用比软引用前度更弱一点,也是描述非必需对象。该类对象的的整个生命周期不超过两次垃圾收集之间的时间,当回收时无论内存是否足够都会回收这些对象。(实现类WeakReference)
4、虚引用无法取得一个对象实例,为一个对象设置一个关联的虚引用的母的是为了在该对象被回收时会收到一个系统通知。(实现类PhantomReference)
标记
一个对象没有任何与GC Roots之间的引用链时,会被视为不可用对象,但在真正死亡前会经历两次标记,在这个期间,对象仍然有可能“自我救赎”,第一次标记发生在对象被视为不可对象时也就是没有引用链时,然后会对这些对象进行一次筛选,筛选的对象可以执行finalize()方法,可以执行的条件是该对象已覆盖finalize()方法并且虚拟机从未调用过该方法。如果可以执行finalize()方法,那么该对象会进入F-Queue队列中,等待虚拟机自动创建的一个低优先级的Finalizer线程去触发该方法,但并不会等待其停止,由于对象可能会执行很慢或者会出现死循环,那么在F-Queue的其他对象就得不到执行方法的机会了。此时是对象避免死亡的最好时机,只要重新与引用链上的对象建立连接即可,例如把自己的一个引用赋给其他变量。那么会避免被第二次标记而被垃圾回收器“真正”的回收。
垃圾回收算法
垃圾回收算法一般有这么几种:
1、标记-清理算法:该算法的原理是先对对象进行标记(前面已经说过,标记过程一般都会进行),然后在这些被标记的对象就是GC时回收的主要目标。但是在清理后在内存中会残留许多的空间碎片,当要为一个比较大的对象进行内存分配时,可能会导致找不到可用的连续内存而导致第二次GC的提前发生。因此这种算法效率不高而且空间使用不彻底。
2、复制算法:该算法是把整个堆内存划分为相等的两块,一块当做保留空间,一块作为回收区域,当进入GC时,虚拟机会将可回收区域的存活对象复制到保留空间,然后对回收区域进行一次清理。
虽然解决了空间碎片问题,但这种算法的真正可用内存变为原来的一半,显然对空间的利用率不高。经某项研究表明,在新生代的对象有98%都是在创建后不久就会死亡的,所以在划分空间时,将内存分为较大的一块Eden空间和两块较小的survivor空间,其中HotSopt默认Eden和survivor的比例是8:1,并且可回收区域占总内存的90%,这样就更有效的利用比较充足的空间内存。当GC时将存活对象复制到只有10%的一块survivor空间,但并不排除该空间不够用的情况,所以“多余”的存活对象就会进入一个被称作可分配担保的额外空间,一般老生代会充当这个角色。
3、标记-整理算法:该算法也是对标记后的对象进行处理,但是处理的方式是将标记的对象进行整理,所谓整理就是将这些对象统一移动到内存中的某一端,然后清除对象边界意外的内存。
4、分代收集算法:该算法是根据对象的年龄来选择其他的收集方式的,新生代对象存活率低,一般采用复制算法,老生代对象存活率高并且没有分配担保,所以一般采用标记-清除或标记-整理算法。
垃圾收集器
上面主要说了垃圾回收方式,那么这些方式是怎么实现的呢,这正是垃圾收集器的工作,垃圾收集器一般都会根据作用的范围(新生或老生代)、使用场景以及各自的特性通过自定义参数来进行组合使用。
主要的垃圾收集器有这么几种:
1、Serial收集器:这是最早的收集器(jdk1.3.1之前),它是一个单线程收集器,并且在回收时必须暂停其他的用户线程直至回收完毕才能继续执行其他线程,这个过程叫做GC停顿,GC停顿是为了使得在可行性分析时避免对象与GC Roots之间的引用关系不断的变化而导致回收错误的情景出现,这种现象也被称作“Stop The World”,然而这种现象的出现使得Serial收集器工作时会带来比较差的用户体验,因为每隔一段时间就会停顿一会儿,这显然不是我们所能接受的。因此大部分的垃圾收集器的不断改进和优化都是以缩小停顿时间为目标的。
2、Serial Old收集器:Serial收集器主要针对新生代的对象,与它同一类型的主要负责回收老生代对象的就是Serial Old收集器,为Client模式下默认收集器。在Server模式下,可以与Parallel Scavenge收集器组合使用。
3、ParNew收集器:该收集器是Serial收集器的唯一区别就是使用多线程进行垃圾回收,但在单CPU下,并不比Serial更加有效。
4、Parallel Scavenge收集器:是一个新生代收集器,该收集器的侧重方向不是停顿时间而是达到一个可控制的吞吐量,吞吐量指CPU运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = u / (u+g),其中“u”指CPU运行用户代码的时间,“g”指垃圾回收时间)。一般停顿时间越短就有了较好的响应速度一般适用于与用户交互的程序;高吞吐量可以较高利用CPU时间,更快的完成一个任务一般用于后台运算。
5、Parallel Old收集器:这是Parallel Scavenge收集器的老年代版本,jdk1.6出现。
6、CMS收集器:该收集器在缩短停顿时间上做的几乎最好,一般用于B/S系统的Server端,基于标记-清除算法实现,而且可以进行并发处理(并发指的是垃圾收集线程和用户线程可以同时进行;还有一个是并行,指的是多个垃圾回收线程并行工作,而用户线程等待)
回收过程可分为四个步骤:①初始标记、②并发标记、③重新标记、④并发清除。其中初始标记和重新标记这两个步骤需要GC停顿,初始标记时只是标记直接与GC Roots连接的对象;并发标记是GC Roots继续向下标记;重新标记主要修改并发标记期间由于用户线程运行而使被标记的对象产生变化后的这些标记记录。
优点是并发收集和低停顿,但也存在缺点:
(1)对CPU资源非常敏感
(2)无法处理浮动垃圾,可能会出现“Concurrent Mode Failure”而导致另一次Full GC的产生。由于CMS收集器是并发处理的,所以当GC完成后,还会遗留一些伴随GC时用户线程产生的新垃圾,这些垃圾称为“浮动垃圾”,这是会临时启用Serial Old收集器来重新进行回收老生代垃圾。
(3)CMS收集器在垃圾回收后会产生空间碎片,当空间无法分配时会提前触发Full GC,但CMS可以通过设置-XX:CMSFullGCsBeforeCompaction参数,指在发生多少次不带压缩的Full GC后便执行一次带压缩的(压缩指进行一次碎片整理)。
7、G1收集器:正式发行商用版是jdk1.7,主要用于server端,它还有这么几个特点:
(1)并行和并发:使用多个CPU缩短停顿时间,并能与用户线程同时进行
(2)分代收集:采用不同方式处理新生对象和老生对象。
(3)空间整合:其中一块Region是基于复制算法,整个堆可视为标记-整理算法。不会产生空间碎片。
(4)可预测的停顿:可指定某个时间片的最大垃圾收集时间。
与其他收集器将Java堆分为新生代和老生代不同的是G1收集器将整个堆分为多个大小相等的Region,G1通过比较每个Region的垃圾回收价值维护一个优先级列表,回收时会优先于价值最大的区域,进一步提高效率。
与CMS收集器相同也会经历初始标记和并发标记两个过程,最终标记也是修改改变的标记记录,但会将这些记录保存在Remembered Set Logs中,最后将这些数据合并到Remembered Set中,最后是筛选回收阶段,根据各个Region的回收价值进行回收。每个Region都有对应的Remember Set,用于记录一些引用的信息,避免GC扫描时进行全堆扫描而降低效率。
内存分配策略
分配策略一般为一下几点:
(1)大部分优先分配在Eden空间。
(2)需要大量连续内存空间的对象直接进入老生代。
(3)生命期长的对象进入到老生代。
那么新、老对象是如何定义的?
虚拟机给每个对象那个都定义了一个年龄计数器,如果对象在Eden空间出生并经过第一次Minor GC还能存活,并且能被Survivor容纳,将会被移动到Survivor空间,此时年龄值为1,然后每次经历一次Minor GC年龄都会增加1岁,如果达到年龄阈值(可设置参数,默认15),则会进入老生代,还有一种情况,如果在survivor空间的相同年龄的所有对象大小总和超过此空间的一半时,年龄大于或等于该年龄的对象也会进入老生代。
小结
总的来说Java中的内存管理还是挺复杂的。主要包括了:
(1)内存的中分为不同的区域对运行时数据进行管理;
(2)一个对象创建的过程以及在内存中如何存储;
(3)如何判断堆中的对象是否已经死亡;
(4)回收对象所用的各种垃圾收集算法;
(5)各种垃圾收集器的工作方式以及优缺点等等。
当然还要了解的是虚拟机配置的各种参数以及在使用垃圾回收器时如何组合会使程序性能更加优化等都是需要研究的目标。