JVM:01.内存结构
参考资料:
- 黑马 JVM 视频
- 《深入理解Java虚拟机》
一、什么是JVM
1. 定义
Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)
JVM很多公司都有发行过,目前主流的是HotSpot JVM
2. 好处
- 一次编写,到处运行
- 自动内存管理,垃圾回收机制
- 数组下标越界检查
- JVM内部使用虚方法调用的机制实现了多态
3. 比较
JVM JRE JDK的区别

4. Java文件执行过程
java文件(编译为)–>二进制字节码(其中包含JVM指令)–> 机器码(字节码通过解释器转为机器码)–>CPU
二、内存结构
整体架构

1. 程序计数器
1.1 作用
用于保存JVM中下一条所要执行的指令的地址
1.2 特点
- 线程私有
- CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
- 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
- 不会存在内存溢出
2. 栈
2.1 定义
- 每个线程运行需要的内存空间,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
1 | |
在控制台中可以看到,主类中的方法在进入虚拟机栈的时候,符合栈的特点

2.2 问题辨析
- 垃圾回收是否涉及栈内存?
- 不涉及。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
- 栈内存的分配越大越好吗?
- 不是。栈内存越大,虽然可以支持更多的递归调用,但因为物理内存是一定的,导致可执行的总线程数就会越少。
- 方法内的局部变量是否是线程安全的?
- 如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
- 如果局部变量引用了对象(基本数据类型不会有线程安全),并逃离了方法的作用范围,则需要考虑线程安全问题。例如形参、返回值等,其他线程能够访问到该线程的局部变量,则容易引起线程安全问题
2.3 内存溢出
Java.lang.stackOverflowError 栈内存溢出
发生原因
- 虚拟机栈中,栈帧过多(无限递归)
也可能是第三方库问题导致无限递归,比如员工类中有部门信息,部门类中有员工集合,导致相互引用无限递归(详见视频P13)
如果使用-Xss命令将栈内存设置减少,能递归的次数会变少,即栈帧减少
- 每个栈帧所占用过大 较少见
2.4 线程运行诊断
1、CPU占用过高
- Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
top命令,查看是哪个进程占用CPU过高
ps H -eo pid(进程id), tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用CPU过高
jstack 进程id可以根据占用CPU过高的线程id 找到有问题的线程,进一步定位到问题代码的源码行号(注意jstack查找出的线程id是16进制的,需要转换)
2、线程死锁问题
- 同样可以使用
jstack 进程id来检查线程死锁问题
3. 本地方法栈
一些带有native关键字的方法需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法。例如Object对象里的一些方法
本地方法栈为这些本地方法提供栈内存
4. 堆
4.1 定义
通过new关键字创建的对象都会被放在堆内存
4.2 特点
- 所有线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制(new的对象没有指向引用了之后会被垃圾回收)
4.3 堆内存溢出
java.lang.OutofMemoryError :java heap space. 堆内存溢出
虽然有垃圾回收机制,但new的对象可能会一直在使用,一直不释放,并且越来越多,就会导致堆内存溢出
有些时候写的程序运行时间短,不会造成堆内存溢出,因为默认的堆内存上限比较大, 因此可以将堆内存设置的小一点再测试代码,看是否会有堆内存溢出问题,可以使用 -Xmx命令
4.4 堆内存诊断
下述命令在Terminal输入即可
jps:查看当前系统中有哪些 java 进程,jps
jmap:查看堆内存占用情况(只能查看某一个时刻), jmap - heap 进程id
jconsole:图形界面的,多功能的监测工具,可以连续监测, jconsole
jvisualvm:同上,性能更强大。详见视频21
5. 方法区
5.1 定义
方法区(method area)只是JVM规范中定义的一个概念,用于存储类信息、常量池、静态变量、常量、编译器编译后的代码等数据
5.2 演变过程
java6
方法区jdk1.6实现的方式叫永久代,其将方法区放置于JVM的内存结构中,由JVM管理
从图中可以看出,在7以及之前堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存,下面的图可能可以帮助我们更好的理解。
很多人都愿意将方法区称作永久代。本质上来讲两者并不等价,仅因为Hotspot将GC分代扩展至方法区,或者说使用永久代来实现方法区。在他虚拟机上是没有永久代的概念的,永久代是Hotspot针对该规范进行的实现。
什么是HotSpot:我们通常使用的Java SE都是由Sun JDK和OpenJDK所提供,这也是应用最广泛的版本。 而该版本使用的VM就是HotSpot VM。简单来说,我们所讲的java虚拟机指的就是HotSpot的版本。
再重复一遍就是,Java7及以前版本的方法区实现方式为永久代。
同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。这也导致了永久代的垃圾收集是和老年代捆绑在一起的,无论谁满了,都会触发永久代和老年代的垃圾收集。
Java8
对于Java8,HotSpots实现方法区的方式叫元空间,如下图所示,其将方法区放置于本地内存中,交给本地内存管理,不由JVM管理。
元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中的“java.lang.OutOfMemoryError: PermGenspace”
同时它也将字符串常量池StringTable放到了堆中,而不是放在以前的常量池中

