一篇文章搞定《JVM的完美图解》
- 前言
- 常见的问题
- 1、双亲委托机制
- 2、类加载过程
- 加载
- 链接
- 初始化
- 3、JVM内存结构图
- 方法区
- 堆
- 栈
- 本地方法栈
- 程序计数器
- 4、对象的组成
- 对象头
- 示例数据
- 对齐字节
- 5、JVM中怎么确定一个对象是否可以GC
- 引用计数法(早期策略)
- 可达性分析算法(主流方案)
- 6、Android中有哪些GC Roots
- 7、对象引用分类
- 强引用(Strong Reference)
- 软引用(Sofe Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
- 8、Finalize() 二次标记
- 9、JVM回收算法
- 标记-清除算法
- 复制算法
- 标记-整理算法
- 分代收集算法
- 10、GC是什么时候触发的
- Scavenge GC
- Full GC
- 11、什么是STW(内存抖动的原因)
- 12、三色标记
- 13、什么是JIT
- 总结
前言
哎呦喂,怎么搞着搞着Android突然到了JVM了呢?
当然了,JVM作为Android使用语言中Java和Kotlin的运行平台。当然需要知道了。
但是这篇文章的目的并不是单纯的学习JVM而是:
1、为了铺垫后面的Binder文章(最重要的)
2、帮大家复习一下JVM的相关问题(附带的)
所以:这篇文章会站在Android的角度去学习JVM
ps:不会很深层,但是需要会的一些内容
本篇文章会以多个问题的形式来解答JVM
常见的问题
这些问题经过一些小排序,看起来有个先后顺序。
1、双亲委托机制
双亲委托机制的步骤可以总计为下面四步:
- 首先判断该Class是否已经加载
- 如果没有则不是自身去查找而是委托给父加载器进行查找,这样依次的进行递归,直到委托到最顶层的Bootstrap ClassLoader
- 如果Bootstrap ClassLoader找到了该Class,就会直接返回
- 如果没找到,则继续依次向下查找,如果还没找到则最后会交由自身去查找
双亲委托中各个加载器的作用 - 启动类加载器(Bootstrap Class Loader):负责加载JDK中的核心类库,如java.lang包中的类。
- 加载Java核心类库,如Object、String等。
- 扩展类加载器(Extension Class Loader):负责加载Java的扩展类库,如javax包中的类。
- 加载JDK的扩展类库,位于JRE的lib/ext目录下的jar文件。
- 应用程序类加载器(Application Class Loader):负责加载应用程序的类,即用户自定义的类。
- 加载应用程序的类,包括开发者自定义的类以及其他依赖的第三方类库。
- 自定义类加载器(Custom Class Loader):开发者自定义的类加载器,可以根据自己的需求去加载类。
- 根据自己的需求加载类,可以实现特定的加载逻辑,如从网络、数据库等加载类。
双亲委托机制的作用
- 根据自己的需求加载类,可以实现特定的加载逻辑,如从网络、数据库等加载类。
- 避免重复加载,如果已经加载过一次Class,就不需要再次加载,而是先从缓存中直接读取。
- 安全方面的考虑,如果不使用双亲委托模式,就可以自定义一个String类来替代系统的String类,这样便会造成安全隐患。采用双亲委托模式会使得系统的核心类在Java虚拟机启动时就被加载,也就无法自定义核心类来替代系统的核心类。
2、类加载过程
加载
- 加载(Loading):通过类的全限定名查找并加载字节码文件,通常可以从本地文件系统、网络等地方通过双亲委托机制来加载字节码文件。
链接
验证、准备和解析这三个阶段可以统称为来链接(Linking)。
- 验证(Verification):对字节码进行校验,确保其符合JVM规范,包括文件格式验证、元数据验证、字节码验证和符号引用验证等。
- 准备(Preparation):为类的静态变量分配内存,并设置默认的初始值。这些变量通常在类的准备阶段会分配内存空间,在初始化阶段再进行具体赋值。
- 解析(Resolution):将类中的符号引用转换为直接引用。符号引用是一种在编译阶段使用的编译原型,直接引用是指直接指向内存中某个方法、变量的指针或者偏移量。
初始化
- 初始化(Initialization):对类的静态变量和静态代码块进行初始化,包括变量赋值和静态代码块的执行。初始化阶段是类加载的最后一个阶段,会执行类的初始化方法。
使用和卸载 - 使用(Usage):使用已经初始化的类,包括调用类的静态方法、实例化类对象等操作。
- 卸载(Unloading):当某个类不再使用时,会被JVM卸载,释放相关的内存和资源。
3、JVM内存结构图
其中方法区(元空间)是线程共享的。栈、本地方法栈、程序计数器是线程私有的。(已经用颜色来分类了)
方法区
方法区主要装一些静态信息,比如:类元数据,常量池,方法信息,类变量等。如下代码HelloWorld.class是类元数据,hello,main都是方法信息等都是放在方法区存储的。
public class HelloWorld {
public static void main(String[] args) { }
public void hello(String who) {}
}
在JDK1.7以前,方法区叫永久代,而 1.8之后叫元空间。原因是JDK1.8为了释放管理压力,把运行时常量池交给堆去管理。
可以这么想:方法区是接口1.7之前是永久代在实现方法区。1.8之后是元空间在实现方法区。
堆
堆中主要存放实例对象,也是GC的主要回收的区域空间(有些回收算法和对象的知识,就在下面两个话题中)。
你可以这么理解,只要看到用关键字new 的对象,数据都放在堆中。如下代码:
HelloWorld helloWorld = new HelloWorld();
其中helloWorld 是HelloWorld 对象的引用,指向new出来的HelloWorld 对象实例,helloWorld 引用是放在栈中的。
New HelloWorld创建的对象是放在堆中的。(下面可以看一下对象的组成,了解对象的大小)
当栈中的引用消失了,那么堆中的对象就可以被回收掉了。
在堆内存中,内存需要划分成两块区域,新生代 和老年代,他俩的大小相对比为1:2。
- 新生代:在堆内存中,新生代又分为三块为eden、from、to这三块内存区域都属于新生代,默认比例是8:1:1
- 1、每次new对象都会先存储到eden中,如果eden区域内存满了。会触发GC回收该区域(复制回收算法)。
- 2、还未回收的对象会放入from或者to,from,to内存其中一块是空的,方便对象在内存中整理标记,每GC一次,from,to两块空间对象就移动一次(新从eden进来的对象和from或者to中已经存在的对象,一起移入到新的from或者to的空间中)。
- 3、这样只有from和to的1比例的空间被浪费:减少了标记清楚法的琐碎空间,避免了平分空间的复制法的浪费一半空间过大
- 4、对象每在from和to中移动一次,这时候对象的年纪也会加1(年纪在对象头中)。当年纪到达15岁的时候,就会进入到老年代了。
- 老年代:当老年代满了,会触发Full GC回收,如果系统太大,Full GC都回收不了,程序会出现类似java.lang.OutOfMemoryError: Java heap space,们可以通过配置JVM参数:如 -Xmx32m设置最大堆内存为32M。
具体来说,JDK1.8中的默认堆内存大小为物理内存的1/4,其中新生代和老年代的比例是1:2。也就是说,新生代占堆内存的1/6,老年代占堆内存的2/3。
栈
- 栈内存空间相对于堆空间比较小,也属于线程私有,栈中主要是一堆栈帧,是先进后出的。
- 栈帧对应就是一个方法,方法中包含局部变量,方法参数,还有方法出口,访问常量指针,和异常信息表。
- 异常信息表可以处理当程序执行报错,会跳转到具体哪行代码执行,JVM中就是通过异常表反馈的。
- 一个线程可能对应多个栈帧,栈帧都是从上往下压入,先进后出。
- 如下图所示,在方法A中调用方法B,在方法B中调用C,在方法C中调用方法D
- 主线程对应栈帧的压栈情况,出栈顺序是D->C ->B ->A,最终程序结束。
public static void main(String[] args){
HelloWord helloWord = new HelloWord();
helloWord.A();
}
public void A(){
B();
}
public void B(){
C();
}
public void C(){
D();
}
public void D(){
}
- java.lang.OutOfMemoryError: Java stack space。栈溢出。就是栈里的调用链太多了。根据上面的举例大家应该明白了。如果不断的压栈不出栈不就会报错了吗?
- 是的啊:所有最常见的OutOfMemoryError(栈溢出错误),就是不断的递归循环的引用,导致引用链过长。不断的压栈的结果。
本地方法栈
和栈结构是一样的,是一块独立的区域。只是对应的是Native方法。
程序计数器
多线程运行程序的时候,是依赖于CPU分配时间片来交替执行。如下图所示。
那么问题来了。在线程的时间片切换时,比如线程1执行了,之后进入等待,线程0开始执行。之后线程0等待,那线程1想恢复,这时线程1上次执行到哪儿了呢?具体是代码的多少行了呢,该行代码有没有执行完毕?
此时这些信息就保存在了程序计数器中。方便下次恢复运行。这也是为什么 程序计数器 是线程独享的原因。
4、对象的组成
那直接给你对照着代码给你创建一个对象来进行解释。
public class data {
public int a;
public int b;
public byte c;
}
那么这个对象在内存空间中占有多少个字节呢?
答案是:24个字节(32位系统)。32个字节(64位系统加8字节为存储Mark Word)。让我们来将这个答案进行拆解,彻底明白对象的组成。(Android后面都要为64位系统的)
对象头
- 标记字段(Mark Word):(32位系统)
- 哈希码HashCode:25bit位(支持哈希表等数据结构的快速查找和比较。哈希码可以通过哈希算法从对象的数据计算得出)
- GC分代的年龄:4bit位(支持分代垃圾回收算法,上面也说到了15岁加入老年代)
- 锁状态标识:2bit位(对象标记字段用于记录对象的锁状态,包括无锁、偏向锁、轻量级锁和重量级锁等。通过检查对象标记字段的状态,虚拟机可以实现对象的并发控制和线程同步)
- 是否偏向锁:1bit位(标识是否是偏向锁)
- 类型指针( Klass Pointer):32bit位 (指向堆空间的指针)
- 数组长度(Array Length):0bit位(只有数组对象保存了这部分数据,正常为32bit位,因为不是数组所以为0)
上面所述对象头为8字节(ps:仅供参考因为虚拟机也可能会对bit位进行合并或优化会有不同)
示例数据
这个比较简单:int是4字节byte是1字节:4*2+1 = 9字节
对齐字节
用于内存对齐的填充字节,以确保对象的存储是按照对齐边界对齐的。边界一般为8字节。所以此对象的对齐空间为7字节。
综上所述一共是24字节也就是对象头8字节+示例数据9字节+对齐字节7字节 = 24字节。
5、JVM中怎么确定一个对象是否可以GC
主要为两种算法用来判断
引用计数法(早期策略)
Java 堆 中每个具体对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。
优点:
- 引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。
缺点: - 无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
可达性分析算法(主流方案)
可达性分析算法也叫根搜索算法,通过一系列的称为 GC Roots 的对象作为起点,然后向下搜索。搜索所走过的路径称为引用链 (Reference Chain), 当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达,也就说明此对象是 不可用的。
如下图所示: Object5、Object6、Object7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。
6、Android中有哪些GC Roots
1、静态变量(Static Roots):类的静态变量被保存在方法区中,当一个类被加载时,其静态变量引用的对象会被视为GC Roots。
2、JNI引用(JNI Roots):保存了通过JNI(Java Native Interface)创建的对象引用。
3、系统类(System Class Roots):包括基本数据类型(如int、double)的类以及重要的系统类,这些类的实例被视为GC Roots。
4、ThreadLocal:每个线程中的ThreadLocal变量会被作为GC Roots(其实是因为他是静态Static 修饰的)
5、 Finalizer引用:处于finalizer队列中待执行finalize()方法的对象会被作为GC Roots,垃圾回收器会在执行finalize()方法之后回收这些对象。
7、对象引用分类
强引用(Strong Reference)
在代码中普遍存在的,类似Object obj = new Object()这类引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。
软引用(Sofe Reference)
有用但并非必需 的对象,可用SoftReference类来实现软引用。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用(Weak Reference)
非必需 的对象,但它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,JDK提供了WeakReference类来实现弱引用。无论当前内存是否足够,用软引用相关联的对象都会被回收掉。(解决一些内存泄漏的问题都会利用到弱引用)
虚引用(Phantom Reference)
虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,JDK提供了PhantomReference类来实现虚引用。为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收时收到一个系统通知。
8、Finalize() 二次标记
一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程。(有一次缓刑的机会)
- 第一次标记:通过可达性分析算法分析对象是否与GC Roots可达。经过第一次标记,并且被筛选为不可达的对象会进行第二次标记。
- 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。
9、JVM回收算法
标记-清除算法
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。
标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
- 优点:
实现简单,不需要进行对象进行移动。 - 缺点:
标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。
复制算法
复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。
简单来说就是将内存区域划分成相同的两个内存块。每次仅使用一半的空间,JVM生成的新对象放在一半空间中。当一半空间用完时进行GC,把可到达对象复制到另一半空间,然后把使用过的内存空间一次清理掉。
- 优点:
按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。 - 缺点:
可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
标记-整理算法
标记-整理算法 采用和 标记-清除算法 一样的方式进行对象的标记,但后续不直接对可回收对象进行清理,而是将所有的存活对象往一端空闲空间移动,然后清理掉端边界以外的内存空间。
- 优点:
解决了标记-清理算法存在的内存碎片问题。 - 缺点:
仍需要进行局部对象移动,一定程度上降低了效率
分代收集算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。
老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
这个其实上面的JVM内存结构图:堆中已经有讲到(这里就直接粘贴过来吧)
在堆内存中,内存需要划分成两块区域,新生代 和老年代,他俩的大小相对比为1:2。
- 新生代:在堆内存中,新生代又分为三块为eden、from、to这三块内存区域都属于新生代,默认比例是8:1:1
- 1、每次new对象都会先存储到eden中,如果eden区域内存满了。会触发GC回收该区域(复制回收算法)。
- 2、还未回收的对象会放入from或者to,from,to内存其中一块是空的,方便对象在内存中整理标记,每GC一次,from,to两块空间对象就移动一次(新从eden进来的对象和from或者to中已经存在的对象,一起移入到新的from或者to的空间中)。
- 3、这样只有from和to的1比例的空间被浪费:减少了标记清楚法的琐碎空间,避免了平分空间的复制法的浪费一半空间过大
- 4、对象每在from和to中移动一次,这时候对象的年纪也会加1(年纪在对象头中)。当年纪到达15岁的时候,就会进入到老年代了。
- 老年代:当老年代满了,会触发Full GC回收,如果系统太大,Full GC都回收不了,程序会出现类似java.lang.OutOfMemoryError: Java heap space,们可以通过配置JVM参数:如 -Xmx32m设置最大堆内存为32M。
具体来说,JDK1.8中的默认堆内存大小为物理内存的1/4,其中新生代和老年代的比例是1:2。也就是说,新生代占堆内存的1/6,老年代占堆内存的2/3。
10、GC是什么时候触发的
由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。
Scavenge GC
- 一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC。
这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。
因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
Full GC
- 年老代(Tenured)被写满
- System.gc()被显示调用
比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。
11、什么是STW(内存抖动的原因)
STW:Stop-The-World:暂停所有的应用线程进行垃圾回收操作的过程。
内存抖动的一个常见原因就是频繁的STW事件。
内存抖动:是指在一段时间内,垃圾回收器频繁地进行内存回收操作,而又无法有效地释放足够的内存空间,从而导致应用程序的性能严重下降和内存使用效率低下的现象。
12、三色标记
JVM的三色标记是一种用于垃圾回收的算法,它将所有对象分为三种不同的颜色,即黑色、灰色和白色。
- 黑色表示已经被标记并且它的所有引用也已经被标记的对象。
- 灰色表示已经被标记但它的引用还未被标记的对象。即它的引用还未被遍历,需要进行进一步的标记。
- 白色表示尚未被标记的对象。
标记过程中,首先将根对象标记为灰色,然后进行遍历,遍历灰色对象的引用并将其标记为灰色,直到没有灰色对象或者引用没有白色对象为止。
最后,清扫阶段将白色对象回收,并将黑色对象重新标记为白色,形成新的回收周期。
三色标记算法通过将标记的任务分为多个步骤,可以在不中断应用程序运行的情况下进行垃圾回收,提高了应用程序的性能和响应能力。
13、什么是JIT
JIT是即时编译(Just-In-Time Compilation)的缩写。
它在运行时动态地将热点代码(最频繁执行的代码)进行实时编译,以便能够更高效地执行。
如下流程图所示:这样能减少逐行解释执行带来的效率问题。
总结
总结就是学就完事了。干!!!