深入理解Java虚拟机系列 - 总结
- 前言
- 一. JVM 内存模型和 Java 对象模型
- 1.1 JVM 内存模型包括哪些?作用分别是?
- 1.2 JVM 内存模型中的各个区域的特点?
- 1.3 对象分配内存的方式有哪些?
- 1.4 对象的内存布局是怎样的?
- ① 对象头
- ② 实例数据
- ③ 对齐填充
- 1.5 对象的访问方式有哪些?
- 二. 垃圾收集器与内存分配策略
- 2.1 JVM 中判断对象死亡的方式有哪些?
- ① 引用计数法
- ② 可达性分析法
- 2.2 JVM的引用类型有哪几种?
- ① 强引用(Strong Reference)
- ② 软引用(Soft Reference)
- ③ 弱引用(Weak Reference)
- ④ 虚引用(Phantom Reference)
- 2.3 对象如何逃脱死亡命运?finalize()干啥的?
- 2.4 垃圾收集算法有哪些?分别有什么特性?
- ① 标记清除法
- ② 复制法
- ③ 标记整理法
- 2.5 新老年代分别适用于什么算法?
- 2.5 在GC的时候,有哪些特性?
- 2.6 JVM 垃圾收集器有哪些?
- 三. 虚拟机类加载机制
- 3.1 类的生命周期包括哪些阶段?
- ① 加载
- ② 验证
- ③ 准备(重点)
- ④ 解析
- ⑤ 初始化(重点)
- 3.2 类加载器是什么?有哪些种类
- 3.3 什么是双亲委派?什么情况下需要打破这个规则?
- 四. Java内存模型和线程
- 4.1 Java 内存模型有哪些规定?
- 4.2 volatile 关键字的作用?
- 4.3 什么是指令重排?目的是啥?
- 4.4 volatile的大致原理
- 4.5 举个volatile关键字的常见用法 - 双重检索?(重点)
- 4.6 原子性、可见性和有序性分别介绍下?
- 4.7 Synchronized和volatile的比较
- 4.8 Java 锁的优化有哪些?
- ① 自旋锁
- ② 锁消除
- ③ 锁粗化
- ④ 偏向锁
前言
本篇文章是对:深入理解Java虚拟机系列文章 的一份精炼总结。
一. JVM 内存模型和 Java 对象模型
1.1 JVM 内存模型包括哪些?作用分别是?
针对JDK8
来说,JVM
内存模型包括:
- 程序计时器:当前线程所执行的字节码的行动指示器。
- 虚拟机栈:存放8大基本数据类型的数据、局部变量表、操作数栈、动态链接、方法出口。
- 本地方法栈:类似于虚拟机栈,但是服务于
Native
修饰的函数。 - 堆:存放
Java
对象实例。 - 元空间:保存元数据的地方,如方法、字段、类、包的描述信息。类加载器存储的位置就是元空间,每一个类加载器的存储区域都称作为一个元空间。
1.2 JVM 内存模型中的各个区域的特点?
如果按照线程是否私有来区分:
- 线程私有:程序计时器、虚拟机栈(8大基本数据类型)、本地方法栈。
- 线程共享:堆(包含方法区,存储常量和静态变量)。
其他特点:
- 程序计时器:线程私有、唯一不会发生
OOM
的区域。 Java
虚拟机栈:线程私有、存放了基本数据类型的变量。- 堆(
GC
堆):虚拟机内存中最大的一块、线程共享、在虚拟机启动的时候创建。存储了静态变量、常量等数据。
1.3 对象分配内存的方式有哪些?
- 指针碰撞(在Java堆内存连续的情况下):所有用过的内存放在一侧,空闲的内存放在另一侧,中间放着一个指针作为分界点的指示器,那所谓分配内存就是仅仅把这个指针向空闲内存的一侧挪一小段与对象大小相等的距离。(例如
Serial、ParNew
使用时会用到) - 空闲列表(Java对内存不连续的情况下):虚拟机维护一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并且更新列表上的记录。(例如
CMS
收集器使用时会用到)
1.4 对象的内存布局是怎样的?
Java
对象的内存存储布局如下,分为三块区域:
① 对象头
对象头又包括:
MarkWord
:存储自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏相关时间戳等- 类型指针:类型指针即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 数组长度(如果是数组类型才有)
② 实例数据
真正保存实例数据的地方。
③ 对齐填充
填充Java
对象,让它为8的整数倍。
1.5 对象的访问方式有哪些?
- 使用句柄
- 直接指针。
二. 垃圾收集器与内存分配策略
2.1 JVM 中判断对象死亡的方式有哪些?
① 引用计数法
有一个地方在引用,计数器+1,引用失效时,计数器-1。任何时刻计数器为0的对象就是不可能在被使用的。:
- 实现简单,判断效率高。
- 但是难以解决对象之间循环引用的问题。
② 可达性分析法
算法的主要思路:
- 通过一系列的称为“GC Root”的对象作为起始点。
- 从这些节点开始向下搜索,搜索走过的路径称为引用链。
- 当一个对象到GC roots没有任何引用链相连(即从GC root到这个对象不可达),则说明这个对象是不可用的。
可以作为Root
对象包括:
- 虚拟机栈中引用的对象。(本地变量表)
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中(
native
方法)引用的变量。
2.2 JVM的引用类型有哪几种?
一共4种,强度从大到小排序:
① 强引用(Strong Reference)
一般是new
出来的对象。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
② 软引用(Soft Reference)
软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围内进行第二次回收。
③ 弱引用(Weak Reference)
被弱引用关联的对象只能生存到下一次GC发生之前,GC工作的时候,无论当前内存是否足够,都会回收到只被弱引用关联的对象。
例如ThreadLocal
类中底层ThreadLocalMap
的Key
就是一个弱引用。
④ 虚引用(Phantom Reference)
虚引用是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就就是能在这个对象被GC回收的时候得到一个系统通知。
2.3 对象如何逃脱死亡命运?finalize()干啥的?
要真正宣告一个对象死亡的话,至少要经历两次标记过程。(GC回收器的算法都会经历2次标记)
如何摆脱死亡的命运呢?大致流程如下:
- 对象在进行可达性分析法后发现没有与
GC Root
相连的引用链,那么他会被第一次标记并且进行筛选. - 筛选的条件是这个对象是否有必要执行
finalize()
方法。 - 如果这个
finalize()
方法被覆盖过(重写)且没被执行过,那么这个对象会被判为有必要执行finalize
方法的。 - 若有必要执行,则这个对象会放到一个叫
F-Queue
的队列之中,并在稍后由一个低优先级的Finalizer
线程去触发这个finalize()
方法。 - **如果这个
finalize()
方法中成功的让此对象重新与引用链上的任何一个对象关联(即可达),那么在二次标记的时候,就会把这个对象移出“即将回收”的集合。**相反,如果执行后,这个对象还是不可达的,那么他就会被回收。
总结:重写一个对象的finalize
函数,让这个对象做到可达,即可能让这个对象逃脱一次被GC
的命运。
2.4 垃圾收集算法有哪些?分别有什么特性?
① 标记清除法
算法划分为两个阶段。
- 标记阶段:标记出所有需要回收的对象。
- 清除阶段:统一回收所有被标记的对象。
缺点:
- 效率问题:标记和清除两个阶段的效率都不高。
- 空间问题:标记清除之后会产生大量不连续的内存碎片。
② 复制法
算法流程如下:
- 将可用内存容量划分为大小相等的两块,每次只使用其中的一块。
- 当这一块的内存用完了,就将还存活着的对象复制到另一块上,然后再把自己曾经使用过的那一块内存空间清理掉。
优缺点:
- 优点:实现简单,效率高。
- 缺点:代价是内存会缩小到原来的一半,开销大(有一半拿来复制用)。
③ 标记整理法
算法流程如下:
- 标记:对要存活的的对象进行标记
- 整理:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优点:
- 标记清除法的升级版,解决了内存碎片的问题。
- 适用于老年代的
GC
算法。
2.5 新老年代分别适用于什么算法?
- 新生代:每次GC都会有大批对象死去,只有少量存活。采用复制算法。
- 老年代:对象存活率高、没有额外空间对他进行分配担保。采用标记清除or标记整理算法进行回收。
2.5 在GC的时候,有哪些特性?
STW(Stop The World)
:枚举根节点时必须停顿,避免引用关系发生变化。使用OopMap
来实现快速定位GC Roots
的枚举。- 发生
GC
的时候,程序一定到达了某个安全点(Safepoint
)或者安全区域。
2.6 JVM 垃圾收集器有哪些?
新生代GC
:
Serial
(单线程)ParNew
(多线程)Parallel Scavenge
(多线程,目标:达到一个可控制的吞吐量。支持自适应调节策略)
老年代GC
:
Serial Old
(单线程、标记整理)Parallel Old
(参考Parallel Scavenge
,只是服务的范围不一样、标记整理)CMS
(目标:获取最短回收停顿时间,并发收集,标记清除)
独立的GC
:
G1
(标记整理、复制算法、并行与并发、分代、可预测停顿)
三. 虚拟机类加载机制
3.1 类的生命周期包括哪些阶段?
① 加载
加载阶段需要做三件事情:
- 获取二进制字节流。
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问接口。
② 验证
确保Class
文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机自身的安全。验证的内容包括:
- 文件格式验证:验证字节流是否符合
Class
文件格式的规范,并能被当前版本的虚拟机处理。 - 元数据验证:对字节码描述的信息进行语义分析,以保证其符合
Java
语言规范。 - 字节码验证:通过数据流和控制流分析,确定程序语义是合法的。
- 符号引用验证:对类自身以外的信息进行匹配性校验。目的:确保解析动作能够正常执行。
③ 准备(重点)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,仅仅针对被static
修饰的变量。
但是值得注意的是!!!如以下代码:
public static int value = 123;
在准备阶段,赋值初始值的时候,value
的值是0,而不是123。但是如果代码是:
public static final int value = 123;
那这个时候value
在准备阶段会赋值为123。static final
修饰的字段在javac
编译时生成constantValue
属性,在类加载的准备阶段直接把constantValue
的值赋给该字段。
④ 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量
- 直接引用:直接引用是直接指向目标的指针、相对偏移量或者是一个能够够间接定位到目标的句柄。
⑤ 初始化(重点)
在初始化阶段,会根据我们自己制定的Java
代码去初始化类变量和其他资源。换句话说,初始化阶段是执行类构造器< clinit >()
方法的过程。
< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})合并而成。
说白了就是:static语句块和声明的static变量。
初始化阶段有以下几个重点知识:
- 编译器收集的顺序是由语句在源文件中出现的顺序做决定。
- 静态语句块中只能访问到定义在静态语句块之前的变量。
- 定义在静态语句块之后的变量,允许前面的静态语句块赋值,但不允许访问。
- 虚拟机会保证在子类的
< clinit >()
方法执行之前,父类的< clinit >()
方法已经执行完毕。即父类中定义的静态语句块要优先于子类的变量赋值操作。 - 接口中不能使用静态语句块,但是仍然有变量初始化的赋值操作。
3.2 类加载器是什么?有哪些种类
如果能通过一个类的全限定名称来获取描述该类的二进制字节流,那这样的角色称作为类加载器。
若两个类来源于同一个Class
文件,被同一个虚拟机加载,只要加载他们的类加载器不同,这两个类就必定不相等。
类加载器的种类如下:
- 启动类加载器(
Bootstrap ClassLoader
):由C++
语言实现,是JVM
自身的一部分。 - 扩展类加载器(
Extension ClassLoader
):负责加载< JAVA_HOME>\lib\ext
目录中的或者被java.ext.dirs
系统变量所制定的路径中的所有类库。(开发者可以直接使用) - 应用程序类加载器(
Application ClassLoader
):负责加载用户类路径(ClassPath
)上所制定的类库。
关系图如下:
3.3 什么是双亲委派?什么情况下需要打破这个规则?
双亲委派模型的工作流程:
- 如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
- 因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候,子加载器才会尝试自己去加载。
什么情况下要打破?
- 某些情况下父类加载器需要委托子类加载器去加载
class
文件。 - 例如
JDBC
中的Driver
接口的实现是由不同的数据库服务商来提供,有Mysql、Oracle、KingbaseES
等数据库。它是由启动类加载器来实现加载(顶层父类),但是具体实现却在子类,因此需要用户程序类加载器加载。 - 这个时候就需要打破双亲委派模型,需要优先委托子类加载器来加载
Driver
的具体实现
四. Java内存模型和线程
4.1 Java 内存模型有哪些规定?
- 所有的变量都存储在主内存中。
- 每条线程有属于自己的工作内存,其中保存了被该线程使用到的变量的主内存副本拷贝。
- 线程对变量的所有操作都必须在工作内存当中进行,而不能直接读写主内存中的变量。
- 不同的线程之间无法直接访问对方工作内存中的变量(私有),线程间变量值的传递需要通过主内存来完成。
如图:
4.2 volatile 关键字的作用?
volatile
关键字是Java
虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile
后,它具备两种特性(不保证原子性):
- 可见性:当一条线程修改了
volatile
修饰的变量的值,那么该新值对于其他线程来说立即可见。 - 禁止指令重排序。 相当于增加了一个内存屏障。
4.3 什么是指令重排?目的是啥?
问题1:什么是指令重排?
指令重排是指JVM
在编译Java
代码的时候,或者CPU
在执行JVM
字节码的时候,对现有的指令顺序进行重新排序。
问题2:指令重排的目的是什么?
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率(指的是不改变单线程下的程序执行结果)。
4.4 volatile的大致原理
-
加了
volatile
修饰的变量,处理器会多出一个带有Lock
的汇编指令。 -
而
Lock
前缀指令主要做了两件事情:
第一个:将当前处理器缓存行的数据回写到内存当中。
第二个:这个写回内存的操作会使其他CPU
缓存了该内存地址的数据无效。(内存屏障的功能之一) -
换句话说,
lock
指令加了一个内存屏障,禁止在内存屏障前后的指令执行重排序优化。
4.5 举个volatile关键字的常见用法 - 双重检索?(重点)
背景:
instance = new Singleton();
并不是一个原子操作,会被编译成三条指令(按顺序)。1.给instance
分配内存。2.初始化其构造器。3.将instance
对象指向分配的内存空间。- 也因此在多线程情况下,如果不加入
volatile
,可能会发生重排序,导致可能的执行顺序是132,从而导致某些线程访问到未初始化的变量。 - 也因此用
volatile
来禁止重排序,通过加入内存屏障的方式来保证执行的顺序为123。
双重检索:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
4.6 原子性、可见性和有序性分别介绍下?
-
原子性:Java内存模型直接保证的原子性变量操作包括:
read、load、assign、use、store、write
。 -
可见性:可见性指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
-
有序性:Java程序中天然的有序性可以概括为:如果在本线程内观察,所有的操作都是有序的。Java提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。
4.7 Synchronized和volatile的比较
volatile
不会进行加锁操作(它只是一种稍弱的同步机制),而Synchronized
会进行加锁。volatile
变量作用类似于同步变量的读写操作,从内存可见性角度来看:1.写入volatile
变量——>退出同步代码块。2.读取volatile
变量——>进入同步代码块。volatile
不如Synchronized
安全(前者无锁,后者有)volatile
不能同时保证内存可见性和原子性,但是Synchronized
可以。
4.8 Java 锁的优化有哪些?
① 自旋锁
如果持有锁的线程能够在很短的时间内释放资源,那么那些正在等待的线程就不用做内核态和用户态的转换而进入堵塞、挂起状态。只要等待一小段时间,就能在其他线程释放资源的瞬间可以立即获得锁。
- 优点:减少CPU上下文的切换,因此对于占用锁资源时间短或者锁竞争不激烈的代码块性能会高一点。
- 缺点:如果竞争激烈,那么可能导致长时间自旋,浪费CPU。
② 锁消除
锁消除是指虚拟机在编译器运行时,对一些代码要求同步,但是被检测到实际上不存在共享数据的竞争,那么对于这一类锁,会进行消除。
例如StringBuffer.append
函数,就由synchronized
关键字修饰。
public String method(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
// 其中append源码:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
这种时候我们可以添加参数进行锁的消除。
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
③ 锁粗化
将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗。
④ 偏向锁
偏向锁是指一段同步代码块一直被一个线程访问,那么该线程会自动获取锁,降低获取锁的代价。 其目标是在只有一个线程执行同步代码块的时候能够提高性能。
- 特点:偏向锁只有遇到其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁,也就是说线程不会主动释放锁。