深入理解Java虚拟机——04_深入JVM与垃圾回收机制
深入JVM与垃圾回收机制
深入JVM与垃圾回收机制
(1) 基本概念:
JVM 是可运行 Java 代码的假想计算机 ,由类加载器,运行时数据区,执行引擎组成。JVM 是运行在操作系统之上的,它与硬件没有直接的交互。
(2) 运行过程:
Java 源文件,通过编译器,能够生产相应的.Class 文件,也就是字节码文件, 而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码 。
① Java 源文件—->编译器—->字节码文件
② 字节码文件—->JVM—->机器码
Java编译器:将Java源文件(.java文件)编译成字节码文件(.class文件,是特殊的二进制文件,二进制字节码文件),这种字节码就是JVM的“机器语言”。javac.exe可以简单看成是Java编译器。
Java解释器:是JVM的一部分。Java解释器用来解释执行Java编译器编译后的程序。java.exe可以简单看成是Java解释器。
(3) 跨平台的原因
每一种平台的解释器是不同的,但是实现的虚拟机是相同的,这也就是 Java 为什么能够 跨平台的原因。Java的跨平台是运行时跨平台
JVM由类加载器,执行引擎,运行时数据区组成。
1.类加载器:在JVM启动时以及程序运行时将需要加载的class文件加载到JVM中
2.执行引擎:负责执行class文件中包含的字节码指令,相当于物理机器上的CPU
3.运行时数据区:将划分给Java程序的内存划分成几个区来模拟物理机器上的存储、记录和调度功能
1.线程
Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。 Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。
2.内存区域
JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法栈】、线程共享区域【堆、方法区】、直接内存。
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁。(在 Hotspot VM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)。
线程共享区域随虚拟机的 启动/关闭 而 创建/销毁。
1)程序计数器(线程私有)
也叫PC寄存器,PC寄存器是用来存储指向下一条指令的地址,也即将将要执行的指令代码。由执行引擎读取下一条指令。
一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器。这类内存也称为“线程私有”的内存。
正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的指令地址(偏移地址)。如果是 Native 方法,则为空。
这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError情况的区域。
利用javap -v xxx.class反编译字节码文件,查看指令等信息
面试常问
1.使用PC寄存器存储字节码指令地址有什么用呢?/ 为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
2.PC寄存器为什么会设定为线程私有?
我们都知道所谓的多线程在一个特定的时间段内指回执行其中某一个线程的方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?**为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,**这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,(如果不线程私有,不同间线程*享的程序计数器记录的指令地址会被覆盖,下次执行此线程就不知道从哪开始继续执行)如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU时间片
CPU时间片即CPU分配各各个程序的时间,每个线程被分配一个时间段。称作它的时间片。
在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。 但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
并行与并发
并行:同一时间多个线程同时执行;<——>串行
并发:一个核快速切换多个线程,让它们依次执行,看起来像并行,实际上是并发
2)虚拟机栈(线程私有)
**是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。**每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
3)本地方法栈(线程私有)
本地方法栈和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务。
4)堆(Heap-线程共享)
运行时数据区
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以 细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
新生代 :老年代 = 1:2
Eden :From Survivor :To Survivor = 8 :1 :1
5)方法区/永久代(线程共享)
即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即 使用Java 堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久代的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。
**运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
3.JVM运行时内存
Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
新生代 :老年代 = 1:2
Eden :From Survivor :To Survivor = 8 :1 :1
1)新生代
是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
①Eden 区:
伊甸园:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。根可达算法(RootSearching)判断是不是垃圾。
②ServivorFrom:
上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
③ServivorTo:
保留了一次 MinorGC 过程中的幸存者。
④MinorGC(YGC) 的过程:
(复制->清空->互换)
MinorGC 采用**拷贝算法(copying)**。
首先通过根可达算法,找出Eden区中的垃圾,将垃圾回收,存活对象进入ServivorFrom中,Eden区置空。
1:Eden、ServicorFrom 复制到 ServicorTo,年龄+1
首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不够位置了或者对象的年龄等于15(PSPO垃圾回收器)【这里的年龄需要根据不同的垃圾回收器判断】,那么就会将该对象放到老年区);
若使用PSPO或者G1垃圾回收器:年龄15放入老年区
若使用CMS垃圾回收器:年龄6放入老年区
若使用ZGC,则不分代
2:清空 Eden、ServicorFrom
然后,清空 Eden 和 ServicorFrom 中的对象;
3:ServicorTo 和 ServicorFrom 互换
最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom 区。
2)老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC(FGC – FullGC) 前一般都先进行 了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
MajorGC(FGC – FullGC) 采用**标记压缩(整理)算法**:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
概念:
MinorGC = YGC
MajorGC = FGC(对全体进行回收,包括老年代新生代)
Eden满时,执行YGC,清空Eden,存活对象进入s0。下次执行进入s1。s0、s1轮流交换
当Old区也满的时候,将会报错(Out Of Memory ),执行一次FGC,程序停止。
分代算法调优:
尽量减少FGC
FGC会造成STW(stop the world),内存越大,停顿时间越长。
YGC也会造成STW但是时间很短,不考虑。FGC时间长
为什么需要STW?
可达性分析的时候为了确保快照的一致性,需要对整个系统进行冻结,不可以出现分析过程中对象引用关系还在不断变化的情况,也就是Stop-The-World。
Stop-The-World是导致GC卡顿的重要原因之一。
3)永久代(jdk7)/元数据区(jdk8)
指内存的永久保存区域,**主要存放 Class 和 Meta(元数据)**的信息,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
Java8与元数据
在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,**元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。**因此,默认情况下,永久代需要指定大小限制,元空间可以指定也可以不指定,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
总结
TLAB(Thread Local Allocation Buffer):JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB,线程会优先存在自己的私有区域。
4.垃圾回收
没有任何引用指向的一个对象(一个垃圾)或者多个对象(循环引用)(多个垃圾)
1.如何定位垃圾
1)引用计数(ReferenceCount)
在对象上专门记录了一个数字,记录多少引用指向这个对象。
问题:循环引用,每个计数都是1,就找不到这堆垃圾了,发生内存泄漏
2)根可达算法(RootSearching)
JVM使用的
根对象包括哪些东西?–记住
JVM stack虚拟机栈中指向的对象
native method stack本地方法栈中JNI(一般说的Native方法)指向的对象
运行时常量池中指向的对象
静态变量所指向的对象
Class所指向的对象(把一个一个class放到内存,那些class对象肯定不是垃圾)
根对象指向的对象,再加上这些对象中的成员变量指向的其他对象,其他对象中成员变量指向的其他对象,这些都不是垃圾。其他通过根找不到的就都是垃圾。
2. 三色标记法
2.1 基本算法
要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:
最终结果:A/D/E/F/G 可达
我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:
- 白色:尚未访问过。
- 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
- 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
三色标记遍历过程
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
- 初始时,所有对象都在 【白色集合】中;
- 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
- 从灰色集合中获取对象:
3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
3.2. 将本对象 挪到 【黑色集合】里面。 - 重复步骤3,直至【灰色集合】为空时结束。
- 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
2.常见的垃圾回收算法
1)标记清除算法(mark sweep)
找出垃圾,将它标记为非垃圾区域
特点:位置不连续 产生碎片 后续可能发生大对象不能找到可用空间的问题。
2)拷贝算法 (copying)
新生代使用该算法,但不是5:5,而是8:1:1
将内存分为两半,只用其中一半,当进行内存回收时,将使用的那一半中存活的对象拷贝到另一半中。拷贝完成时,使用的那一半全部清除。下一次就往下面那一半分配内存,需要回收时,下那一半的对象拷贝到上面那一半。
特点:没有碎片,但是浪费空间。且存活对象增多的话,Copying 算法的效率会大大降低。
3)标记压缩算法(mark compact)
老年代中使用该算法
将后面的存活对象拷贝到前面标记为垃圾的那块内存中或者未使用的内存中。在做标记和清除的过程中,同时做了一次压缩整理。
特点:没有碎片,效率偏低
4)分代收集算法
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的区域,一般情况下将 GC 堆划分为新生代和老年代。
新生代每次垃圾回收时都有大量垃圾需要被回收,老年代只有少量需要被回收。
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,但通常并不是按照 1:1 来划分新生代,而是8:1:1
当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被 移到老年代中。
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
5.常见的垃圾回收器
垃圾回收器的发展路线,是随着内存越来越大的过程而演进的。
从分代算法演化到不分代算法(前6个分代,G1概念分代,物理不分代,ZGC、Shenandoah不分代,Epsilon特殊)
1.Serial
新生代,串行回收(单线程),复制算法
为什么需要STW?[见为什么需要STW?](# 为什么需要STW?)
Serial(英文:连续)是最基本垃圾收集器,使用复制算法。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程(STW),直到垃圾收集结束。
Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial 垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器。
2.Parallel Scavenge
新生代,并行回收(多线程),复制算法
Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码 的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)), 高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而 不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
新生代Parallel Scavenge收集器与ParNew收集器工作原理类似,都是多线程的收集器,都用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。
3.ParNew
新生代,并行回收(多线程),复制算法
ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。【Parallel:平行的】
ParNew虽然是除了多线程外和Serial收集器几乎完全一样,但是ParNew垃圾收集器是很多java 虚拟机运行在Server模式下新生代的默认垃圾收集器。
4.Serial Old
老年代,串行回收(单线程),标记整理算法
Serial Old 是 Serial 垃圾收集器老年代版本,它同样是个单线程的收集器,使用标记-整理算法, 这个收集器也主要是运行在Client默认的java虚拟机默认的年老代垃圾收集器。 在Server模式下,主要有两个用途: 1. 在JDK1.5之前版本中与新生代的Parallel Scavenge收集器搭配使用。 2. 作为年老代中使用CMS收集器的后备垃圾收集方案。 新生代Serial与年老代Serial Old搭配垃圾收集过程图:
新生代Parallel Scavenge收集器与ParNew收集器工作原理类似,都是多线程的收集器,都用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。新生代Parallel Scavenge/ParNew与年老代Serial Old搭配垃圾收集过程图:
5.Parallel Old
老年代,并行回收(多线程),标记整理算法
Parallel Old收集器是Parallel Scavenge的老年代版本,使用多线程的标记-整理算法,在JDK1.6 才开始提供。
在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只 能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞 吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge 和年老代Parallel Old收集器的搭配策略。 新生代Parallel Scavenge和年老代Parallel Old收集器搭配运行过程图:
6.CMS(Concurrent Mark Sweap)
老年代,并发的,标记-清除算法,垃圾回收和应用程序同时执行,降低STW的时间(200ms)
Concurrent mark sweep(CMS)收集器是一种老年代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。
最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。
CMS工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:
6.1初始标记(STW)
初始标记阶段需要STW。
该阶段进行可达性分析,标记GC ROOT能直接关联到的对象(根可达算法中最根的对象),所以STW时间很短。
注意是直接关联,间接关联的对象在下一阶段标记。
6.2并发标记
进行GC Roots Tracing跟踪的过程,和用户线程一起工作,不需要暂停工作线程。 在第一个阶段被暂停的线程重新开始运行。
由前阶段标记过的对象出发,所有可到达的对象都在本阶段中标记。
问题:
由于工作线程,垃圾回收线程同时工作,会造成两个问题,产生标记失误。
-
浮动垃圾:本来不是垃圾的,运行过程中,变成了垃圾。不是很严重,可以下次再回收。
-
本来是垃圾的,运行过程中,不在是垃圾了。
所以需要进行重新标记:
6.3重新标记(STW)
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。 重标记需要STW。
6.4并发清除
清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看 CMS收集器的内存回收和用户线程是一起并发地执行。 CMS收集器工作过程:
7.G1
标记整理算法,降低STW的时间(10ms)
相比与CMS 收集器,G1 收集器两个最突出的改进是:
-
基于标记-整理算法,不产生内存碎片。
-
可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。
8.ZGC
降低STW的时间(1ms)
9.Shenandoah
无需了解
10.Eplison
无需了解
1.8默认的垃圾回收器:PS + ParallelOld
6.了解生产环境下的垃圾回收器组合
-
JVM的命令行参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-
JVM参数分类
标准: - 开头,所有的HotSpot都支持
非标准:-X 开头,特定版本HotSpot支持特定命令
不稳定:-XX 开头,下个版本可能取消
-XX:+PrintCommandLineFlags
-XX:+PrintFlagsFinal 最终参数值
-XX:+PrintFlagsInitial 默认参数值