第一篇:[JVM入门指南01]内存区域与溢出异常——主要介绍JVM的运行时数据区
第二篇:[JVM入门指南02]GC垃圾回收机制——主要介绍JVM执行引擎的垃圾回收机制
第三篇:[JVM入门指南03]类加载和Android虚拟机——主要介绍JVM类加载机制和JVM的执行引擎
JVM架构
[JVM入门指南01]内存区域与溢出异常
本文将介绍JVM的结构
、对象的创建和分配过程
、内存溢出
。
JVM介绍
java文件执行流程:
- .java文件通过JDK的编译器变成.class文件。
- .class文件和java类库,通过类加载器ClassLoader进入JVM的方法区。
- JVM的执行引擎执行,把字节码翻译机器码,并跟硬件进行交互。
JVM是一种规范,它规范了.class文件与本地硬件交互的一种规范。比如对于c/c++开发者来说,他们拥有了对象的“所有权”,对象的开始和终结需要他们的维护。而相对Java开发者,他们在虚拟机内存管理垃圾回收机制下,就不需要维护对象的终结,JVM帮我们自动回收对象,这使开发者更加便捷,但一旦发生内存泄漏和溢出,如果不熟悉JVM则很难找出问题所在,所以学习JVM能帮助我们快速定位内存泄漏和溢出原因。
JVM的特性
平台无关性:
一套java代码可以在window、linux、mac中运行,因为JVM这个中间平台已经做好了与硬件交互的解释。
语言无关性:
JVM接收的是.class文件,所以无论你是写java语言还是其他语言,只要按照规范生成为.class文件就可以与JVM上运行。
JVM特性JVM的分类
JVM只是一种规范,可以按照规范自定义JVM。本篇文章讲解的是Oracle公司的Hotspot虚拟机。
JVM发展
JVM整体内存结构
JVM的整体内存结构包括:虚拟机栈、程序计数器、堆、方法区、直接内存。
运行时数据区域
虚拟机栈
每启动一个线程都会创建一个新的虚拟机栈
。在线程私有区中包括了虚拟机栈和本地方法栈,但在Hotspot虚拟机中把这两个栈合成一个栈,
作用:
存储当前线程运行java方法的指令、数据、返回地址。
大小限制:
可用-xxs
设置虚拟机栈的大小
栈帧:
它是栈元素,每执行一个方法,就会有相应的一个栈帧进入虚拟机栈。
栈帧结构:
- 局部变量表
局部变量表是一组变量表
存储空间,用于存放方法参数和方法内的局部变量
。在java程序编译为class文件时,就在方法的Code属性的max_locals
数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表以变量槽Slot为最小单位,分别存储了:
- 8大基本数据类型:boolean、byte、char、short、int、float、long、double
- reference:表示对一个对象实例的引用。一、此引用可以直接或间接地查找到对象在Java堆中的数据存放的起始地址索引。二、此引用可以直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
- returnAddress:指向一条字节码指令的地址。
如果执行的是实例方法(非static方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过this
来访问这个引用。
-
操作数栈
在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候通过操作数栈来传参。 -
动态链接
将符号引用指向直接引用 -
返回地址
返回栈帧方法的返回事件。
程序计数器
指向当前线程正在执行的字节码指令的地址。
堆
堆java堆几乎
所有的对象实例都在这里分配内存,java堆不需要连续的内存,既可以固定大小又可以动态扩展大小,是虚拟机中管理内存中最大的一块,是垃圾回收机制重点回收区域。
java堆是线程共享区域,但是有些内存分配也可以设计为多个线程的私有的分配缓冲区TLAB,以提升对象分配的效率。
java堆结构:
-
新生代(占1/3):存放生命周期不长的小对象
Eden空间(占 8/10)
From空间(S0,占 1/10)
To空间(S1,占 1/10) -
老年代(占2/3):存放大对象
方法区
方法区方法区(永久代)是存放已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。方法区不需要连续的内存,既可以固定大小又可以动态扩展大小。垃圾收集器在此区域主要回收常量池和类型(class对象)的卸载。
运行时常量池
运行时常量池运行常量池是在常量池表中存放的编译期生成的各种字面量和符号引用,这部分内容将在
直接内存
直接内存并不是虚拟机运行时的数据区域。在JDK1.4中加入NIO类,引入一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存。然后通过堆里面的DirectByteBuffer对象作为引用进行操作。
JVM结构小结
JVM分为线程私有区和共享区:
私有区中存放着虚拟机栈
和程序计数器
。虚拟机栈跟线程一一对应,每一个线程对应着一个虚拟机栈,在虚拟机栈中以栈帧
为元素,栈帧跟方法一一对应,每执行一个方法就有一个栈帧入栈
,每完成一个方法就有一个对应的栈帧出栈
,栈帧的结构分为:局部变量表、操作数栈、动态链接、方法出口。程序计数器则记录当前方法执行到的字节码的行数,在轮流执行的并发编程中能接上次执行的位置往下执行。
共享区主要有堆
和方法区
。堆中又划分为年轻代和老年代,其中年轻代又划分为:Eden、From、To代,几乎所有的对象实例都在堆中分配。方法区主要存储类型信息和常量、静态变量等。
对象的创建和分配原则
对象的创建过程
对象在创建时首先会进行类加载检查(是否被ClassLoader加载过),即能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表类是否被加载、解析、初始化过,如果没有,则去ClassLoader类加载,否则进行对象分配内存、初始化类变量的默认值、设置对象头、执行类的构造函数。
对象创建过程
这里重点讲下分配内存的方式和并发的安全问题,和对象在堆中的结构。
分配内存
对象的分配内存就是在Java堆中分配一块内存给对象,但是根据堆中的已使用内存和空闲内存的存放位置,分为两种分配内存方式:
-
指针碰撞 : 如果JAVA堆是规整的,已使用内存集体放一边,可闲内存集体放一边,那么只需要指针往空闲的内存移动一定位置就可以为对象分配内存,所以叫指针碰撞
-
空闲列表:如果JAVA堆是不规整的,已使用内存和空闲内存相互交错在一起,那么就需要维护出一张空闲内存的列表,把新的对象分配在空闲内存列表中,所以叫空闲列表。
多线程并发的分配问题:
堆是线程共享的,所以在多线程时有可能会发生同一位置被多个线程的对象分配,那么也有两种解决办法:
- CAS加上失败重试机制
- 本地线程分配缓冲(TLAB)
每一个线程都在堆中分配一个独立的区域,每个对象的分配内存就是当前线程在堆中分配区域分配。
对象在堆中的结构
对象 对象对象的内存布局分为:
-
对象头
MarkWord: 哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳
类型指针:指向Class对象 -
实例数据
存放类的变量数据 -
对齐填充
占位符作用,对象是以8字节为单位
引用对对象实例的访问
两种访问方式-
使用句柄:通过指向一个句柄池间接的访问对象,优点:对象地址改变后不要更改reference的指向,缺点:查找地址需要时间。
-
直接指针:reference直接指向堆中的实例地址,优点:时间快,缺点对象实例移动reference也需要改变。
对象的分配原则
对象分配原则是否能在栈上分配
逃逸分析:在方法中创建的对象,没有被其他方法或线程中用到,那么这个对象可以在栈中分配。
优先在Eden中分配:
大多数情况下,对象在Eden中分配,如果Eden区没有足够的空间分配,将触发Minor GC。在Minor GC时如果S0,S1的空间不够分配或GC年龄达到指定值,则根据分配担保机制
转移到老年代。
大对象直接进入老年代
超过指定大小的大对象将直接进入老年代。这样可以避免Eden区和两个S区来回复制。长的字符串和数组是常见的大对象。
长期存活的对象进入老年代
在Eden区的对象实例发生GC时会被移动到S0区并在对象头的GC年龄+1,之后每GC一次,还在S0区的对象的GC年龄就会+1,如果超过了指定次数就会移入老年代。
动态对象年龄判断
如果在S空间中,相同GC年龄的对象的大小的总和大于S空间的一半,则大于或等于此GC年龄的对象全部移入老年代。
空间分配担保机制
在发生Minor GC之前要进行一次空间分配担保机制的判断,决定是发生Minor GC还是Full GC。因为在Minor GC时可能会有对象进入老年代,如果老年代的空间不够就会发生OOM的风险,所以空间分配担保机制则是尽量去降低这种风险发生。
- 发生Minor GC
- 老年代的最大连续空间大于新生代的所有对象总空间
- 如果1不成立,则查看是否可以担保,如果可以担保,继续查看老年代的最大连续空间大于历次晋升到老年代空间的对象的平均大小。
- 发生Full GC
- 如果上述1不成立,且不可以担保
- 如果可以担保,继续查看老年代的最大连续空间小于历次晋升到老年代空间的对象的平均大小。
对象的创建分配小结:
对象的创建流程是,首先ClassLoader类加载检查
:如果该类的符号引用已经被加载、解析、初始化过后则表示已经被ClassLoader加载过。被ClassLoader加载过后,则到堆中分配内存,分配方式有指针碰撞和空闲列表
两种,在分配内存中有并发的安全问题所以有了两种解决方式:CAS和TLAVB
。分配内存地址后,就开始设置类变量的初始值。设置完默认值后,就开始设置对象头,其中对象头包括:GC年龄、哈希码、类型指针等。对象则包括对象头、实例数据和对对齐填充。最后是执行对象的构造函数进行初始化。栈中对堆的对象实例的访问有使用句柄
和直接访问
两种。
对象在分配原则有:
- 根据逃逸分析决定是否在栈中分配
- 新对象优先分配在Eden区
- 大对象直接分配在老年代
- 长期存活的对象进入老年代
- 同年龄的对象总和大于S区的一半,则以上年龄的对象进入老年代。
- 分配担保机制,在每次发生Minor GC之前等会判断老年代的最大连续空间是否足够可能从新生代进来的对象。
内存溢出
堆溢出
java堆用于存储对象实例,只要不断的产生对象,并且GC Root到对象中有可达性导致不能垃圾回收,当堆的对象总量超过规定的最大值就会产生内存溢出OutOfMemeyError。
栈溢出
- 如果是对虚拟机栈存放不下,比如是栈创建太多或栈的容量太大,则会导致内存溢出OutOfMemeyError。
- 如果是对栈帧存放不下,比如栈帧太多或栈帧太大或栈最大容量太小,则会导致栈溢出StackOverflowError。
方法区和运行常量溢出
方法区(元空间)存放着类型信息,比如Class对象,而Class对象
直接内存溢出
直接内存的最大值默认与Java堆的最大值一致。
[JVM入门指南02]GC垃圾回收机制
概述
在JVM中主要的结构为:虚拟机栈、堆、方法区。其中虚拟机栈的栈帧在编译器就已经确定大小的,随着方法的结束或线程的技术,虚拟机栈的内存也随着回收。而Java堆和方法区这两个区域则有很显著的不确定性,这部分内存的分配和回收都是动态的,GC所关注的真是这部分内存该如何管理。
本篇文章就以下三方面GC所要完成的三件事:
- 哪些内存需要回收?(对象存活算法)
- 什么时候回收?(触发GC的条件)
- 如何回收?(GC的工作原理)
哪些内存需要回收
如何判定哪些对象需要回收?在堆里面存放了几乎所有的对象实例,在GC之前第一件事就是要确定这些对象哪些还“存活”着,哪些已经“死去”。其中判断对象是否存活有两种算法:可达性分析算法、引用计数算法。
可达性分析算法
通过一系列的“GC Roots”根对象作为始节点,开始往下遍历有引用关系的对象形成一条“引用链”,通过这条引用链的可达的对象是存活的,不可达的对象则是不再使用。
那么什么样的对象可以GC Roots呢?主要是以下对象:
- 虚拟机栈中的引用对象
- 方法区的类静态的引用对象、常量的引用对象
- Java虚拟机内部引用:基本数据类型的Class对象、异常对象、系统类加载器
- 所有同步锁持有的对象
- 反映虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存。
可达性分析算法是大多数系统使用的对象存活判断算法。
引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它,计数器就+1,当一个引用失效,计数器就-1,当计数器>0时就表示对象为存活使用状态,不得回收。大多数不会用这个办法来判断对象存活,因为当对象实例相互引用时,当栈中的引用已经失效,对象也还是不能回收。
上面的可达性分析算法和引用计数算法都用到了引用
,这种引用默认是强引用。在JDK1.2版本之后,Java除了有强引用外,还增加了三种引用:
- 软引用(SoftReference): 内存溢出前,可对持有该引用的对象回收
- 弱引用(WeakReference):GC就会被回收,相当于没有引用。
- 虚引用(PhantomReference): 无法通过其获得对象实例,唯一目的是当对象被回收时能收到一个系统通知。
如果对象被可达性分析算法
或引用计数算法
识别为无使用对象就可以被GC回收吗?不是,一个对象要至少要经过两个的标志过程。其还会查询对象是否重写了finalize()
方法,如果重写了就会用另一个Finalize线程去执行这个finalize方法,这个方法可以时对象重新被引用,但官方并不推荐这样做。
堆中的对象实例可以使用可达性分析算法和引用计数算法来决定是否回收。但方法区的常量池和类型信息是否回收却另有条件:
-
常量池的字符串和符号引用
虚拟机中没有任何对象有引用到它。 -
类型卸载
1.该类的所有实例被回收
2.该类的ClassLoader被回收
3.Class对象没有被任何地方引用
以上三种条件同时被满足。
什么时候回收
根据两个分代假说:绝大多数的对象都是朝生夕死
,熬过越多次的GC回收的对象就越难回收
。把堆进行了分代:新生代(Eden、From、To)、老年代,在GC时也进行了分代回收。
Minor GC: 回收新生代的无使用对象,新生代的对象的特性是大多数是朝生夕死的。触发时机有:
- Eden区空间不足,触发Minor GC
由于Eden空间大小有限,所以Minor GC触发的更加频繁,这就需要收集算法速度快、效率高,一般使用标记-复制算法
对这一区域进行回收(后面讲)。
Major GC:回收老年代的无使用对象。一般使用标记-清除算法
或标记-整理算法
进行回收。
Full GC: 回收堆和方法区的无使用对象。Full GC回收范围比较大,执行的时间较长可能会造成卡顿,所以要尽量减少Full GC的次数。触发时机大致有:
-
老年代的空间不足
由新生代对象的进入老年代、大对象直接进入老年代等,如果在老年代的最大连续空间上无法存放这些对象时,就会进行一次Full GC回收。 -
方法区的空间不足
方法区主要存储类型信息和常量池,也有空间不足的风险,会进行Full GC回收 -
System.gc()被显示调用会Full GC回收
三种垃圾收集算法:
1. 标记-清除算法
原理:用可达性分析算法
将不可用的对象进行标记,然后对无用的对象进行清除。
缺点:在对象很多的情况下,标记的效率低。清除对象之后会产生内存碎片,内存不连续。
作用:在老年代回收中一些收集器会使用此算法
2. 标记-复制算法
原理:将内存空间一分为二,一半用于对象的存放,一半空闲。如果存放对象的区域满了,使用可达性分析算法
把存活的对象移动标记出来,然后复制到另一个空的区域,同时把之前的区域全部清空变成空的连续空间。
缺点:如果存活对象很多,要产品大量的内存复制开辟。内存空间只能用一半优点浪费资源。
作用:在新生代朝生夕死的对象中一般用此回收算法。但新生代中对复制算法进行了优化,但这种算法加入了分配担保机制防止存活对象过多分配不了的情况。使用了一种Appel式的回收算法:
3. 标记-整理算法
原理:标记的过程跟标记-清除算法
一样,然后整理存
活的对象往一端移动,然后存活边界之外的对象全部清除
。
缺点:移动对象有一定的风险。对象太多效率不高
作用:主要作用在老年代。
如何回收
GC使用的垃圾收集器进行回收,随着不断的发展,垃圾收集器也越来越多,这里列举常规的垃圾收集器并进行分为三类:单线程收集器、多线程收集器、并发收集器。
单线程收集器
单线程的收集器的组合有:Serial/Serial Old收集器。它们不仅仅用一个收集线程去完成收集操作,而且在收集线程工作的时候,用户线程必须停止等待,直到收集完成为止。如图是Serial/Serial Old收集器示意图:
Serial/Serial Old收集器如果客户端的内存资源受限,处理器核心数较少或单核处理器来说,其简单高效的可以使收集器最快的工作完。
多线程并行收集器
多线程的收集器有:ParNew、Parallel Scavenge、Parallel Old,其中Parallel Scavenge/Parallel Old为组合收集器。这些多线程收集器仅仅是增加了垃圾收集线程,用户线程依然是停止等待垃圾收集的。
Parallel Scavenge/Parallel Old组合收集器parNew收集器:其实就是Serial的多线程版本,目前能与Serial收集器和CMS收集器合作。
Parallel Scavenge收集器:一般配合Parallel Old收集器使用。相比于parNew收集器,它更加注重是吞吐量
的控制,吞吐量就是用户线程执行的时间占总CPU运行的时间,吞吐量当然是越大越好。
多线程一般用服务端,因为多线程的执行,有时间片轮转的消费时间,如果对于单处理器来说无疑处理效率更慢。但对于资源很好,不用与用户交互的分析运算的服务端却可以增加执行效率。
并发收集器
并发收集器有:CMS收集器,是一款以系统停顿的时间尽量较短,用户体验较好为目标的收集器。它的收集线程可以与用户线程并发执行。CMS有三次的标记
(初始标记、并发标记、重新标记)和一次清理
(并发清理),在三次的标记中有两次标记需要较短用户线程停止,一次较长的与用户线程并发的标记,和与用户线程并发的清除。
初始标记:标记GC Roots关联的第一个对象,时间很短
并发标记:和用户线程并发执行GC Roots的引用链(可达性分析算法),时间较长
重新标记:重新查找在并发标记阶段,用户线程运行生成的新的引用链。时间比初始标记长一点。
并发清除:用标记-清除算法
把无用对象进行清除。
三大缺点:一:CPU敏感,并发对核心数少的处理器对用户线程的运行可能会造成影响。二:浮动垃圾:在并发清理阶段产生的垃圾只能等下一次GC回收。三:内存碎片,标记-清理法会产品大量的不连续的内存空间。
小结
本文从那些内存需要回收
,什么时候回收
,如何回收
作为执行分别写出了两个对象存活判断算法、Class区回收的条件、回收的分代机制与收集时机、三个收集算法和常用的垃圾收集器。
[JVM入门指南03]类加载和Android虚拟机
JVM的整体架构
JVM架构ClassLoader:——负责加载已被编译的java文件(.class),验证连接。分配和初始化静态变量和静态代码。
运行时数据区:——负责所有的程序数据:堆、方法区、栈。往期关于运行时数据区的介绍文章:[JVM入门指南01]内存区域与溢出异常
执行引擎:——执行我们编译和加载的代码并清理产生垃圾的区域。关于垃圾回收清理,可以看往期的垃圾回收介绍文章:[JVM入门指南02]GC垃圾回收机制
关于运行时数据区
和垃圾回收机制
在前面两篇文章中已经讲过,所以这篇主要讲类加载
和 执行引擎的执行
。
类加载ClassLoader
Java类加载机制
java的类加载主要是把对.Class文件字节码的检查和生成Class对象。过程有:加载、验证、准备、解析、初始化。其中验证、准备、解析称为连接,则过程有:加载、连接、初始化。
加载:根据类的全限定名读取字节码,并生成相应的Class对象。
连接:
- 验证: 对字节码的验证
- 准备:给静态变量分配内存,并给一个默认值(还没有赋值)
- 解析:将常量池中的符号引用替换为直接引用。
初始化:初始化阶段就是执行类构造器<clinit>()方法的过程
java类加载java类加载中有两个特性:双亲委托、缓存机制
双亲委托:在加载一个类时,会先递归让父类加载器去尝试加载类,如果父类可以加载则下面的子类加载器则不用加在,如果父类加载器也不可以加载,则递归给子类加载器尝试加载类。
缓存机制:加载过的Class都会被缓存起来,当需要使用到某个Class时,会先从缓存中查找该Class,没有才从类加载器中加载。
Android类加载机制
Android的加载器类的通用父类为ClassLoader
,其本身有一个内部类BootClassLoader
用于加载FrameWork层的类,ClassLoader主要实现了loadClass()
方法用于实现缓存机制和双亲委托机制。BaseDexClassLoader
同样继承ClassLoader
,是PathClassLoader
和DexClassLoader
的父类,其中BaseDexClassLoader主要是实现了findClass()
方法用于自己加载dex。PathClassLoader加载App安装目录内的dex,DexClassLoader加载任意位置的dex,
执行引擎
JVM的执行引擎
Interpreter & JIT:
这两种解释器是并肩工作的。
Interpreter
是解释执行解释器
,解释执行解释器在程序运行时,会把字节码翻译成机器码。解释执行的缺点是当一个方法被重复执行,每一个都需要重新解释执行一遍。
JIT(Just In Time)
是即时编译器
,它会把一些经常执行的、大量重复执行的热区代码进行即时编译成机器码并将其更改为本机代码,下次执行热区代码时就可以直接调用本机代码不用再次解释。
Android的执行引擎
Android手机发展以来经历了两个虚拟机:Dalvik
、ART
。JVM是按照有无限电量和几乎无限的存储的设备而设计的,但是Android设备则是电量和存储资源都很有限,所以Android设备并没有直接采用JVM来作为虚拟机使用,而是通过规范改造优化后的Dalvik
,在Android5.0之后更是再次更换改造后的ART
。
Android的主要改造优化有三点:
- 运行数据区的栈更改为
寄存器
,减少操作数栈的出入栈操作。 - JVM虚拟机接收的.class文件更改为
dex文件
。java/kotlin经过java/kotlin编译器后编译成.class文件,这些.class文件通过dex编译器打包编译成dex文件。dex文件的执行效率更高,需要的空间更少。
Android的打包 - 执行引擎除了有Interpreter和JIT编译器,在ART中还有
AOT
编译器。
Dalvik的执行引擎
Dalvik虚拟机的执行引擎和JVM的执行引擎一样,都是一般代码在运行时通过解释执行解释器
编译,热区代码进行即时编译器
编译。但是Dalvik在Android5.0之后就不再使用了。
ART的执行引擎
在Android5.0以上,安卓改用了ART作为虚拟机,ART虚拟机中新增了一个AOT编译器,在应用安装的时候,AOT编译器将 dex
文件编译为一个.oat
二进制文件,App运行时直接执行.oat
文件,不用再编译文件。这样做,使App的运行速度更快,但也带来了两个问题:1. 安装应用的时间久,2. 内存占用较大。
ART的执行引擎后优化为:Interpreter、JIT、AOT一起执行:
- 如果没有
.ort
文件,程序第一次执行使用Interpreter解释执行解释器执行。 - 遇到热区代码,则用JIT解释器执行,并把其存储在一个Profils文件缓存中
- 设置在空闲的时刻,启动AOT解释器把Profils文件缓存的代码执行为.ort文件,下次再执行的时候,则执行.ort文件。
Android的.dex编译
这块不属于Android的执行引擎部分,但本人是学Android的,所以就顺带讲讲。
dex文件编译过程通过java/kotlin编译器生成的.class文件,还需要经过
desugar
、proguard
两个步骤:desugar: 俗称脱糖,因为在Dalivk/ART虚拟机中并不会支持那么多的java字节码,对于一个高版本的java的语法字节码,要通过
desugar
将其转换成安卓虚拟机能识别的代码。proguard: 主要做一些混淆操作等。
但这也会带来更长时间的编译,开发人员要等待程序运行起来的时间就越久。然后Google又把dusugar
和dex编译器
作为合并优化合为一个D8
后面Google又进一步做了优化,又用R8代替了proguard和D8,并对字节码做了优化。
proguard和R8的优化有:
- 去掉无用的类、方法、变量
- 代码优化,如指令重排
- 混淆,将类、方法名进行混淆。
网友评论