演变的原因
为什么Java8改用元空间实现?
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。当使用元空间时,可以加载多少类的元数据由系统的实际可用空间来控制
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- 字符串池存在永久代中,容易出现性能问题和内存溢出。
- Oracle 可能会将HotSpot 与 JRockit 合二为一(暂不了解)
5.3 方法区内存溢出
1.8 以前内存溢出称为永久代内存溢出
可以手动设置JVM参数-XX:MaxPermSize=8m,便于检测方法区内存溢出
1 | |
1.8 之后内存溢出称为元空间内存溢出
可以手动设置JVM参数-XX:MaxMetaspaceSize=8m,便于检测方法区内存溢出
1 | |
方法区内存溢出场景:
- Spring
- MyBatis
这些框架经常动态生成代理类、实现类等,所以方法区内存溢出的情况也非常常见,尤其是1.8之前,导致永久代内存溢出很常见,1.8之后元空间使用了系统内存,充裕了很多,垃圾回收机制由元空间自行管理,比永久代的垃圾回收效率提高很多
5.4 运行时常量池
java编译后的二进制字节码包含三个部分:
- 类的基本信息
- 常量池
- 类方法定义(这部分包含JVM指令)
想要得到字节码文件需要对java文件进行反编译,使用命令:javap -v Helloworld.class
1 | |
(在这之前需要先生成class文件,可以用javac Helloworld.java在当前目录生成,或者terminal定位到out文件夹)


