JVM概述
# JVM概述
# 1、JVM运行时区
# 1.1、程序计数器
程序计数器就是当前线程执行的行数指示器
# 1.2、虚拟机栈/本地方法栈
Hot-Spot 虚拟机把虚拟机栈和本地方法栈合二为一,内部主要存储局部变量表、操作数栈、动态连接、方法出口等信息
每一个方法从调用到执行完毕的过程,就是一个栈帧在虚拟机栈中从入栈到出栈的过程
# 1.4、堆
# 1.5、方法区
存储的是被虚拟机加载的类型信息、常量、静态变量、即时编译后代码缓存等数据
# 1.6、直接内存
直接内存代表着不是JVM内存的一部分,属于操作系统内存,但是也可以被JVM使用,比如引入NIO之后可以使用Native函数库直接分配堆外内存,然后存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样的场景可以提交效率,避免在Java堆和Native堆进行来回复制数据。
直接内存的大小可以根据-XX:MaxDirectMemorySize来设置,如果不进行设置,则大小与堆的最大值-Xmx一致。
# 2、Java对象
对象实例是否全部分配到堆上?
不是的,随着Java的发展,逃逸分析越来越精确,比如标量替换、栈上分配等优化手段,一些Java实例也可以直接在虚拟机栈中进行分配,这些实例随着栈帧的出栈而销毁,不需要GC的处理。
# 2.1、如何分配对象内存
- 指针碰撞 如果堆中的内存是完全规整的,所有未被使用的内存都被放在一起,那么分配一个对象内存只需要把指针向空闲的内存方向移动一定的距离就可以了。
- 空闲列表 如果堆中的内存并不规整,已使用内存和空闲内存交叉在一起,那么JVM需要维护一个空闲内存的列表,在分配内存的时候从列表中取出足够大的空间划分为实例对象。
具体使用哪种方式进行内存分配,需要根据内存是否规整来决定;内存是否规整,取决于JVM使用哪种垃圾收集器;当使用的是Serial、ParNew等带有压缩整理的收集器时,系统采用的是指针碰撞来分配内存;当使用CMS来当做垃圾收集器时,理论上只能使用空闲列表来分配内存
除了划分可用内存之外,还有一个需要考虑的问题,就是并发问题。如果对A分配内存的时候,这块内存同时被B使用了,这种问题有两种解决方案:
一种是进行同步处理,虚拟机使用CAS的失败重试进行内存分配,来保证更新内存的原子性
一种是每个线程在JVM堆中预先分配一小块内存(本地线程分配缓冲区Thread Local Allocation Buffer TLAB),线程先在TLAB中分配内存,如果内存分配完了,后续在需要进行同步分配;
虚拟机是否使用TLAB,使用参数控制 -XX: +/-UseTLAB
# 2.2、对象的组成
对象在堆内的存储布局分为三个部分:对象头(Header)、实例数据(Instance)和对齐填充(Padding)
# 2.3.1、对象头
对象头分为两类信息
1、存储对象自身的运行时数据 (Mark Word)
- 哈希码(HashCode)
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- 等
2、类型指针
JVM通过这个指针来确定该对象是哪一个类的实例
此外,如果对象是一个数组,那么对象头还必须要存储数组长度。
# 2.3.2、对齐填充
并不是必须的,仅仅是占位符的作用。因为HotSpot 虚拟机要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。对象头已经被精心设计为了8字节的整数倍(1或者2),以为如果实例对象的大小不满足8字节的整数倍,那么缺少的部分将由对齐填充来补全
# 2.3、对象的访问
Java程序使用对象的方式是在栈上的reference数据来操作对堆中的具体对象。如何定位到堆中的实例化对象,一般有两种方法作为实现:句柄和指针 (HotSpot主要使用指针)
1、如果使用句柄的话,在java堆中需要单独划分一块内存作为句柄池,reference中存储的对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息;
使用句柄的好处就是reference中存储的是稳定的句柄地址,在对象移动时(GC移动对象)改变的只是句柄中的实例数据指针,而reference本身不需要更改
2、使用指针直接访问对象,reference存储的直接就是对象地址
使用指针的好处就是能直接访问对象,少了一次中间的开销
# 3、垃圾收集
Java 与 C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想 进去,墙里面的人却想出来
# 3.1、如何找到垃圾
哪些对象是GC Roots对象:
- 在虚拟机栈(栈帧中的局部变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中 类静态属性引用的对象,譬如Java类的引用类型静态变量
- 在方法区中常量引用的对象,譬如字符串常量池中的引用
- 在本地方法栈中引用的对象(Native方法)
- Java虚拟机内部的引用,如基本数据类型对应的Class对象、异常对象、类加载器等
- 所有被同步锁(synchronized)持有的对象
# 3.2、垃圾收集算法
# 3.2.1、记忆集(Remembered Set)
为什么会存在记忆集,为了解决什么问题?
由于大部分的垃圾收集算法都是分代收集,把整个堆划分为了年轻代和老年代,而年轻代和老年代有着各自的垃圾收集器,在进行垃圾收集的过程中,可能存在着跨代引用
的问题,这个问题会导致在收集年轻代的时候,为了避免跨代引用,同时需要进行扫描整个老年代,防止老年代中有对象在引用年轻代的某些对象,这种扫描代价比较大。
记忆集
就是为了解决跨代引用问题,记忆集存储在新生代,里面的数据结构把整个老年代划分为若干区域,并标识出哪一块存在跨代引用,在发生年轻代GC的时候,只需要把存在跨代引用的哪一块内存中的对象加入GCRoots进行扫描,从而避免了对于老年代的全量扫描
# 3.2.1.1、卡表(Card Table)
记忆集是一个抽象的概念,描述应该记录什么样的数据,而卡表就是具体的实现。类似于Map和HashMap之间的关系
卡表的实现简单到可以只是一个字节数组,HotSpot 默认的卡表标记逻辑如下:
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素,都对应着其标识的内存区域中的一小块内存(卡页 Card Page
)。
HotSpot 中使用的卡页是2的9次幂,即512字节,也就是把卡表标识的内存区域分成了512字节的一个个卡页。
一个卡页内包含多个对象,只要有一个对象存在跨代引用,就把这整个卡页标识为1,也就是卡页变脏(Dirty),当发生GC时,很轻松就可以获取到脏页中的对象放入GCRoots中。
# 3.2.1.2、写屏障
现在还有一个问题,就是如何触发卡页变脏?
时间点是明确的,就是发生跨代引用时,需要把对应的卡表变脏,也就是在对象引用字段赋值的那一刻进行变脏的操作。如果是解释执行的过程中,比较好处理,找到对应的指令码,JVM就可以介入;但是在编译执行的时候,代码已经是01的机器码了,那就需要找到一个其它的手段来进行操作,这个就是写屏障(Write Barrier)
写屏障可以看做是在虚拟机层面对“引用字段赋值”的AOP切面,在引用对象赋值时会产生一个环绕通知,赋值前的通知是写前屏障,赋值后的通知是写后屏障。在G1之前的垃圾收集器使用的都是写好屏障,也就是先更新引用关系,然后在写好屏障内更新卡表状态;
void oop_field_store(oop*field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新 ,
post_write_barrier(field, new_value);
}
2
3
4
5
6
# 3.2.1.3、伪共享
伪共享是并发底层需要考虑的问题,因为现代CPU是使用缓存行
(Cache Line)为单位进行存储的,如果并发情况下,多线程处理的变量恰好共享一个缓存行,那么就会互相影响导致性能降低,这就是伪共享问题。
如何避免或者优化,待补充.......
# 3.2.2、标记清除算法
分为标记和清除两个步骤:
- 首先标记出需要回收的对象(即垃圾对象)
- 统一回收所有标记的对象
优点:简单
缺点:
1、不稳定,存在大量需要回收的对象时,必须进行大量的标记和清除工作,执行效率会随着对象数量的增高而降低
2、导致内存碎片问题,碎片太多会导致无法进行大对象的存储,而触发下一次的垃圾回收
# 3.2.3、标记复制算法
将可用内存按照容量分为大小相等的两块,每次只使用其中的一块,当其中一块的内存使用完了,就将还存活的对象复制到另一块上面,然后把已使用的那一块内存整体进行清除
优点:不会产生内存碎片、效率高
缺点:浪费内存空间
# 3.2.4、标记整理算法
分为标记-整理两个阶段
- 首先标记出需要回收的对象(即垃圾对象)
- 让所有存活对象都向内存一侧移动,然后清理掉边界以外的内存
优点是解决了内存碎片问题
缺点是需要更新对象的引用,并且这种移动需要停止用户线程,也就是STW(Stop The Word)
# 3.3、安全点
问题1:为什么需要安全点?解决了什么问题?
不论是什么垃圾收集器,在GCRoots枚举期间(也就是确定具体哪些对象可以放在GCRoots中)都是需要STW的,那么如何减少这一段的STW时间就至关重要了,HotSpot 虚拟机使用了一组OopMap的数据结构来存储了哪些地方存在这些引用。一旦类加载完成,HotSpot就会把对象内偏移量上是什么类型的数据计算出来,在即时编译中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样的话垃圾收集器在扫描的时候就可以知道这些信息,而不用全量的扫描方法区等地方去查找GCRoots了。
问题2:有了OopMap那么还会有什么问题?
因为真实的代码中引起关联关系变化的地方非常多,如果每一个指令都会生成对应的OopMap,那将会占用大量的空间,那么解决方案就是只有在特定的地方才会生成OopMap,这些位置就是
安全点
有了安全点之后,用户程序并不是在任意的代码指令行都可以停止进行垃圾收集,而是强制要求执行安全点后才会停止。
问题3:什么指令会成为安全点:
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。所以,方法调用、循环跳转、异常跳转等都属于指令复用,这些功能的指令才会产生安全点
问题4:发生GC时,如何让所有线程都跑到最近的安全点停顿下来?
HotSpot使用的主动式中断思想,就是当垃圾收集器需要中断线程的时候,不直接对线程操作,而是简单的设置一个标志,每个线程在运行过程中需要不停的去主动轮训这个标志,一旦发现中断标志位真时,就在离自己最近的安全点主动挂起。
# 3.4、安全区域
问题1:为什么引入安全区域?
安全点解决了大部分问题,但是如果遇到了线程“不执行”的状态怎么办?比如线程Sleep状态、Blocked状态,这时候线程无法响应虚拟机的中断请求,虚拟机也不可能等待线程这么长的时间,这种情况就必须引入安全区域的概念
安全区域能够确保在某一段代码片段之中,引用关系不会变化,因此,在这一区域的任意时间开始垃圾收集都是安全的
问题2:如何解决上述问题?
当用户线程进入安全区域的代码时,会首先标识自己已经进入了安全区域,那么当这段时间内虚拟机要发生垃圾收集时,就不必去管这些已经声明在安全区域的线程了(上述问题,因为线程Sleep状态或者锁定状态,那么这段时间内引用关系不会发生变化,那么此线程就处于安全区域了)。当线程要离开安全区域时,要检查虚拟机是否完成了根节点的枚举过程,如果完成了,那么此线程可以继续运行,如果没有完成,那么线程必须一直等待直到收到可以离开安全区域的信号为止
# 3.5、三色标记
在扫描遍历中遇到的对象,按照是否访问过分为三种颜色:
- 白色:从未被访问过
- 黑色:已经被访问过,并且当前对象的所有引用都被访问过
- 灰色:已经被访问过,但是当前对象至少还有一个引用没有被访问
三色标记在用户线程和收集线程同时工作的情况下,会出现严重的错误标记问题!
产生原因:以下两种情况同时存在会出现错误标记问题
- 用户线程增加了一个或者多个黑色到白色对象的引用
- 用户线程删除了全部灰色对象到该白色对象的直接或者间接引用
如何解决:
两种情况同时存在才会出现错误标记,对应的只需要处理一种情况就可以解决错误标记问题,那么对应的就会出现两种解决方案
1、增量更新(Incremental Update)
增量更新破坏的是第一个条件,当黑色对象增加了对于白色对象的引用,那就把这个新增加的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根节点,重新扫描一次。(CMS使用)
2、原始快照(Snapshot At The Beginning, SATB)
原始快照破坏的是第二个条件,当灰色对象删除指向白色对象的引用时,将这个删除的引用记录下来,在并发扫描结束后,将记录中引用关系中的灰色对象为根对象,重新扫描一次。(G1使用)
# 4、垃圾收集器
# 4.1、CMS
CMS(Concurrent Mark Sweep)是以获取最短停顿时间为目标的收集器,使用的标记清除算法,会产生内存碎片
整个收集过程分为四个步骤:
1、初始标记(需要STW) 仅仅只标记RCRoot能直接关联的对象,时间比较快
2、并发标记(用户线程并发) 耗时较长,但是能用户线程并发
3、重新标记(需要STW)
修正并发标记期间错误标记的对象(也就是解决三色标记错标的增量更新过程)
4、并发清除(用户线程并发)
清除标记过程中为垃圾的对象
CMS的缺点:
1、CMS是一块吃处理器资源的收集器,默认开启的垃圾收集线程数量是:(处理器核心数量+3)/4
2、浮动垃圾问题。CMS并发标记和并发清除期间,用户线程依旧在运行,也就是说这一段时间依然在产生的新的垃圾对象,但是这些对象只有等待下次GC的时候才能被标记和清除,这些对象就称之为浮动垃圾。浮动垃圾生产的速度如果超过了GC清除的速度,那么就会产生并发失败“Concurrent Mode Failure”而导致一次完全STW的FULL GC
,这一次的FULL GC是Serial Old 收集器在执行接管。相关的参数如下:
设置多大比例后触发CMS垃圾收集
-XX: CMSInitiatingOccupancyFraction
2
3、内存碎片问题。内存碎片过多会提前触发FULL GC,解决方案涉及两个参数(JDK9废除了这两个参数)
默认开启,CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程(STW时间会加长)
-XX:+UseCMSCompactAtFullCollection
CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)
-XX:CMSFullGCsBeforeCompaction
2
3
4
# 4.2、G1
G1(Garbage First)是一款面向服务端应用的垃圾收集器,是JDK9的默认垃圾收集器。G1可以面向堆内存任何地方来组成回收集(Collection Set CSet
),衡量标准不再是它属于哪个分代,而是哪一块内存中的垃圾数量最多,回收收益最大,这就是G1的Mixed GC模式
G1把连续的堆内存划分为大小相等的区域(Region),Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象(大小超过一个Region大小的一半)。
设置Region大小,返回1-32MB,为2的n次幂
-XX: G1HeapRegionSize
2
G1在收集的时候将Region作为最小单元,也就是每次收集的区域都是Region的整数倍,后台会维护一个优先级列表,然后根据用户设定的允许停顿时间,来优先处理收益最大的Region
每次GC停顿时间 默认200ms
-XX:MaxGCPauseMillis
2
# 4.2.1、G1存在的问题和解决思路:
问题1:如何解决跨Region的对象引用?
G1在每个Region中都维护了自己的记忆集,这些记忆集维护了别的Region指向自己的指针,并标记这些指针在哪些卡页之内。G1的记忆集是一种哈希表,Key是Region的起始地址,Value是一个集合,存储的是卡表的索引号(卡表是“我指向谁”,这种结构记录了“谁指向我”),因此G1有着比其余收集器更高的内存占用
问题2:怎么处理浮动垃圾?
G1为每个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发过程中新产生的对象都被分配在两个指针之上;如果内存回收速度赶不上对象产生速度,也会导致长时间STW的FULL GC。
# 4.2.2、G1收集的过程:
- 初始标记:仅仅标记GCRoots能直接关联的对象,并且修改TAMS指针的值,需要STW,但是耗时很短。
- 并发标记:三色标记扫描整个堆中的对象并标记
- 最终标记:需要短暂的STW,处理错误标记的对象
- 筛选回收:负责更新Region的统计数据,对Region的回收价值进行排序,然后根据用户设定的暂停时间来定制回收计划,然后把决定回收的那一部分的Region中的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间,这里的操作需要STW(因为涉及到了对象的移动)。
# 4.3、ZGC
ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等 技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器
# 4.3.1、动态Region
ZGC也采用了基于Region的堆内存布局,但是不同的是,ZGC的Region具有动态性(动态创建和销毁,以及动态的容量)。
- 小型Region:容量固定2MB,用于放置小于256KB的小对象
- 中型Region:容量固定32MB,用于放置大于等于256KB,小于4MB的对象
- 大型Region:容量不固定,可以动态变化,但是必须是2的整数倍,用于存放大于等于4MB以上的大对象
# 4.3.2、染色指针
为什么需要染色指针?
之前,JVM需要存储对象相关的额外数据,都是存储在对象头中,比如哈希码、分代年龄、锁记录等,这种存储方式,如果在对象被移动的情况下,不能保证对象被访问成功;或者一些场景下,不需要访问对象,但是又想知道对象的某些信息。所以ZGC就把这些对象的信息直接存储在了对象的引用指针上,这个的好处就是可达性分析就不是遍历对象图去标记对象,而是遍历引用图去标记引用。
染色指针是一种将少量信息存储在指针上的技术。在64位的系统中,理论上可以访问的内存达到16EB(2的64次方)字节,但是实际上也使用不了这么多,主流的Linux64位系统只支持46位(64TB)的物理地址空间
ZGC在46位可用的指针上面,将高4位用于存储四个标志信息,通过这四个标志信息,就可以直接知道对象的三个标记状态、是否进入重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。当然,相应的,因为指针被占用了4位,也就是说使用了ZGC的服务器,最大可用内存只剩下了42位,也就是4TB了。
# 4.3.3、虚拟内存映射(待补充)
...
...
...
# 5、GC日志
在JDK9之前,HotSpot 并没有提供统一的日志处理框架,到了JDK9,才把 所有日志都归到了 -Xlog:上
============================ JDK8 ============================
-XX:+PrintGC 查看 GC 基本信息
-XX:+PrintGCDetails 查看 GC 详细信息
-XX:+PrintGCApplicationConcurrentTime 查看 GC 过程中用户线程并发时间
-XX:+PrintGCApplicationStoppedTime 查看 GC 过程中用户线程停顿时间
-XX:+PrintTenuringDistribution 查看熬过收集后剩余对象的年龄分布信息
============================ JDK9+ ============================
查看 GC 基本信息
-Xlog:gc
查看 GC 详细信息
X-log:gc*
-Xlog:safepoint 查看 GC 过程中用户线程并发时间/停顿时间
-Xlog:gc+age=trace 查看熬过收集后剩余对象的年龄分布信息
2
3
4
5
6
7
8
9
10
11
12
13
# 5.1、GC日志相关参数
# 5.2、GC相关参数
# 6、JVM性能监控工具
# 6.1、命令行监测工具
jps:虚拟机进程状况工具
jps [ options ] [ hostid ] jps -l 2388 D:\Develop\glassfish\bin\..\modules\admin-cli.jar 2764 com.sun.enterprise.glassfish.bootstrap.ASMain 3788 sun.tools.jps.Jps
1
2
3
4
5
6jstat:虚拟机统计信息监视工具,可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据
jstat [ option vmid [interval[s|ms] [count]] ] 每 250 毫秒查询一次进程 2764 垃圾收集状况, 一共查询 20 次 jstat -gc 2764 250 20 查询一次进程 2764 垃圾收集状况 jstat -gcutil 2764 S0 S1 E O P YGC YGCT FGC FGCT GCT 0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577 查询结果表明: 新生代 Eden 区(E,表示 Eden)使用 了 6.2%的空间, 2 个 Survivor 区(S0、S1,表示 Survivor0、Survivor1) 里面都是空的, 老年代(O,表示 Old)和永久代(P,表示 Permanent) 则分别使用了 41.42%和 47.20%的空间。 程序运行以来共发生 Minor GC (YGC,表示 Young GC)16 次,总耗时 0.105 秒; 发生 Full GC(FGC, 表示 Full GC)3 次,总耗时(FGCT,表示 Full GC Time)为 0.472 秒; 所有 GC 总耗时(GCT,表示 GC Time)为 0.577 秒
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16jinfo:Java配置信息工具,实时查看和调整虚拟机各项参数。
jmap:Java内存映射工具,用于手动生成堆转储快照(dump文件);也可以查看堆详细信息
jhat:dump文件分析工具,内置了一个微型的HTTP/WEB服务,可以在浏览器中查看dump文件
jstack:Java堆栈跟踪工具。用于生成JVM当前时刻的线程快照。目的主要是为了定位线程的死锁、死循环、请求时间过长导致挂起等。
# 6.2、可视化检测工具
# 6.2.1、JConsole
JConsole(Java Monitoring and Management Console)是一款基于 JMX (Java Manage-ment Extensions)的可视化监视、管理工具。它的主要功能 是通过 JMX 的 MBean(Managed Bean)对系统进行信息收集和参数动态 调整
# 6.2.2、VisualVM
VisualVM(All-in-One Java Troubleshooting Tool)是功能最强大的运 行监视和故障处理程序之一,曾经在很长一段时间内是 Oracle 官方主力发 展的虚拟机故障处理工具