这次社招选的这本作为 JVM 资料查阅,记录一些重点
1. 虚拟机历史
Sun Classic VM :已退休
HotSpot VM:主流虚拟机,热点代码探测技术
Mobile / Embedded VM :移动端、嵌入式使用的虚拟机
2.2 运行时数据区域
程序计数器(线程级):当前线程所执行的字节码的行号指示器。
虚拟机栈(线程级):存放局部变量、操作数栈、动态链接、方法出口等信息。其中局部变量包含编译时可知的基本数据类型和对象引用。
本地方法栈(线程级):与虚拟机栈类似,为虚拟机使用到的本地方法服务。
堆:对象分配。
方法区:存储已经被虚拟机加载的类型信息、常量、静态变量等。
2.2 补充 - 直接内存
直接内存不是虚拟机运行时数据区的一部分。使用 Native 函数直接分配堆外内存,然后通过一个存储在堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
2.3 对象的创建
1. 读到 new 指令。
2. 检查这个指令的参数能否在常量池定位到一个类的符号引用。检查这个类是否已经加载、解析、初始化,若没有则执行。
3. 分配内存(对象所需的内存大小在类加载完成后就可以完全确定)。
4. 初始化为零值。
5. 记录对象头。
6. 初始化。
对对象的访问有句柄式和直接指针两种类型
2.3 补充 - 分配内存时保持线程安全
方案一:对分配内存空间的动作进行同步处理;
方案二:每个线程在堆中预先分配一小块内存,优先使用本地缓冲区,耗尽后才需要进行同步锁定。
2.3 补充 - 对象的内存布局
对象头:存储对象自身的运行时数据(Hashcode,锁等),类型指针(对象指向其类型元数据的指针),当对象是一个 java 数组时,还需要记录数组长度。
实例数据:包含父类 & 子类的数据。
对齐补充:补充为 8 字节的整数倍。
3.2 判断对象是否可以回收
1. 引用计数法:利用引用计数器来记录引用数量。无法解决循环引用问题。
2. 可达性分析:通过 GC Roots 向下搜索,如果某个对象不可达,则说明不再使用。可以作为 GC Roots 的对象包含:虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区常量引用的对象、虚拟机内部的引用、被同步锁持有的对象、反映虚拟机内部情况的变量。
强引用:通过引用赋值 Object obj = new Object()
软引用:用于描述一些还有用但非必须得对象。 SoftReferrence,系统要发生移除异常前,才进行回收。
弱引用:对象只活到下次 GC 之前。WeakReferrence。
虚引用:不影响回收,作用仅是在回收时可以收到一个系统通知。 PhantomReference 。
对象在可达性分析后发现不可达,进行第一次标记 -> 放入 F - Queue 中 -> Finalizer 线程执行 finalize() 方法 -> 对 F - Queue 中的对象进行二次标记。
在枚举根节点时,必然会停顿用户进程。
3.2 补充 方法区中的回收
主要回收废弃的常量和不再使用的类型。不再使用的类型(该类的所有实例都已经回收 & 类加载器已经回收 & 该类的 Class 对象没有任何引用)
3.3 分代收集基础上的垃圾回收算法
1. 标记 - 清除算法
2. 标记 - 复制算法:内存分为大小相等的两块,每次只使用其中的一块。Appel 式回收将内存划分为一块 Eden 区两块 Survivor 区域,每次使用 Eden + 一块 Survivor,当出现极限情况会占用老年代内存。
3. 标记 - 整理算法:存活的对象需要移动到整理
3.4 安全点 & 安全区域
安全点:用户程序执行可以停下来的时间点
安全区域:安全点无法保证挂起的线程可以执行到,可以视作延长了的安全点。
- 如果线程在执行关键操作(如执行系统调用)时收到挂起请求,JVM可能会延迟挂起,直到线程完成当前操作并进入下一个安全点。
虽然用户可以通过Thread.suspend()
方法请求挂起一个线程,但JVM可能会根据当前的执行环境和线程状态,延迟挂起操作,直到线程到达一个安全点。这种做法有助于确保程序的稳定性和数据的一致性。然而,需要注意的是,Thread.suspend()
方法已经被标记为过时(deprecated),并且不推荐在现代Java应用程序中使用,因为它可能会导致死锁和其他问题。现代Java应用程序更倾向于使用Thread.interrupt()
方法来请求线程中断,并通过轮询中断状态来实现线程的协作挂起。
3.5 卡表
为了解决跨代引用问题,新生代会维护一个「记忆集」,避免把整个老年代都加入 GC Roots 的扫描范围。通常使用卡表来作为解决方案。
只要对应的内存中存在一个跨代指针就标记,扫描时将其加入范围内。通过「写屏障」技术在每次赋值之后维护。
3.6 垃圾回收器
Serial :单线程工作。Stop the world。
ParNew:Serial 的多线程版本。
Parallel:基于标记复制算法,尽可能达到一个可控制的吞吐量。适用于后台运算不需要太多交互的任务。
Serial Old:Serial 的老年代版本。
Parallel Old: Parallel 的老年代版本。
CMS:最短响应时间。标记清除算法。
G1:虽然保留了新生代和老年代的概念,但不再固定区域转为划分为 Region,每个 Region 可以独立作为某个代。
Shenandoah:提供并发标记、并发回收、并发引用更新的处理,旨在提供最小停顿时间。
ZGC:基于 Region 内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射技术实现的可以并发的标记整理算法的垃圾处理器。
3.6 补充 G1 的卡表
CMS垃圾收集器的记忆集设计
-
记忆集的作用:
-
记忆集用于记录从非收集区域指向收集区域的指针集合。在CMS中,主要是记录老年代中哪些对象引用了新生代的对象71。
-
-
卡表的实现:
-
卡表是记忆集的具体实现方式之一。在CMS中,卡表是一个字节数组,每个字节对应一个卡页(通常是512字节)。如果卡页中的某个对象引用了新生代的对象,对应的卡表字节会被标记为171。
-
-
卡表的更新:
-
在CMS的并发标记阶段,如果老年代对象引用了新生代对象,卡表会被更新,标记相应的卡页为“脏卡”。这样在Minor GC时,只需要扫描这些脏卡对应的老年代对象71。
-
-
并发标记和重新标记:
-
CMS的并发标记阶段会并发地进行GC Roots Tracing,而重新标记阶段则会修正并发标记阶段由于用户程序变动导致的问题71。
-
G1垃圾收集器的记忆集设计
-
记忆集的复杂性:
-
G1的堆内存被划分为多个大小相等的区域(Region),每个区域可以是Eden、Survivor或老年代区域。G1的记忆集需要记录跨Region的引用关系72。
-
-
卡表的局限性:
-
在G1中,由于堆内存的划分方式,传统的卡表结构不再适用。G1采用了“空间换时间”的策略,通过增加记忆集的结构复杂度来减少GC的时间78。
-
-
记忆集的实现:
-
G1的记忆集在概念上采用了"point-in"的思想,即记录了哪个区域指向我。这种记忆集在Card Table的基础上增加了HashTable的数据结构,Key是某个老年代Region的起始位置,Value是这个老年代Region的所有存在跨代指针的卡页的起始位置的集合78。
-
-
跨Region引用的处理:
-
G1的每个Region都维护有自己的记忆集,记录了其他Region中的对象到该Region的引用。这样在进行垃圾回收时,只需要扫描这些记录的引用关系,而不需要扫描整个堆77。
-
-
记忆集的空间开销:
-
G1的每个Region都维护有自己的记忆集,这导致G1比其他垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作
-
7.2 类加载的时机
加载
- 通过类的类名来获取定义此类的二进制字节流
- 字节流转化为方法区的运行时数据结构
- 生成 .Class 对象
准备
- 为类中定义的变量分配内存并设置初始值。
解析
- 常量池里的符号引用替换为直接引用的过程。包含接口、字段、方法、接口方法等
8.2 运行时栈
8.3 重载和重写是如何实现的 - 分派
静态分派
Human man = new Man(),其中 Human 为变量的静态类型或外观类型,编译时可知。Man 为实际类型或运行时类型,编译时不可知(eg:通过计算才确定 new 啥的写法)。
Java 中的静态分配由于同时参考静态类型 & 参数,所以属于多分派类型。
重载是通过静态类型进行判断的。
动态分派
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
重写即为动态分派的体现,根源在于 invokevirtual 指令的运行逻辑会优先使用实际类型。事实上,Java 中只有虚方法存在,字段永远不可能是虚的(子类会屏蔽父类的同名字段)。
Java 中的动态分派属于单分派类型,只受实际类型影响。
8.3 补充 - 虚方法表
动态分派的实现方式之一:类型在方法区会建立一个虚方法表,用虚方法表代替元数据查找。虚方法表中存放着各个方法的实际入口地址。若没有重写,子类的虚方法表和父类相同方法的入口一致,都指向父类的实现入口。
此外,还会他用过类型集成关系分析、守护内联、内联缓存等来争取更大的优化。
8.4 动态类型语言
动态类型语言的类型检查主体过程在运行期而不是编译期。Java 是静态语言。
8.5 解释执行 & 编译执行
-
编译执行 (Compile and Execute):
- 编译阶段:源代码(如C、C++、Java等语言编写的程序)首先需要通过编译器转换成机器代码或字节码。编译器检查源代码的语法错误,进行类型检查,优化代码,最终生成可执行文件或字节码文件。
- 执行阶段:编译后的机器代码由计算机的操作系统加载并执行,或者字节码由虚拟机(如Java虚拟机)加载并解释执行。
-
解释执行 (Interpret and Execute):
- 解释执行通常指的是源代码直接由解释器逐行解释并立即执行,无需编译成机器代码。这种执行方式常见于脚本语言(如Python、JavaScript、Ruby等)。
- 解释器读取源代码,转换为中间表示(如果需要),然后立即执行这些操作,而不需要等待整个程序编译完成。
-
即时编译 (Just-In-Time Compilation, JIT):
- 某些语言(如Java)使用即时编译技术,将字节码在运行时编译成机器代码。这种方式结合了编译执行和解释执行的优点,允许程序在开始时快速启动(像解释执行),并在运行过程中优化性能(像编译执行)。
10.3 泛型
Java 中的泛型为类型擦除式泛型,只在源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型。
10.3 补充 自动装箱 & 拆箱
Java 的自动装箱(Autoboxing)和拆箱(Unboxing)是 Java 5 引入的两个特性,它们允许基本数据类型(如 `int`、`double` 等)和对应的包装类(如 `Integer`、`Double` 等)之间的自动转换。
### 自动装箱(Autoboxing)
自动装箱是指自动将基本数据类型转换为对应的包装类类型。这个过程是编译器在代码编译时自动完成的。例如:
```java
Integer refInt = 5; // 自动装箱,将 int 类型 5 转换为 Integer 类型
```
在这个例子中,整数值 `5` 被自动转换为 `Integer` 对象。
### 自动拆箱(Unboxing)
自动拆箱是指自动将包装类类型转换为对应的基本数据类型。这同样是由编译器在编译时自动完成的。例如:
```java
int num = refInt; // 自动拆箱,将 Integer 类型转换为 int 类型
```
在这个例子中,`refInt` 是 `Integer` 类型的对象,它被自动转换为 `int` 类型的变量 `num`。
### 转换规则
- `int` 与 `Integer`
- `double` 与 `Double`
- `float` 与 `Float`
- `long` 与 `Long`
- `short` 与 `Short`
- `byte` 与 `Byte`
- `char` 与 `Character`
- `boolean` 与 `Boolean`
### 注意事项
- 自动装箱和拆箱在编译时由编译器处理,因此在运行时不会有明显的性能损失。
- 包装类 `Long`、`Integer` 和 `Short` 提供了缓存机制,对于一定范围内的值(通常在 `-128` 到 `127` 之间),会使用相同的实例来避免创建过多的对象。这个范围可以通过 `java.lang.Integer.IntegerCache` 高度自定义。
- 过度使用自动装箱可能导致性能问题,尤其是在涉及到大量数据的情况下,因为它会增加对象的创建和垃圾回收的负担。
- 在进行算术运算时,如果涉及到基本数据类型和包装类型,Java 会自动拆箱基本数据类型,然后再进行运算。
自动装箱和拆箱使得在需要使用对象的情况下,可以更加方便地使用基本数据类型,同时保持代码的简洁性和可读性。然而,开发者应该注意它们可能带来的性能影响,并在适当的时候手动进行装箱或拆箱操作。
11.3 解释器和编译器
程序需要快速启动和执行的时候,解释器可以首先发挥作用,省去编译时间立刻运行。程序启动后,编译器逐渐发生作用,把越来越多的代码译为本地代码获得更高的执行效率。
11.4 方法内联
原理上是将目标方法的代码复制到发起调用的方法之中,减少方法调用。但实际需要进行很多优化准备(因为大部分调用都是虚方法)。例如采用类型继承关系分析、内联缓存等。
11.5 逃逸分析
分析对象动态作用域,当一个对象在方法里面被定义后,可能被外部方法引用,称为方法逃逸。被外部线程访问,称为线程逃逸。
若一个对象逃逸程度较低,可以采取不同程度的优化:
1. 栈上分配:如果确定对象不会逃逸到线程外,直接分配在栈上。
2. 标量替换:如果对象不会被方法外访问,可以将其拆分到最小基本类型。分配到栈上。
3. 同步消除:如果对象不会被线程外访问,可以消除其同步措施。
11.6 公共子表达式消除
如果一个表达式之前已经被计算过了,并且从之前的计算到现在E中所有变量都没有变化,那么无需重复计算。
12.3 Java 内存模型
12.4 Volatile
1. 保证此变量对所有线程的可见性
适用于:运算结果并不依赖变量的当前值,或者能确保只有单一线程修改此值;变量不需要与其他状态变量共同参与不变约束。
2. 禁止指令重排序
12.5 原子性、可见性、有序性
1. 原子性:基本数据类型的访问读写都是原子性的,当需要更大范围的保证时,提供了 synchronized 关键字。
2. 可见性:通过变量修改后把新值同步回主内存,在读取变量前从主内存刷新变量值来实现。此外 synchronized 和 final 关键字也可以保证可见性。
3. 有序性:在本线程内观察,所有操作都是有序的,在另外一个线程中观察则是无序的。 volatile 和 synchronized 都可以保证。
12.6 先行发生原则
先行发生:内存模型中定义的两项操作之间的偏序关系,即发生操作B之前,操作A的影响能被B观察到。
1. 程序次序规则:一个线程内,按控制流顺序执行。
2. 管程锁定原则: unlock 晚于 lock
3. volatile :对 volatile 的写先行发生于读
4. 线程启动:线程的 start() 动作先行发生于此线程的每一个动作
5. 线程终止:线程的所有操作都先行发生于对此线程的终止检测
6. 线程中断:对 interrupt的调用先行发生于被中断线程的代码检测到中断事件的发生
7. 对象终止:初始化先行于 finalize()
8. 传递性。
12.7 线程状态
13.1 线程安全
线程安全的不同级别
1. 不可变:例如 final 修饰的变量。
2. 绝对线程安全:很难达到
Vector
是 Java 中的一个同步的 List
实现,它的方法默认都是同步的,这意味着在多线程环境下,多个线程可以安全地访问 Vector
对象,而不需要额外的同步控制。
然而,即使 Vector
的方法是同步的,这并不意味着在多线程环境下对 Vector
进行的所有操作都是安全的。以下是一些可能导致问题的情况:
-
迭代器失效:
- 如果在一个线程中正在遍历
Vector
,而另一个线程删除了元素,那么遍历的迭代器可能会失效。这是因为删除操作可能会改变底层数组的结构,导致迭代器的状态与实际数据不一致。
- 如果在一个线程中正在遍历
-
并发修改异常:
- 尽管
Vector
的方法是同步的,但如果在一个线程中删除元素后,另一个线程立即尝试访问该元素,可能会抛出ConcurrentModificationException
。这是因为Vector
无法保证在删除操作和访问操作之间的原子性。
- 尽管
-
索引变化:
- 当一个线程删除了一个元素后,
Vector
的大小会减小,但其他线程可能还不知道这个变化。如果这些线程仍然使用旧的索引来访问元素,可能会访问到错误的位置,甚至抛出IndexOutOfBoundsException
。
- 当一个线程删除了一个元素后,
-
可见性问题:
- 在多线程环境中,一个线程对
Vector
的修改可能对其他线程不可见,直到修改后的值被写回到主内存。如果其他线程没有看到最新的值,可能会使用错误的数据。
- 在多线程环境中,一个线程对
-
方法级别的同步:
Vector
的同步是方法级别的,这意味着每次调用方法时都会进行同步。但是,如果一个线程在执行一个方法的过程中被中断,而另一个线程在此时调用了另一个方法,可能会发生不一致的状态。
3. 相对线程安全:对象的单次操作是线程安全的,但对于一些特定顺序的连续调用需要额外的手段来保证正确性。Java 中大部分声称线程安全的对象都属于此类。
4. 线程兼容
5. 线程对立:无论是否采取了同步措施,都无法在多环境使用。
13.2 线程安全的实现方法
1. 互斥同步:保证共享数据值被一条线程使用。例如 synchronized 和 lock
2. 非阻塞同步:CAS 方案
3. 无同步方案: