本文为JVM系列文章的第一篇, 主要围绕以下三个半方面展开
- JMM (Java内存模型)的组成: 程序计数器,栈,本地方法栈,堆, 方法区 (JDK8之前/后(含)实现是永久代/元空间)及各阶段OOM分析
- 常见JVM监控工具:jmap,jhat,Jconsole,jvisualvm等在CPU,I/O,memory飙升问题定位中的简单使用以及对应的优化思路
- 垃圾回收,分代模型, 基于根可达和引用计数器模型的内存管理
什么是JVM
定义
Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)
好处
一次编写,到处运行
自动内存管理,垃圾回收机制
数组下标越界检查
比较
JVM JRE JDK的区别

程序计数器
作用
用于保存JVM中下一条所要执行的指令的地址
特点
- 线程私有
CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一行指令 - 不会存在内存溢出

虚拟机栈 -Xss256kb
定义
每个线程运行需要的内存空间,称为虚拟机栈
每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
每个线程只能有一个活动栈帧,对应着当前正在执行的方法
演示
代码
1 | public class Main { |
图解
问题辨析
- 垃圾回收是否涉及栈内存?
不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。 - 栈内存的分配越大越好吗?
不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。 - 方法内的局部变量是否是线程安全的?
如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题.
实战问题定位
内存溢出
Java.lang.stackOverflowError 栈内存溢出
发生原因:
- 虚拟机栈中,栈帧过多(无限递归)
- 每个栈帧所占用过大
表现: CPU占用过高
诊断流程:
Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
- top命令,查看是哪个进程占用CPU过高 (假如找到进程pid 22618)
- ps H -eo pid, tid(线程id), %cpu | grep 22618 (刚才通过top查到的进程号,通过ps命令进一步查看是哪个线程占用CPU过高)
- jstack 进程id 通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的,需要转换
长时间不返回结果
发生原因: 死锁.
表现, CPU标高而且但是一直不返回结果.
用和上一小节同样的方式找到对应进程. 然后执行jstack pid进程id看输出最后一部分:
本地方法栈
一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法
截至目前, 之前讲解涉及的内存区域(程序计数器, 栈, 本地方法栈) 都是线程私有的.
堆
定义
通过new关键字创建的对象都会被放在堆内存特点
- 所有线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制
堆内存溢出
java.lang.OutofMemoryError :java heap space. 堆内存溢出
堆内存诊断
如果怀疑是堆内存溢出相关的错误, 可以设置对内存小一点, 可以尽早定位. 例如下面的代码:
1 | package cn.itcast.jvm.t1.heap; |
首先了解idea如何配置-Xmx

设置好-Xmx 最大堆内存大小后, 用不同参数运行上面代码观察结果:

可以看出OOM溢出时, 设置-Xmx越大, 迭代次数越多, 占用对内存越大.
jps
查看当前系统有哪些Java进程jmap
查看堆内存占用情况. 命令: **`jmap -heap 具体PID`**

⚠️注意: 由于Mac使用的openjdk1.8版本不支持jmap后跟-heap参数, 未生成截图.上图只参考, 具体数据与本例有差异.但是输出结构可以参考. 1 | Attaching to process ID 29620, please wait... |
演示堆内存执行流程:
不同时间点打印命令: `jmap -heap pid`会有不同的输出
(1, 2) 之间打印: Eden Space 的used部分是:6.4Mb
(2, 3)之间打印: Eden Space 的used部分是:16.4Mb (多出来的10Mb就是new byte[]数组占用的)
(3, 程序结束) 打印: Eden Space 的used部分是:1.2Mb (因为3前一行执行了gc, 垃圾回收释放了没有被继续引用的对象。array = null; 即不再被引用)jconsole
图形化界面, 多功能监测工具, **可以连续监测**.
还是上面的例子使用Jconsole进行监测的流程.
1. 程序运行时在console输入: **`jconsole`** 会跳出如下界面:

2. 点击insecure connect. (本地测试)

3. 概览界面 (含四个部分)

4. 如果想要监测对应的部分, 点击对应的tab即可.例如我们此时关注的是对内存, 那么点击memory:

另外**`jconsole`**还为我么提供了一些其他功能, 比如可以在界面手动触发GC.
jvisualvm
可有有些例子GC之后内存暂用就下来了, 但是有些时候GC并不能解决问题. 可能是因为我们代码逻辑问题导致一些不再被引用的变量不会被回收. 所以内存占用持续在高位.
针对这种情况, 我们演示一个例子.
- 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package cn.itcast.jvm.t1.heap;
import java.util.ArrayList;
import java.util.List;
/**
* 演示查看对象个数 堆转储 dump
*/
public class Demo1_13 {
public static void main(String[] args) throws InterruptedException {
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {
students.add(new Student());
// Student student = new Student();
}
Thread.sleep(1000000000L);
}
}
class Student {
private byte[] big = new byte[1024*1024]; //1Mb
} - JPS找到进程

jvisualvm监控
⚠️注意: 因为Mac导出堆dump文件的方式是使用:jmap -dump: live,format=b,file=dump20220711-92360.hprof 92360(Mac没有jvisualvm工具.)
file后面的文件名可以是绝对路径也可以是相对路径. 此处选择了先对路径, 执行完发现项目路径多了多一个文件.
- 在结果中查看占用内存最大的前20个对象.
Windows系统


5. 查看最大对象的具体组成:
返回去看Demo13中Student代码可以得到验证, 每个Student确实是1M:
1 | private byte[] big = new byte[1024*1024]; //1Mb |
方法区
⚠️注意: 方法区是规范, 永久代或者元空间都是对方法区的实现.
定义
JVM中对方法区的高度概括:
1 | 1. The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. |
对上述定义的翻译:
- 方法去线程共享.
- 方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。
- 方法区存储的是class结构信息. 包括: 运行时常量池, 变量和方法数据, 以及实例或接口初始化时的构造或特殊方法.
- JVM启动就会创建方法区
- 方法区逻辑上属于堆空间. (简单的JVM实现可以不处理方法区的GC或者压缩.)
- 本规范对方法区的位置或用于管理已编译代码的策略没有强制要求.
- 方法区大小可以固定,或者根据需求弹性伸缩. 而且不必占用连续内存.
JVM规范对与方法区相关的OOME异常:
1 | The following exceptional condition is associated with the method area: |
如果方法区的内存不能满足新的变量内存内存分配的请求, 那么JVM会抛OOME.
方法区内存溢出
1.8之前永久代内存溢出, 1.8(含)之后元空间溢出.
JDK1.8演示
1 | package cn.itcast.jvm.t1.metaspace; |
VM参数设置: -XX:MaxMetaspaceSize=8m
运行结果:
JDK1.6演示
同样的, 上述测试做两次改动验证JDK1.6永久代溢出
- 将cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, “Class” + i, null, “java/lang/Object”, null); 第一个参数改成V_6
- VM参数改成 -XX:MaxPermSize=8m
当然也别忘了修改IDEA使用的SDK.
运行结果:
既然方法区(不管是永久代还是元空间)只是存储Class类结构, 那实际开发中怎么还会出现方法区溢出的情况呢?
其实这种情况还是挺常见的, 比如: 我们在使用Spring等框架时, 底层会通过反向代理帮我们在程序运行过程中动态生成很多代理对象.
组成
运行时常量池
进入class文件目录: cd out/production/jvm/cn/itcast/jvm/t5
执行反编译命令: javap -v HelloWorld (其中-v参数表示查看详细信息) 得到如下结果:
1 | ╰─ javap -v HelloWorld ─╯ |
反编译的信息大概就是上面提到的:
- 类基本信息

