深入理解 JVM 虚拟机
深入理接 Java 虚拟机
Java 内存区域 & 内存溢出异常
运行时数据区域
程序计数器
- 较小的内存空间;
- 当前线程执行的字节码的行号指示器;
- 字节码解释器通过修改这个的值获取下一条执行的字节码指令;
- 每个线程一个独立的。
Java 虚拟机栈
- 线程私有;
- 每个方法执行,
VM Stack
就会创建一个栈帧(Stack Frame
);- 局部变量表:相当于
C++
中的栈。下面数据以slot
的形式存放。所需的内存空间再编译期确定完成。- 基本数据类型
- 对象引用
- returnAddress
- 操作数栈
- 动态连接
- 方法出口
- 局部变量表:相当于
本地方法栈
Java 堆
几乎所有的对象实例以及数组都在堆上分配。
Java 堆
中分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB
)提升对象分配的效率,更好的回收内存,更快的分配内存。
方法区
存储被虚拟机加载的:
- 类型信息
- 常量
- 静态变量
- JIT 编译后的代码缓存
是堆的一个逻辑部分。永久代实现方法区,能像管理 Java 堆
一样管理方法区。现在是在本地内存中实现的元空间。
内存回收:
- 常量池的回收
- 类型的卸载
运行时常量池
- 是方法区的一部分;
- 存放 编译期生成的字面量 和 符号引用 以及 翻译过来的直接引用;
- class 文件中
constant_pool
表 的 perclass
或者interface
运行时表示;
直接内存
NIO
中 DirectByteBuffer
。
对象的创建
当遇到 new
时。
- 检查指令的参数是否在常量池中定位到一个类的符号引用;
- 检查符号引用是否被加载,解析,初始化。如果没有,则进行前面操作;
- 为新生对象分配内存:
- 方法:
- 指针碰撞:规整内存。一侧放分配的,另一侧空的。
- 空闲列表:不规整内存。
规整由是否带有
Compact
功能来决定。
- 分配时保证线程安全:
- 同步:
CAS
- 每个线程在
Java 堆
中先分配一小块内存,TLAB,需要分配内存现在TLAB
中分配。当TLAB
用完了才会分配新的缓存区时同步锁定。
- 同步:
- 方法:
- 新分配的内存初始化
0
; - 对象必要的设置对象头中相关信息;
- 调用构造函数
<init>()
对象内存布局
- 对象头:
- 对象自身运行时的数据
Mark Word
(8 字节)- HashCode
- GC 年龄分代
- 锁状态
- 线程持有的锁
- 偏向线程
ID
- 偏向时间戳
- 类型指针:确定这个对象时哪个类型的实例(8 字节,指针压缩后 4 字节)
- 数组长度,如果对象是数组
- 对象自身运行时的数据
- 实例数据:相同宽度的字段总是被分配到一起存放,父类分配在子类之前(
+XX:FieldsAllocationStyle
)。子类较窄的允许插入到父类间隙中(+XX:CompactFields
)。 - 对齐填充:8字节整数倍。
对象的访问定位
- 句柄:开出一块内存:句柄池,句柄包括:到对象实例数据的指针,到对象类型数据的指针。
reference
存放的时对象的句柄地址。 - 直接指针:
reference
存放的时对象的直接地址。需要考虑如何放置访问类型数据的相关信息。
垃圾收集器 & 内存回收策略
可达性分析
GC Roots
作为起始节点集,根据引用关系向下搜索。
- 虚拟机栈中引用的对象;
- 方法区中静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI引用的对象;
- 虚拟机内部的引用:
Class 对象
- 常驻的异常对象
- 系统类加载器
- 被同步锁持有的对象;
- 反映虚拟机内部情况的
JMXBean
、本地代码缓存
、JVMTI中注册的回调
。
引用类型
- 强:引用复制
xxx = new XXX()
- 软(SoftReference):还有用,但非必须。系统将要发生内存溢出异常前,列进回收范围之中进行第二次回收。
- 弱(WeakReference):非必须对象。只能生存到下一次垃圾收集发生为止。无论内存是否够用。
- 虚(PhantomReference):不会对其生存时间构成影响,也无法通过其来获取对象的实例。目的:在对象被回收前收到一个系统通知。
finalize() 自救
finalize
将要被弃用,不写了。
回收方法区
收集:
- 废弃的常量;
- 不再使用的类型。
满足条件:
- 所有实例都已经被回收;
- 加载该类的类加载器已经被回收;
- 对应的
Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法
分代回收
基于:
- 弱分代假说:绝大多数对象朝生夕灭;
- 强分代假说:熬过越多次越难以消亡。
新生代、老年代。
- 跨代引用假说:跨代引用占极少数
对象不是孤立的,可能存在跨代引用,新生代的对象被老年代引用,进而进行
Minor GC
新生代回收时需要遍历老年代。
只需在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代分成了若干块,标记出老年代的哪一块内存会存在跨代引用。
GC 种类:
- Minor GC:只收集新生代;
- Major GC:只收集老年代(
CMS
独有); - Mixed GC:整个新生代以及部分老年代(
G1
独有); - Full GC:整个
Java 堆
和方法区
。
GC 算法:
- Mark-Sweep:
-
- 标记所有需要回收的对象;
-
- 同一对标记的进行回收。
执行效率不稳定,大量对象慢;产生空间碎片会引起下一次垃圾回收。
- 同一对标记的进行回收。
-
- Mark-Copy:为了解决上面大量对象效率低:
- 半区复制:将内存划分为两个大小相同的块。每次只是用其中一块。将存活对象复制到另一块,清除原来的那块。
- Appel 式回收:新生代划分为一个较大的
Eden
和两个较小的Survivor
。Eden
、一个Survivor
一次性复制到另一个Survivor
。清理原来的两个区。Eden
: 一个Survivor
为 8 : 1。- 当
Survivor
不足以容纳一次Minor GC
时,有一个逃生门设计,即另一个内存区域(老年代)进行分配担保。即无法容纳的对象直接进入老年代了。
- Mark-Compact:当存活率较高时,上面的算法就会进行较多的复制,效率会降低。如果不想浪费 50% 的空间,就需要额外的空间进行担保,
Mark-Copy
不适合老年代。- 类似于
Mark-Sweep
,但是不是清除而是向空间的另一端移动,然后直接清理掉边界以外的内存。 M-Compact
vs.M-Sweep
:- 移动需要更新这些对象的引用,负担大;必须要
Stop The World
。回收时复杂
- 不移动,需要考虑空间碎片,要解决只能依赖更复杂的内存分配器和访问器:如
分区空闲分配链表
。分配时复杂
但是整个吞吐量(
GC
的用户程序)来说,移动更划算。 或者多数时间使用M-Sweep
,容忍内存碎片。受不了了来一下M-Compact
。- 移动需要更新这些对象的引用,负担大;必须要
- 类似于
HotSpot 算法细节
根节点枚举
根节点枚举期间必须 STW。
使用 OopMap
:类加载动作完成,会把对象内类型的偏移量计算出来。来获取哪些地方存放对象引用的。
安全点
可能导致引用变化的指令太多,不能为所有指令生成 OopMap
。只是在 安全点 生成了 OopMap
。
强制要求必须执行到安全点后才能暂停。
安全点位置的选取:是否有让程序长时间执行的特征(明显特征指令序列的复用)来选定。
- 方法调用
- 循环跳转
- 异常跳转
安全点如何停止线程:垃圾回收时让所有线程跑到最近的安全点,然后停顿。
- 抢先式:所有线程先中断,没有在安全点的回复这些线程,再跑一会儿中断。
- 主动式:设置一个标志位,各个线程不停地主动轮询这个标志,一旦发现这个为真,就再最近的安全点上主动中断挂起。
- 轮询标志地方:
- 与安全点重合;
- 所有创建对象和其他需要在
Java 堆
上分配内存的地方(检查是否即将要发生垃圾收集,避免没有足够内存)。
- 轮询操作使用:内存保护陷阱的方式。eg.
test %eax, 0x160100
。把内存页0x160100
设置为不可读,执行上面指令时会产生一个自陷异常信号,在预先注册的异常信号处理器中挂起线程实现等待。
- 轮询标志地方:
安全区域
因为存在程序“不执行”的情况(sleep
, Blocked
)。线程无法响应中断请求也就不能走到安全的地方挂起自己。而不能等待器获得时间片。
安全区域:保证某一段代码片段中,引用关系不发生变化。在这个区域中任意地方开售垃圾收集都是安全的。安全点的拉长。
- 在执行安全区域内的代码时,首先标识进入了安全区域。如果发生了垃圾收集就不必区关这些线程。
- 线程离开安全区域需要检查是否将已经完成了根节点枚举,如果没完成就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆集与卡表
为了避免整个老年代加进 GC Roots
的扫描范围(跨代引用)。
用于记录 非收集区域 -> 收集区域 的指针集合的 ADT
。
- 最简单的:非收集区域中所有含跨代引用的对象数组。
但是
垃圾收集
并不需要这些细节。只需要通过记忆集判断某一块非收集区域是否存在有指向手机区域的指针就行。
记录精度:
- 字长精度:一个
Word
是否包含? - 对象精度:一个对象里有字段包含?
- 卡精度(卡表,是上面
ADT
的实现):精确到一个内存区域,该区域是否包含?- 以字节数组的形式。数组中每个元素对应标识的内存区域中一块特定大小的内存块(卡页:2^N)。
写屏障
如何维护卡表元素。when,who 让它变脏。
- when: 其他分代区域中的对象引用了本区域的对象;
- how:每一个赋值操作中。写屏障,类似于引用字段类型赋值的
AOP
。在这个AOP
中进行维护。- 赋值前:大多数
- 赋值后:
G1
以后
写屏障缺点:
- 开销:每次对引用更行都会有开销;
- 伪共享:都是以缓存行的方式。多线程修改相互独立变量但是这些变量在一个缓存行中产生。解决:只有检查没有被标记过时才将其标记变脏。
并发的可达行分析
GC Roots
再继续往下遍历对象图,这一段时间与堆容量正比。
三色标记法:
- 白色:尚未被垃圾回收器访问过;
- 黑色:已经被访问,且这个对象所有引用被扫描过;
- 黑色不可直接指向白色;
- 灰色:已经被访问,但对象上至少存在一个引用还没被扫描过。
对象消失:在标记时并发修改了引用关系。
产生的原因(两个条件同时满足):
- 赋值器插入一条或多条从黑色 -> 白色的新引用;
- 赋值器删除 全部灰色 -> 白色 的直接引用或间接引用。
只需破坏一个即可,方法如下:
- 增量更新:破坏第一个条件。(
CMS
)- 当黑色对象插入新的指向白色对象的引用关系时,将新插入的引用记录;
- 并发扫描结束,将记录的黑色对象为跟,再扫描一次;
- 相当于黑色对象插入了指向白色对象之后 -> 灰色对象。
- 原始快照:破坏第二个条件。(
G1
,Shenandoah
)- 记录下删除的引用;
- 并发结束之后,再将灰色为根,重新标记;
- 无论引用关系是否删除,都会按照刚开始扫描那一刻的对象图进行搜索。
都是通过写屏障实现。
经典垃圾回收器
经典 是与 JDK 11
发布的高性能低延迟分开。
上面连线表示可以搭配使用
Serial(Mark-Copy)
单线程工作的收集器。单线程是指在进行 GC
时,必须暂停其他所有工作的线程(Stop The World
),直到收集结束。
- 缺点:
- 慢
- 优点:
- 额外内存消耗(
Memory Footprint
)最小; - 对于核心数少的环境,没有线程交互的开销,可以获得最高的单线程收集效率。
- 额外内存消耗(
在客户端模式下不错。
ParNew(Mark-Copy)
上面的并行版本,除了同时使用多条线程 GC
,没啥区别。
JDK 7
之前,服务端首选。
成功的原因是 CMS
收集老年代不能与 Parallel Scavenge
合作。它是唯一能与 CMS
配合工作的。随着 CMS
兴起而兴起。
默认开启的收集线程数与处理核心数相同。
GC 中并发与并行的区别:
Parallel: 并行是多条垃圾收集器线程之间的关系,同一时间多条线程协同工作,通常默认用户线程处于等待。
Concurrent: 并发是垃圾回收器线程与用户线程之间的关系,同一时间垃圾回收器线程与用户线程都在运行。
Parallel Scavenge(Mark-Copy)
同样是 标记-复制
算法。目标是达到一个可控制的吞吐量。 CMS
尽可能地缩短垃圾收集时用户线程停顿的时间。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
- -XX:MaxGCPauseMillis,最大停顿时间,设置越小,次数越多;
- -XX:GCTimeRatio,直接设置吞吐量大小,假设
19
,则1/(1 + 19)
,垃圾回收时间占5%
。 - -XX:UseAdaptiveSizePolicy,不需要人工指定
-Xmn
,-XX:SurvivorRatio
,-XX:PretenureSizeThreshold
Serial Old(Mark-Compact)
- 客户端模式使用;
CMS
失败后的后备方案。
Parallel Old(Mark-Compact)
Parallel Scavenge
老年代版本。
在注重吞吐量或资源稀缺时,优先考虑 Parallel Scavenge
+ Parallel Old
。
CMS(Mark-Sweep)
- 初始标记(initial mark
STW
) - 并发标记(concurrent mark)
- 重新标记(remark
STW
) - 并发清除(concurrent sweep)
缺点:
- 处理器资源非常敏感;
- 面向并发设计的都对其敏感;
- 占用一部分线程,导致应用程序变慢;
- 无法处理“浮动垃圾”,可能出现
Concurrent Mode Failure
失败导致另一次完全STW
的Full GC
;- 浮动垃圾:在并发标记、并发清除阶段产生的垃圾;
- 由于有浮动垃圾,所以不能等老年代几乎被填满再收集,必须留一部分空间供并发收集时程序运行使用(会产生新的对象);
- 当预留的空间无法满足分配新对象的需要,就会并发失败(
Concurrent Mode Failure
)。冻结用户线程的执行,临时使用Serial Old
重新进行老年代的垃圾收集; Mark-Compact
回产生大量的空间碎片,提前触发Full GC
。- -XX:+UseCMSCompactAtFullCollection
Full GC
开启碎片整合; - -XX:CMSFullGCsBeforeCompaction 在执行
cms
若干次不整理空间的Full GC
进行Full GC
。
- -XX:+UseCMSCompactAtFullCollection
G1
面向局部收集 + 基于 Region
的内存布局形式。
面向堆内存任何部分来组成回收集(
Collection Set
),衡量标准不再是分代,而是哪块内存存放的垃圾数量最多,回收收益最大。
Mixed GC
- 仍遵从分代收集;
- 把
Java 堆
划分为多个大小相等的独立区域(Region
),每个区域根据需求,扮演Eden
、Survivor
或老年代
。能对扮演不同角色的Region
使用不同策略; Humoongous
区域存放大对象: > 0.5 Region 大小的对象。> 1 Region 大小的对象 用多个连续Humoongous
存放;- 大多数行为将
Humoongous
当作老年代看待。
- 大多数行为将
-XX:G1HeapRegionSize
设置Region
大小,1MB ~ 32MB
,2^N
;- 建立了可预测的停顿时间模型,是因为每次回收都是
Region
的整数倍;- 跟踪各个
Region
的价值:回收获得的空间大小以及回收所需时间的经验值; - 放在优先队列中;
-XX:MaxGCPauseMillis
设置的允许收集停顿时间来优先处理回收效益最大的Region
。
- 跟踪各个
挑战:
Region
中跨 Region
引用对象怎么解决?- 使用记忆集,每个
Region
维护自己的记忆集- 记忆集记录别的 Region 指向自己的指针,并标记这些指针在哪些卡也的范围之内;
- 本质上是 哈希表:
key
别的Region
的其实地址,Value
是一个集合,存卡表的索引号; - 双向索引,我 <-> 谁:
卡表
:我 -> 谁; - 占用额外的
10 - 20
内存。
- 使用记忆集,每个
- 并发标记阶段
收集线程
、用户线程
互不干扰?- 原始快照(
STAB
); - 每个
Region
设计两个TAMS
指针,Region
中一部分空间划分出来用于并发回收过程中的新对象回收。并发回收时新分配的对象地址必须在这两个指针位置上,这个地址以上默认存活,不纳入回收范围;
- 原始快照(
- 如何建立可靠的停顿预测模型
-XX:MaxGCPauseMillis
?- 衰减均值;
- 记录每个
Region
回收耗时、Region
记忆集里的脏卡数量等各个步骤的成本,得出平均值,标准偏差,置信度等; - 通过观察的值预测现在开始回收,哪些
Region
组成的回收集可以不超过期望的时间获得最高收益。
运作过程:
- 初始标记:
- 标记
GC Roots
直接关联到的对象; - 修改
TAMS
指针的值,下一阶段用户线程并发运行能正确地在可用的Region
中分配新对象; - STW,很短。
Minor GC
时同步进行。
- 标记
- 并发标记:
- 对堆中对象进行可达性分析,递归扫描整个堆的对象图,找出要回收的对象;
- 扫描完成后,还要重新处理
STAB
记录下的在并发时有引用变动的对象。
- 最终标记:
- 并发阶段结束后,遗留下来的最后少量的
STAB
记录; - STW 很短。
- 并发阶段结束后,遗留下来的最后少量的
- 筛选回收:
- 更新
Region
的统计数据,排序价值,制定回收计划; - 把决定回收的那一部分
Region
的存活对象复制到空的Region
,回收旧Region
全部空间; STW
多线程。
- 更新
追求能够应付应用的内存分配速率,而不是一次清理整个堆。
不会产生碎片的原因:
- 整体上是
Mark-Compact
; - 局部上是
Mark-Copy
。
缺点:
- 内存占用(
Footprint
)和额外内存负载(Overload
) 高; - 记忆集每个
Region
维护一份,占整个堆容量的20%+
; - 写后屏障维护卡表,写前屏障跟踪并发的指针变化。写屏障消耗资源;
- 写前、写后要做的事情放到队列里 ,异步执行。
垃圾回收日志
类型 | Prior JDK 9 | JDK 9 |
---|---|---|
基本信息 | -XX:+PrintGC |
-Xlog:gc |
详细信息 | -XX:+PrintGCDetails |
-Xlog:gc* |
堆、方法区可用容量变化 | -XX:+PrintHeapAtGC |
-Xlog:gc+heap=debug |
用户线程并发、停顿时间 | -XX:+PrintGCApplication[Concurrent|Stopped]Time |
-Xlog:safepoint |
剩余对象的年龄分布情况 | -XX:+PrintTenuringDistribution |
-Xlog:gc+age=trace |
内存分配与回收策略
内存分配,概念上都在堆上,实际也有可能被拆散为标量类型间接在栈上。
新生对象通常会分配在新生代,少数情况(对象大小超过一定阈值)直接分配在老年代。
对象优先 Eden
当 Eden
没有足够空间进行分配,发起 Minor GC
。
大对象直接进老年代
-XX:PretenureSizeThreshold
只针对 Serial
,ParNew
。
长期存活对象将进入老年代
-XX:MaxTenuringThreshold
如果 Survivor
相同年龄所有对象大小的总和 > Survivor
的一半,年龄 >= 该年龄的直接进老年代。
空间分配担保
担保就是老年代进行分配担保,把 Survivor
无法容纳的对象直接送入老年代。
前提是老年代有空间容纳存活下来的对象。
取平均值有点赌博,如果担保失败,则进行 Full GC
。
虚拟机性能监控、故障处理工具
jps 进程状况
jps vmid -l -v
:v 是启动时 jvm
参数。
jstat 统计信息
类加载、内存、垃圾收集、即时编译等运行时数据。
jstat option vmid interval count
-gcutil 空间占比
-gc 堆状况
jinfo 配置信息
jinfo -flag XXX pid
查看参数的值。
jinfo -sysprops
查看 System.getProperties()
。
jmap 内存映射
生成堆转储快照(heapdump
)。
生成手段:
- -XX:+HeapDumpOnOutOfMemoryError 内存溢出异常自动生成;
- -XX:+HeapDumpOnCtrlBreak
ctrl + break
生成,或kill -3
; jmap -dump:format=b,file=xxx.bin pid
。
jmap -dump vmid
堆详细信息。
jhat 堆转储快照分析
jhat XXX.bin
后打开 localhost:7000
。
jstack 堆栈跟踪
jstack -lm pid
堆栈和锁的附加信息。
类加载机制
Class
文件加载到内存,对数据进行校验、转换解析和初始化,最终形成可以被使用的Java 类型。
类型的加载、连接、初始化都是在运行期间完成。
动态扩展的特性是依赖运行期动态加载、和动态连接实现的。
初始化的时机(都是如果没有初始化就需要初始化):
有且只有:
new
、getstatic
、putstatic
、invokestatic
:- 使用
new
实例化数据; - 读取或设置类型的静态字段(被
final
在编译期把结果放入常量池); - 调用类型的静态方法。
- 使用
- 使用
reflect
进行反射调用; - 类在初始化时,发现其父类还没有进行初始化;
- 虚拟机启动,执行用户指定的主类,会初始化这个类;
MethodHandle
方法句柄;- 默认方法执行。
上述称为对一个类型进行主动引用。其他所有的称为被动引用,都不会触发初始化。
被动引用 e.g.:
- 子类引用父类静态变量;
- 新建数组,比如
new SuperClass[10]
,其实是[XXX.SuperClass
自动生成的,直接继承于Object
的子类,创建动作由newarray
触发; static final
字段的引用,因为其在常量池中。
类加载过程
加载
- 通过一个类的全限定名来获取类的二进制字节流;
- 字节流代表的静态存储结构转化为方法区的运行时数据结构;
- 内存中生成代表这个类的
Class
对象,作为方法区这个类的各种数据的访问入口。
数组不通过类加载器创建,由 VM
直接在内存中动态构造出来的。数组类的元素类型最终还是要靠类的加载器来完成加载。
- 如果组件类型是引用类型,这个数组被标识在组件类型的类加载器的类名称空间上;
- 如果组件类型不是,就标记为引导类加载器相关。
加载完后,
- 二进制字节流存储在方法区;
- 在堆内存实例化一个Class 类的对象,作为程序访问方法区中的类型数据的外部接口;
- 加载与连接阶段的部分动作是交叉进行。
验证
文件格式、元数据、字节码、符号引用。
符号引用转化为直接引用。
准备
类中定义的变量(static
)分配内存并设置类变量初始值。
类变量内存都在方法区(方法区是逻辑上的分区)中分配。Java 8
及之后,类变量随着 Class 对象
一起存放在 Java 堆
中。
类变量的赋值动作 putstatic
指令程序编译后,在类构造器 <cinit>()
中。
static final
这类 ConstantValue
准备阶段会是赋值后的值。
解析
常量池内的符号引用替换为直接引用的过程。
- 符号引用:以一组符号来描述所引用的目标;
- 直接引用:直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
类或接口解析
D
:代码所处的类,N
:未解析过的符号引用,C
:解析为的直接引用。
C
不是数组,则把N
传给D
的类加载其加载这个类;C
是数组,数组类型是对象,N
则是[Ljava/lang/Integer
, 则需要加载Integer
;- 上面没有问题则生成类有效的类或接口。
字段解析
方法解析
接口方法解析
初始化
初始化类变量和其他资源:执行 <clinit>()
:
<cinit>()
自动收集所有的类变量的赋值动作
+静态语句块中的语句
;- 与构造函数不同,不需要显示的调用父构造器,保证子类的
<cinit>()
调用前,父类的被调用; <cinit>()
对于接口来说不是必须;<cinit>()
多线程正确加锁同步。
类加载器
每一个类加载器都有独立的命名空间。
双亲委派模型
- Bootstrap:
<JAVA_HOME>/lib
或-Xbootclasspath
并且是名字能被识别的:rt.jar
、tools.jar
; - Extension:
<JAVA_HOME>/lib/ext (<JAVA_HOME>/jre/lib/ext)
; - Application:
getSystemClassLoader()
的返回值,加载用户路径。
使用组合的形式。
破坏双亲委派机制
- 新加了
protected findClass()
,如果父类加载失败,自动调用自己的findClass()
方法完成; JNDI 服务
:对资源进行查找和集中管理,调用由其他厂商实现并部署在ClassPath
下的 JNDI 服务提供者接口的代码。- 使用 线程上下文类加载器,
Thread.setContextClassLoader()
进行设置,未设置则从父线程中继承,全局都没设置则是 AppClassLoader。
- 使用 线程上下文类加载器,
- Hot Swap、Hot Depolyment:
OSGI
虚拟机字节码执行引擎
运行时栈帧结构
- 方法作为最基本的执行单元;
- 栈帧则是方法调用和方法执行背后的数据结构,是虚拟机栈的栈元素;
- 局部变量表;
- 操作数栈;
- 动态连接;
- 方法返回地址。
局部变量表
存放方法参数和方法内部定义的局部变量。
容量以变量槽为最小单位。
使用索引定位的方式使用局部变量表。
当方法调用时,使用局部变量表完成参数值 -> 参数列表 的传递。如果是实例方法,则 0
号为用于传递方法所属对象实例的引用。
变量槽可以重用。
定义的变量,作用域并不一定会覆盖整个方法,如果超过变量作用域,则变量槽交给其他变量重用。
操作数栈
方法的执行过程中,各种字节码指令往操作数栈写入和提出内容。
可以和局部变量表共享区域。
动态连接
指向运行时常量池该栈帧所属方法的引用,用于动态连接。 符号引用转为直接引用:
- 静态解析:类加载或第一次运行期间;
- 动态解析:每一次运行期间都转化。
方法返回地址
- 正常返回完成:任意一个方法返回的字节码指令;
- 主调方法的
PC 计数器
的值作为返回地址; - 栈帧中可能保存这个地址。
- 主调方法的
- 异常调用完成:异常,且没在方法体内妥善处理。
- 异常处理表;
- 栈帧中一般不存。
必须返回到最初方法被调用的位置,方法返回时在栈帧中保存一些信息,帮助恢复它的上册主调方法的执行状态。
方法退出执行的操作:
- 恢复上层方法的局部变量表和操作数栈;
- 返回值压入调用者栈帧的操作数栈中;
PC 计数器
值只想方法调用指令后面的一条指令。
方法调用
并不是方法中的代码被执行。确定被调用方法的版本。
一切方法调用在 Class 文件
里存储的都是符号引用,不是方法实际运行时内存布局中的入口地址(直接引用)。
解析
将一部分符号引用 -> 直接引用。
- 在程序运行之前有一个可确定的调用版本;
- 在运行期间不可变。
调用目标在编译那一刻就已经确定。
编译期可知,运行期不可变:
- 静态方法;
- 私有方法。
因为不能通过继承或别的方式重写出其他版本。
invokestatic
和invokespecial
。静态、私用、实例构造器、父类、final 修饰。
支持的调用方法字节码:
- invokestatic;
- invokespecial;
<init>()
;- 私有;
- 父类中的方法。
- invokevirtual: 虚方法;
- invokeinterface: 接口方法,运行时再确定实现该接口的对象;
- invokedynamic: 运行时动态解析出调用点限定符所引用的方法,再执行。
分派
静态分派(重载 Overload)
abstract class Human {
}
class Man extends Human {
}
class Woman extends Human {
}
Human man = new Man();
最后一句:Human
是静态类型,Man
是运行时/实际类型。
- 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,最终的静态类型在编译期可知;
- 实际类型:变化的结果在运行时才可确定,编译时并不知道实际类型。
使用哪个重载版本,完全取决于传入参数的数量和类型。\
静态分派:类似上面所有依赖静态类型决定发放执行的分派。
动态分派(重写 Override)
invokevirtual
过程:
- 找到操作数栈顶的第一个元素所指向的对象的对象的实际类型;
- 在类型中找到描述符和简单名称都相符的方法,通过则返回直接引用;
- 否则,按继承关系,从下往上进行上一步;
- 没找到,则抛
AbstractMethodError
。
单分派、多分派
方法宗量:方法的接收者与方法的参数的统称。
编译阶段(静态分派):
- 方法的接收者;
- 方法的参数。
静态分派属于多分派。
运行阶段(动态分派):
- 方法接收者
动态分派属于单分派。
虚方法表:存各个方法的实际入口地址。在类加载、连接阶段初始化。
动态语言支持
基于栈的字节码解释执行引擎
- 词法分析;
- 语法分析;
- 源码转为抽象语法树。
基于栈的指令集架构。,指令大多数时零地址指令,依赖操作数栈工作。
优点:
- 可移植性;
- 代码相对紧凑;
- 编译器实现更简单。
缺点:
- 完成相同功能所需相对指令更多,因为入栈、出栈本身产生大量指令;
- 栈在内存,频繁的栈访问就是频繁的内存访问。
- 常用的操作映射到寄存器,避免内存访问。
类加载及执行子系统
Web 服务器
面临的问题:
- 同一服务器上两个
Web 应用程序
使用的Java 类库
相互隔离; Java 类库
可以共享(与上面相反);- 自身安全不受
Web 应用程序
的影响,服务器使用的类库与应用程序的类库互相独立
; JSP
能HotSwap
。
一个
ClassPath
不够,所以都提供了好几个有着不同含义的ClassPath
路径共用户存第三方库。
类加载器 | ClassPath | 说明 |
---|---|---|
Common | /common | Tomcat 和所有 Web 应用程序 共同使用 |
Catalina | /server | Tomcat 可用,Web 应用程序 不可见 |
Shared | /shared | Tomcat 和所有 Web 应用程序 共同使用,Tomcat 不可见 |
WebApp | /webApp/WEB-INF | 仅对 该 Web 应用程序 使用,Tomcat 和 其他 Web 应用程序 不可见 |
前端编译与优化
编译期:
编译器 | 内容 | 代表 |
---|---|---|
前端编译器 | *.java -> *.class | javac; ECJ |
JIT | 字节码 -> 本地机器码 | HotSpot C1, C2; Graal |
AOT(静态提前编译器) | 程序 -> 目标机器指令集相关的二进制代码 | Jaotc; GCJ; JET |
Javac
- 准备过程: 初始化插入式注解处理器;
- 解析和填充符号表:
- 词法、语法分析,源码的字符流 -> 标记集合,构造出抽象语法树;
- 填充符号表。产生符号地址和符号信息。
- 插入式注解处理器的处理;
- 分析与字节码生成过程:
- 标注检查:语法静态信息进行检查;
- 数据流及控制流分析:程序动态运行过程进行检查;
- 解语法糖:语法糖还原;
- 字节码生成:前面各个步骤转换为字节码。
解析与填充符号表
语法、词法分析
字符流 -> Token
集合。构造抽象语法树。
填充符号表
符号表:符号地址 + 符号信息。产出一个待处理列表。
注解处理器
语义分析与字节码生成
语法糖
泛型
类型擦除
编译期间将 ArrayList<Integer>
-> ArrayList
。只在元素访问、修改时自动插入一些强制类型转换和检查指令。
类型擦除后,插入强制转型没法做了,因为原始类型、Object 不支持强制转换。那么,就别支持了,都用包装类。自动加入装箱、拆箱。
无法取到泛型类型信息。
擦除仅仅是对
Code 属性
字节码进行擦除,实际元数据保留了泛型类型。
自动装箱、拆箱与遍历循环
Integer.valueOf()
和 Integer.intVaue
。
遍历循环:迭代器。
变长参数:数组类型的参数。
==
在不遇到算术运算的情况下不会自动拆箱以及equals()
不处理数据的类型转换。
后端编译与优化
Java 内存模型与线程
高速缓存与缓存一致性。
内存模型:特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
乱序执行:处理器对输入代码乱序执行优化,然后将结果重组,保证结果与顺序执行的结果一致。
JIT
也有。
Java 内存模型
定义程序中各种变量的访问规则。变量:
- 实例字段;
- 静态字段;
- 构成数组对象的元素。
局部变量方法参数
主内存与工作内存
- 所有变量都存储在主存;
- 每条线程有自己的工作内存;
- 该线程使用的变量的主内存副本;
- 大对象的话,对象的引用,对象中某个在线程访问的字段是有可能被复制的。
- 线程对对象的所有操作必须在工作内存中进行;
不能直接写主存数据;
- 该线程使用的变量的主内存副本;
- 不同的线程无法直接访问对方工作内存中的变量;
- 线程间变量值的传递需要通过主存完成。
- 主存对应物理上虚拟机内存的一部分,物理硬件的内存;
- 工作内存对应虚拟机栈中部分区域,优先存储于寄存器和高速缓存中。
Java 与线程
把进程的资源分配和执行调度分开,各个线程可以共享进程资源,又可以独立调用。是处理资源调度的最基本单位。
- 内核线程实现(1:1 轻量级进程:内核线程);
- 操作系统内核支持的线程;
- 内核完成线程切换;
- 操纵调度器对线程进行调度,线程任务映射到各个处理器;
- 使用的是内核线程的高级接口 轻量级进程(LWP);
- 各种线程操作都需要进行系统调用,需要在用户态和内核态来回切换。
- 用户线程实现(1:N 进程:用户线程);
- 广义:一个线程只要不是内核线程都是用户线程(UT);
- 狭义:完全建立在用户空间的线程库;
- 用户线程的操作完全在用户态中(创建、销毁、切换和调度。实现复杂);
- 用户轻量级进程混合实现(N:M 用户线程:轻量级进程)。
- 用户线程与轻量级进程并存;
- 轻量级进程作为用户线程与内核线程的桥梁。
Java
使用 1:1。
线程调度
- 协同式:线程执行时间由线程本身控制。执行完后,主动通知系统切换到另一个线程;
- 抢占式:系统分配执行时间。
线程状态
- New:创建后尚未启动;
- Runnable:Running + Ready;
- Waiting:无限期等待;
- Object::wait()
- Thread::join()
- LockSupport::park()
- Timed Waiting:限期等待;
- Thread::sleep()
- LockSupport::parkNanos/parkUntil()
- Blocked: 阻塞。等待着获取到一个排他锁;
- Terminated:已结束运行。
线程安全与锁优化
线程安全:多个线程同时访问一个对象,如果不用考虑这些线程在运行是环境下调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果。
代码本身丰撞了所有必要的正确性保障手段,令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证线程安全。
线程安全的实现方法
互斥同步:共享数据在同一时刻只被一条(或一些(信号量))线程使用。
- 临界区;
- 互斥量;
- 信号量。
互斥是因,同步是果。
synchronized
是插入 monitorenter
和 monitorexit
指令。需要一个 reference
类型的参数来指明锁对象。
ReentrantLock
特点:
- 等待可中断;
- 公平锁;
- 锁绑定多个条件。
但还是推荐优先使用 synchronized
:
Java
语法层面,足够清晰,简单;Lock
得确保在finally
释放锁;synchronized
已得到优化,未来其也更易优化。
线程阻塞和唤醒都会带来性能开销。互斥同步是悲观的并发策略。
非阻塞同步:可以使用冲突检测的乐观并发策略,需要硬件支持,操作和冲突检测得具备原子性。
- 不管风险,先操作,如果没有其他线程争用共享数据,直接成功;
- 被争用,产生了冲突,再进行其他的补偿措施,e.g. 不断的重试,直到没有竞争的共享数据为止。
CAS
需要三个操作数:
- 内存位置,变量的内存地址:V;
- 旧的值:A;
- 准备设置的新值:B。
锁优化
自旋和自适应自旋
让后面请求锁的线程等一会儿,等待需要让线程执行一个忙循环(自旋)。
自旋默认超过 10 次或 -XX:PreBlockSpin
没成功则挂起。
自适应自旋:由前一次在同一个锁上的自旋时间和锁的拥有者的状态来决定。
锁消除
对被检测到不可能存在共享数据竞争的锁进行消除。
基于逃逸分析。如果一段代码中,堆上所有数据不会逃逸被其他线程访问,可以把它们当栈上数据对待,线程私有。
锁粗化
如果一连续操作都对一个对象反复加锁和解锁,甚至循环体内,频繁进行互斥同步操作会导致不必要的性能损失。
锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级锁
没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗。
对象头:
- 存储对象自身的运行数据;
- 方法区对象类型数据的指针;
- 数组时,额外的数组长度。
步骤:
- 当前线程的栈帧中建立一个锁记录(
Lock Record
)的空间,用来存储锁对象目前的Mark Word
的拷贝(Displaced Mark Word
); CAS
将对象的Mark Word
更新为指向Lock Record
的指针;- 成功,则拥有对象的锁,将
Mark Word
的锁标志位设置为00
; - 失败,至少一条线程竞争,检查对象的
Mark Word
是否指向当前线程的栈帧;- 是,直接进入同步;
- 否,被其他线程抢占,膨胀为重量级锁,锁标志位
10
。
- 成功,则拥有对象的锁,将
绝大部分的锁,在整个同步周期不存在竞争。
偏向锁
偏向第一个获得它的线程。
- 锁标志
01
,偏向模式1
,CAS
获取这个锁的线程的ID
记录在对象的Mark Word
中;- 成功,持有偏向锁的线程每次进入同步块时,不再进行任何同步操作;
- 另一个线程尝试获得锁,立马结束。根据锁对象目前是否被锁定的状态决定是否撤销偏向(
0
),撤销后标志位恢复到未锁定,恢复到位锁定(01
)或轻量级锁(00
)。