文章目录
基础知识
Java虚拟机(Java Virtual Machine )是一台执行java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必仅仅由Java语言来编写,只要字节码文件满足一定的格式规范即可。所有的Java程序都是运行在Java虚拟机内部,JVM是运行在操作系统之上的,没有与硬件做直接的交互。
Java语言在设计之初的初衷是: “一次编译,到处运行”。靠的就是它发展至今强大的虚拟机架构。总结JVM的优点就是:跨语言的平台,全自动的内存管理,优秀的垃圾回收器,以及可靠的即时编译器。
JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。
一、Java代码的执行过程
-
Java源文件 --> 编译器 --> 字节码文件Class File
Java源代码在在装载入内存之前会先进行编译,编译的过程主要是词法分析、语法分析、生成抽象语法树、语义分析、最终交给字节码生成器产生编译阶段的产物(.class字节码文件)。 -
字节码文件 --> JVM --> 机器码
首先需要准备好编译好的 Java 字节码文件(即class文件),计算机要运行程序需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区),但是字节码文件是JVM定义的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行,这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地图制作等),这就用到了本地 Native 接口(本地库接口)。【字节码文件 --> JVM --> 机器码】详细划分为一下步骤:
- 创建 JVM实例,调用类加载子系统加载 class,将类的信息存入方法区
- 创建 main 线程,使用的内存区域是 JVM 虚拟机栈,开始执行 main 方法代码
- 如果遇到了未见过的类,会继续触发类加载过程,同样会存入方法区
- 需要创建对象,会使用堆内存来存储对象
- 不再使用的对象,会由垃圾回收器在内存不足时回收其内存
- 调用方法时,方法内的局部变量、方法参数所使用的是 JVM 虚拟机栈中的栈帧内存
- 调用方法时,先要到方法区获得到该方法的字节码指令,由解释器将字节码指令解释为机器码执行
- 调用方法时,会将要执行的指令行号读到程序计数器,这样当发生了线程切换,恢复时就可以从中断的位置继续
- 对于非 java 实现的方法调用,使用内存称为本地方法栈
- 对于热点方法调用,或者频繁的循环代码,由 JIT 即时编译器将这些代码编译成机器码缓存,提高执行性能
-
最后操作系统将执行这些机器指令
Java代码的编译过程(.java --> .class)
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程,包括:
- 词法、语法分析,将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表,产生符号地址和符号信息。
- 插入式注解处理器的注解处理过程:
在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成。这个方法会判断是否还有新的注解处理器需要执行,如果有的话,通过JavacProcessing-Environment类的doProcessing()方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行处理。 - 分析与字节码生成过程,包括:
- 标注检查,对语法的静态信息进行检查。
- 流及控制流分析,对程序动态运行过程进行检查。
- 解语法糖,将简化代码编写的语法糖还原为原有的形式。
- 字节码生成,将前面各个步骤所生成的信息转化成字节码。
上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号,从总体来看,三者之间的关系与交互顺序如图所示。
JVM执行过程(.class --> 可执行文件)
JVM实例化
当一个程序开始运行,虚拟机就开始实例化了,多个程序启动就会有存在多个虚拟机实例,虚拟机实例会随着程序的退出或关闭而消亡,多个虚拟机实例之间的数据不能共享。
JVM允许一个程序并发执行多个线程,Hotspot JVM 中的Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。 Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的CPU 上。当原生线程初始化完毕,就会调用Java 线程的run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。
Hostpot JVM 后台运行的系统线程:
Thread | 备注 |
---|---|
虚拟机线程(VM thread) | 这个线程等待JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要JVM 位于安全点。这些操作的类型有:stop-theworld垃圾回收、线程栈dump、线程暂停、线程偏向锁(biased locking)解除。 |
周期性任务线程 | 这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。 |
GC 线程 | 这些线程支持 JVM 中不同的垃圾回收活动。 |
编译器线程 | 这些线程在运行时将字节码动态编译成本地平台相关的机器码。 |
信号分发线程 | 这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。 |
装载ClassLoader:类加载的过程
加载 —— 链接(验证,准备,解析) —— 初始化
JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这
五个过程。
1. 加载
加载过程完成以下3件事:
(1)通过类的完全限定名称获取定义该类的二进制字节流;
(2)将该字节流表示的静态存储结构转换为Metaspace元空间区的运行时存储结构;
(3)在内存中生成一个代表该类的java.lang.Class对象,作为元空间区中该类各种数据的访问入口。
2. 验证
验证字节码文件是否合法,包括文件格式验证、元数据验证、字节码验证、符号引用验证。
3. 准备
(1)类变量是被static关键字修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是元空间区的内存;
(2)实例变量不会在这个阶段分配内存,它会在对象实例化时,随着对象一起分配在堆中。注意:实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次;
(3)初始值一般为0。
4. 解析
将常量池中的符号引用转换为直接引用
5. 初始化
(1)初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>()
方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源;
(2)<clinit>()
是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。此阶段会按照顺序收集静态变量与静态代码块中的内容到<clinit>()
方法中进行类变量的赋值,所以,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问;
(3)若该类有父类,则先执行父类的<clinit>()
方法;
(4)虚拟机必须保证一个类的<clinit>()
方法在多线程下被同步加锁。
初始化阶段就是执行类构造器<clinit>()
方法的过程。()方法并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
类加载器
如果一个类被不同的加载器加载,那虚拟机会认定是不同的类,这样Java体系最基础的行为也无从保证,应用程序会变得混乱。所以有了双亲委派模型,双亲委派—共分了四层的加载器。除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,子类使用”组合“关系来复用父类加载器的代码,而不是继承。
- 启动类加载器(引导类加载器)
负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。 - 扩展类加载器
负责加载 JAVA_HOME\lib\ext 目录中的,或通过java.ext.dirs 系统变量指定路径中的类库。 - 应用程序类加载器
负责加载用户路径(classpath)上的类库。
JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器。 - 自定义类加载器
双亲委派
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
采用双亲委派的一个好处是比如加载位于rt.jar 包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。
执行:对象的实例化过程
- 主动引用:
- 通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。
- 通过反射方式执行以上三种行为。
- 初始化子类的时候,会触发父类的初始化。
- 作为程序入口直接运行时(也就是直接调用main方法)。
- 被动引用:
- 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。
- 定义类数组,不会引起类的初始化。
- 引用类的常量,不会引起类的初始化。
1. 类加载检查
当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化过,如果没有,那么就要先执行相应的类加载过程。
2. 分配内存
在类加载完成后,对象所需要的内存大小完全确定,可以对新生对象分配内存。
分配内存有两种方式:
(1) 内存规整
如果内存规整,就是指针碰撞,将指针向空闲空间方向移动与对象大小相等的距离
(2)内存不规整
如果内存不规整,那就是空闲列表,维护一个列表,记录哪些内存块可用,在分配时,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
划分空间时的并发问题的解决方法:
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS+失败重试的方式保证更新操作的原子性;
- 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
通俗的理解:
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
3. 初始化内存空间
分配到的内存空间(对象头除外)全部分配为0 值,这步保证了对象的实例字段在Java 代码中可以不赋值就直接使用,使得程序可以访问到这些字段的数据类型所对应的零值。
4. 进行对象头的设置
在对象的对象头中保存—些必要的信息:这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5. 执行构造函数
从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——<init>
构造函数(,即Class文件中的方法)还没有执行,目前所有的字段都为默认的零值,所以一般来说,执行 new 指令之后会接着执行<init>
构造方法,把对象按照程序逻辑的意愿进行初始化,这样一个真正可用的对象才算完整创建出来。
具有父类的子类的实例化顺序如下:
JVM内存回收(Java对象销毁)
一个对象如果没有任何与之关联的引用,即他们的引用计数都不为0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
Copying 复制算法
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小
的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用
的内存清掉。
最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
Mark-Sweep 标记清除算法
分为两阶段:标记和清除
- 标记阶段标记出所有需要回收的对象
- 清除阶段回收被标记的对象所占用的空间
该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
Mark-Compact 标记整理算法
标记阶段和Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
分代收集算法
根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
新生代 MinorGC (复制->清空->互换)
新生代又分为Eden 区、ServivorFrom、ServivorTo 三个区。
Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden 区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。
ServivorFrom:上一次GC 的幸存者,作为这一次GC 的被扫描者。
ServivorTo:保留了一次MinorGC 过程中的幸存者。
MinorGC 采用复制算法:
1:eden、servicorFrom 复制到ServicorTo,年龄+1
首先,把Eden 和ServivorFrom区域中存活的对象复制到ServicorTo 区域(如果有对象的年
龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果ServicorTo 不
够位置了就放到老年区);
2:清空eden、servicorFrom
然后,清空Eden 和ServicorFrom 中的对象;
3:ServicorTo 和ServicorFrom互换
最后,ServicorTo 和ServicorFrom 互换,原ServicorTo 成为下一次GC 时的ServicorFrom
区。
老年代 MajorGC 标记清除/整理算法
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以MajorGC 不会频繁执行。在进行MajorGC 前一般都先进行
了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足
够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC 进行垃圾回收腾出空间。
MajorGC 采用标记清除/整理算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没
有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减
少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的
时候,就会抛出OOM(Out of Memory)异常。
GC 分代收集算法VS 分区收集算法
当前主流VM 垃圾收集都采用”分代收集”(Generational Collection)算法, 这种算法会根据对象存活周期的不同将内存划分为几块, 如JVM中的 新生代、老年代、永久代,这样就可以根据各年代特点分别采用最适当的GC 算法。
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC 所产生的停顿。
GC 垃圾收集器
Java 堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收算法;老年代主要使用标记-整理垃圾回收算法,因此java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,JDK1.6 中Sun HotSpot 虚拟机的垃圾收集器如下:
- Serial/Serial Old GC(串行垃圾收集器):Serial 是一个单线程的收集器,它不但只会使用一个CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。Serial垃圾收集器是java 虚拟机运行在Client 模式下默认的新生代垃圾收集器。
- Parallel Scavenge/Old GC(并行垃圾收集器) :它重点关注的是程序达到一个可控制的吞吐量,自适应调节策略也是ParallelScavenge 收集器与ParNew 收集器的一个重要区别。
- ParNew GC:ParNew 收集器默认开启和CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。ParNew虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多java虚拟机运行在Server 模式下新生代的默认垃圾收集器。主要和CMS收集器配合使用。
- CMS GC(Concurrent mark sweep并发标记-清除垃圾收集器):主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。后面详细讲解整个过程。
- Garbage-First GC(G1垃圾收集器):G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保G1 收集器可以在有限时间获得最高的垃圾收集效率。
- ZGC(Z垃圾收集器)
- Shenandoah GC(谢南多厄垃圾收集器)
- Epsilon GC(埃普西隆垃圾收集器)
- C4 GC(Azul C4垃圾收集器)
- IBM Metronome GC(IBM Metronome垃圾收集器)
- SAP GC(SAP垃圾收集器)
CMS 收集器的过程主要分为4个阶段
- 初始标记:只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。【触发STW】
- 并行标记:GC线程和用户线程同时工作,GC从GC Roots 开始遍历整个对象图,不需要暂停工作线程。
- 重新标记:因为上个过程是并发进行的,所以有些对象在并发标记过程中新产生的,为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停-所有的工作线程。【触发STW】
- 并发清理:开始清理GC Roots 不可达对象,GC线程和用户线程一起工作,不需要暂停工作线程。
- 并发重置:将存活对象上的标记移除掉,避免影响下一次。
G1 收集器的过程主要分为4个阶段
- 初始标记:只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。【触发STW】
- 并行标记:GC线程和用户线程同时工作,GC从GC Roots 开始遍历整个对象图,不需要暂停工作线程。
- 重新标记:因为上个过程是并发进行的,所以有些对象在并发标记过程中新产生的,为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停-所有的工作线程。【触发STW】
- 筛选回收:
- 先对 Region 的回收价值进行排序,然后根据期望暂停时间,选择性回收Regi on
- 回收时采用标记复制,多条收集器线程并发执行
- 不追求一次全部清理完
- 触发STW
G1垃圾收集分类
Young GC:Young GC并不是说现有的Eden区放慢了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数-XX:MaxGCPauseMillis设定的值,那么增加年轻代的Region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMillis设定的值,那么就会触发Young GC
Mixed GC:不是Full GC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定Old区垃圾收集的优先顺序)以及 大对象区,正常情况G1的垃圾收集是先做Mixed GC,主要使用复制算法,需要把各个Region中存活的对象拷贝到别的Region里去,拷贝过程中如果发现 没有足够的空的Region 能够承载拷贝对象就会触发一次Full GC
Full GC:停止系统程序,然后采用单线程进行标记,清理和压缩整理,好空闲出一批Region来供下一次Mixed GC使用,这个过程是非常耗时的.
JAVA 四种引用类型
- 强引用
在Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java 内存泄漏的主要原因之一。 - 软引用
软引用需要用SoftReference 类来实现,对于只有软引用的对象来说,**当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。**软引用通常用在对内存敏感的程序中。 - 弱引用
弱引用需要用WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM 的内存空间是否足够,总会回收该对象占用的内存。 - 虚引用
虚引用需要PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
============================ 下面内容有时间再详细整理========================
二、JVM内存管理
JVM 内存布局(JVM 运行时数据区域)
JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区
域【JAVA 堆、方法区】。
- Java堆(Heap):线程公有。存放对象Object。
- 方法区(Method Area)/永久代/元数据区:线程公有。
- Java虚拟机栈(VM Stack):线程私有。基础类型(int、Boolean、char)、对堆中的对象的引用。
- 本地方法栈(Native Method Stack):线程私有。
- PC寄存器:线程私有。
线程共享区域随虚拟机的启动/关闭而创建/销毁。
线程私有区域生命周期与线程相同, 依赖用户线程的启动/结束而创建/销毁(在Hotspot VM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)。