- 常量池表

- 指令加载

常量池就是一张表, 虚拟机指令根据这张表找到要执行的类名, 方法名和参数类型, 字面量等信息.
运行时常量池, 常量池是放在.class中的, 当该类被加载时, 这个常量池信息就会被放入运行时常量池, 其中的符号地址也会被替换为真实地址.StringTable (运行时常量池的重要组成之一)
测试demo:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package cn.itcast.jvm.t1.stringtable;
/**
* 重点: 1, 对象创建都是懒惰的; 2。都会被加到StringTable, StringTable长度固定,不可扩容。
*/
// StringTable [ "a", "b" ,"ab" ] hashtable 结构,不能扩容
public class Demo1_22 {
// 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
System.out.println(s3 == s4); // false
String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab
System.out.println(s3 == s5);
}
}


根据上面的分析, 如果对s3和s4进行比较, 返回结果为false, 虽然字面量值一样, 但是s3是在方法区的常量池中的StringTable里, 但是s4是new 出来的, 在堆内存中.
为了验证结果, 重新运行:
StringTable的延迟加载特性
下面的代码
1 | /** |
当debug运行执行到第一个 System.out.print("1"); (还未执行)时, 字符串个数时2275,
当debug运行执行到第二个 System.out.print("1"); (还未执行)时, 字符串个数时2285,
逐行debug观察可以知道, 前面每执行一行, 字符串个数多一个, 但是第二轮的1-0 10个没有继续往里加, 因为已经存在StringTable里了.

String的intern方法底层原理
1 | public class Demo1_23 { |


2⃣️在1⃣️的基础上main方法加了第一行和最后一行比较, 因为这种情况下, 执行到intern方法调用行的时候, StringTable里已经有”ab” 常量了, 所以不会往里放, 但是返回的还是StringTable里的”ab”, 所以System.out.println( s2 == "ab");的返回为true
3⃣️在2⃣️的基础上加了最后一行, 因为x是StringTable里的字符串常量, 而s是StringBuilder返回的 new出来的, 所以在堆中.所以结果为false.

StringTable特性
最后, 再总结下StringTable的特性
面试题讲解:
JDK8 对 StringTable做的优化

为了验证上面的优化:
1 |

为了方便测试, 记得设置永久代JVM参数, maxPermSize否则.


该参数默认开启
Idea设置堆空间大小-Xmx8m 并且关闭UseGCOverheadLimit
重新测试:
通过统计信息查看验证StringTable也会垃圾回收
开启JVM参数: -Xmx16m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
StringTable性能调优
a. 修改-XX:StringTableSize参数从1009到400000, 效率提升十几倍(耗时 1939ms -> 143 ms).
因为这个参数时空值buckets大小的, buckets越大, hash越分散, 查找重复率的效率越高.



b. 如果发现有业务场景是需要对大量重复的字符串进行使用, 那么可以考虑通过intern入池来降低内存占用.
比如: twitter想要统计数亿计的用户的地址信息, 因为地址信息大部分都是重复的, 符合这个条件, 所以可以考虑通过这种方式提升效率.
该调优方式的demo验证.
代码:
1 | package cn.itcast.jvm.t1.stringtable; |
Round One
Round Two
Round Three
直接内存
定义
Java的NIO库允许Java程序使用直接内存。直接内存是在java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
特点
不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的直接区域。
直接内存是在Java堆外的、直接向系统申请的内存区间。
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。
通常,访问直接内存的速度会优于Java堆,即读写性能高。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
Java的NIO允许Java程序直接使用内存,用于数据缓冲区。
也可能导致OutOfMemoryError异常
优于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的综合依然受限与操作系统能给出的最大内存。
缺点
分配回收成本较高
不受JVM内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
内存溢出
java.lang.OutOfMemoryError: Direct buffer memory
1 | package cn.itcast.jvm.t1.direct; |

垃圾回收
对直接内存分配的占用空间使用System.gc()进行显示垃圾回收.
一开始占用20.7G
分配后,回收前占用21.7
显示回收后 内存占用20.2:
分配回收底层原理
看上去System.gc() 使得内存被回收了, 但是实际上, 直接内存的分配和回收都是由底层Unsafe类的allocateMemory或者setMemory方法分配, 并由freeMemory方法回收的.
具体测试可见:
1 | package cn.itcast.jvm.t1.direct; |
在分配/回收直接内存代码行前后断点查看系统内存占用情况可以看出效果. 与上一个例子类似, 此处不再贴图.
在了解了直接内存的分配和回收原理后, 即可以使用unsafe的方法来进行直接内管理. 因为System.gc()是fullGC, 会STW造成比较大的影响, 所以一般为了以避免开发人员在代码中使用,会在JVM参数里设置:-XX:+DisableExplicitGC 来禁用显式调用gc.
垃圾回收
如何判断是否可以回收
引用计数器方式
定义
当一个对象被另一个对象引用时, 引用计数器就+1, 反之, 不再被其引用时, 引用计数器就-1, 直到最后, 引用计数器为0 时, 则表示该对象占用的堆内存即为垃圾, 可以被回收.
缺点
虽然该练简单, 可操作. 但是致命点在于其无法解决循环引用的垃圾对象. 比如a->b->c->a的三个对象(-> 表示引用), a,b,c相互都被他们前一个对象引用, 但是如果a,b,c都不被除了他们以外的任意对象引用, 那么他们三个都可以被看作垃圾, 但是却因为引用计数器不为0所以不会被回收. 造成一堆垃圾.
根可达性算法
定义
在进行垃圾回收前, JVM的垃圾回收器会将堆内存中的所有对象进行标志, 如果是根可达(被跟对象直接或者间接引用)的是有用的, 否则则可以标记为垃圾.
那么, 哪些对象是根呢?
1 | import java.io.IOException; |
可以先使用:jmap -dump: live,format=b,file=dump20220711-92360.hprof 92360 导出内存快照转储文件.
然后使用eclipse插件Memory Analyzer打开, 并找到GC root菜单, 即可看见哪些根对象类型.

对上面类型的分析:
- System Class: 系统类, 由启动类加载器BootstrapClassLoader加载的类.

- Native Stack: jvm在实现某些方法时会不可避免的调用某些本地方法/(即操作系统的方法native method), 而这些方法执行时引用的对象, 即为Native Stack类型的跟对象.

- Thread : 正在运行的线程, 其栈帧内的对象所引用的对象 也可以是GC Roots.(上面代码中list1是栈帧内的局部变量, 其引用的对象是堆中的new ArrayList(), 这个被引用的对象就是GC Roots). 可以在
list1 = null前后进行转储文件的导出查看GC Roots. 发现引用置空后Thread类型里main主线程里的ArrayList 行Root没有了.
- Busy Monitor: synchronized修饰的代码块, 会被一个monitor对象所关联, 被枷锁的对象不能被回收, 否则会”死锁”. 所以这类对象也能是GC Roots.
优缺点
四种引用
强: 被GC Roots直接或者间接引用.
软:
弱
虚
终结器引用
垃圾回收算法
分代回收垃圾机制
垃圾回收器
垃圾回收调优
常见JVM设置:
堆设置
-Xms:初始堆大小
-Xmx:最大堆大小
-Xmn:新生代大小
-XX:NewRatio:设置新生代和老年代的比值。如:为3,表示年轻代与老年代比值为1:3
-XX:SurvivorRatio:新生代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:为3,表示Eden:Survivor=3:2,一个Survivor区占整个新生代的1/5
-XX:MaxTenuringThreshold:设置转入老年代的存活次数。如果是0,则直接跳过新生代进入老年代
-XX:PermSize、-XX:MaxPermSize:分别设置永久代最小大小与最大大小(Java8以前)
-XX:MetaspaceSize、-XX:MaxMetaspaceSize:分别设置元空间最小大小与最大大小(Java8以后)
收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行老年代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器
垃圾回收统计信息
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename
并行收集器设置
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
并发收集器设置
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器新生代收集方式为并行收集时,使用的CPU数。并行收集线程数。
-XX:+PrintStringTableStatics
-XX:+PrintGCDetails
-verbose:gc
javap
jps
jhat
jmap
jinfo
jstat
jstatd
jstack
jvisualvm