其中的常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(#1之类的)变为真实地址
5.5 字符串常量池 StringTable
5.5.1 定义
字符串作为最常用也是最基础的引用数据类型,JVM为String提供了字符串常量池(简称“串池”)来提高性能
字符串常量池在数据结构上是一个hashtable,长度一开始是固定的,并且不能扩容。这里用StringTable[ ]来表示
5.5.2 串池的位置
jdk1.6是在永久代的常量池中

jdk1.6以后,即1.7/1.8,主要说法是1.8,jdk1.8放在了堆中,注意此时串池不在常量池中了,常量池在元空间中并且一起放到了本地内存里。
1.8做出修改的原因是:永久代的内存回收效率很低,永久代是需要Full GC的时候才触发永久代的垃圾回收。Full GC得等到老年代的空间不足才会触发,这个触发的时机有点晚,间接导致串池的回收效率不高,进而导致永久代的内存不足

具体证明案例详见视频36
5.5.3 字符串的实例化方式
方式一:通过字面量定义的方式,将字符串常量"abc"存储在字符串常量池,目的是共享。
方式二:通过new + 构造器的方式,创建了一个字符串对象存储在堆中,堆中的对象又有一个value的字段,其指向常量池中的字符串(如果串池中没有的话,就新建一个)
见 JavaSE–Java进阶:08.Java常用类–一、String类–3 String实例化的区别
5.5.4 字符串的延迟加载
1 | |
JVM执行String s1 = "a"; 执行到这一句时,才会把 a 符号变为 “a” 字符串对象,然后在串池里找,有没有”a”,没有就在StringTable[ ]里放入“a”,即StringTable["a"]。如果有,就使用串池中的对象
JVM执行String s2 = "b"; 执行到这一句时,才会把 b 符号变为 “b” 字符串对象,然后在串池里找,有没有”b”,没有就在StringTable[ ]里放入“b”,即StringTable["a","b"]。如果有,就使用串池中的对象
JVM执行String s3 = "ab"; 执行到这一句时,才会把 ab 符号变为 “ab” 字符串对象,然后在串池里找,有没有”ab”,没有就在StringTable[ ]里放入“ab”,即StringTable["a","b","ab"]。如果有,就使用串池中的对象
这里也体现了串池的特点:常量池中的字符串仅是符号,第一次用到时才变为对象,被称为字符串的延迟加载,这是一种懒惰式行为
5.5.5 字符串的唯一性
串池另一个特点是,避免重复创建字符串对象,取值不同的字符串对象在串池中是唯一的,即向串池中添加字符串对象时,会先找有没有,没有才添加,有就使用串池中的对象
5.5.6 字符串的拼接
常量相加
1 | |
其中,s4涉及到字符串的拼接,是两个常量相加,s4会直接去串池中找到"ab",从字节码中也能够看出

因此,如果判定s3 和 s4是否相等,会输出 ture
1 | |
变量相加
1 | |
其中,s5涉及到字符串的拼接,是两个变量相加,其本质是:
1 | |
最后的这个toString()方法,又是创建了一个String,所以有又相当于
1 | |
从字节码中也能够看出这些本质:

所以s5虽然值是”ab”,但它是创建在堆里的一个对象,而s3是字符串常量池中的一个对象,因此如果执行下面代码,会输出false
1 | |
本质对比
字符串常量拼接的本质其实是 javac在编译期间的优化,因为都是常量,相加的结果不会变了,结果在编译期就已经确定为”ab”,运行时不会更改。
而变量相加在运行时,其引用的值可能会修改,因此不能使用同样的方法。原理是使用StringBuilder()
5.5.7 字符串的intern方法
intern 方法,尝试将串池中还没有的字符串对象放入串池
jdk8
1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 同时会把串池中的对象返回
详见代码分析
1 | |
如果”ab“先创建,就不一样了
1 | |
jdk6
1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
详细代码暂时略过。如有需要详见黑马32集
5.5.8 串池相关面试题
1 | |
5.5.9 串池的垃圾回收
串池中的内存不够,且很多字符串没有被引用,就会触发垃圾回收机制
首先需要借助虚拟机命令-XX:+PrintStringTableStatistics,让程序能够打印串池信息
对于下面一段代码,其运行结果为打印0。其中,串池信息如图所示
1 | |

现在创建10000个新的字符串入池,观察变化,发现多了10000个左右的字符串对象
1 | |

为了演示垃圾回收,手动将堆内存调小-Xmx10m,使其触发垃圾回收。并打印垃圾回收信息-XX:+PrintGCDetails -verbose:gc
1 | |

5.5.10 串池的性能调优
串池的底层结构是哈希表,如果哈希表桶(buckets)的个数比较多,链表比较短,哈希碰撞的几率就会减小,查找速度变快。反之则会很慢
考虑将字符串对象是否入池
如果代码中有较多重复的字符串,可以考虑将字符串入池。因为intern会返回串池的对象,能够减少字符串的对象
演示案例:(代码略过,详见视频38)
重复10次读取一个48万个单词的文本文档,并不让其触发GC(比如放到一个List里)。借助 Jvisualvm工具观察内存
不入池,字符串会占据300M左右的内存
入池,字符串会占据30-40M左右的内存
调整-XX:StringTableSize=桶个数
演示案例:(代码略过,详见视频38)
需要读取一个48万个单词的文本文档,并让其入池,默认的堆内存大小很容易将其读到内存中,手动设置桶的个数命令为:
-XX:StringTableSize=200000
当buckets个数为60013(默认)时,其耗费的时间为0.6s
当buckets个数为200000时,其耗费的时间为0.4s
当buckets个数为1009(最小)时,其耗费的时间为12s
因此,如果代码中的字符串常量个数非常多时,可以将StringTableSize调大,减少查找时间
6. 直接内存
直接内存不受 JVM 内存回收管理,而是操作系统管理的内存。它常见于 NIO (同步非阻塞IO) 操作时,数据读写时用于数据缓冲区
由于它属于操作系统管理,Java使用的时候,虽然其分配回收成本较高,但大文件的读写性能非常高
正常文件的读写过程需要将磁盘文件先放入到系统缓存区,Java不能访问系统缓存区,因此文件全部读到系统缓存区后,再放入Java缓冲区

而使用直接内存的话,Java可以访问到操作系统里的这块直接内存区域,少了一次缓冲区的复制操作,速度得到成倍的提升

直接内存也会引发直接内存的内存溢出java.lang.OutOfMemoryError: Direct buffer memory
因为jvm垃圾回收不会回收掉直接内存这部分的内存,所以可能原因是直接或间接使用了ByteBuffer中的 allocateDirect方法的时候,而没有做clear
视频剩下的没有记录,用到再补充