JVM知识点汇总
JVM内存模型
- 虚拟机栈(方法栈),线程私有,生命周期与线程相同,线程在执行每个方法时都会同时创建一个栈帧,用来存储局部变量表、操作栈、动态链表、方法出口等信息。通过压栈出栈的方式进行方法调用。
- 本地方法栈与栈类似,也是用来保存线程执行方法时的信息,不同的是,执行Java方法使用栈,执行native方法使用本地方法栈。线程私有。
- 程序计数器,保存当前线程所执行的字节码位置,每个线程工作时有一个独立的计数器。程序计数器为执行Java方法服务,执行native方法时,程序计数器为空。程序计数器就是记录当前线程执行程序的位置,改变计数器的值来确定执行的下一条指令,比如循环、分支、方法跳转、异常处理,线程恢复都是依赖程序计数器来完成。
Java虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换能恢复到正确的位置,每条线程都需要一个独立的线程计数器,所以程序计数器是线程私有的。 - 堆是JVM管理的内存中最大的一块,堆被所有线程共享,目的是为了存放对象实例(new的对象),几乎所有的对象实例都在这里分配。当堆内存没有可用空间时,会抛出OOM异常。根据对象存活的周期不同,JVM会把堆内存进行分代管理,由垃圾回收器进行对象的回收管理。
堆内存分为两个部分:年轻代和老年代。年轻代(新生代)代又分为Eden区和2个Survivor区(S1,S2)。年轻代和老年代的默认比例为1:2(-XX:NewRatio)。Eden:S1:S2=8:1:1(-XX:SurvivorRatio) - 方法区,方法区也称永久代,用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。在JDK8之前的HotSpotJVM,存放这些“永久的”的区域叫“永久代”,永久代是一片连续的堆空间,默认大小为64M。JDK8之后,JVM不再有永久代,但类的元数据信息(metadata)还在,只不过不在时存储在连续的堆空间上,而是移动到了本地内存上的元空间(Metaspace)。
JMM内存可见性
JMM是Java内存模型,与JVM模型是两回事。JMM的主要目标是定义程序中变量的访问规则。如上图所示,所有的共享变量都存储在主内存中共享,每个线程都有自己的工作内存,工作内存中保存的是主内存中变量的副本,线程对变量的读写等操作必须在自己的内存中进行,而不能直接读写主内存中的变量。
JVM保证
原子性
JMM保证对long和double外的基础数据类型的读写操作是原子性的。另外关键字synchronized也可以提供原子性保证。synchronized的原子性是通过Java的两个高级的字节码指令menitorenter和monitorexit来保证的。
可见性
JMM可见性的保证,一个是通过synchronized,另外一个就是通过volatile。volatile强制变量的赋值会同步刷新回主内存,强制变量的读取会从主内存重新加载,保证不同的线程总是能够看到该变量的最新值。
有序性
对有序性的保证,主要通过volatile和一系列happens-before原则。volatile的另一个作用就是阻止指令重排序,这样就可以保证变量读写的有序性。
JVM加载机制
类加载子系统
- 类加载子系统负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识。
- 把加载后的class类信息存放于方法区,除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
- 如果调用构造器实例化对象,则该对象存放在堆区
类加载的执行过程
类使用的7个阶段
类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、 使用(Using) 、 卸载(Unloading) 这七个阶段。其中验证、准备、解析三个部分统称为连接(Linking)。
其中加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段不一定,解析在某些情况下可以初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(动态绑定)。
加载
加载是类加载的第一个阶段,有两种时机会触发类加载:
- 预加载
- 运行时加载
链接
- 验证
链接阶段的第一步,确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不回危害虚拟机自身的安全。.class文件不一定是从Java源码编译而来,可以使用任何途径产生,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃。 - 准备
正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都会在方法区中分配。
注意点:- 此时进行内存分配的仅仅是类变量(static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中。
- 这个阶段赋初始值的变量指的是那些不被final修饰的static变量。
- 局部变量不想类变量存在准备阶段。类变量有两次赋初始值的过程,一次是在准备阶段,赋予初始值(也可以是指定值);另一次在初始化阶段,赋予程序定义的值。因此没有为类变量赋值也没有关系,它仍然有一个默认的初始值。但局部变量如果没有赋初始值,是不能使用的。
- 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
类加载过程的最后一个步骤。初始化阶段就是执行类构造器clinit()方法的过程,clinit()方法并不是程序员在代码中直接编写的方法,它是Javac编译器的自动生成物,clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
public class TestClinint{
static{
i=0;//给变量赋值可以正常编译通过
System.out.println(i);//编译器会提示“非法向前引用”
}
static int i =1;
}
clinit()方法与类的构造函数init()不同,clinit不需要显示地调用父类构造器,Java虚拟机会保证在子类的clinit方法执行前,父类的clinit方法已经执行完毕。
类加载器
类加载器的作用
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
类加载器分类
- jvm支持两种类型的加载器,分别是引导类加载器和自定义类加载器
- 引导类加载器是由C/C++实现的,自定义加载器是由Java实现的
- jvm规范定义自定义加载器是指派生于抽象类ClassLoader的类加载器
- 程序中常见的类加载器是:引导类加载器BootStrapClassLoader、自定义类加载器(Extension Class Loader、System Class Loader、User-Defined ClassLoader)
启动类加载器
- 使用C/C++实现,嵌套在jvm内部
- 用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
- 并不继承自java.lang.ClassLoader,没有父加载器
扩展类加载器
- Java语言编写
- 从java.ext.dirs系统属性所执行的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载
- 派生于ClassLoader,父类加载器为启动类加载器
系统类加载器
- Java语言编写
- 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载的,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 派生于ClassLoader,父类加载器为扩展类加载器
- 通过ClassLoader.getSystemClassLoader()方法可以获取到该类加载器。
自定义类加载器
自定义类加载器,定制类的加载方式
双亲委派模型
双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父类加载器在自己的搜索范围内找不到指定的类时,子加载器才会尝试自己去加载。
Java的类加载使用双亲委派模式,即一个类在加载时,会先把这个请求委托给自己的父类加载器去执行,如果父类加载器还存在父类加载器,就继续向上委托,直到顶层的启动类加载器,如上图蓝色向上的箭头。如果父类加载器能够完成类加载,就成功返回,如果父类加载器无法完成加载,那么子加载器才会尝试自己去加载,如图中橙色向下箭头。
这种双亲委派模型的号出,可以避免类的重复加载,另外也避免了Java类的核心API被篡改。
黑客自定义一个 java.lang.String 类,该 String 类具有系统的 String 类一样的功能,只是在某个函数稍作修改。比如 equals 函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到 JVM 中。此时,如果没有双亲委派模型,那么 JVM 就可能误以为黑客自定义的java.lang.String 类是系统的 String 类,导致“病毒代码”被执行。
实现过程:
- 首先,检查一下指定名称的类是否已经加载锅,如果加载过了,就不需要再加载,直接返回。
- 如果此类没有加载锅,那么,再判断一下是否有父类加载器;如果有父类加载器,则有父类加载器加载(调用parent.loadClass(name,false);)或者时调用bootstrap类加载器来加载
- 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。
换句话说,如果自定义类加载器,就必须重写findClass方法。
自定义类加载器
为什么要自定义类加载器
- 隔离加载类:隔离模块,把类加载到不同的应用选中
- 修改类加载方式:除了Bootstrap加载器外,其他的加载并非一定要引入。根据实际情况再某个时间点按需进行动态加载。
- 扩展加载源:可以从数据库、网络、或其他终端上加载
- 防止源码泄露
自定义函数调用过程
自定义类加载器实现
所有用户自定义类加载器都应该继承ClassLoader类
再自定义ClassLoader的子类中,通常有两种做法:
- 重写loadClass方法(是实现双亲委派逻辑的地方,修改会破坏双亲委派机制,不推荐)
- 重写findClass方法(推荐)
垃圾回收机制及算法
垃圾回收-对象是否已死
判断对象是否存活-引用计数算法
实现方式:给每个创建的对象添加一个引用计数器,每当此对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法很难解决对象直接相互循环引用的问题。
优点: 实现简单,执行效率高、很好和程序交织
缺点: 无法检测出循环利用
判断对象是否存活-可达性分析算法
通过乙烯类的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链,当一个对象到任何GC Roots都没有引用链时,则表明对象不可达,即该对象时不可用的。
可作为GCRoots的对象:
- 栈帧中的局部变量表中的reference引用所引用的对象
- 方法去中static常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器
- 所有被同步锁(synchronized关键字)所持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地缓存代码等
JVM判断对象是否存活
finalize()方法最终判定对象是否存活
即使再可达性分析算法中判定为不可达的对象,也不是非死不可的,要真正宣告一个对象死亡,至少要经历两次标记过程:
第一次标记
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件时此对象是否有必要执行finalize()方法
没有必要
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为没有必要执行
有必要
如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的 队列之中, 并在稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize() 方法。 finalize()方法是对 象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己 (this关键字) 赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集 合; 如果对象这时候还没有逃脱, 那基本上它就真的要被回收了。
引用
JDK1.2后对引用的概念做了扩充,将引用分为强引用、软引用、弱引用、虚引用,强度依次递减。
弱引用与软引用的区别:
- 更短的生命周期
- 一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
虚引用与软引用和弱引用的区别:
- 虚引用必须和引用队列(ReferenceQueue)联合使用
- 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存前,把这个虚引用加入到与之关联的引用队列中
垃圾回收算法
分代收集理论
根据对象的生命周期将内存划分,然后进行分区管理,建立在两个分代假说上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
部分收集(partial GC):指目标不是完整收集整个Java堆的垃圾收集
- 新生代收集
- 老年代收集
- 混合收集
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
标记-清除算法(Mark-Sweep)
最早出现也是最基础的垃圾收集算法,分为 “标记” 和 “清除” 两个过程:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点:
- 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除动作,导致标记和清楚两个过程的执行效率都随对象数量增长而降低。
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收工作。
标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,Fenichel提出了一种称为“半区复制”的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块的内存用完了,就将还存活这的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
缺点:
- 需要提前预留一般的内存区域用来存放存活的对象,这样导致可用的对象区域减小一般,总体的GC更加频繁
- 如果出现存活对象数量比较多时,需要复制较多的对象,成本上升,效率降低
- 如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的
标记-整理算法
标记-复制算法再对象存活率较高时要进行较多的复制操作,效率会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代的存亡特征,提出了另外一种有针对性的整理-标记算法。标记过程与标记-清除算法一样,但后续不是直接堆可回收对象进行清理,而是让所有存活的对象都像内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,后者是移动式的。是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
垃圾收集器
串行垃圾回收(Serial)
为单线程环境设计,有且仅有一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境
并行垃圾回收(Parallel)
多个垃圾回收器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景
并发垃圾回收(CMS)
用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用对响应时间有要求的场景。
G1垃圾回收器
G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收。
组合关系
JDK8中默认使用Parallel Scavenge GC、ParallelOld GC
JDK9默认使用G1垃圾收集器
JDK14弃用Parallel Scavenge GC、ParallelOld GC
JDK14移除了CMS GC
GC性能指标
吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值
暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间
内存占用:Java堆所占内存的大小
收集频率:垃圾收集的频次
CMS垃圾收集器
CMS垃圾收集过程:
- 初始标记 stopTheWorld
- 并发标记
- 重新标记 stopTheWorld
- 并发清除
CMS垃圾收集器的缺点
- CMS收集器对CPU资源非常敏感
- CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生
- 空间碎片:CMS时一款基于标记-清除算法实现的收集器,所以会有空间碎片的现象
G1垃圾收集器
G1垃圾收集器简介
Garbage First是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
G1垃圾收集器特点
- G1把内存划分为多个独立的区域Region
- G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理隔离,而是一部分Region的集合
- G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW
- G1整体整体采用标记整理算法,局部是采用复制算法,不会产生内存碎片
- G1的停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设置时间
- G1跟踪各个Region里面的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限时间内高效的收集垃圾
G1 算法取消了堆中年轻代与老年代的物理划分,但它仍然属于分代收集器。G1 算法将堆划分为若干个区域,称作 Region,如下图中的小方格所示。一部分区域用作年轻代,一部分用作老年代,另外还有一种专门用来存储巨型对象的分区。
G1 回收过程如下。
-
G1 的年轻代回收,采用复制算法,并行进行收集,收集过程会 STW。
-
G1 的老年代回收时也同时会对年轻代进行回收。主要分为四个阶段:
- 依然是初始标记阶段完成对根对象的标记,这个过程是STW的;
- 并发标记阶段,这个阶段是和用户线程并行执行的;
- 最终标记阶段,完成三色标记周期;
- 复制/清除阶段,这个阶段会优先对可回收空间较大的 Region 进行回收,即 garbage first,这也是 G1 名称的由来。
三色标记法
把遍历对象图过程中遇到的对象,按是否访问过这个条件标记为三个颜色:
- 白色:尚未访问过
- 黑色:本对象已经访问过,而且本对象引用到的其他对象也全部访问过了
- 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完,全部访问后,会转换为黑色
假设线在有白灰黑三个集合,其遍历访问过程为:
- 初始时,所有对象都在【白色集合】中
- 将GC Roots直接引用到的对象挪到【灰色集合】中
- 从灰色集合中获取对象
- 将本对象引用到的其他对象全部挪到【灰色集合】中
- 将本对象挪到【黑色集合】中
- 重复步骤3,直至【灰色集合】为空时结束
- 结束后,仍在【白色集合】的对象即为GC Roots不可达,可以进行回收。
注:如果标记结束后对象仍未白色,意味这已经找不到该对象在哪了,不可能会再被重新引用。
当STW时,对象间的引用是不会发生变化的,可以轻松完成标记。而当需要支持并发标记是,即标记期间引用线程还在继续跑,对象间的引用可能发生变化,多标(浮动垃圾)和漏标 的情况就有可能发生。