对于Java程序员来说,虚拟机和多线程方面的知识是必不可少的。这里就来聊一聊Java虚拟机的一些基础和概念,主要内容源自《深入理解Java虚拟机》这本书。
首先为什么要有虚拟机呢?因为对象的创建和销毁是一个很频繁的操作,由程序员来维护,一方面成本有点高,增加开发成本;另一方面,如果操作不当,发生了内存泄露,要自己去调试代码,找出原因。所以虚拟机的这种机制的诞生可以说是程序员的福音,解放了生产力。但凡事有利也有弊,虚拟机的引入使得Java在性能方面跟C++比还是有一定差距,像网络游戏或者数据库这种对性能要求比较高的应用,都会选择用C/C++来开发。同时,虽然我们有了虚拟机,但还是要对它的运行机制和性能调优有所了解,不然万一发生java内存泄露,就会无从下手了。
Java内存区域
图中的五大数据区域可以分为两类:1.由所有线程共享的数据区域 2. 线程隔离的数据区
第一类:方法区,堆
第二类:虚拟机栈,本地方法栈,程序计数器
(图片源自博客http://blog.sina.com.cn/s/blog_ed30769e0102v233.html)
程序计数器(Program Counter Register)
可以把它看作是当前线程所执行的字节码的行号指示器。我们通过改变这个计数器的值来选取下一个需要执行的字节码指令。每个线程都有一个独立的程序计数器。如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器值为空。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈(Java Virtual Machine Stacks)
它描述的是Java方法执行的内存模型:每个方法被执行都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。局部变量表所需的内存空间是在编译期间完成分配的。
这个区域规定了两种异常:
1.如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
2.内存动态扩展时,若无法申请到足够的内存时会抛出OutOfMemoryError异常。
本地方法栈(Native Method Stacks)
此方法区与Java虚拟机栈类似,区别在于它是为虚拟机使用到的Native方法服务。有的虚拟机(如Sun HotSpot虚拟机)直接把它们合二为一了。
与虚拟机栈一样,它也会抛出 StackOverflowError和OutOfMemoryError异常。
Java堆(Java Heap)
所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
可以通过-Xmx和-Xms的设置来控制堆内存大小。
如果堆无法扩展时,将会抛出OutOfMemoryError异常。
方法区(Method Area)
它也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java虚拟机对这个区域的限制非常宽松。垃圾收集行为在这个区域是比较少出现的;这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池(Runtime Constant Pool)
它是方法区的一部分; 用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行期间也可能将新的常量放入池中,比如说使用String类的intern()方法。
无法申请到内存时会抛出OutOfMemoryError异常。
直接内存(Direct Memory)
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中的内存区域,但这部分内存被频繁地使用,也可能会导致OutOfMemoryError异常出现。
在JDK1.4中加入了NIO,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过存储在堆里面的DirectByteBuffer对象作为这块内存的引用操作。
对象访问
Object obj = new Object();
虚拟机栈:会在本地变量表中存储一个对象引用
堆:存储了Object类型所有实例数据值
方法区:存储对象的类信息
不同的虚拟机的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。
句柄访问方式
堆会划分出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息
好处:引用中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而引用本身不需要修改。
直接指针访问方式( Sun HotSpot采用的方式)
堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,引用中直接存储的就是对象地址。
好处:速度更快,节省了一次指针定位的时间开销。
OutOfMemory异常
堆溢出:通过-XX:+HeapDumpOnOutOfMemoryError可以在出现内存溢出时Dump出当前的内存堆转储快照以便事后进行分析。
虚拟机栈和本地方法栈溢出:通过-Xss设置栈容量。一般栈深度可以到达1000-2000。栈容量过大,多线程时容易耗尽内存,因为单个线程耗内存比较多。
运行时常量池溢出:常见的PermGen issue。Java8对方法区做了调整,所以这个问题不会再出现了。
方法区溢出:往往出现于动态生成大量Class的应用中。
本机直接内存溢出:可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与堆的最大值一样。
对象回收
如何确定对象已经死去,可以采取回收了呢?
引用计数法(Python)
非常直观的一种方法,增加一处引用时加一,减少一处引用时减一。
但它的一个主要缺陷是很难解决对象之间的循环依赖问题。
根搜索算法(Java,C#,Lisp)
基本思路:通过一系列GC roots对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC roots没有任何引用链相连时,则证明此对象是不可达的,作为回收对象。
GC roots对象包括下面几种:
1.虚拟机栈(栈帧中的本地变量表)中的引用的对象
2.方法区中的类静态属性引用的对象
3.方法区中的常量引用的对象
4.本地方法栈中JNI的引用的对象
在JDK1.2之后,Java扩充了引用的概念,分为四种:
1.强引用(strong reference):一般引用,有引用,不会回收对象。
2.软引用(soft reference):系统将要发生溢出时,会把它们引用的对象列入回收范围进行一次回收。若内存还是不够,才抛异常。
3.弱引用(weak reference):被引用的对象只能活到下一次垃圾收集发生之前。
4.虚引用(phantom reference):一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。设置此引用得目标就是为了能在这个对象被回收时收到一个系统通知。
宣告一个对象死亡,至少要经过两次标记过程
如果一个对象在根搜索后,没有关联得引用链,它将会被第一次标记并且进行一次筛选,筛选得条件是此对象是否有必要执行finalize()方法。
如果没有覆盖此方法或者方法已被虚拟机调用过,则视为“没有必要执行”。
如果需要执行finalize()方法,则会被放置到一个F-Quenue队列,由低优先级得Finalizer线程去执行。执行此方法,但并不保证运行结束(为了运行效率考虑)。
稍后会对F-Queue进行第二次小规模得标记,只要在finalize方法中与引用链上得任意一个对象产生关联就会在此次标记时被移除“即将回收”的集合。
回收方法区
在堆中,尤其是新生代中,一次垃圾回收可以回收70%~95%的空间,而方法区的垃圾收集效率远低于此。
方法区的垃圾回收主要内容:废弃常量和无用的类。
类需要满足下面3个条件才能算是“无用的类”,可以进行回收:
1.该类所有的实例已经被回收
2.加载该类的ClassLoader已经被回收
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
是否对类进行回收,由参数-Xnoclassgc进行控制
垃圾收集算法
标记-清除算法
算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
缺点:
1.效率问题,标记和清除过程的效率都不高
2.空间问题,会产生大量不连续的内存碎片。如果发现不能分配内存给大对象时,不得不再触发一次垃圾回收
复制算法
将可使用的内存按容量分为两块,每次只使用其中的一块。
经过实验发现,新生代中的对象98%是朝生夕死的,所以内存可以分配为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor空间;回收内存时,将还存活的对象拷贝到另外一块Survivor空间上,然后清理掉另外两块的空间。
Sun HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。当Survivor空间不够时,需要依赖老年代进行分配担保,若有担保直接分配进老年代。
标记-整理算法
复制算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。所以老年代一般不能直接选用这种算法。
标记-整理算法在标记后,让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
垃圾收集器
Serial收集器
历史最悠久的收集器,在JDK1.3.1之前是虚拟机新生代收集唯一的选择。单线程的收集器。
优点:简单高效
缺点:收集时必须暂停其他所有的工作线程(stop the world)。
使用场景:虚拟机运行在Client模式下默认新生代收集器。
ParNew收集器
它是Serial收集器的多线程版本。默认开启的收集线程数与CPU的数量相同。JDK1.4引入。
除了Serial收集器外,只有它能与CMS收集器配合工作。
使用场景:运行在Server模式下的虚拟机首选的新生代收集器。
Parallel Scavenge收集器
目标:达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。适合在后台运算而不需要太多交互的任务。
-XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间
-XX:GCTimeRatio用来设置吞吐量大小,默认99
-XX:+UserAdaptiveSizePolicy, 开启GC自适应的调节策略。
使用场景:新生代收集器
Serial Old收集器
它是Serial收集器的老年代版本,使用标记整理算法。
使用场景:Client模式下的老年代。
Parallel Old收集器
它是Parallel Scavenge收集器的老年代版本。JDK1.6引入。
注重吞吐率的场合,可以考虑采用Parallel Scavenge加Parallel Old收集器。
CMS收集器
它是一种以获取最短回收停顿时间为目标的收集器,并发执行。标记清除算法:初始标记,并发标记,重新标记,并发清除。其中初始标记和重新标记仍需stop the world。
缺点:
1.对CPU资源非常敏感。默认回收线程数是(CPU数量+3)/ 4。
2.无法处理浮动垃圾,可能出现“concurrent mode failure”失败而导致另一次full gc的产生。要是在运行收集期间,预留的内存无法满足程序需要,就会出现“concurrent mode failure”失败,这时会临时采用serial old收集器来进行老年代的垃圾收集,这样停顿时间会变长。
3.收集结束时会产生大量碎片,容易因无法分配大对象,而触发full gc。可以启用参数-XX:+UseCMSCompactAtFullCollection来享受full gc后来一次碎片整理。另外有-XX:CMSFullGCsBeforeCompaction这个参数来设置执行了多少次不压缩的full gc后,进行一次带压缩的。
G1收集器
目标是替代CMS收集器。可以分代收集,不会产生碎片。有分区(region)的概念,优先回收垃圾最多的区域。通过remenbered set来避免全堆扫描。
收集可分为四个步骤:初始标记,并发标记,最终标记,筛选回收。
内存分配与回收策略
对象优先在Eden分配
当Eden区没有足够空间时,会触发一次minor gc。虚拟机提供-XX:+PrintGCDetails这个参数来打印内存回收日志以及进程退出时当前内存各区域的分配情况。
大对象直接进入老年代
这样做的目的时避免Eden区里面发生大量的内存拷贝。可通过-XX:PretenureSizeThreshold参数设置多大的对象进入老年代。
长期存活的对象将进入老年代
熬过15次minor gc后,到达15岁,就会被晋升到老年代。关于这个年龄,可以通过-XX:MaxTenuringThreshold来设置。
动态对象年龄判定
为了适应不同程序的内存情况,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
空间分配担保
在发生minor gc时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小。
如果大于,则改为进行一次 full gc。
如果小于,先查看HandlePromotionFailure设置(JDK1.6默认开启)是否允许担保失败。如果允许,进行minor gc;如果不允许,还是要 full gc。
如果最后发生担保失败,还是要重新发起一次full gc。