JVM相关知识点总结
JVM的运行机制
-
Java虚拟机是用于运行Java字节码文件的虚拟机
-
JVM包括了
- 一套字节码指令集
- 一组程序计数器
- 一个虚拟机栈
- 一个虚拟机堆
- 一个方法区
- 一个垃圾回收期
-
Java虚拟机包括了
- 一个类加载器子系统(
Class Loader SubSystem
) - 运行时数据区(
Runtime Data Area
) - 执行引擎(包括即时编译器(
JITCompiler
)、垃圾收集器(Garbage Collection
)) - 本地接口库(
Native Interface Library
)(通过调用本地方法库Native Method Library
与操作系统进行交互)
- 一个类加载器子系统(
-
类加载器子系统用于将编译好的
.class
文件加载入JVM之中 -
运行时数据区,用于存储在JVM运行过程之中产生的数据,包括了程序计数器、方法区、本地方法区、虚拟机栈、虚拟机堆
-
执行引擎包括即时编译器以及垃圾回收器,即时编译器用于将Java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程之中不再使用的对象。
-
本地接口库用于调用操作系统的本地方法库完成具体的指令操作
多线程
在多核操作系统上,Java虚拟机允许一个进程内同时并发执行多个线程。
-
基本过程如下:
- 首先是JVM的前期准备工作,准备好需要本地存储、缓冲区分配、同步对象、栈和程序计数器等
- 之后JVM会调用操作系统的接口创建一个与之相对应的原生线程
- 待原生线程初始化完毕之后,就会调用Java线程的
run()
方法执行该线程 - 线程结束之后会释放原生线程和Java线程所对应的资源
-
在JVM后台运行的线程主要有以下几个;
- 虚拟机线程(
JVM Thread
):虚拟机线程在JVM到达安全点(Safe Point
)的时候出现 - 周期性线程任务:通过定时器调度线程来实现周期性操作的执行
- GC线程:GC线程支持JVM之中不同的垃圾回收活动
- 编译器线程:编译器线程在运行时将字节码动态编译成本地平台机器码,是JVM跨平台的具体实现
- 信号分发线程:接受和发送到JVM的信号并调用JVM方法
- 虚拟机线程(
JVM的内存区域
-
JVM内存区域,分为:
- 直接内存
- 线程共享区域
- 程序计数器
- 虚拟机栈
- 本地方法区
- 线程私有区域
- 堆
- 方法区
-
线程私有区域的生命周期与线程相同,随着线程创建而创建,随着线程结束而销毁
-
线程共享区域随着虚拟机的启动而创建,随着虚拟机的关闭而销毁
-
直接内存也叫做堆外内存,并不是JVM运行时数据区的一部分,但是在并发编程之中被频繁的使用。Java进程可以通过使用对外内存技术来避免在Java堆和Native堆之间来回复制数据带来的资源占用和性能消耗,因此对外内存在高并发的环境下被频繁使用。
程序计数器:线程私有,无内存溢出问题
- 程序计数器是一块很小的内存区域,用于存储当前运行的线程所执行的字节码的行号指示器
- 是唯一没有内存溢出的区域
- 方法执行时,程序计数器记录的是实时虚拟机字节码指令的地址,如果该方法执行的是Native方法,则程序计数器的值为空(
Undefined
)
虚拟机栈:线程私有,描述Java方法的执行过程
-
虚拟机栈是描述Java方法执行过程的内存模型,在当前栈帧(
Stack Frame
)中存储了局部变量表、操作数栈、动态链接(Dynamic Linking
)、方法出口等信息。同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接方法的返回值和异常分派(Dispatch Exception
) -
栈帧用来记录方法的执行过程,在方法被执行时,虚拟机会为其创建一个与之对应的栈帧,方法的执行和返回则对应着栈帧的入栈和出栈。
-
因为虚拟机栈为线程私有,所以内个运行之中的线程均最多只有一个栈帧处于活动状态
本地方法区:线程私有
- 本地方法区和虚拟机栈的作用类似,区别是虚拟机栈为执行Java方法服务,而本地方法区则为执行Native方法而服务
堆:也叫做运行时数据区,线程共享
- 在整个JVM运行的过程之中创建的对象和产生的数据都会被存储在堆中,堆是被线程共享的内存区域,它也是垃圾收集器进行垃圾回收的最主要的区域。
- 由于现代JVM才用分代收集算法,因此Java堆从GC的角度可以细分为:新生代、老年代和永久代
方法区:线程共享
-
方法区也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器码、运行时常量池的数据
-
方法区
- 常量
- 静态变量
- 类信息
- 类的版本
- 字段
- 方法
- 接口描述
- 及时编译后代码
- 运行时常量池
-
JVM使用Java堆的永久代来实现方法区,永久代的内存回收主要针对常量池的回收和类的卸载,因此可回收的对象很少。
-
常量被存储在运行时常量池(
Runtime Constant Pool
)之中,是方法区的一部分。
JVM的运行时内存
- JVM的运行时内存即JVM堆
- 新生代(默认占三分之一堆空间)
- Eden区(默认占十分之八的新生代空间)
- SurvivorFrom区(默认占十分之一的新生代空间)
- SurvivorTo区(默认占十分之一的新生代空间)
- 老年代(默认占三分之二堆空间)
- 永久代
- 新生代(默认占三分之一堆空间)
新生代:Eden区、SurvivorFrom区和SurvivorTo区
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:
- 把在Eden区和SurvivorFrom区存活的对象复制到SurvivorTo区。如果某对象的年龄到达老年代的标准(默认为15,0-15,最大值也为15,因为一共占有 4 bit),则将其复制到老年代,同时把这些对象的年龄加 1 ;如果SurvivorTo区内存不够,则也直接进入老年代;如果对象属于大对象,则也直接将其复制进入老年代。
- 清空Eden区和SurvivorFrom区的对象
- 将SurvivorFrom区和SurvivorTo区互换,原来的SurvivorTo区将是下一次GC的SurvivorFrom区
老年代
- 老年代主要存放有长生命周期的对象和大对象
- 老年代GC的过程叫做MajorGC。老年代中,对象比较稳定,GC并不会被频繁触发。在进行MajorGC之前会先进行MinorGC,如果进行了MinorGC之后,仍然出现老年代空间不足或者无法找到足够大的连续空间分配给大对象的时候,会触发MajorGC进行垃圾回收,释放JVM的内存空间
- MajorGC采用标记清除算法,该算法会首先扫描所有对象并标记存活对象,然后回收未被标记的对象,并释放内存空间
- 因为要先扫描老年代的所有对象,所有MajorGC耗时较长。同时,标记清除算法容易产生内存碎片。所以,当老年代没有内存空间可分配时,就会抛出内存溢出的异常
永久代
-
永久代指的是永久保存的区域,主要存放
Class
和Meta
(元数据)的信息、Class
在类加载时被放入永久代。 -
GC不会在程序运行期间对永久代的内存进行清理,导致了永久代的内存会随着加载的
Class
文件的增加而增加,在加载的Class
文件过多时,JVM就会抛出内存溢出的异常(比如:Tomcat应用Jar文件过多导致JVM内存不足而无法启动) -
在 Java 8 之中,永久代已经被元数据区(也叫做元空间)取代。元数据区的作用与永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此,元空间的大小并不受 JVM 内存的限制,只和操作系统的内存有关
-
在 Java 8 中, JVM 将类的元数据放入本地内存(
Native Memory
)中,将常量池和类的静态变量放入 Java 堆中,这样 JVM 能够加载多少元数据信息就不再由 JVM 的内存决定,而是取决于操作系统的实际可用内存空间大小。
垃圾回收与算法
如何确定垃圾
- 垃圾确定
- 引用计数法
- 循环引用的问题
- 可达性分析
- 以一系列
GC Roots
的点作为起点向下搜索,当一个对象到任何GC Roots
都没有引用链相连的时候,说明其已经死亡 - 根搜索算法
- 栈中的引用
- 方法区中的静态引用
- JNI (Java Native Interface, Java 本地接口)中的引用
- 以一系列
- 引用计数法
1. 引用计数法
- 引用计数法即 :在为对象添加一个引用的时候,引用计数加 1 ;删除一个引用的时候,引用计数减 1 ;如果一个对象的引用计数为 0 的时候,则表示该对象没有被引用,可以被回收。
- 但是,引用计数容易出现循环引用的情况,导致两个对象的引用一直存在(两个对象的互相引用,导致引用计数一直为 1),从而都不会被回收。
2. 可达性分析
- 具体做法:
- 首先是定义一些
GC Roots
对象 - 然后以这些对象作为起点向下搜索,如果
GC Roots
和一个对象之间没有可达路径,则称该对象是不可达的 - 不可达对象需要至少两次标记才能判定其是否可被回收,如果两次标记之后该对象仍然是不可达的,则将被垃圾回收器回收
- 首先是定义一些
Java 中常用的垃圾回收算法
- Java 中常用的垃圾回收算法
- 标记清除算法
- 效率低
- 内存碎片多
- 复制算法
- 从 Eden 区、SurvivorFrom 区到 SurvivorTo 区
- 标记整理算法
- 分代收集算法
- 标记清除算法
1. 标记清除算法
- 标记清除算法是最基础的垃圾回收算法,其过程分为标记和清除两个阶段。
- 标记阶段,标记所有需要回收的对象;清除阶段,清除标记阶段所标记的需要进行回收的对象,并释放其所占用的内存空间
- 因为标记清除算法在清楚所标记的需要回收的对象之后,并没有对剩余对象进行整理,所以如果清除的小对象太多的话,会出现内存碎片化的问题,导致大对象无法获得连续可用的空间。
2. 复制算法
-
复制算法是为了解决标记清除算法所带来的碎片化的问题而设计的。
-
复制算法的操作过程如下:
- 首先将内存划分为两块大小相等的区域,即区域 1 和区域 2 。
- 新生成的对象都被放在区域 1 ,在区域 1 中对象满了的话,会对其进行一次标记,之后将标记后仍然存活的对象全部复制到区域 2 之中
- 之后对区域 1 中的内存空间进行清空。
-
复制算法的内存清理效率高且易于实现
-
但是内存空间的利用率只有一半,因此存在大量的内存浪费。同时,系统中大量长时间存活的对象在两个区域之间来回复制,会影响系统运行的效率
-
因此,复制算法只在对象为
朝生夕死
的状态下运行效率较高
3. 标记整理算法
该算法结合了标记清楚算法和复制算法的优点,标记阶段和标记清除算法的标记阶段一样,在标记完成之后将存活的对象移到内存的另外一端,然后清除该端的对象并释放内存。
4. 分代收集算法
即之前学的 JVM 的运行时内存的部分内容
Java 中 4 种引用类型
- Java 中一切皆对象,对象的操作都是由该对象的引用实现的。
- Java 中一共有 4 种引用类型
- 强引用
- 在 Java 中最常见的引用就是强引用。在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。
- 强引用对象一定为可达性状态,所以不会被垃圾回收机制回收。
- 因此,强引用是造成 Java 内存泄漏(
Memory Link
)的主要原因
- 软引用
- 软引用通过
SoftReference
类实现。 - 如果一个类只有软引用,那么在系统内存空间不足的时候,该对象就会被回收
- 软引用通过
- 弱引用
- 弱引用通过
WeakReference
类实现,如果一个对象只有弱引用,则在垃圾回收过程之中一定会被回收
- 弱引用通过
- 虚引用
- 虚引用通过
PhantomReference
类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态
- 虚引用通过
- 强引用
分代收集算法和分区收集算法
分代收集算法
- JVM 根据对象的存活周期不同,将内存划分为新生代、老年代和永久代,对不同年代的特点使用不同的 GC 算法。
1. 新生代与复制算法
- 新生代主要存储短生命周期的对象,因此在垃圾回收阶段会标记大量的已经死亡的对象及少量存活的对象,因此只需要才用复制算法即可
2. 老年代与标记整理算法
- 老年代主要存储长生命周期的对象和大对象,可回收对象一般较少,因此才用标记整理算法去进行垃圾回收
分区收集算法
- 分区算法将整个堆空间划分为连续的大小不同的小区域,对每个小区域都单独进行内存使用和垃圾回收,更加灵活的释放内存
- 分区收集算法可以根据系统可接受的停顿时间,每次都快速回收若干个小区域的内存,以缩短垃圾回收时系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收
- 如果垃圾回收机制一次回收整个堆的内存,则需要更长的系统停顿时间,长时间的系统停顿将影响系统运行的稳定性
垃圾收集器
因为新生代和老年代所对应的垃圾回收算法不同,JVM针对新生代和老年代提供了多种不同的垃圾收集器。
针对新生代提供的垃圾收集器Serial
、ParNew
、Parallel Scavenge
,针对老年代的垃圾收集器有Serial Old
, Parallel Old
,CMS
,还有针对不同区域的G1分区收集算法
-
新生代
- Serial:单线程复制算法
- ParNew:多线程复制算法
- Parallel Scavenge:多线程复制算法
-
老年代:
- CMS:多线程标记整理算法
- Serial Old:单线程标记整理算法
- Parallel Old:多线程标记整理算法
-
G1:多线程标记整理算法
Serial 垃圾收集器:单线程,复制算法
- Serial 垃圾收集器基于复制算法实现,它是一个单线程收集器,在他正在进行垃圾收集时必须暂停其他所有工作进程,直到垃圾回收结束
- 采用复制算法,简单高效
- 对于单CPU运行环境来说,没有线程的交互开销,可以获得最高的单线程垃圾收集效率,因此 Serial 垃圾收集器是 Java 虚拟机运行在 Client 模式下的新生代的默认垃圾收集器
ParNew 垃圾收集器:多线程,复制算法
- 该垃圾收集器是 Serial 垃圾收集器的多线程实现,同样采用了复制算法,除了多线程的工作模式之外,和 Serial 垃圾收集器几乎一样
- ParNew 垃圾收集器默认开启与 CPU ,同等数量的线程进行垃圾回收,在 Java 程序启动的时候可以通过
-XX:ParallelGCThreads
参数调节 ParNew 垃圾收集器的工作线程数
Parallel Scavenge 垃圾收集器:多线程,复制算法
- Parallel Scavenge 垃圾收集器是为了提高新生代垃圾收集效率而设计的垃圾收集器,基于多线程复制算法实现,在系统吞吐量上有很大优化,可以更高效的利用 CPU 尽快完成垃圾回收任务。
- Parallel Scavenge 通过自适应调节策略提高系统吞吐量,提供了三个参数用于调节垃圾回收的停顿时间以及吞吐量
- 控制最大垃圾收集停顿时间:
-XX:MaxGCPauseMillis
参数 - 控制吞吐量大小:
-XX:GCTimeRadio
参数 - 控制自适应调节策略开启与否:
UseAdaptiveSizePolicy
参数
- 控制最大垃圾收集停顿时间:
Serial Old 垃圾收集器:单线程,标记整理算法
- 该收集器是 Serial 收集器的老年代实现,一样使用单线程,不同的是采用的算法为标记整理算法。 Serial Old 垃圾收集器是 JVM 运行在 Client 模式下的老年代的默认垃圾收集器。
Parallel Old 垃圾收集器:多线程,标记整理算法
- 采用多线程并发进行垃圾回收,根据老年代长生命周期的特点,基于多线程的标记整理算法实现。
- 该收集器在设计上,优先考虑系统的吞吐量,其次考虑停顿时间等因素
- 如果系统对吞吐量的要求较高,则可以优先考虑新生代的 Parallel Scavenge 垃圾收集器和老年代的 Parallel Old 垃圾收集器的配合使用
CMS 垃圾收集器
-
CMS (Concurrent Mark Sweep)垃圾收集器是为老年代设计的垃圾收集器,其主要目的是达到最短的垃圾回收停顿时间,基于线程的标记清除算法实现,以便在多线程并发环境下以最短的垃圾收集停顿时间提高系统的稳定性。
-
垃圾回收过程包含如下 4 个步骤:
- 初始标记:只标记和 GC Roots 直接关联的对象,速度很快,需要暂停所有的工作线程
- 并发标记:和用户线程一起工作,执行 GC Roots 跟踪标记过程,不需要暂停所有工作线程
- 重新标记:在并发标记过程中用户线程继续运行,导致在垃圾回收过程中部分对象的状态发生变化,为了确保这部分对象的状态的正确性,需要对其重新标记并暂停工作线程
- 并发清除:和用户线程一起工作,执行清除 GC Roots 不可达对象的任务,不需要暂停工作线程
-
并发标记和并发清除的过程并不需要暂停用户线程的工作,有效缩短了垃圾回收时系统的停顿时间,同时其并行度和效率也有很大的提升。
G1 垃圾收集器
- G1 (Garbage First)垃圾收集器为了避免全区域垃圾收集引起的系统停顿,将堆内存划分为大小固定的几个独立区域,独立使用这些区域的内存资源,并跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,在垃圾回收过程中,根据系统允许的最长垃圾收集时间优先回收垃圾最多的区域,优先回收垃圾最多的区域。
- G1 垃圾收集器通过内存区域独立划分使用和根据不用优先级回收各区域垃圾的机制,确保 G1 垃圾收集器在有限的时间内获得最高的垃圾收集效率
- 相比较于 CMS 垃圾收集器, G1 垃圾收集器有两个突出的改进:
- 基于标记整理算法,并不产生内存碎片
- 可以精确的控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收
Java 网络编程模型
阻塞 I/O 模型
- 定义:阻塞 I/O 模型是常见的 I/O 模型, 在读写数据的时候客户端会发生阻塞。
- 工作流程:用户线程发出 I/O 请求之后,内核会检查数据是否就绪,此时用户线程一直阻塞等待内存数据就绪;在内存数据就绪之后,内核将数据复制到用户线程之中,并返回 I/O 的执行结果到用户线程,此时用户线程解除阻塞状态并开始处理数据
- 典例:
data = socket.read()
,如果内核数据没有准备就绪,那么 Socket 线程会一直阻塞在 read() 中等待内核数据准备完毕
非阻塞 I/O 模型
-
在非阻塞 I/O 模型中,用户线程需要不断的询问内核数据是否准备就绪,在内核数据还未就绪的时候,用户线程可以处理其他任务,在内核数据就绪后可以立即获取数据并进行相应的操作
-
典例:
while(true){ data = socket.read(); if (data = true){ break; } else { //用户处理其他任务 } }
多路复用 I/O 模型
- 多路复用 I/O 模型是多线程并发编程用的较多的模型, Java NIO 就是基于多路复用 I/O 模型实现的。
- 在多路复用 I/O 模型中会有一个被称为 Selector 的线程不断轮询多个 Socket 的状态, 只有在 Socket 有读写事件时,才会通知用户线程进行 I/O 读写操作。
- 非阻塞 I/O 模型在每个用户线程中都进行 Socket 状态检查,而在多路复用 I/O 模型中是在系统内核中进行 Socket 状态检查的,这也是多路复用 I/O 模型比非阻塞 I/O 模型效率高的原因
- 对于多路复用 I/O 模型来说,在事件响应体(消息体)很大时, Selector 线程就会成为性能瓶颈,导致后续的事件得不到响应处理,从而影响下一轮的事件轮询。在实际应用之中,在多路复用方法体内一般不见做复杂的逻辑运算,只做数据的接受和转发,将具体的业务逻辑操作转发给后面的业务线程去进行处理
信号驱动 I/O 模型
异步 I/O 模型
Java I/O
Java NIO
JVM 类加载机制
JVM 的类加载阶段
JVM 的类加载一共分为 5 个阶段:加载、验证、准备、解析(这三个阶段均包含在连接过程中)、初始化。在一个类初始化完成之后就可以使用该类的信息,在一个类不再被需要的时候可以从 JVM 中卸载。
加载(Loading)-->
验证(Verification)-->准备(Preparation)-->解析(Resolution)//连接部分(Linking)
-->初始化(Initialization)-->使用(Using)-->卸载(Unloading)
1. 加载
- 指的是 JVM 读取 Class 文件,并且根据 Class 文件描述创建 java.lang.Class 对象的过程
- 类的加载过程主要包括:
- 将 Class 文件读取到运行时数据区的方法区内
- 在堆中创建 java.lang.Class 对象
- 封装类在方法区的数据结构
2. 验证
- 主要用于确保 Class 文件符合当前虚拟机的要求,保障虚拟机的自身安全,只有通过验证的 Class 文件才会被 JVM 加载
3. 准备
- 主要工作是在方法区中为类变量分配内存空间并设置类中变量的初始值。
- 初始值指的是不同数据类型的默认值
4. 解析
- JVM 会将常量池中的符号引用转换为直接饮用
5. 初始化
- 通过执行类构造器的
<client>
方法为类进行初始化 <client>
方法是在编译阶段由编译器自动收集类中的静态语句块和变量的赋值操作组成的- JVM 规定只有在父类的
<client>
方法执行成功之后,子类的<client>
才会被执行 - 若一类中既没有静态语句块也没有变量赋值操作,那么编译器不会为该类生成
<client>
方法
类加载器
-
JVM 提供了 3 种类加载器
- 启动类加载器 ( Bootstrap Classloader )
- 扩展类加载器 ( Extension Classloader )
- 应用程序类加载器 ( Application Classloader )
-
除此之外,我们可以通过继承 java.lang.ClassLoader 实现自定义类加载器(User Classloader)
双亲委派机制
- 双亲委派机制指的是,一个类在收到类加载请求之后不会尝试自己加载这个类,而是把该类加载请求向上委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。
- 如果父类加载器在接收到类加载请求之后发现自己也无法加载该类,则父类会将该信息反馈给子类并向下委派子类加载器加载该类,知道该类被成功加载
- 若找不到该类,则 JVM 会抛出 ClassNotFound 异常
双亲委派机制的类加载流程如下:
- 将自定义加载器 挂载 到应用程序类加载器
- 应用程序类加载器将类加载请求委托给扩展类加载器
- 扩展类加载器将类加载请求委托给启动类加载器
- 启动类加载器在加载路径(%JAVA_HOME%/jdk/lib 路径下的 jar 包)下查找并加载 Class 文件,如果未找到目标 Class 文件,则交由扩展类加载器加载
- 扩展类加载器在加载路径(%JAVA_HOME%/jre/lib/ext 路径下的 jar 包)下查找并加载 Class 文件,如果未找到目标 Class 文件,则交由应用程序加载器加载
- 应用程序加载器在加载路径(在ClassPath路径下指定,如未设置,则为应用程序当前路径)下查找并加载 Class 文件,如果未找到目标 Class 文件,则交由自定义加载器加载
- 在自定义加载器下查找并加载用户指定目录下的 Class 文件,如果在自定义加载器路径下未找到目标 Class 文件,则抛出 ClassNotFound 异常
- 双亲委派机制的核心是保障类的唯一性和安全性
- 如果在 JVM 中存在包名和类名相同的两个类,则该类无法被加载,JVM 也无法完成类加载流程