Java虚拟机学习笔记
走进Java
自动内存管理机制
在Java虚拟机自动内存管理机制的帮助下,Java程序运行不容易出现内存泄漏和内存溢出问题,但是一旦出现问题,如果不了解Java虚拟机的自动内存管理机制,排查错误将会很艰难
运行时数据区域
Java虚拟机在执行Java程序会将所管理的内存划分成若干个不同的数据区域
- 程序计数器:线程私有的内存,看作当前线程所执行的字节码的行号指示器,用于选取下一条需要执行的字节码指令,每个线程有独立的程序计数器用于线程切换后能恢复到正确的执行位置
- Java虚拟机栈:线程私有,生命周期与线程相同。存储Java方法执行的内存模型,每个Java方法执行的同时会创建一个栈帧(Stack Frame)用于存储方法的局部变量表、操作数栈、动态链接、方法出口等信息。局部变量区存储了各种基本数据类型、对象引用(reference)和returnAddress类型。这个区域有两个异常,StackOverflowError异常:线程请求的栈深度大于虚拟机运行的深度,OutOfMemoryError异常:虚拟机栈动态拓展无法申请到足够内存
- 本地方法栈:线程私有,与虚拟机栈类似,只是执行的方法类别不同,虚拟机栈执行Java方法服务,本地方法栈执行Native方法服务,Native方法对编程语言、使用方法、数据结构没有规定,抛出异常与虚拟机栈相同
- Java堆:所有线程共享的大内存,所有对象实例以及数组都要在堆上分配内存存储。Java堆也称作GC堆(Garbage Collected Heap),用于管理垃圾收集器。若堆中内存没有完成实例分配且堆无法扩展,则会抛出OutOfMemoryError异常
- 方法区:所有线程共享的内存区域,用于存储被虚拟机加载的类信息、常量、静态变量等数据,也称作“永久代”,区域的垃圾收集机制较为宽松。方法区无法满足内存分配需求时将抛出OutOfMemoryError异常
- 运行时常量池:方法区的一部分,用于存放Class文件编译期生成的各种字面量和符号引用
具体每个数据区的作用可总结如下:
Java对象创建
以HotSpot虚拟机在Java堆中对象分配、布局、访问为例
- 检查类加载:虚拟机遇到new指令,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化过,如果没有则必须执行相应的类加载过程
- 分配内存:类加载检查后,虚拟机为新生对象分配内存,即从Java堆中将一块确定大小的内存划分出来存储对象,划分的方法有指针碰撞(Java堆内存规整)和空闲列表(Java堆内存已使用与空闲内存交错)
- 初始化零值:虚拟机将分配到的内存空间均初始化为零值
- 设置对象:主要对对象的对象头(Object Header)进行相应设置
- 初始化对象:执行对象的init方法进行初始化对象,让对象可用
Java对象模型
对象被存储在堆内存中,由以下形式存储
对象头:占有2个机器码(数组对象为3个字节码)
- 第一个机器码-Mark Word:非固定的数据结构,存储对象自身运行的数据,如HashCode、GC分代年龄、锁状态标识、锁标志位、偏向线程ID等
- 第二个机器码-类型指针:指向存储在方法区的对象类型数据,JVM通过这个指针确定对象是哪个类的实例
- (第三个机器码-数组长度:记录数组长度)
实例数据:存放类的属性数据信息,即程序代码中定义的各种类型的字段内容
- 对齐填充:JVM要求对象起始地址必须为8字节整数倍,用于填充多余空间字段
Java对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象,引用访问对象的方式主流有两种
句柄访问:Java堆中分配出一部分内存作为句柄池,reference指向对象的句柄地址,句柄中包含了对象实例数据(Java堆)与类型数据(方法区)各自的地址
直接指针访问:reference直接存储对象实例地址,对象实例中存放对象类型数据地址
两种访问方式优点:
- 句柄访问对象被移动时(如垃圾收集)只需要改变句柄中的实例数据指针,而栈的本地变量的reference本身不需要改变
- 直接指针访问优点是访问速度快,节省了一次指针定位时间开销
垃圾回收与内存分配策略
GC与内存分配概述
为何需要学习?:当需要排查内存溢出、内存泄漏问题,当GC成为系统达到更高并发量的瓶颈时,我们需要对GC与内存分配进行监控和调节
哪些内存需要回收?:Java堆和方法区的内存分配与回收是动态的(由程序运行过程决定),因此需要回收。而程序计数器、虚拟机栈、本地方法栈中的内存随着方法或者线程结束会自己回收,不需要过多考虑内存动态回收。
判断对象是否还在使用
垃圾收集器在对堆内存进行回收时,需要先判断堆内的对象是否还在使用(有途径引用对象),有以下算法:
引用计数算法:给堆内对象添加引用计数器,若有引用指向它就加1,否则减1,为0则回收,但是Java虚拟机中没有选择此算法管理内存,原因它不能解决对象之间相互循环引用的问题
可达性分析算法:通过图论的观点,GC Roots对象作为起点,路径表示引用链,堆内对象为节点,当一个对象到GC Roots对象间无法通过引用链相连,即为不可达状态,则表示这个对象不可用,GC会判定这个对象可回收
可作为GC Roots对象包括
- 虚拟机栈(栈帧中的本地变量表)的引用指向的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即Native方法)引用的对象
引用类型
根据引用强度和垃圾回收力度,存在四种引用强度逐渐降低、垃圾回收力度逐渐增大的引用类型
- 强引用:程序员直接申明的、类似”Object obj=new Object()”的引用,垃圾收集器永远不会回收强引用对象
- 软引用:SoftReference类实现,在系统将要发生内存溢出会回收软引用关联的对象
- 弱引用:WeakReference类实现,下一次垃圾收集发生时,无论内存是否足够都会回收只被弱引用关联的对象
- 虚引用:PhantomReference类实现,不影响关联对象的垃圾回收,无法通过虚引用取得关联对象的实例,主要目的用于关联对象被回收时收到一个系统通知
方法区判断对象是否可用
方法区的垃圾回收机制性价比较低,永久代的垃圾回收主要涉及两部分,废弃常量和无用的类
废弃常量:栈中没有任何引用关联常量池中的常量即为废弃常量
无用的类:即无用的对象类型数据,需要同时满足三个条件
- Java堆中不存在该类的任何实例,即类的所有实例已被回收
- 加载该类的ClassLoader已被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,即无法通过反射访问该类方法
垃圾收集算法
标记-清除法:分为标记和清除两个阶段,首先从内存的根集合进行扫描,对需要回收的对象进行标记,在标记完成后统一回收所有标记对象
算法不足点有两种:
- 效率问题:标记和清除的效率均不高
- 空间问题:不需要进行对象的移动,仅对需要回收的对象进行处理,导致产生大量不连续的内存碎片
复制算法:将内存划分成容量大小相等的两块(也许是容量不等的多块),每次只使用其中的一块(也许使用多块,保留多块),可称为使用块与保留块,当使用块内存用完后,扫描内存块将还在使用的对象按次序复制到保留块上,再把使用块内存清空
优点:内存分配完整,只要移动堆顶指针按顺序分配内存即可,效率高,实现简单
不足:内存缩小
适用环境:对象存活率较低的内存环境,这样复制时效率高,如新生代中
标记-整理算法:与标记-清除算法相似,不过后续步骤不是对标记的可回收对象进行清理,而是让所有可使用的对象都向一端移动,形成连续的内存
分代收集算法:一般根据对象的存货周期的不同将内存划分为几块,如将Java堆内存分为新生代和老年代,这样可以根据年代的特点选择合适的收集算法
新生代对象存活率低,选择复制算法,只需付出少量复制成本就可以完成垃圾回收
老年代对象存活率高,则使用标记-清除或者标记-整理算法
发起回收
- 枚举GC Roots根节点:使用准确式GC,即当执行系统停顿时,并不需要遍历所有执行上下文和全局的引用位置,而是通过一组称为OopMap的数据结构在类加载完成、JFT编译时,会记录下栈和寄存器中哪些位置是引用,这样GC扫描时这些引用关联的对象就是GC Roots根节点
- 安全点(Safepoint):为每条指令均生成OopMap空间成本太高,一般是在安全点生成,安全点即是能让程序长时间执行的指令块(指令序列复用),如方法调用、循环跳转、异常跳转等,均可以产生安全点
- 多线程安全点:一般采用主动式中断,即发生GC时,不直接对线程进行操作,而是设置一个标志,让所有线程去轮询标志,若轮询到标志为真时就会触发线程中断
- 安全区域(Safe Region):对于不执行的程序,如处于Seep状态和Blocked状态的线程,是无法轮询无法响应JVM的中断请求的,对于这种情况需要使用安全区域解决。安全区域保证代码片段中引用关系不会变化,因此在安全区域中任何地方开始GC都是安全的
垃圾收集器
垃圾收集器是内存回收算法的具体实现,我们通过JDK 1.7的HotSpot虚拟机的垃圾回收器感受一下
共有七种垃圾收集器,3种用于新生代,3中用于老年代,G1用于整个Java堆,连线表示可以搭配使用,我们简单介绍其中5种,重点分析CMS和G1
新生代垃圾收集器(复制算法)
- Serial收集器:单线程,GC时会暂停其他工作线程,简单高效,Client模式虚拟机首选的新生代收集器
- ParNew收集器:并行(Parallel)多线程收集器,是Serial收集器的多线程版本,在多核CPU环境下GC有更好表现,Server模式虚拟机首选的新生代收集器
- Parallel Scavenge收集器:并行多线程收集器,侧重点不在缩短GC时用户线程停顿时间,而在提高吞吐量(运行用户代码时间/(运行用户代码时间+GC时间)),追求高效率利用CPU时间,尽快完成运算任务,适合在后台运算而不需太多交互的任务
老年代垃圾收集器
Serial Old收集器(标记-整理算法):单线程,Serial的老年代版本,Client模式虚拟机首选
Parallel Old收集器(标记-整理算法):多线程,Parallel Scavenge收集器的老年代版本,与其组合,使用在注重吞吐量及CPU资源敏感的场合
CMS(Concurrent Mark Sweep)(标记-清除算法):并行多线程收集器,主要用于Java Web服务端,以获取最短回收停顿时间为目标 ,具有高并发、低停顿的特点
CMS的GC过程主要有四阶段
- 初始标记:中断其他线程,标记GC Roots能直接关联的对象
- 并发标记:并行其他线程,对初始标记的对象进行Tracing
- 重新标记:中断其他线程,修正并发标记期间程序运作导致标记产生变动的标记
- 并发清理:并行其他线程,对没有标记的对象进行回收
CMS有明显三个不足:
- CPU资源敏感:并发阶段因为占用一部分线程(CPU资源)导致应用程序变慢,吞吐量降低
- 无法处理浮动垃圾:并发清理阶段并行的其他用户线程产生的垃圾CMS无法处理
- 空间碎片多:由于CMS采取标记-清除算法,会有大量空间碎片产生
Java堆垃圾收集器
- G1收集器:并行收集器,主要面向服务端应用,主要有以下特点
- 采用标记-整理算法,避免产生碎片
- 可预测的停顿:让使用者通过指定一个参数来控制在一个长度为M的时间片内垃圾回收的时间不超过N
- 独立区域收集:避免全区域、新生代、老年代的GC,G1将整个Java堆划分成多个大小相等的独立区域,进行根据优先级的分批次的独立区域GC,因此可以做到基本不牺牲吞吐量完成GC
- G1收集器:并行收集器,主要面向服务端应用,主要有以下特点
GC日志格式
1
33.125: [Full GC [DefNew: 3324K->152K(3712K),0.0149142 secs ] 3324K->152K(3712K), 0.0031680 secs]
33.125:GC发生时间,从Java虚拟机启动以来经历的秒数
Full GC:垃圾收集停顿类型,Full表示Stop-The-World
DefNew:GC发生的区域,Serial收集器收集新生代为DefNew,ParNew收集器收集新生代为ParNew
3324K->152K(3712K):GC前区域使用容量->GC后区域使用容量(区域总容量)
0.0149142 secs:GC此区域所占用时间
] 3324K->152K(3712K):括号外表示整个Java堆的情况
0.0031680 secs:整个Java堆的GC占用时间