《深入理解Java虚拟机》读书笔记
一、java虚拟机内存区域划分
java虚拟机在执行java程序时会将管理的内存划分成几个区域
1、程序计数器
是当前线程所执行的字节码的行号指示器
线程隔离的数据区,线程私有的内存,生命周期与线程相同
较小的内存区域,是当前线程执行的字节码的行号指示器,用于支持分支、循环、跳转、异常处理、线程恢复
如果执行的是Native的java方法,计数器为空,因为Native方法是java通过JNI直接调用本地c/c++库的接口,不会产生java相关的字节码,内存也不由jvm决定(在c内存模型上分配)
唯一一个在java虚拟机规范中没有规定OutOfMemoryError的区域,因为当线程执行到下一条指令的时候,只会改变当前程序计数器中保存的地址,不用申请新的内存来保存,不会内存溢出
2、java虚拟机栈
线程隔离的数据区,线程私有的内存,生命周期与线程相同
每个方法在执行的同时都会创建一个栈帧,用于储存局部变量表、操作数栈、动态链接、方法出口等信息
方法调用直至完成对应着栈帧的入栈出栈(如果方法中再调用方法,遵循先进后出)
如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError异常
如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,会抛出OutOfMemoryError异常
3、本地方法栈
和java虚拟机栈类似,本地方法栈为虚拟机使用到的Native方法服务
区别是当线程调用的是Native方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的Native方法
4、java堆
所有线程共享的内存区域,虚拟机启动时创建,生命周期和虚拟机进程一致
几乎所有的对象都在堆上分配内存,是垃圾回收的主要场所
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
5、方法区
所有线程共享的内存区域,虚拟机启动时创建,生命周期和虚拟机进程一致
储存已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码数据等
当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError异常
jdk1.7之前hotspot把方法区实现为永久代,和堆一样在虚拟机内存中
jdk1.7时hotspot把永久代中的字符串常量池移到了堆中
jdk1.8时hotspot把方法区实现为元数据,在本地内存中,字符串常量池还在堆中
二、垃圾收集
1、判断对象是否存活
(1)引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,引用失效时计数器减1,计数器为0就判断对象不被使用。
如果有两个不被使用的对象互相引用了,引用计数器算法就无法通知GC收集器回收他们
(2)可达性分析算法
把虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI引用的对象作为GC Roots,当一个对象到GC Roots没有任何引用链时,判断这个对象是不可用的
java、C#等主流实现都是通过可达性分析算法
2、判断对象是否要被回收
经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots存在引用链时,被第一次标记,如果对象有必要执行finalize(),会被放在F-Queue队列中等待虚拟机自动建立的低优先级Finalizer线程去执行触发它的方法,如果对象在finalize中没有产生GC Roots的引用链,虚拟机会对这个对象进行第二次标记,确定回收对象
finalize()只能被执行一次,如果之前逃过一次回收的对象再次面临回收就没有机会了
3、垃圾收集算法
(1)标记-清除算法
首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象
缺点是标记和清除效率不高,清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致需要分配大对象时无法找到足够的连续内存,而提前触发另一次垃圾收集动作
(2)复制算法
将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次性清理掉
实现简单运行高效,但是可用内存只有原来的一半
现在的商业虚拟机都采用这种收集算法来回收新生代,HotSpot将新生代分为Eden、Survivor1和Survivor2三块,默认比例是8:1:1,第一次回收将存活对象存进Survivor1,下一次回收将存活对象存进Survivor2,交替进行
(3)标记-整理算法
在老年代中,如果还使用复制算法在对象存活率较高的时候就要进行较多的复制,会降低效率,而且没有额外空间可以分配担保,来应对所有对象都存活的情况
所以老年代一般使用标记-整理算法,类似标记-清除,不过在标记后不是直接对可回收对象进行清理,而是将存活的对象都向一端移动,然后直接清理掉另一端的内存
(4)分代收集算法
一种垃圾收集的方法,把java堆分为新生代和老年代,新生代因为每次垃圾收集只有少量对象存活,使用复制算法,老年代因为存活率高、没有额外空间进行分配蛋堡,使用标记-清理或标记-整理算法,当前的商业虚拟机的垃圾收集都采用这种方法
4、垃圾收集器
新生代垃圾收集器:Serial、ParNew、Parallel Scavenge
老年代垃圾收集器:Serial Old、Parallel Old、CMS收集器
全代垃圾收集器:G1收集器
(1)Serial
复制算法,新生代收集器,中断,单线程,stop the world,先只作为Client模式下的默认新生代收集器
(2)ParNew
复制算法,新生代收集器,中断,Serial的多线程版本,在使用CMS作为老年代收集器时的默认选项,因为CMS只能与Serial和ParNew这2个新生代收集器搭配
(3)Parallel Scavenge
复制算法,新生代收集器,中断,和ParNew很像,但是更加关注吞吐率,提供2个参数用于精确控制吞吐量大小和停顿时间,还有UseAdaptiveSizePolicy开关可以打开GC自适应调节,自动调节Xmn大小、Eden和Survivor比例、晋升老年代对象大小等
(4)Serial Old
标记整理算法,老年代收集器,中断,单线程,主要在Client模式下使用,在jdk1.5之前与Parallel Scavenge搭配(因为它不能搭配另外2个),作为CMS的后备预案(在CMS运行GC时内存不足后会被虚拟机启动)
(5)Parallel Old
标记整理算法,老年代收集器,中断,多线程,解决了jdk1.6之前Parallel Scavenge只能搭配Serial Old的窘境,和Parallel Scavenge组成吞吐率组合,充分利用服务器多cpu的能力
(6)CMS
标记清除算法,老年代收集器,多线程,初始标记(中断,很快),并发标记(并发),重行标记(中断,很快),并发清除(并发)
优点是并发收集和低停顿
缺点是:
1、当cpu数量不足4个时虎占用不少cpu资源
2、无法处理标记后出现的垃圾,如果内存满了会出现"Concurrent Mode Failure"失败,触发Serial Old预案,停顿时间拉长
3、标记清除算法会产生空间碎片,在无法分配大对象时会默认触发一次碎片合并整理,也会延长停顿时间
(7)G1
整体是标记整理算法,多线程,不在物理上区分新生代和老年代,而是划分为多个大小相等的独立区域,不在java堆中进行全区域的垃圾回收,而是维护一个优先列表,优先回收价值最大的区域,Garbage-First,因为公司还在用jdk1.8,这里先不仔细研究了
(8)总结
目前主要搭配是3种:
Parallel Scavenge+Parallel Old(jdk1.7、1.8默认),吞吐量优先
ParNew+CMS,响应时间优先
G1(jdk1.9默认),响应时间优先
看了毕玄的《为什么不建议<=3G的情况下使用CMS GC》以后知道了
在heap size<=3G的情况下不要用CMS,因为小空间下CMS会触发很多问题
在3G<heap size<8G的情况考虑用CMS但还是以Parallel Old为主
在heap size>8G后用CMS,因为Parallel Old会延时太长
5、垃圾收集器参数总结
UseSerialGC:虚拟机运行在Client模式下的默认值,打开后使用Serial+Serial Old的组合
UseParNewGC:打开后使用ParNew+Serial Old的组合
UseCon从MarkSweepGC:打开后使用ParNew+CMS的组合,Serial Old作为CMS出现Concurrent Mode Failure失败后的补救
UseParallelGC:虚拟机在Server模式下的默认值,打开后使用Parallel Scavenge+Serial Old的组合
UseParallelOldGC:打开后使用Parallel Scavenge+Parallel Old的组合
SurvivorRatio:新生代中Eden与Survivor的比例,默认为8,表示新生代Eden与2个存活区的比例为8:1:1
PretenureSizeThreshold:直接晋升到老年代的对象大小,大于这个参数的对象将直接在老年代分配,默认值是0,不管多大都先在Eden中分配
MaxTenuringThreshold:晋升到老年代的年龄,大于这个参数的对象在n次Minor GC后进入老年代,这个参数用4bit存放,最大可以配15,Parallel Scavenge和G1默认为15,CMS默认为6,第一次晋升以这个为准,之后会动态年龄判定,如果在存活区中相同年龄所有对象大小的总和大于存活区的一半,年龄大于或等于该年龄的对象可以直接进入老年代(如果没有动态年龄判定的话,所有对象要经过默认15次Minor GC才进入老年代,Eden区或者存活区可能早就放不下了)
UseAdaptiveSizePolicy:动态调整java堆中各个区域的大小和进入老年代的年龄
HandlePromotionFailure:是否允许分配担保失败(后面会说,已废弃)
ParallelGCThreads:设置并行GC时进行内存回收的线程数,默认值cpu8个以内和cpu数相同,大于8个后为cpu数的5/8
GCTimeRatio:GC时间占总时间的比率,默认为99%,即允许1%的GC时间,仅在Parallel Scavenge时生效
MaxGCPauseMillis:设置GC的最大停顿时间,仅在Parallel Scavenge时生效
CMSInitiatingOccupancyFraction:设置CMS在老年代空间被使用多少后触发,默认68%
UseCMSCompactAtFullCollection:设置CMS在完成垃圾收集后是否要进行一次内存碎片整理,默认是开启
CMSFullGCsBeforeCompaction:设置CMS在多少次垃圾收集后启动一次内存碎片整理,默认是0,就是每次
关于UseCMSCompactAtFullCollection和CMSFullGCsBeforeCompaction:
CMS是否要在Full GC后进行内存碎片整理依赖3个条件:
1、UseCMSCompactAtFullCollection和CMSFullGCsBeforeCompaction主要看后者,满足次数后强制进行
2、用户调用了System.gc(),而且DisableExplicitGC没有开启时
3、新生代报告说老年代将无法容纳下一次晋升的对象时
所以CMSFullGCsBeforeCompaction参数是用来降低Full GC后进行内存碎片整理的频率的,配置后可以减少每次Full GC的时间,但是加大了碎片化问题
6、内存的分配与回收
(1)新对象的分配
对象优先在新生代Eden区分配,如果配置了PretenureSizeThreshold参数,超过了这个值的新对象直接进入老年代
(2)何时进行Minor GC
当Eden区没有足够空间进行分配时,进行一次Minor GC:
jvm将Eden区和上一个存活区中存活的对象复制到另一个存活区中(符合年龄标准的长期存活的对象进入老年代,参考MaxTenuringThreshold参数和动态对象年龄判定标准),之后清空Eden区和上一个存活区,完成一次Minor GC
(3)何时进行Full GC
关于何时进行Full GC曾经有一个空间分配担保的概念:
(jdk1.6 update24之前)在发生Minor GC之前,jvm会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果小于说明存在失败风险,如果配置了的HandlePromotionFailure为true,表示允许风险,尝试进行一次Minor GC,如果出现了担保失败就会在失败后重新发起一次Full GC(虽然失败的情况会绕圈子,但是大部分情况可以避免Full GC过于频繁),如果配置的为false,表示不允许冒险,直接进行一次Full GC
(jdk1.6 update24之后)废弃了HandlePromotionFailure参数,策略修改为:
1、在YGC执行前,min(取"目前新生代已使用的大小"与"之前平均晋升到old的大小"二者的较小值) > 旧生代剩余空间大小 ? 不执行YGC,直接执行Full GC : 执行YGC
2、在YGC执行后,平均晋升到old的大小 > 旧生代剩余空间大小 ? 触发Full GC : 什么都不做
有些时候可能看到老年代还没满但Full GC执行的状况,就是因为有这种悲观策略,jdk还是希望jvm以一种保险、稳定的状态运行
(4)Parallel GC演示
参考阿里中间件博客《GC悲观策略值Parallel GC篇》演示Parallel GC的内存回收过程
代码:
import java.util.; public class SummaryCase{ public static void main(String[] args) throws Exception{ //循环7次,每次创建1个3MB大小的对象 List caches=new ArrayList(); for(int i=0;i<7;i++){ caches.add(new byte[1024*1024*3]); } //回收对象 caches.clear(); //循环2次,同上每次创建1个3MB大小的对象 for(int i=0;i<2;i++){ caches.add(new byte[1024*1024*3]); } } }
jvm的参数配置为"-Xms30m -Xmx30m -Xmn10m -XX:+UseParallelGC",新生代+老年代30MB,新生代Eden区8MB,2个存活区各1MB,老年代20MB,新生代收集器使用Parallel Scavenge
上面代码的执行过程中eden和old大小的变化状况:
依照这2个策略:
1、在YGC执行前,min(取"目前新生代已使用的大小"与"之前平均晋升到old的大小"二者的较小值) > 旧生代剩余空间大小 ? 不执行YGC,直接执行Full GC : 执行YGC
2、在YGC执行后,平均晋升到old的大小 > 旧生代剩余空间大小 ? 触发Full GC : 什么都不做
进行如下解释:
第一次循环:3MB的对象进入Eden区
第二次循环:同上,Eden区只剩2MB了
第三次循环:第3个3MB的对象来了,无法进入Eden区,需要触发1次Minor GC,存活区只有1MB放不下,跳过存活区,Eden中年龄1岁的2个3MB对象总和大于Eden区的一半4MB,直接进入老年代,所以最后Eden中存在第3个对象3MB,老年代中存在前2个3MB的对象
第四次循环:同上,相安无事,2个Minor GC前后检查策略都没有异样
第五次循环:同第三次循环
第六次循环:同第四次循环
第七次循环:重点来了,第7个3MB的对象来了,又需要触发1次Minor GC,前置检查策略:目前新生代已使用大小6MB与之前平均晋升到老年代的大小6MB的最小值(6MB)还是小于老年代剩余的大小8MB,所以正常执行Minor GC,执行完之后的后置检查策略发现:目前新生代已使用大小3MB与之前平均晋升到老年代的大小6MB的最小值(3MB)已经大于老年代剩余的大小2MB了,所以需要在这次Minor GC后再触发1次Full GC,但是因为这时cache里的对象还没被回收,Full GC后老年代里还是6个对象共18MB
代码中caches.clear()回收之前添加的对象
第八次循环:同第二次循环,因为Eden还够,不需要触发Minor GC,也就没有2次策略检查
第九次循环:需要执行Minor GC了,前置策略发现:目前新生代已使用大小6MB与之前平均晋升到老年代的大小6MB的最小值(6MB)已经大于老年代剩余的大小2MB了,所以这次不执行Minor GC改为直接执行Full GC(由于Full GC又叫全局GC,有时也会对新生代进行回收,比如这次从结果上看是Full GC时触发了内部的Minor GC虽然没有计次和记录时间,然后第7和第8个对象计划晋升到老年代,这时进行回收操作回收了第一组循环的7个对象,最后第8个对象成功进入老年代,第9个新对象则留在了新生代,Full GC次数加1)
Serial GC收集器同条件下执行后GC的结果略有不同,这里不说了,可以看这里GC悲观策略之Serial GC篇
三、类加载
类从被记在到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析3个部分统称为连接
虚拟机有且只有在5种情况必须立即对类进行初始化:
1、遇到new、gtstatic、putstatic或invokestatic这4条字节码指令时(使用new关键字实例化对象的时候、读取或设置一个类的静态字段时、调用类的静态方法时)
2、使用java.lang.reflect包进行类的反射调用时
3、类在初始化时发现父类还没进行过初始化,需要先触发父类的初始化
4、虚拟机启动时会先初始化包含main方法的类
5、java.lang.invoke.MethodHandle实例,动态语言支持相关
1、加载
类加载的过程,先通过类的全限定名来获取定义类的二进制字节流,然后将字节流代表的静态储存结构转化为方法区的运行时数据结构,最后在内存中生成一个代表这个类的class对象
二进制字节流的获取途径很多,有ZIP、JAR、WAR包、网络、运行时计算生成(动态代理)、JSP文件、数据库中等
2、验证
是连接阶段的第一步,包括文件格式验证、元数据验证、字节码验证、符号引用验证
3、准备
为被static修饰过的类变量分配内存并设置初始值(一般是零值,如果被final修饰过就是实际的值)的阶段
4、解析
是虚拟机将常量池内的符号引用替换为直接引用的过程,包括类或接口的解析、字段解析、类方法解析、接口方法解析
5、初始化
初始化变量为设置的值
6、双亲委派
要求除了顶层的启动类加载器以外,其他的类加载器都应当有自己的父类加载器
工作过程是如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,直到传送到顶层的启动类加载器
四、字节码执行
五、并发