JVM基础原理
(一)基础概念
- 什么是虚拟机
Java 程序的执行过程:
.java文件–>javac编译器–>.class文件–>JVM虚拟机加载到方法区–>机器码–>调用操作系统函数
总结:JVM 是一个虚拟化的操作系统,类似于 Linux 或者 Windows 的操作系统,只是它架在操作系统上,接收字节码也就是 class,把字节码翻译成操作系统上的机器码且进行执行
- 运行时数据区
线程共享区:堆、方法区
线程私有区:虚拟机栈、本地方法栈、程序计数器
直接内存
虚拟机栈
总结:一个线程对应一个虚拟机栈,每一个方法对应一个栈帧,栈帧按照先后执行顺序压入虚拟机栈中,以先进后出的原则出栈。
知识点:虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。如果不断的入栈,不出栈的话,会出现StackOverflowError异常。
栈帧
包括4个区域:局部变量表、操作数栈、动态连接、完成出口
操作数栈本质上是 JVM 执行引擎的一个工作区。
Java 语言特性多态,部分符号引用在运行期间转化为直接引用,这种转化就是动态链接。
本地方法栈
管理本地native方法的调用,HotSpot 直接把本地方法栈和虚拟机栈合二为一。
程序计数器
管理行号,记录比如循环、跳转、异常字节码地址,唯一不会OOM的区域。
方法区
存储了每一个类的结构信息。
总结JVM 运行内存的整体流程:
JVM 在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的。
(二)底层理解运行时数据区
栈:存储方法调用的过程,并存储基本类型的变量以及对象的引用,变量出了作用域就自动释放。线程私有。
堆:存储对象。线程共享。
Class常量池:字面量和符号引用
运行时常量池:在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。
(三)内存溢出
栈溢出
参数:-Xss1m
StackOverflowError:无限递归
OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存
栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。
堆溢出
参数:-Xms
OutOfMemoryError:申请内存空间,超出最大堆内存空间
方法区溢出
运行时常量池溢出;Class 信息占用的内存溢出
直接内存溢出
参数:MaxDirectMemorySize
(四)对象的创建与访问
-
类加载
把class加载到JVM的运行时数据区的过程
-
检查加载
是否已经被加载过,是否可以找到对应的符号引用
-
分配内存
有两种分配内存的方式:指针碰撞,空闲列表
指针碰撞:
内存是规整的,使用过的内存在一边,空闲的内存在另一边,中间有个指针来区分,要分配新的内存,只需要把指针移动新分配的内存大小的位置即可。
空闲列表:
有个列表记录哪些内存块是可用的,在分配的时候从列表中找一块足够的空间分配给对象实例。
有两种解决并发安全的方案:CAS重试机制、本地线程分配缓冲
CAS重试机制:
本地线程分配缓冲(TLAB)
线程初始化时,堆内存中申请一块指定大小的内存,只给当前线程使用。
-
内存空间初始化
对象的实例字段初始化零值
-
设置
设置 对象头信息:类的元数据信息、对象的哈希码、GC分代年龄等
-
对象初始化
(五)对象的分配与存活
可达性分析
根可达算法:基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。
GC Roots 的对象:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象:线程栈变量
- 方法区中类静态属性引用的对象:静态变量
- 方法区中常量池引用的对象:常量池
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象:JNI
各种引用
- 强引用
Object obj = new Object()
- 软引用 SoftReference
OOM之前,会先进行回收,如果还是不够,才会溢出。
- 弱引用 WeakReference
生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。
- 虚引用 PhantomReference
幽灵引用,最弱(随时会被回收掉)
对象的分配策略
- 栈上分配
默认开启逃逸分析,如果对象不可逃逸,就会在栈上分配。生命周期跟随线程,不需要进行垃圾回收。
- 本地线程分配缓冲
- 大对象直接进入老年代
避免大量内存复制;避免提前进行垃圾回收;
- 对象优先在Eden区分配
如果Eden区没有足够的空间分配时,虚拟机将发起一次Minor GC。
- 长期存活的对象存入老年代
对象头会存年龄计数器,GC分代年龄。Eden-Survivor-老年代。
- 对象年龄动态判断
并不一定是必须要达到MaxTenuringThreshold才能晋升老年代,如果Survivor中相同年龄的对象空间总和大于Survivor空间一半时,大于或者等于该年龄的对象将晋升到老年代。
- 空间分配担保
Minor GC之前会判断,如果老年代最大可用连续空间大小比新生代Eden区所有对象的总空间要大,则不会存在风险,直接进行Minor GC。然后判断是否允许担保失败,如果不允许,则进行Full GC。如果允许,则会判断老年代最大可用连续空间是否比历代晋升到老年代对象的平均大小,如果大于,则会尝试进行一次Minor GC,如果担保失败,则会进行一次Full GC。
(六)类加载器
类生命周期
加载、验证、准备、初始化、卸载的时序的固定的。
加载时机
JVM 虚拟机的实现都是使用的懒加载。
加载步骤:
- 根据类的全限定名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个类的对象。
解析
解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。
初始化
初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化”。
- 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法 句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前 被初始化。
双亲委派机制
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
垃圾回收
(一)分代回收机制及GC种类
分代回收理论
绝大部分的对象都是朝生夕死。
熬过多次垃圾回收的对象就越难回收。
整堆回收(Full GC):收集整个 Java 堆和方法区。
(二)垃圾回收算法
复制算法(Copying)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。
实现简单,运行高效,对应的引用(直接指针)需要调整。复制回收算法适合于新生代。
Appel 式回收
Eden:From:To 8:1:1
每次使用Eden+From 区域,GC时,把存活的对象复制到To区域。
标记-清除算法(Mark-Sweep)
首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。
标记清除之后会产生大量不连续的内存碎片。标记清除算法适用于老年代。
标记-整理算法(Mark-Compact)
首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端 边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。
(三)垃圾回收器
Stop The World(STW)
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。
搭配关系
Serial/Serial Old:几百M
Parallel Scavenge/Parallel Old:默认,追求吞吐量
ParNew/CMS:小于6G,追求响应时间
G1:6G-8G,追求响应时间
CMS
CMS 垃圾收集器是基于标记清除算法实现的,主要用于老年代垃圾回收。
回收过程:
- 初始标记:标记GC ROOTS直接关联的可达对象。对象占比不高,所需时间较短,需要STW。
- 并发标记:将初始标记对象的子对象全部标记。用户线程与并发标记线程同时处理。所需时间长,不需要STW。
- 重新标记:可能存在漏标或者误标对象重新标记。所需时间短,需要STW。
- 并发清除
存在问题(使用重启解决):
- CPU敏感:用户线程与并发线程同时处理,需要更多CPU资源,否则容易卡顿。
- 浮动垃圾:并发清理过程中产生的垃圾对象,如果剩余空间小于浮动垃圾大小时,则会执行单线程Serial Old进行替换。
- 内存碎片:如果大对象分配没有空间分配时,会执行单线程Serial Old进行替换。
Garbage First (G1)
- Region:
分为:Eden,Survivor,Old,Humongous。化整为零,将堆内存划分为相同大小的Region。一般是1M-32M大小,2的次幂。
- Humongous:
当单个对象大于一个Region区域的一半空间大小时,使用一个或者多个连续的Region来存放,标记为Humongous。
- 算法:
Eden、Survivor:使用复制算法。
Old,Humongous:使用标记整理算法,不存在内存碎片。
- MaxGCPauseMillis:
垃圾回收最大占用时间,软目标,会尽可能去实现,根据设定的时间,去筛选出最有价值的区域进行回收。
- TAMS(Top At Mark Start)
会在每一个Region划一块区域存放在并发标记时,新分配的对象的指针。
- SATB(Snapshot at the beginning)
快照算法,解决漏标问题。
- 回收过程:
初始标记-并发标记-最终标记-筛选回收
HostSpot 的细节实现
三色标记:
黑色:根对象,或者该对象与它的子对象都被扫描过。
灰色:对本身被扫描,但是还没扫描完该对象的子对象。
白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
GC 并发情况下的漏标问题:
CMS:增量算法,从根节点重新在扫一次。
G1:SATB,快照,删除的对象重新扫描。
性能优化
(一)参数设置
-XX:-HeapDumpOnOutOfMemoryError
在 java.lang.OutOfMemoryError 异常出现时,输出一个 dump 文件,记录当时的堆内存快照。
-XX:HeapDumpPath=./java_pid.hprof
用来设置堆内存快照的存储文件路径
-XX:+PrintGCDetails, +XX:+PrintGCTimeStamps
调试跟踪之打印简单的 GC 信息
-Xlogger:logpath
设置 gc 的日志路径
-Xms:设置堆的初始大小
-Xmx:指定内存分配池的最大大小
-Xmn:设置年轻代的堆的大小
-XX:SurvivorRatio:eden / survivor空间大小比率
-Xss:设置线程栈大小
-XX:MetaspaceSize:设置元空间大小
-XX:MaxMetaspaceSize:设置元空间最大空间
-XX:MaxTenuringThreshold:设置分代年龄
-XX:ParallelGCThreads:设置并行线程数
-XX:+UseConcMarkSweepGC:使用CMS垃圾回收器
-XX:+ UseG1GC:使用G1
(二)命令行工具命令
- jps
列出当前机器上正在运行的虚拟机进程。
-l: 输出应用程序主类完整 package 名称或 jar 完整名称.
-v: 列出 jvm 参数,
- jstat
监视虚拟机各种运行状态信息。
-gc
- jinfo
查看和修改虚拟机的参数。
-flag -[参数] pid 可以修改参数
-flags
JVM 的命令行参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
- jmap
-heap 打印 heap 的概要信息
-histo 打印每个 class 的实例数目,内存占用,类全名信息.
-histo:live 只统计活的对象数量
jmap -histo pid | head -20 显示前20的
-dump 生成的堆转储快照
- jhat
在服务器上生成堆转储文件分析,不推荐,占用服务器的资源
- jstack
用于生成虚拟机当前时刻的线程快照。用于排查死锁情况。
jstack -gc [pid] 1000 10 | awk ‘{print $13,$14,$15,$16,$17}’
(三)Arthas
- Dashboard (当前系统的实时数据面板)
- Thread(查看当前 JVM 的线程堆栈信息)
thread –b 找出阻塞当前线程的线程
thread -i 1000 -n 3 每过 1000 毫秒进行采样,显示最占 CPU 时间的前 3 个线程
thread --state WAITING 查看处于等待状态的线程
- JVM(查看当前 JVM 信息)
- Jad(反编译指定已加载类的源码)
- trace(方法内部调用路径,并输出方法路径上的每个节点上耗时)
- monitor(方法执行监控)
- watch(方法执行数据观测,出入参)
(四)GC 调优策略
- 降低 Minor GC 频率
单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。
增大新生代空间:增加了 T1,但省去了 T2 的时间
- 降低 Full GC 的频率
减少创建大对象,增大堆内存空间。
(五)JVM预调优
- 计算内存需求
虚拟机栈的大小在高并发情况下可以变小
元空间(方法区)保险起见还是设定一个最大的值
- 选定 CPU
- 选择合适的垃圾回收器
吞吐量优先:PS
响应时间优先:在 JDK1.8 的话优先 G1,其次是 CMS 垃圾回收器
- 设定新生代大小、分代年龄
- 设定日志参数
(六)CPU 占用过高排查实战
- 先通过 top 命令找到消耗 cpu 很高的进程 id
- 执行 top -p [pid] 单独监控该进程
- 在第 2 步的监控界面输入 H,获取当前进程下的所有线程信息
- 找到消耗 cpu 特别高的线程编号
- jstack pid
nid = 16进制 线程编号
- 解读线程信息,定位代码位置
总结:
产生的原因:GC100%,业务线程100%
(七)内存占用过高内存占用过高思路
jmap -histo [pid] | head -20
(八)内存泄漏和内存溢出辨析
内存溢出:实实在在的内存空间不足导致;
内存泄漏:该释放的对象没有释放,常见于使用容器保存元素的情况下。
如何避免:
内存溢出:检查代码以及设置足够的空间
内存泄漏:一定是代码有问题
往往很多情况下,内存溢出往往是内存泄漏造成的。