Java-虚拟机
Java内存区域详解
运行时数据区域
结构图:
线程共享区域
堆区
存放常量池、实例对象等公共元素,所有线程共享。整个内存模块分新生代和年老代,新生代区分Eden、from Survivor和to Survivor。
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
堆区的的内存使用分为年轻代和年老代,GC过程中会将年轻代的数据回收或转移到年老年,属于内存溢出的重灾区。
数据存放范围
- 常量池:
- 字符串常量池:存放所有字符串常量,包括直接量创建的字符串和通过 String 类的方法创建的字符串。
- 数字常量池:存放所有数字常量,包括基本类型常量和包装类常量。
- 对象实例:
代码中通过new 生成的实例对象、数组会进入堆区 - 线程本地变量:
线程本地变量是指每个线程独有的变量,它会被存储在堆区中。 - 类实例:
类实例是指由Class.newInstance()方法创建的对象,它会被存储在堆区中。
GC过程演示
- 程序启动或线程执行时实例化对象会进入新生代Eden
- 当Eden存放满时会触发GC,回收过程会标记Eden和from Survivor(假设此时为空)中存在引用的对象
- 将Eden和from Survivor中标记的对象拷贝到to Survivor中
- 清空Eden和from Survivor中未标记的对象,并将和from Survivor变更为和to Survivor,同理to Survivor变更为from Survivor;注意程序运行过程只使用Eden和from Survivor中的对象
- 步骤4中from Survivor区域多次未被GC的对象会进入年老代(Old)
- 年老代会根据内存的使用率判断是否进行GC
- 再次GC时会根据2-6的步骤进行重复
方法区(本地内存)
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。Java8中元空间是对方法区的一种实现方式,用于存放Class字节码文件,包含类名、方法名等元数据。元空间实际上是独立于JVM的一块本地内存区域。
运行时常量
区别于字符串常量存储在堆中的常量池,运行时常量存储在本地内存中,与元数据共享空间,运行时常量在程序运行过程中才知道具体数据。
线程私有区域
栈区
线程私有,存放私有变量、执行栈以及程序计数器
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
HotSpot 虚拟机对象创建过程
当程序执行遇到new指令时,会先判断当前对象的Class字节码文件是否已经加载解析,若没有,则进行加载,加载过程如下
加载
- 通过类的全限定名获取该类的二进制字节码。
- 将字节码解析成JVM可以理解的格式。
- 将类的元数据存储在方法区中。
验证
- 验证字节码的合法性,确保其符合JVM规范。
- 验证字节码的安全性,确保其不会对JVM造成危害。
准备(为各种变量分配内存空间-静态变量改步骤已经完成)
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象的头部信息
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
执行 init 方法(执行构造方法)
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的访问定位
对象实例化后在堆中已经生成了数据,但是使用该对象还需要在栈中建立引用指针。
Java垃圾回收机制
内存分配和回收原则
- 对象优先在 Eden 区分配
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。下面我们来进行实际测试一下。 - 大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。G1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的阈值,来决定哪些对象会直接进入老年代。Parallel Scavenge 垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。 - 长期存活的对象将进入老年代
虚拟机给每个对象一个对象年龄(Age)计数器,大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。 - 主要进行 gc 的区域
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:- 部分收集 (Partial GC):新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC):收集整个 Java 堆和方法区。
Yong GC时会判断年老代的剩余空间是否大于新生代需要晋升的对象大小,如果不大于,则升级为Full GC;
死亡对象判断方法
- 引用计数法
给对象中添加一个引用计数器:- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
JVM中的主流算法并没有使用引用技术法,因为当两个对象循环引用时引用计数法无法回收这两个对象
- 可达性分析法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
GC Roots对象包含如下:- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
通过可达性分析发会对所有找不到GC Roots起点的对象执行finalize方法,当再次GC时扫描到这些对象,并判断已经执行了finalize方法后才会进行回收。
- 引用类型区分
Java中的引用类型可以区分为强引用、软引用、弱引用和虚引用,GC执行时会保留强引用的变量,回收其它引用类型变量- 强引用
Java变量声明中,没有显性指定引用类型的变量都是强引用 - 软引用(SoftReference)
GC过程中,当内存空间不足时会回收软引用变量 - 弱引用(WeakReference)
GC过程中,返现弱引用对象时无需判断内存使用情况,直接回收 - 虚引用(PhantomReference)
GC过程发现虚引用时直接回收,区别于弱引用,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
- 强引用
垃圾回收算法
标记-清除算法
标记所有不需要回收的对象
将没标记的内存进行回收
容易产生内存碎片,造成内存浪费,执行效率低
复制算法-年轻代
将内存分为两块,把不需要回收的对象拷贝到另一块空白空间,然后清除旧空间
需要较大的内存空间,适合年轻代
标记-整理算法-年老代
标记不需要被回收的对象
整理对象的存储位置,形成连续空间
删除没被标记的元素
分代收集算法
不同的内存区域使用不同的算法
垃圾收集器
Serial 收集器(串行回收器)
使用单线程完成垃圾回收,新生代采用标记-复制算法,老年代采用标记-整理算法。由于是单线程进行回收,所以GC过程会导致业务线程较长时间的阻塞,不建议采用。
ParNew 收集器(并行回收器)
采用多线程完成垃圾回收,新生代采用标记-复制算法,老年代采用标记-整理算法。回收执行过程与 Serial 一样,区别在于ParNew 是多线程并行GC,可以提高GC的执行效率,但是依然会造成业务线程阻塞,因此也不建议采用。
Parallel Scavenge 收集器(JDK8默认收集器)
新生代采用标记-复制算法,老年代采用标记-整理算法。对CPU使用敏感,不会完全阻塞业务线程的执行。当业务线程对CPU使用过高时GC线程会停顿,直到业务线程CPU降下来后再开始GC。
CMS 收集器(适用于年老代)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
明显的缺点: - 对 CPU 资源敏感;
- 无法处理浮动垃圾;
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
G1
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
G1 收集器的运作大致分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收-采用整理清除的方式
类加载器
Java中依赖于类加载器实现将Class字节码文件加载到Jvm内存中。
加载规则
- 每个类都会指向自己的类加载器ClassLoader
- JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好
- 使用ClassLoader加载类时会先判断加载器中是否已经存在了指定的类对象,若不存在则进行加载。
加载器分类
- BootstrapClassLoader(启动类加载器):
最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类 - ExtensionClassLoader(扩展类加载器):
主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 - AppClassLoader(应用程序类加载器):
面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。
双亲委派模型
执行流程:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
- 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。
双亲委派特点
通过将类加载分层,指定不同层级的加载管理当前范围的类
使用从顶层到当前加载器依次加载的方式,避免了不同层级对同一个类多次加载的情况
JDK监控和故障处理工具
命令行工具
jps (JVM Process Status):
类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
- 命令格式:
jps [-q]|[-l]|[-v]|[-m]
- -q:只显示Java进程的VID
- -l: 输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 路径。
- -v:输出虚拟机进程启动时 JVM 参数。
- -m: 输出传递给 Java 进程 main() 函数的参数。
- -q:只显示Java进程的VID
jstat(JVM Statistics Monitoring Tool)
jstat(JVM Statistics Monitoring Tool) 使用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。
- 命令格式:
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]
常见的 option 如下:- jstat -class vmid:显示 ClassLoader 的相关信息;
- jstat -compiler vmid:显示 JIT 编译的相关信息;
- jstat -gc vmid:显示与 GC 相关的堆信息;
- jstat -gccapacity vmid:显示各个代的容量及使用情况;
- jstat -gcnew vmid:显示新生代信息;
- jstat -gcnewcapcacity vmid:显示新生代大小与使用情况;
- jstat -gcold vmid:显示老年代和永久代的行为统计,从 jdk1.8 开始,该选项仅表示老年代,因为永久代被移除了;
- jstat -gcoldcapacity vmid:显示老年代的大小;
- jstat -gcpermcapacity vmid:显示永久代大小,从 jdk1.8 开始,该选项不存在了,因为永久代被移除了;
- jstat -gcutil vmid:显示垃圾收集信息;
示例:jstat -gc -h3 48597 1000 10
显示进程48597的GC情况,每1000ms打印一次,没打印3行加一行标题,一共打印10行
jinfo (Configuration Info for Java)
Configuration Info for Java,显示虚拟机配置信息;
jmap (Memory Map for Java)
生成堆转储快照;生产环境慎用,生成内存快照可能会影响业务线程的运行
使用示例:jmap -dump:format=b,file=/home/zhouwenjie/文档/oom/heap.hprof 48597
执行如上命令即可以在指定目录生成内存快照
jhat (JVM Heap Dump Browser)
用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;
对上面jmap生成的快照文件进行分析,使用jhat /home/zhouwenjie/文档/oom/heap.hprof
文件读取完后通过http://localhost:7000/ 查看
jstack (Stack Trace for Java)
生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。
执行示例:jstack 48597>stackDump01.log
依次执行如上命令可获取5个线程快照文件,同时对5个文件进行分析,可以查看到每个线程的运行状态。
文件内容格式:
可视化工具
MAT:分析Java内存溢出文件
核心分析思路为先找到大对象,从大到小对大对象分析GC root,特别关注GC root中涉及到的业务代码模块
参考Java性能分析