JVM面经
目录
一、JVM内存结构
JVM的内存结构,指的就是JVM定义的【运行时数据区域】,主要分为以下5个部分:
1. 程序计数器
⭐⭐ 「背诵版」: 当某个线程的时间轮转片用完后,就需要进行「线程切换」,切换意味着「中断」和「恢复」,那就需要有一块区域来保存「当前线程的执行信息」,因此引入了程序计数器,它的本质是一个PC寄存器,是线程私有的,没有垃圾回收;作用是: 用来存储指向下一条JVM指令的字节码地址,以便执行引擎读取下一条JVM指令;且程序的「分支、循环、跳转、异常、线程恢复」都依赖于程序计数器;
- java代码的执行过程: 「java代码 → 二进制字节码(.class文件) → JVM指令 → 解释器 → 机器码 → CPU引擎执行」,
2. 虚拟机栈
⭐⭐ 「背诵版」: 每个线程在创建的时候都会创建一个「虚拟机栈」,每次方法调用都会创建一个「栈帧」。每个「栈帧」会包含几块内容:「局部变量表、操作数栈、动态连接和返回地址」,它的作用就是:为线程提供运行内存,在方法执行过程中,保存方法的局部变量,参与变量计算,并进行方法的调用与返回;
-
栈帧: 栈帧主要存储了:局部变量表 + 操作数栈 + 动态连接(运行时将符号引用转换为直接引用) + 方法返回地址 + 附加信息
栈帧就是每次方法调用时所占的内存,每个线程只能有一个活动栈帧,对应着正在执行的方法; 栈帧入栈表示被调用,出栈则表示执行完毕或者返回异常。 -
是否有线程私有:是
-
是否有垃圾回收:否。 垃圾回收不涉及栈内存,因为方法调用结束后,栈帧被弹出栈,栈内存将会自动释放;
-
栈内存溢出的情况:StackOverflowError;1)方法递归调用死循环,导致栈帧溢出; 2)一次方法的调用超过了虚拟机栈的最大内存
-
栈内存分配越大越好吗?: 不是越大越好,总运行内存/虚拟机栈内存 = 活动线程总数。虚拟机栈的内存是线程私有的,因此栈内存越大,那么我们可用的线程数量就越少。
-
栈帧内的局部变量是否是线程安全的?: 需要进行逃逸分析分情况讨论;
1)如果局部变量没有逃离方法的作用域,则是线程安全的;
2)如果局部变量引用了其它对象,并逃离了方法的作用域,则是线程不安全的;
逃逸分析(JIT优化)
逃逸分析就是判断某个对象是否逃离了所在线程的作用域;JIT编译在新建一个对象时有三个优化手段:
优化手段 | 描述 |
---|---|
线程同步省略 | 如果一个对象被发现只能由一个线程访问到,那么可以不考虑线程同步(即加锁控制线程访问顺序);引申知识点:线程同步的三种方式(同步代码块加锁、同步方法加锁、Lock锁) |
将分配堆内存转换成分配占内存 | 避免不必要的垃圾回收 |
标量替换 | 如果一个对象的属性始终没有逃逸出方法的作用域,则可以将此属性提取出来直接作为一个基本数据类型来用,而无需创建该对象; |
3. 本地方法栈
⭐⭐ 「背诵版」: 本地方法栈跟虚拟机栈的功能类似,虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地native方法的调用,这里的「本地方法」指的是「非Java方法」,一般本地方法是使用C语言实现的。例如wait() 、hashcode()、notify()等;
- 是否有线程私有:是
4. 方法区
⭐⭐ 「背诵版」: 在JDK1.8之前,使用永久代(堆内存)来实现的方法区;而JDK1.8之后,则是用元空间(本地内存)实现的;方法区的作用是: 用于存储已被JVM加载的类相关信息,包括「类信息」和「常量池」;「类信息」包括了类的版本、字段、方法、接口和父类等信息;「常量池」可以分为「静态常量池」和「运行时常量池」;静态常量池,主要存储的是「字面量」以及「符号引用」等信息;运行时常量池,存储的是「类加载」时生成的「直接引用」等信息;而我们常说的字符串常量池,在JDK1.8后就存储在堆中了;
5. 堆
⭐⭐ 「背诵版」: 「堆」是线程共享的区域,通过new关键字创建的对象都会使用堆内存,JDK1.8后字符串常量池(StringTable)也在堆中;「堆」被划分为「新生代」和「老年代」,「新生代」又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成;将「堆内存」分成这几块区域,主要与「垃圾回收机制」有关;
堆内存溢出(OOM)的常见原因 |
---|
创建超大对象 |
超出预期的访问量/数据量,如秒杀服务; |
过度使用终结器,没有被立即GC回收; |
内存泄漏:大量对象引用没有释放,JVM无法自动回收;例如:File、IO等资源没有回收; |
new一个对象在堆中的历程
步骤 | 概述 | 描述 |
---|---|---|
1.类加载检查 | 检查new的这个对象所属的类是否已经被JVM加载、解析与初始化完成 | ① 在方法区中class文件的静态常量池中检查是否能找到这个类的符号引用; ② 静态常量池中找到了,再去方法区中的运行常量池中查找该符号引用所指向的类,是否已经被JVM加载、解析与初始化过; ③ 如果没有,则进行JVM类加载过程;如果有,则进行下一步,为该对象分配堆内存; |
2.分配堆内存 | 分配对内存空间(指针碰撞/空闲列表) | ① JIT优化:若逃逸分析后发现一个对象只能被一个线程访问到,可以将其优化为栈内存分配; ② 对象在堆上的两种分配方式(①指针碰撞②空闲列表);①若垃圾回收使用的标记整理算法,则使用的是指针碰撞法;②若垃圾回收使用的是标记清除法,则使用的是空闲列表法; ③ 对于对象创建过程中存在的并发安全问题;有两种解决方式(①CAS+失败重试②本地线程分配缓冲区TLAB);这里重点介绍本地线程分配缓冲:每个线程在堆中预先分配一块小内存,哪个线程需要分配内存,就在这一块本地线程分配缓冲区进行分配,当这块内存用完了,虚拟机就给他分配新的内存,此时才需要同步锁定; |
3.初始化零值 | 保证对象的实例字段在java中不赋值就可以直接使用; | 比如将boolean字段初始化为false; |
4.设置对象头 | 对象在内存中有三块区域:对象头、实例数据和对其填充; | 对象头中包含着两部分的数据:①类型指针(Klass Pointer) ②标记字段(Mark Word) ①「类型指针」: 是一个指向它所属类的指针,虚拟机可以通过该指针确定该对象是哪个类的实例; ②「标记字段」: 其中存储着对象自身的运行数据,包括(哈希码、GC分代年龄、锁状态标志、持有锁的线程、偏向线程ID、偏向时间戳等),Mark Word是实现轻量级锁和偏向锁的关键,而且synchronized使用的锁对象就是存储在Mark Word中; |
5.执行init方法 | 初始化对象 | 执行构造器函数进行对象初始化; |
- 对象头的组成
直接内存(属于操作系统)
⭐⭐ 「背诵版」: 直接内存是「系统缓存区」与「java缓存区」两者共享的一片通道区域,使得JVM可以直接从磁盘文件中读取数据;如果没有直接内存,那么读取数据的过程入下:磁盘文件 → 系统缓存区 → 堆内存,需要从系统缓存区拷贝数据到堆内存中,才能读取;因此,直接内存的IO更快;此外,JVM可以通过虚引用的ByteBuffer去关联直接内存。
二、JVM内存模型
1. JVM内存模型的规则
JVM内存模型的规则 |
---|
Java内存模型规定所有的变量都存储在【主内存】中,每条线程还有自己的【工作内存】 |
线程的工作内存中保存了该线程中用到的变量的【主内存副本拷贝】 |
线程对变量的所有操作都必须在【工作内存】中进行,【不能直接读写主内存】 |
不同的线程之间也【不能直接访问对方工作内存中的变量】 |
线程间变量的传递均需要自己的【工作内存和主存】之间进行数据同步进行。 |
1)JVM内存模型提供的原子性指令
- JVM定义了八种原子指令,来进行主内存与工作内存的交互
指令 | 作用对象 | 描述 |
---|---|---|
lock | 主内存 | 将主内存中的变量锁定,为一个线程所独占;JVM开放给我们的是【MonitorEnter】指令 |
unclock | 主内存 | 将lock加的锁定解除,此时其它的线程可以有机会访问此变量;JVM开放给我们的是【MonitorExit】指令 |
read | 主内存 | 将主内存中的变量值读到工作内存当中 |
load | 工作内存 | 将read读取的值保存到工作内存中的变量副本中 |
use | 工作内存 | 将值传递给线程的代码执行引擎 |
assign | 工作内存 | 将执行引擎处理返回的值重新赋值给变量副本 |
store | 工作内存 | 将变量副本的值存储到主内存中 |
write | 工作内存 | 将store存储的值写入到主内存的共享变量当中 |
jvm没有把lock和unlock开放给我们使用,但jvm以更高层次的指令【MonitorEnter】和【MonitorExit】指令开放给我们使用,对应了【synchronized关键字】,也就是说synchronized满足原子性
指令规则 |
---|
read 和 load、store和write必须成对出现 |
assign操作,工作内存变量改变后必须刷回主内存 |
同一时间只能运行一个线程对变量进行lock,当前线程lock可重入,unlock次数必须等于lock的次数,* 该变量才能解锁。 |
对一个变量lock后,会清空该线程工作内存变量的值,重新执行load或者assign操作初始化工作内存中* 变量的值。 |
unlock前,必须将变量同步到主内存(store/write操作) |
2)如何保证原子性、可见性、有序性?(总结性答案)
性质 | 描述 |
---|---|
原子性 | 一个操作是不可中断的,要么全部执行成功要么全部执行失败,由JVM的8大原子操作指令实现 |
可见性 | 可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改进行同步,保持数据一致性 |
有序性 | 不允许处理器进行指令重排序进行优化,严格按照代码的既定顺序执行 |
⭐⭐ 「背诵版」: JVM内存模型与「并发」相关,它定义了一套多线程读写共享数据时的规范,能够保障数据的 「可见性」「有序性」「原子性」 ;按条背即可:
关键词 | 原子性 | 可见性 | 有序性 |
---|---|---|---|
synchronized | 通过Monitor对象监视器的获取与释放来实现,其中的MonitorEnter与MoniorExit指令对应着JVM内存模型的8大原子指令中的lock与unlock指令 ;因此synchronized具有原子性; | 当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中;因此synchronized具有可见性; | 锁在同一时刻只能由一个线程进行获取,因此,线程在访问读写共享变量时只能【串行】执行;所以synchronized具有有序性 |
volatile | × | volatile修饰的变量转换为汇编语言后,多了一条以【LOCK#为前缀的指令】,可以【锁定主内存】只被当前线程修改,同时根据缓存一致性协议,使得其它线程的处理器能够通过嗅探技术发现主内存的某个变量地址被修改了之后,使得本线程内的该变量的缓存行失效,查询时去主内存中查询,保证volatile具有可见性 | 「volatile」 修饰的变量,通过设置【读写内存屏障】(写前与写后设置写屏障,读前读后设置读屏障),用来防止指令重排,保证volatile具有有序性 |
CAS | 一种乐观锁的思想,CAS的底层时依赖于一个UnSave类来调用操作系统底层的CAS指令,是一条CPU系统原语,能够保证原子性; 注意: 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,但是我们可以取巧,将多个共享变量合成一个类去保证原子性; | × | × |
volatile + CAS | 能够实现无锁并发,【JUC原子类的底层】,能够保证原子性 ,CAS的原理; | 能够保证可见性,volatile可见性的原理 | 能够保证有序性,volatile有序性的原理 |
happens-before | × | 它规定了哪些写操作对其他线程的读操作可见,是一套可见与有序的规则总结,能够保证可见性与有序性 | 能够保证有序性 |
2. synchronized(有序性、可见性、原子性)
1)synchronized的底层原理:基于锁对象的Monitor的获取与释放
JVM为每个对象中都提供了一个Monitor(对象监视器),synchronized主要就是通过锁对象的Monitor的获取与释放来实现的;
- 1)synchronized 修饰代码块时,编译后的字节码会有 「MonitorEnter」 和 「MonitorExit 」 指令,分别对应的是获得锁和释放锁;这两个指令分别对应着JVM内存模型8种原子性指令中的 「lock」 与 「unlock」;
- 2)synchronized修饰方法时,不会又这两条指令,而是会给方法加上标记 「ACC_SYNCHRONIZED」,这样 JVM 就知道这个方法是一个同步方法,于是在进入同步方法的时候就会进行执行竞争锁的操作,只有拿到锁才能继续执行;
- 被synchronized 修饰的方法或代码块就会被Monitor监视到,确保每次只能有一个线程能够访问到这部分代码;没有获取到锁🔒的线程是不能执行这部分代码的;
- 此外java还给我们提供了一个显式的锁Lock,synchronized是隐式的锁;
- 一个对象关联一个Monitor对象,当一个对象持有monitor对象地址后,它就处理锁定状态,当线程执行到MonitorEnter指令时,则会尝试获取对象所对应的Monitor
- 不加synchronized的对象不会关联monitor
多线程竞争Monitor时,过程如下:
阶段 | 执行(背诵) |
---|---|
Step1 | 当线程执行到对象方法的临界区时,首先查看对象头中的Mark Word是否已经有关联的Monitor对象 |
Step2 | 1)如果已经关联了Monitor对象: ① 将当前线程添加到Monitor对象的竞争队列(Contention List) ; ② 将竞争队列中有资格竞争锁的线程转移到候选人队列(EntryList); ③ 从EntryList中拿到一个线程,去竞争锁,该线程叫做OnDeck ④ 当该线程获得锁后,称为该线程为Owner,改变对象的Mark Word指向当前线程; ⑤ 当前线程执行完成后,Monitor再释放线程,重新到Entrylist中查找一个新的拥有者(非公平竞争) |
Step3 | 2)如果没有关联Monitor对象: 则直接改变对象MarkWord,指向当前线程; |
Step4 | wait()的线程,会进入Monitor的阻塞队列WaitSet,等待被唤醒然后重新加入到候选人队列EntryList |
2)synchronized的锁升级特性:对象头中的Mark Word字段
锁升级 | 执行(背诵) |
---|---|
无锁 | 所有线程均能访问 |
偏向锁 | 锁不存在多线程竞争,且总是被同一个线程获得;实现手段:当某个线程第一次获得锁时,对象头中的标记字段Mark Word会记录下这个偏向线程的id;在第二次有线程获得锁时,会检查Mark Word字段,若发现本次获得锁的线程id与之前的偏向线程id是同一个,则无需进行线程同步操作(加锁) |
轻量级锁 | 存在 第二个线程申请锁,但不存在竞争。交替持有锁时,锁升级为轻量级锁,此时「Mark Word」字段指向持有锁的线程「lockRecord」; 在轻量级锁下,其它线程将会基于「自旋」的形式尝试获取锁,而「不会阻塞」; |
重量级锁 | 存在多线程竞争锁,则升级为重量级锁;此时Mark Word字段指向对象监视器Monitor;在重量级锁下,其它线程将会被阻塞,等待锁的释放; |
3) synchronized的锁优化
锁优化🔒 | 执行(背诵) |
---|---|
锁升级 | 无锁 → 偏向锁 → 轻量级锁 → 重量级锁; |
锁消除 | JIT编译优化,自动去掉那些不可能存在竞争的锁; |
锁粗化 | JIT编译优化,避免重复性的加锁与释放锁,比如for循环内部加锁,可以移到for循环外部; |
基于CAS的自旋锁与自适应锁 | 引入原因: 为了避免当线程没有获取到锁时,直接阻塞而切换线程。这是因为持有锁的线程可能持有时间很短,自旋等待一会即可获得,此时切换线程的开销是不值得的;①自旋锁: 就是重试次数固定 ②自适应锁: 根据锁持有者的状态与历史自旋次数决定自旋次数; |
4)synchronized与其它锁的区别
4.1)synchronized与volatile的区别
类型 | 原理 | 其它线程是否阻塞 | 修饰区域 | 原子性、有序性、可见性 |
---|---|---|---|---|
synchronized | 锁定当前区域,获取到锁的才能访问 | 是 | 变量、方法、代码块、类 | 全能保证 |
volatile | LOCK#前缀指令 + 缓存一致协议保证可见性,内存读写屏障保证有序性 | 否 | 只能在变量级别使用 | 不能保证原子性 |
4.2)synchronized与Lock的区别
类型 | 自动与手动 | 修饰区域 | 是否会死锁 | 是否能知道获取锁成功 |
---|---|---|---|---|
synchronized | 自动获取锁和释放锁 | 变量、方法、代码块、类 | 否,异常会自动释放锁 | 否 |
Lock | 全手动 | 仅能在代码块上加锁 | unlock使用不当会死锁 | 能通过Lock的返回值判断是否获得锁 |
4.3)synchronized与可重入锁ReentrantLock的区别
类型 | 实现层面 | 是否是可重入锁 | 是否等待可中断 | 公平锁/非公平锁 | 通知唤醒阻塞线程方式 |
---|---|---|---|---|---|
synchronized | 依赖于JVM的lock与unlock指令,原理是Monitor的获取与释放 | 是 | 否 | 非公平锁 | notify()、notifyAll():被通知唤醒的线程由JVM选择 |
ReentrantLock | 依赖于JDK,原理是AQS,AQS的底层又是CAS | 是 | 等待的线程可以选择放弃抢锁 | 可以指定公平还是非公平,默认是非公平锁 | 可以结合Condition类,进行选择性通知某些线程 |
3. volatile(可见性、有序性)
volatile底层原理
1)volatile如何保证可见性?
对于可见性,通过LOCK#前缀指令保证当前内存锁定主内存,进行安全修改;然后基于缓存一致性协议+其它线程的处理器嗅探技术,使得其它线程的缓存行失效,查询时从主内存查,保证数据一致性;
- Java程序执行时会编译为字节码通过加载器加载到JVM中,JVM执行字节码最终将其转变为汇编代码相关的CPU指令;
- 对于使用【volatile关键字修饰的变量】,将其转变为汇编指令后比其他普通的变量多一行以【LOCK#为前缀的指令】;
LOCK#前缀指令相当于一个内存屏障,作用是:将主内存中的变量锁定,只能被一个线程所访问,类似于synchronized;
步骤 | 描述 |
---|---|
1、【LOCK#前缀指令】锁定主内存,更新当前线程的缓存行与主内存 | ① 当volatile修饰的变量进行写操作时,JVM就会向CPU发送 【LOCK#前缀指令】 ② 此时当前处理器的缓存行就会被锁定,修改工作内存;在这个过程中,通过【缓存一致性协议】确保修改的原子性,然后更新对应的主存地址的数据; |
2、缓存一致性协议,使得其它线程的缓存行失效,强制去主内存读取 | ① 当上一步修改了当前处理器的工作内存与主内存后,其它线程的工作内存数据就不一致了 ② JVM通过【LOCK#前缀指令】更新了当前处理器的数据之后,其他处理器就会通过【嗅探技术】发现数据不一致,从而使当前缓存行失效,当需要用到该数据时直接去内存中读取,保证读取到的数据时修改后的值; |
2)volatile如何保证有序性?
对于有序性,volatile通过设置【读写内存屏障】来保证,硬件层的内存屏障主要分为两种Load Barrier,Store Barrier,即读屏障和写屏障。对于Java内存屏障来说,它分为四种,即这两种屏障的排列组合。
1、每次执行use操作的时候都先执行read和load操作,让volatile修饰的变量每次获取的都是新的值
2、每次执行assign的时候,随后都会执行store和write操作,让volatile修饰的变量每次都刷新到主内存中
3. CAS(原子性)
- CAS的底层: 基于UnSafe()类调用CPU的系统原语CAS指令,是一条硬件对对并发支持的指令,该指令是原子性的;它有三个操作数:内存位置V、旧的预期值A与新值B,当且仅当V符合预期值A时,才会用新值B替换旧值A,否则就自旋重试;
- 基于CAS实现的一些内容:
① concurrentHashMap: JDK1.8后使用 CAS + synchronized实现;
② ReentrantLock: 底层基于AOS锁框架,AQS底层又基于CAS;
③ AUC原子类的底层: volatile + CAS
CAS的缺陷及解决办法:
缺陷 | 解决办法 |
---|---|
ABA问题 | 使用volatile + CAS 实现的AUC原子类中的AtomicReference类(自带版本号机制),自旋 + CAS的无锁操作,保证变量可见性 |
只能保证一个变量的原子操作 | 对多个变量CAS是,可以加互斥锁,或者将多个变量封装成一个对象,然后用AtomicReference类即可实现多个变量的CAS |
循环时间长,开销大 | 设置自旋次数固定 |
4. volatile + CAS(有序性、可见性、原子性)
- 可以实现无锁并发,适用于竞争不激烈,多核CPU场景,能够有效避免synchronized的线程阻塞;
- JUC原子操作类 的底层就是基于volatile + CAS实现的,存在java.util.concurrent.atomic包下;
原子类型 | |
---|---|
基本类型 | ① AtomicIntefer:整型原子类 ② AtomicLong:整型数组原子类 ③ AtomicBoolean:布尔型原子类 |
数组类型 | ① AtomicIntergerArray:整型数组原子类 ② AtomicLongArray:长整型数组原子类 ③ AtomicReferenceArray:引用类型数组原子类 |
引用类型 | ① AtomicReference:引用类型原子类 ② AtomicMark’ableReference:原子更新带有标记位的引用类型 ③ AtomicStampedReference:长整型数组原子类:原子更新带有版本号的引用类型===>能够解决CAS的ABA问题 |
对象属性修改类型 | ① AtomicInterFieldUpdater:原子更新整型字段的更新器 ② AtomicLongFieldUpdater:原子更新长整型字段的更新器 ③ AtomicMarkableReference:原子更新带有标记位的引用类型 |
5. happens-before(有序性、可见性)
-
它规定了哪些写操作对其他线程的读操作可见,是一套可见与有序的规则总结,能够保证可见性
三、JVM垃圾回收机制
⭐⭐ 「背诵版」: 垃圾回收首先就要判断对象是不是垃圾,我们认为当堆中的某个对象不在被引用了,就可认为它是垃圾,其所占的空间就可以被回收;在JVM中是通过 「可达性分析」 来判断一个对象是否需要被回收的;
1、判断对象是否可以被回收:「引用计数法」与「可达性分析法」
-
「引用计数法」 维护了一个计数器:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收。但是它有一个明显的缺点,就是如果对象存在循环依赖,那就无法进行回收。
-
JVM使用的是「可达性分析法」 ,它可以细分成两个阶段【1-根节点枚举(需要STW),2-从根节点遍历对象图(不需要STW)】 其原理就是扫描堆中的对象,沿着「GC Roots」开始向下搜索,并通过 「三色标记法」 进行辅助标记,同时通过 「增量更新」 与 「原始快照算法」 确保不会发生「误删除」的问题。当扫描完毕,标记依然为白色的对象(即没有直接或间接引用「GC Roots」的对象)就可以被当作垃圾回收掉。
可达性分析的两个阶段:
「阶段1:根节点「GC Roots」枚举」:
① 必须STW,因为如果没有STW,在垃圾回收线程执行回收时,其他用户线程可能会改变「GC Roots」以及引用关系;
② 为了快速枚举「GC Roots」,Hotspot虚拟机引入了一个 「OopMap哈希表」 ,用于存储对象的引用类型与位置;这样当GC时,就不用一个一个区域的扫描了,只需要按照OopMap即可快速找到「GC Roots」,这是典型的用空间换时间。同时,JVM强制要求只有线程达到「安全点」时,才会生成OopMap中,然后进行根节点枚举,进入安全区(引用关系不会发生变化的一段时间),接着进行GC;
「安全点」的位置: 在循环的末尾、方法return之前、可能出现异常的位置
③ 那么怎么保证发生GC时,让所有用户线程都执行到最近的安全点,然后停顿下来呢?有两种方式:抢占式中断与主动式中断;JVM采用的是「主动式中断」
;它们的原理分别是(背表格);
中断方式 | 原理(背诵) |
---|---|
抢占式中断 | 在GC发生时,先把所有用户线程中断掉,如果发现有的用户线程不在安全点上,就恢复这个线程,直到执行到安全点再中断; |
主动式中断 | 在GC发生时,不会直接中断线程,而是全局设置一个标志位,用户线程会不断的「轮询」这个标志位,当发现标志位为真时,线程就会在最近的一个安全点处主动中断; |
可以作为「GC Roots」的对象有:静态属性、常量、加锁的对象、java.lang.class的核心类对象、活动线程内引用的对象
「阶段2:从根节点遍历对象图」
首先介绍 「三色标记法」,它的作用是帮助我们搞清楚在阶段2出现的问题;
节点 | 表示 |
---|---|
白色 | 表示还没有被处理的节点对象;若标记结束,某个节点还是白色的,表明不可达,该对象可以作为垃圾被回收; |
灰色 | 表示正在处理,但是没有处理完的节点对象(至少还存在一个引用没有被扫描过); |
黑色 | 表示已经被访问处理完成,存在到根节点的引用,不可被回收对象节点; |
注意:所有节点均不能被二次访问
所谓从「根节点遍历对象图」的过程,就是三色标记的过程,再此过程中,因为不需要STW,因此会出现以下几个问题;
「问题1: 浮动垃圾问题」 比如,某个已经被处理完的黑色节点,此时一个用户线程断开了该节点与根节点的引用,此时实际上这个节点应该变成白色(变成垃圾),但是由于节点不能被二次访问,导致产生了浮动垃圾。
解决方式: 对于浮动垃圾,其实问题不大,待到下次GC即可回收,因此可以不做处理;
「问题2:误删除问题,将黑色对象标记成了白色,导致存活对象被回收」 例如: A(黑色)和B(白色)都已经被访问过了,当前访问的是节点C(灰色),引用关系是 「GC Roots」→ A (黑)→ C(灰) → B(白:未处理);
此时一个用户线程删除了 C(灰) → B(白:未处理) 的引用,并且插入了一条从A(黑色)到B(白色)的新引用,由于A已经访问过了,且C(灰)与B(白)之间已经断开,因此B(白)不能再次处理变成(黑色),此时GC就会将存活对象回收掉,程序就会出错!
总结一下,「误删除问题」 需要同时满足 「两个条件」:
①:插入一条或多条从黑色节点(已访问)到白色节点(与灰色节点连接着)的新引用;
②:删除了灰色节点(与黑色连接着)到白色节点(未访问)的全部引用连接;
因此,防止误删除问题,只需要破坏其中一个条件即可;
方法 | 误删除问题的解决方式 | 原理 |
---|---|---|
增量更新 | 破坏条件①:插入一条或多条从黑色节点(已访问)到白色节点(与灰色节点连接着)的新引用; | 它的意思是,只要插入了一条黑色到白色的新引用,那么黑色就会变回成灰色,待到全部扫描结束后,再二次处理这些灰色节点。 |
原始快照 | 破坏条件②:删除了灰色节点(与黑色连接着)到白色节点(未访问)的全部引用连接; | 它的意思是,当删掉灰色与白色之间的引用时,用快照保存这一刻的全部对象图,然后待全部扫描结束后,根据保存那一刻的对象图,对灰色节点后的引用二次处理 |
⭐5种引用类型:
类型 | GC时是否会被回收 | 引用队列 |
---|---|---|
强引用 | 永远不会被回收 | 不需要引用队列 |
软引用 | 当GC结束,内存依然不足时,会被回收 | 可以搭配引用队列释放软引用自身 |
弱引用 | 一旦GC就会被回收 | 可以搭配引用队列释放弱引用自身 |
虚引用(利用bytebuffer关联直接内存) | GC时,bytebuffer首先会被回收,但是虚引用与直接内存不会被立即回收,而是进入引用队列,等待ReferenceHandle线程调用虚引用的unsafe.freeMemory(),此时才会被回收,但该线程优先级低,不知道何时会调用 | 必须结合引用队列 |
终结器引用 | 第1次GC终结器引用入队,然后等待Finalizer(优先级低)线程通过终结器引用找到被引用对象,并且调用它的finalize方法之后。待到第2次GC才会被回收,由于Finalizer线程优先级特别低,所以过度使用终结器可能会导致对象长时间不会被回收掉,从而OOM | 不需要 |
2. 垃圾回收算法
算法 | 适用区域 | 垃圾回收器 | 原理 | 优缺点 | 内存分配 |
---|---|---|---|---|---|
标记清除 | 老年代 | CMS(并发标记清除) | 标记: 根据可达性分析找到回收对象,并记录待回收内存的起始与终止位置;清除: 将起始地址与终止地址放入空闲地址列表中 | 会造成内存碎片。或许原本总空闲区域足够,但是由于连续空间不够而导致OOM | 空闲列表 |
标记整理 | 老年代 | G1(整体来看是标记整理,局部来看是标记-复制) | 依赖于重定位寄存器,使用紧凑技术进行内存整理,能够有效解决内存碎片问题 | 缺点是:整理过程中涉及到了内存地址的变化,因此需要STW;优点是:没有内存碎片 | 指针碰撞 |
复制 | 新生代 | G1(整体来看是标记整理,局部来看是标记-复制) | GC时,Eden园与幸存区From中存活的对象复制到幸存区To,同时进行标记整理。然后将Eden园与幸存区From的剩余垃圾对象进行回收,最后Fron与To区域概念互换 | 优点是不会产生内存碎片,缺点是需要占用双倍的内存空间 | 指针碰撞 |
请介绍一下垃圾回收算法?
- ⭐⭐背诵版: 通过「分代回收机制」,按照不同的分区采用不同的垃圾回收算法,以提高垃圾回收的效率。新生代分为Eden区和幸存区From、To,通常采用的是「复制」算法。复制算法的优点是不会产生内存碎片,缺点是需要双倍的内存空间。而老年代采用的是「标记-整理」算法或者「标记-清除」算法。在效率上来看,标记-清除更好,但是会产生「内存碎片」,且标记清除算法在分配内存时对应的方法是「空闲列表法」。而标记-整理依赖于「重定位寄存器」,通过「紧凑技术」进行内存整理,可以解决「内存碎片」的问题,但是由于整理过程中涉及到了「内存地址的变化」,因此需要「STW」,它在分配内存时对应的方法是「指针碰撞法」。因此,可以根据我们对「吞吐量」与「响应时间」要求的不同来选择对应的算法,「CMS垃圾回收器」用的就是并发标记清除法;而「G1垃圾回收器」从全局看用的是标记-整理算法,而从局部来看用的是标记-复制算法。
3. 分代垃圾回收机制
分代 | 区域 | 特点 |
---|---|---|
弱分代 | 新生代 | 新生代中的对象生命周期具有朝生夕灭的特点,当达到一定年龄阈值,即可晋升到老年代 |
强分代 | 老年代 | 经过多次垃圾回收依然存活的对象,且达到老年代年龄阈值 |
跨代引用 | GC Roots 在老年代中,而新生代的对象引用了老年代的对象 | 基于此假说,我们就不再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构 (记忆集:Remembered Set) ,这个结构把老年代划分为若干个小块 (卡表) ,标识出老年代存在跨代引用的那一块区域 (脏卡); 再次发生Minor GC时,只需要根据Remembered Set扫描老年代,找GC Roots即可,提高搜索效率 |
1)为什么垃圾回收要进行分代?
- ⭐⭐背诵版: 不同的对象的生命周期是不一样的。按照对象的年龄可以分为新生代和老年代,新生代的对象是「朝生夕灭」的,而老年代是经过多次垃圾回收依然存活的对象。进行分代,就可以按照不同的分区采用不同的垃圾回收算法,以提高垃圾回收的效率。新生代分为Eden区和幸存区From、To,采用的是复制算法;而老年代在JDK1.8之前JVM推荐的是CMS(并发标记清除),在JDK1.9后默认推荐的是G1;
2)Minor GC触发时机
- Eden区内存占满时,触发Minor GC;
- Full GC前会触发一次Minor GC;
3)Full GC触发时机
- 老年代空间不足时,先会触发一次Minor GC,若空间依然不足,则触发Full GC;
- 通过Minor GC 后,晋升到老年代的对象所需要的内存空间 > 老年代剩余的内存空间,则触发Full GC;
- 主动调用System.gc()方法时,但不是一定会执行;
4)空间分配担保原则
空间分配担保原则就是确保Minor GC后,不会出现OOM的一个机制。具体的,流程如下:
阶段 | 执行(背诵) |
---|---|
Step1 | Eden内存满了,触发Minor GC |
Step2 | 检查老年代的可用空间 > 新生代存活对象的所需空间?若老年代空间足够,则Minor GC无风险,可以直接Minor GC |
Step3 | 若老年代空间 < 新生代存活对象的所需空间,则检查是否允许担保失败?若不允许,则直接触发Full GC,若空间依然不足,则OOM |
Step4 | 若允许担保失败,则尝试Minor GC之前,检查老年代空间 > 历次晋升到老年代的对象所需空间的平均值? |
Step5 | 若老年代空间 < 历次晋升到老年代的对象所需空间的平均值,则直接触发Full GC,若空间还不足,则OOM |
Step6 | 若老年代空间 > 历次晋升到老年代的对象所需空间的平均值,则尝试Minor GC,此时有风险 |
Step7 | 若Minor GC后Eden和From的存活对象 < 幸存区To的空间,则复制到To,然后From和To概念互换 |
Step8 | 若Minor GC后Eden和From的存活对象 > 幸存区To的空间,则判断存活对象的空间 < 老年代可用空间? |
Step9 | 若老年代空间足够,则复制到老年代;反之,则直接触发Full GC,若空间依然不足,则OOM |
四、垃圾回收器
1. 串行化
- 在垃圾回收线程执行时,其他用户线程全部阻塞;
- 新生代:「复制」;老年代:「标记-整理」
2. 并行:吞吐量优先
- 全部线程并行的进行垃圾回收,期间没有用户线程;
- 新生代:「复制」;老年代: 并行的「标记-整理」
3. CMS垃圾回收器:响应时间优先
- 新生代:「复制」;老年代: 并发「标记-清除」
- 优点: 由于是并发标记和并发回收,因此全局响应时间快;
- 缺点: 1)并发回收导致 「CPU资源紧张」 ,CMS默认启动的回收线程数:(CPU核数 + 3) / 4;
2)无法清理并发回收阶段产生的 「浮动垃圾」 ,只能等到下次GC时回收;
3)由于是标记-清除算法,所以会产生 「内存碎片」 ;
4)有 「并发失败」 的风险。因为在并发回收阶段,其他用户线程也会创建新的对象,因为不能等到老年代空间满的时候才Full GC,要预留出一部分空间给其他用户线程创建的对象分配内存。由于标记清除,如果预留的连续空间不足,则GC并发失败,此时就会「CMS」就会退化为「串行化Full GC」,用户线程将全部被阻塞,然后用标记整理算法进行回收,性能会大幅度下降;
阶段 | CMS垃圾回收器-执行 | 是否需要STW | 浮动垃圾 |
---|---|---|---|
初始标记 | 标记 GC Root 及其下一级对象 | 需要STW,原因:因为在初始标记阶段,其他用户线程还正在执行,很有可能会改变GC Root 及其引用。因此要等全部线程执行到安全点处,然后进入安全区进行标记,确保在标记过程中对象间的引用关系不会发生改变 | 无 |
并发标记 | 基于初始标记的结果,进行可达性分析,标记存活对象 | 无需STW。虽然其他用户线程在此阶段可能会改变引用关系,但是它的下一阶段是重新标记,因此不用担心 | 有 |
重新标记 | 用于修正并发标记阶段改变的引用关系,因为并发标记阶段会改变引用关系,也会产生新的垃圾 | 必须STW,防止重新标记阶段用户线程改变引用关系,以及产生新的垃圾 | 无 |
并发回收 | 基于标记-清除算法,清理垃圾 | 无需STW, 因为标记-清除算法不涉及到内存地址的改变,因此可以与其他用户线程并发执行; | 有 |
4. G1垃圾回收器
-
算法: 从「整体」来看是「标记-整理」算法,从「局部」(两个Region之间)来看是「标记-复制」算法;
-
优点: 由于其「全局并发标记,以及「停顿预测模型」,可以使G1垃圾回收器能够同时兼顾「吞吐量优先」与「响应时间优先」的特点
-
停顿预测模型: G1会在垃圾回收时,会计算出本次回收每个region的回收价值(回收时间、可回收空间等)并维护一个优先列表,每次回收时都优先将回收价值最大的区域回收掉,根据该停顿预测模型,可以将STW时间控制在我们的目标暂定时间内;
-
Region模型:
1)新生代:由不连续的Eden区和不连续的幸存区组成,由于空间不连续,故新生代在调整大小时很容易;
2)老年代:由老年区和巨型区(humongous)组成,巨型区用于存储巨大对象(标准region大小的50%)
注: 巨型区是由一组连续的H区组成的,它的作用是:让大对象直接进入老年代,避免了大对象在新生代中反复的迁移(复制清理的过程)
-
阶段1:G1—Young GC回收:
阶段1 | 触发时机 | 是否需要STW | 收集范围 |
---|---|---|---|
Young GC | Eden区内存空间占满 | 并行回收,需要STW | Eden与幸存区(不区分From和To) |
算法 | 执行过程 |
---|---|
复制 | 将新生代中存活的对象复制到一个或多个幸存区中,满足年龄晋升条件的对象将直接进入老年代; |
清理 | 对新生代中死亡的对象进行清理 |
停顿预测模型 | Young GC后根据目标STW时间,重新计算规划Eden与幸存区的大小; |
- 阶段2:全局并发标记 + 筛选回收
阶段2 | 触发时机 | 是否需要STW | 标记范围 |
---|---|---|---|
全局并发标记 | 堆内存空间的占用达到指定阈值 | 分阶段 | 新生代、老年代 |
标记过程 | 目的(背诵) | STW | 延伸点 |
---|---|---|---|
初始标记 | 标记存有GC Root的区域,以及引用了老年代中对象的区域(跨代引用);修改TAMS指针(用户线程在此过程可能会新建对象,分配的新内存空间,TAMS指针上的位置是空闲区域,而且是默认存活的,不纳入G1回收) | 会STW,但是初始标记伴随着一次Young GC,可以借助Young GC的STW来完成初始标记 | TAMS指针、跨代引用 |
根区域扫描 | 扫描根区域中的GC Root,将老年代中与GC Root直接关联的对象压入扫描栈,等待后续可达性分析 | 并发执行,不会STW | 跨代引用 |
并发标记 | 从扫描栈中获取对象,进行可达性分析,将堆中存活的对象都标记出来 | 并发执行,不会STW | 可达性分析 |
最终标记 | 基于原始快照(SATB)算法,处理并发标记阶段,引用关系发生变化的对象 | 并行执行,会STW | 增量更新与原始快照 |
筛选回收 | (注意,该阶段只是统计各区域的回收价值,并没有真正清理回收) 基于停顿预测模型与目标暂停时间,计算各个Region回收的价值并进行排序;重新维护每个Region的记忆集Remember Set,并统计完全空闲的Region,加入空闲列表;(注意:该阶段并没有对存活的对象进行迁移,对象迁移的工作会在之后的YGC或Mixed GC中进行 ) | 并行执行,会STW,无内存碎片 | 停顿预测模型、维护Remember Set |
- 阶段3:Mixed GC
阶段3 | 触发时机 | 是否需要STW | 收集范围 |
---|---|---|---|
Mixed GC | 全局并发标记+筛选回收阶段完成后,若堆中垃圾达到了回收阈值 | 复制算法,会STW | 新生代 + 筛选价值高的若干老年区 |
算法 | 存活对象的 迁移与清理 |
---|---|
复制 | 若只回收新生代中的对象,则由Young GC完成;若新生代+部分老年区都要回收,则用Mixed GC完成 |
G1垃圾回收器与CMS垃圾回收器的区别?
垃圾收集器 | 收集范围 | STW | 内存碎片 | 浮动垃圾 | 回收过程 |
---|---|---|---|---|---|
CMS | CMS是老年代的收集器,新生代需要配合其他收集器进行使用(比如:串行化与并行串行化) | 追求最小的停顿时间 | 有 | 有 | 第四阶段是并发清除 |
G1 | G1的收集范围是新生代与老年代,不需要配合其他收集器使用 | 基于停顿预测模型 | 无 | 无 | 第四阶段是并行的筛选回收 |
五、类加载技术
1. 类加载过程
java 的类加载,就是获取.class文件的二进制字节码数组并加载到 JVM 的方法区,并在 JVM 的堆区建立一个用来封装 java 类相关的数据和方法的java.lang.Class对象实例。
类加载过程 | 步骤 |
---|---|
加载 | ①根据类的全限定类型获取该类的的二进制字节码 ②将二进制字节码的静态存储结构 转换为 方法去的运行时数据结构 ③在堆中为该类生成一个class对象 |
连接:验证 | 验证该class文件中的字节流信息是否符合JVM的要求,而不会威胁到JVM的安全 |
连接:准备 | 为class对象的静态变量分配内存,并初始化零值 |
连接:解析 | 将符号引用转换为直接引用 |
初始化 | 调用类构造器的过程 |
2. 类加载器
类加载器 | 作用 |
---|---|
启动类加载器 BootStrapClassLoader | 用来加载java核心类库,无法被java程序直接引用 |
扩展类加载器 ExtensionClassLoader | 用来加载java的扩展类库,JVM提供了一个扩展库目录 |
应用程序类加载器 ApplicationClassLoader | 根据java的类路径来加载类,java应用的类都是通过它来加载的,可以在此处打破双亲委派模型 |
自定义类加载器 | 可以继承classLoader类,重写loadClass()和findClass(),打破双亲委派模型 |
3. 双亲委派机制
- 1)什么是双亲委派
JVM 并不是在启动时就把所有的.class文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。除了启动类加载器外,其他所有类加载器都需要继承抽象类ClassLoader,这个抽象类中定义了三个关键方法,理解清楚它们的作用和关系非常重要。
public abstract class ClassLoader {
//每个类加载器都有个父加载器
private final ClassLoader parent;
public Class<?> loadClass(String name) {
//查找一下这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
//如果没有加载过
if( c == null ){
//先委派给父加载器去加载,注意这是个递归调用
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果父加载器为空,查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加载器没加载成功,调用自己的findClass去加载
if (c == null) {
c = findClass(name);
}
return c;
}
protected Class<?> findClass(String name){
//1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
...
//2. 调用defineClass将字节数组转成Class对象
return defineClass(buf, off, len);
}
// 将字节码数组解析成一个Class对象,用native方法实现
protected final Class<?> defineClass(byte[] b, int off, int len){
...
}
}
从上面的代码可以得到几个关键信息:
-
JVM 的类加载器是分层次的,它们有父子关系,而这个关系不是继承维护,而是组合,每个类加载器都持有一个 parent 字段,指向父加载器。(AppClassLoader的parent是ExtClassLoader,ExtClassLoader的parent是BootstrapClassLoader,但是ExtClassLoader的parent=null)
-
「defineClass()」 方法的职责是调用 native 方法把 Java 类的字节码解析成一个 Class 对象。
-
「findClass()」 方法的主要职责就是找到.class文件并把.class文件读到内存得到字节码数组,然后调用 defineClass 方法得到 Class 对象。子类必须实现findClass 。
-
「loadClass()」 方法的主要职责就是实现双亲委派机制:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。
-
1)类加载器之间存在父子关系,这种关系不是继承关系,是组合关系。如果parent=null,则它的父级就是启动类加载器。启动类加载器无法被java程序直接引用。
-
2)双亲委派就是类加载器之间的层级关系,加载类的过程是一个递归调用的过程,首先一层一层向上委托父类加载器加载,直到到达最顶层启动类加载器,启动类加载器无法加载时,再一层一层向下委托给子类加载器加载。
-
3)加载一个类时,也会加载其父类,如果该类中还引用了其他类,则按需加载,且类加载器都是加载当前类的类加载器。
-
4)双亲委派的目的主要是为了保证java官方的类库加载安全性,不会被开发者覆盖。
2)为什么要双亲委派?
-
双亲委派保证类加载器,自下而上的委派,又自上而下的加载,保证每一个类在各个类加载器中都是同一个类。
-
一个非常明显的目的就是保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖。例如类java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。
3)打破双亲委派模型的方式?
- ① 自定义类加载器,继承ClassLoader,并重写findClass,如果想不遵循双亲委派的类加载顺序,还需要重写loadClass;
- ② 跳过AppClassLoader和ExtClassLoader ,指定自定义的类加载器的parent = null,即默认让启动类加载器进行加载;
- ③线程上下文类加载器,作用是反向委托,打破双亲委派模型,完成父类加载器请求子类加载器完成类加载的行为; 例如JNDI服务,JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能去加载ClassPath下的类。SPI机制是JDK提供接口,第三方jar包实现具体的实现类。因此由于实现类不在启动类加载器中,需要让线程上下文加载器通过class.forName()来加载类路径下的类,故需要反向委托给子类进行加载;
4)打破双亲委派模型的例子?
- Tomcat、JDIN服务、JDBC、ES、Spring、Dubbo等