JVM思维导图
在学习一门技术之前要遵循3W方法论(What-Why-How)
What:这个技术是什么?
Why:我为什么要学它?
How:我应该怎样去学它?
初识JVM
什么是Java虚拟机(JVM)?
JVM(Java Virtual Machine)是 Java 虚拟机的缩写,是 Java 程序的运行环境。它是一种用于执行 Java 字节码的虚拟机,充当了 Java 应用程序和底层操作系统之间的中间层。
JVM的特点
- 跨平台性:这是JVM 的主要特征之一,Java 程序在编译为字节码后可以在任何支持 JVM 的平台上运行,摆脱了硬件平台的束缚,实现了"一次编译,到处运行"的理想。
- 自动内存管理:JVM 提供了自动的内存管理机制,包括内存分配、垃圾回收和内存优化。开发者无需手动分配和释放内存,JVM 会自动管理对象的生命周期和内存回收,通过垃圾回收器(Garbage Collector)自动回收不再使用的对象,避免了内存泄漏和悬挂指针等问题。
- 即时编译:JVM 通过即时编译器将热点代码动态编译成本地机器码,提高程序的执行性能。编译器可以根据程序的运行情况进行优化,使得Java应用能随着运行事件的增长而获得更高的性能。
Java编译和运行的流程
- 程序员编写的java文件,通过javac可以编译成字节码文件.class
- 在JVM 执行字节码.class,通过字节码指令转换成机器码指令,将工作交给底层计算机系统去执行。
- 最终我们在计算机上就能看到程序执行的效果。
JVM的组成
Java内存区域
JVM运行时数据区域
概述:JVM在运行Java程序过程中管理的内存区域
程序计数器
- 它是一块很小的空间,它记录的是线程正在执行的执行的字节码指令地址(保存现场)。
- 在多线程情况下,线程是通过操作系统按时间片的方式调度执行的,这时可能会涉及到线程的切换(CPU上下文切换)。为了线程切换后能恢复到正确的执行位置,每个线程都有独立的程序计数器,各线程之间计数器互不影响,这类内存区域称为"线程私有"的内存。
- 每个线程只存储一个固定长度的程序计数器,它是不会不会发生内存溢出的。
Java虚拟机栈
-
与程序计数器一样,它也是线程私有的。
-
虚拟机栈描述的是Java方法执行线程内存模型,每个方法被执行的时候,虚拟机都会同步创建一个栈帧用于存储局部变量表,操作数栈,动态连接,方法出口等信息。
-
栈是采用先进后出的数据结构,每一个方法备用到执行完毕的过程,其实就是一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。栈内存溢出时会出现StackOverflowError的错误
栈帧的组成
-
局部变量表:方法执行过程中存放所有的局部变量,编译成字节码文件时就可以确定局部变量表的内容。
-
操作数栈:用来存储和传递操作数和中间结果,以便进行计算。运算指令会从操作数栈中取出需要的操作数,执行相应的运算操作,然后将结果存回操作数栈中。
-
动态链接:当前类方法中引用其他类的属性或方法时,动态连接保存了这些内容的引用地址,可以快速找到对应的属性或方法。
-
方法返回地址:存储着方法出口的地址,方法正常结束或者有异常时会进行弹栈,表明方法已执行完成或出现异常。
本地方法栈
- 与Java虚拟机栈的作用非常类似,本地方法使用native来修饰,底层实现是由C或C++实现的。如下图方法所示
堆
- 线程共享。
- 堆内存是空间最大的一块内存区域,创建出来的对象(对象实例、字符串常量、静态变量)都存在于堆上。
- 堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出OutOfMemory错误。
- 要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total),限制:Xmx必须大于 2 MB,Xms必须大于1MB。
- Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请分配,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。
方法区(元空间)
- 线程共享。
- 它主要用于存储已被虚拟机加载的类信息、运行时常量池、即时编译器编译后的代码等数据。
- JDK8之前方法区是基于JVM内存中的,由永久代实现,受JVM内存大小参数的限制,在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制(当然,如果物理内存被占满了,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中
类信息
- 类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表。
- 常量池表用于存放编译期生成的各种字面量与符号引用,这些内容在类加载完后存放到方法区运行时常量池中。
运行时常量池
- 运行时常量池主要存放在类加载后被解析的字面量与符号引用,符号引用与直接引用的映射关系等。
- 常量池具备动态性,运行期间也可以将新的常量放入池中,用得比较多得就是String类的intern()方法。
直接内存
- 直接内存是本地内存,受本机物理内存的限制,但不会受到JVM内存的限制。
- 在jdk1.4中加入了NIO(New Input/Putput)类,引入了一种基于通道(channel)与缓冲区(buffer)的新IO方式,它可以使用native函数直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据。
常见问题?
1. 成员变量、局部变量、类变量分别存储在内存的什么地方?
类变量
- 类变量是用static修饰符修饰,定义在方法外的变量,随着java进程产生和销毁
- 在java8之前把静态变量存放于方法区,在java8时存放在堆中
成员变量
- 成员变量是定义在类中,但是没有static修饰符修饰的变量,随着类的实例产生和销毁,是类实例的一部分
- 由于是实例的一部分,在类初始化的时候,从运行时常量池取出直接引用或者值,与初始化的对象一起放入堆中>局部变量
局部变量
- 定义在类的方法中的变量
- 在所在方法被调用时放入虚拟机栈的栈帧中,方法执行结束后从虚拟机栈中弹出,所以存放在虚拟机栈中
2. 什么是字面量?什么是符号引用?什么是直接引用?
字面量
- 字面量是指程序中直接使用的常量值,String str = “hello”; 这个"hello"就是字面量
符号引用
- 在字节码.class文件中,因为类还没加载,无法知道类的内存地址,当A类引用B类时,只能通过唯一符号(符号引用)来标识某个类,通过唯一标识找到B类的过程就叫符号引用。
直接引用
- 直接引用是指直接指向内存中内存地址。
- A类引用B类,在类加载完成后才会生成内存地址,同时方法区的运行时常量池中产生一份符号引用与内存地址的关系表,通过内存地址可以直接找到B类的过程就叫直接引用。
3. 运行时常量池和字符串常量池有什么区别?
- 编译阶段,会将类元信息放到方法区,有一部分空间主要存放字面量和符号引用,在类加载时会将字面量和符号引用解析为直接引用存储在运行时常量池。
- 字符串常量池存储字符串对象的引用,而不是字符串具体的值。
4. 在类加载过程中把符号引用变为直接引用?
- 在类加载过程中,JVM将符号引用转换为直接引用,并将其存储在方法区等运行时数据区中,以便在程序执行过程中进行直接访问和管理,实现更高的性能。
Java内存结构图
Java垃圾收集器
垃圾收集概述
程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
垃圾收集器(Garbage Collection,简称:GC), GC主要用于Java堆的管理。Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。
在系统运行期间,会产生大量的对象实例,对于一些对象实例用完后已经没有引用指向(对象已无法访问),这些对象属于内存垃圾,为了能够回收没用的对象,就诞生了垃圾回收器。
既然JVM种有垃圾收集器,为什么我们还要去了解垃圾收集和内存分配呢?
答案很简单:当需要排查各种内存溢出,内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们必须对这些进行监控和调节。
判断对象是否存活
引用计数法
实现原理:为每个对象头维护一个独立的 counter 计数器,当对象被引用时加1,取消引用时减1。当计数器为 0 时,对象就是不可能再被使用的。
引用计数法虽然会占用一些额外的内存空间来计数,但它的实现原理简单,效率也很高,Java虚拟没有选用计数算法来管理内存,主要原因是引用计数算法存在对象之间循环引用的问题。
下图就是循环引用的例子
可达性分析法
Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Roots)和普通对象,对象与对象之间存在引用关系。
实现原理:下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个对象到GC Roots对象是可达的,对象就不可被回收。如果某个对象到GC Roots间没有任何引用链相连,那么就可以判定对象是不再使用。
固定可作为GC Roots对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用对象,比如方法中使用到的参数、局部变量、临时变量等。
- 方法区中静态属性、常量引用的对象。
- 本地方法(Native 方法)栈引用的对象。
- Java虚拟机内部引用的对象,如基本数据类型对应的Clas对象。
- 所有被同步锁(Synhronized关键字)持有的对象。
- 当前活动线程正在运行的线程对象。
等等…
GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
常见的几种对象引用
可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:
- 强引用:类似 “Object obj = new Object()” 这类的引用,就是强引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。
- 软引用:软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
- 弱引用:弱引用的强度比软引用更弱一些,弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
- 虚引用:最弱的一种引用关系,虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。常规开发中是不会使用的。
垃圾收集算法
垃圾回收的核心算法:释放不再存活对象的内存,使得程序能再次利用这部分空间。下图为垃圾收集的四种算法
标记清除算法
核心思想分为两个阶段
- 标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段:从内存中删除没有被标记也就是非存活对象。
缺点:存在内存空间碎片化问题,由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
上图所示,假设GC总共回收了9个字节,图中的4字节,3字节,2字节的区域,目前我创建了5字节的对象,这时就无法分配出合适的内存及进行存储。
复制算法
核心思想
- 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间。
- 在垃圾回收GC阶段,将From中存活对象复制到To空间。
- 将两块空间的From和To名字互换。
例子:
1.将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
2.GC阶段开始,将GC Root搬运到To空间 。
3.将GC Root关联的对象,搬运到To空间 。
4.清理From空间,并把名称互换。
优点:
- 实现简单,运行高效,只需要遍历一次存活对象复制到To空间即可。
- 不会产生内存碎片化,将对象按顺序放入To空间中,所以对象以外的区域都是可
用空间,不存在碎片化内存空间。
缺点:
- 每次只能让一半的内存空间来为创建对象使用。
标记整理算法
核心思想分为两个阶段
- 标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段:将存活对象移动到堆的一端,清理掉非存活对象的内存空间。
优点:
- 内存使用率高:整个内存都可以使用。
- 不会产生内存碎片化问题:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。
缺点:效率低比前两个算法低,整理阶段需要进行存活对象移动并更新所有引用这些对象的地方
分代收集算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
年轻代和老年代的堆内存占用比例分别为1:2。
年轻代分为三个区域,eden、S0、S1,它们的内存占用比例分别为8:1:1。
创建出来的对象,首先会被放入Eden伊甸园区。
随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。
此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入S0。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
垃圾回收器
Serial垃圾收集器(单线程)
只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程,即 Stop The World。
一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此 Serial 垃圾收集器适合客户端使用。
由于 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。
Serial Old 垃圾收集器(单线程)
Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们唯一的区别就是:Serial Old 工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。
ParNew 垃圾收集器(多线程)
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。
ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
Parallel Old 垃圾收集器(多线程)
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
Parallel Scavenge 垃圾收集器(多线程)
Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:
- Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
- ParNew:追求降低用户停顿时间,适合交互式应用。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
CMS 垃圾收集器
CMS(Concurrent Mark Sweep,并发标记清除)收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
为什么分代GC算法要把堆分成年轻代和老年代?
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 年轻代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
为什么年轻代一般使用复制算法,老年代使用标记-清除或标记-整理算法?
- 系统中的大部分对象都是非存活对象,创建出来之后很快就被回收,使用复制算法可以将小部分的存活对象复制到另一个区域,效率较高。类比:一个文件夹中有1000张图片,其中3张有用的,使用标记-清除算法需要删除997张图片,使用复制算法把3张有用的复制到另外一个文件夹,然后把之前的文件夹删掉效率是不是会更高。
- 老年代存放长期存活的对象,对于复制算法会浪费掉一半的内存空间,其次需要复制大量存活对象,效率就会降低,该算法不适合老年代。所以老年代适合使用标记-清除或标记-整理算法。
什么对象能直接进入老年代?
- 大对象直接进入老年代。
- 动态年龄判定规则,如果Survivor区域内年龄1+年龄2+年龄3+年龄n的对象总和大于Survivor区的50%,此时年龄n以上的对象会进入老年代,不一定要达到15岁
- 年龄大于阈值(默认15次),进入老年代
- Minor GC后,存活的对象空间大于survivor空间,直接进入老年代。
类加载机制
类的生命周期
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段,其中验证、准备、解析三个部分统称为连接:
类加载过程
类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。
加载
加载阶段,Java虚拟机需要完成以下三件事:
- 将class字节码内容加载到内存中。
- 并将静态数据转换成方法区运行时数据结构。
- 在堆中形成代表这些类的java.lang.Class对象,作为方法区中类数据的访问入口。
类加载器所做的工作实质是把类文件从硬盘读取到内存中。
验证
这一阶段设计的目的是检测Java字节码文件是否遵守了《Java虚拟机规范》约束要求。这个阶段一般不需要程序员参与。
准备
准备阶段为静态变量(static)分配内存并设置默认值。
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
解析
解析阶段主要是将常量池中的符号引用替换为直接引用。
- 符号引用就是在字节码文件中使用编号来访问常量池中的内容。
- 直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
初始化
初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
例题巩固:
- 例题A
- 例题B
- 例题C
- 例题D
类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。
加载器分类
- 启动类加载器:用于加载Java核心类库,通常位于jre包下的类。
- 扩展类加载器:负责加载jre/lib/ext目录下的JAR包。
- 应用程序类加载器:负责加载应用程序classpath下的类文件。
- 自定义类加载器:用户可以根据需要创建自己的类加载器,以加载特定位置或方式的类文件。
类加载器的双亲委派机制
当类加载器收到某个类加载请求时,它首先会将这个加载请求委派给父类加载器去尝试加载,会一直往上递归委派。只有当父加载器无法完成这个类的加载时,才会给对应的子类去加载,一直往下尝试去加载。
双亲委派机制流程图:
双亲委派主要解决的三个问题:
双亲委派具体是有什么用?
- 避免类的重复加载:双亲委派机制可以避免同一个类被多次加载,上层的类加载器如果加载过类,就会直接返回该类,避免重复。
- 通过双亲委派机制,让顶层的类加载器去加载核心类,避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
打破双亲委派机制
打破双亲委派的三种方式:
- 自定义类加载器
-
线程上下文类加载器
-
OSGi框架类加载器
文章参考资料
- 黑马JVM
- 深入理解Java虚拟机