深入理解JVM 内存区域
笔记
- JVM(Java Virtual Machine)俗称Java虚拟机,是Java 语言实现跨平台运行的核心,此篇介绍了JVM相关的基础理论知识。
- Java官方文档 (opens new window)
# 分析
1、申请内存 我们的Java 程序在JVM 中运行之前,JVM 必须存在。所以,首先JVM 会根据参数(配置参数或者默认参数)向操作系统申请内存空间[^1] ,然后根据根据内存大小找到内存分配表,然后把内存段的起始地址和终止地址分配给JVM。
2、初始化运行时数据区(内存分配) JVM 获取内存空间后,接下来就是根据参数分配堆、栈以及方法区的内存大小。 -Xms30m -Xmx30m -Xss1m -XX:MaxMetaspaceSize=30m
后续三个步骤,我们通过代码代码来说明,示例代码如下:
3、类加载
这里主要是把class 加载到方法区、还有class 中的静态变量和常量也要加载方法区。(类加载的详细后续会讲解)
4、执行方法与创建对象 启动main 线程,执行main 方法,开始执行第一行代码。此时堆内存中会创建一个Teacher 对象T1,对象引用T1 就存放在栈中,通过引用指向堆中的实例。后续代码中遇到new 关键字,会再创建一个Teacher 对象,对象引用Teacher 就存放在栈中。
该方法执行后在JVM 中情况如下:
# 小结
- Java程序在JVM 中执行时,JVM 启动后会先申请内存,再进行运行时数据区的初始化,然后把类相关信息加载到方法区,最后执行方法并创建对象。
- 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈的过程。
- 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理。
# 从底层深入理解运行时数据区
# 堆空间分代划分
堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为Eden 和Survivor 区,最后Survivor 由From Survivor 和To Survivor
组成。
# GC 的概念
GC(Garbage Collection): 即垃圾回收器,在JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在JVM 中GC 的重要区域是堆空间。 我们也可以通过一些额外方式主动发起它,比如System.gc(),主动发起。
# JHSDB 工具
JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是HotSpot 虚拟机中一组用于映射Java 虚拟机运行信息的,主要基于Java 语言实现的 API 集合。
# JDK1.8 的开启方式
开启HSDB 工具
Jdk1.8 启动JHSDB 的时候必须将sawindbg.dll(一般会在JDK 的目录下)复制到对应目录的jre 下。
然后进入到安装JDK 的lib 目下进入命令行(例如:D:\Dev\Java\jdk1.8.0_221\lib),执行 java -cp .\sa-jdi.jar
sun.jvm.hotspot.HSDB
# JDK1.9 及以后的开启方式
进入JDK 的bin 目录下,我们可以在命令行中使用jhsdb hsdb 来启动它。
# JHSDB 工具的使用
# 代码改造
- VM 参数加入:
-XX:+UseConcMarkSweepGC
-XX:-UseCompressedOops
Java官方文档 (opens new window)
- 为了验证堆中对象分代的存在,我们在T1创建后主动发起垃圾回收。
- 添加参数设置并启动代码
# JHSDB 中查看对象
1、jps 命令查看JVM 进程号
因为JVM 启动有一个进程,需要借助一个命令jps 查找到对应程序的进程。
2、在JHSDB 工具中attach 上去
3、查看堆参数
上图中可以看到实际JVM 启动过程中堆中参数的对照,我们可以发现:在不启动内存压缩的情况下。堆空间里面的分代划分都是连续的。
4、查看对象
我们可以看到JVM 中所有的对象,都是基于class 的对象
全路径搜索类的class
双击这个class文件,我们可以查看详情
可以发现两个Teacher的对象(就是我们在代码中创建的T1、T2对象),然后选中单个对象,点击inspect 可以查看当前对象详情
5、对象分代存在
从上面我们可以看出,由于我们在T1对象创建后T2对象创建前手动进行了GC,所以T1 在Eden(Eden属于新生代),T2 在老年代。
# JHSDB 中查看栈
详情如下:
从上图中可以验证栈内存,同时也可以验证到虚拟机栈和本地方法栈在Hotspot 中是合二为一的实现了。
# 小结
上述代码运行后,最终在JVM 中内存分配情况如下:
# 栈的优化技术——栈帧之间数据的共享
一般地,两个不同的栈帧的内存区域是独立的,也就是一个栈帧一个独立内存区域,对于不同对象的栈帧来说是肯定是必须的,但如果多个栈帧指向的都是同一个对象,就显得浪费内存空间。
所以,大部分的JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做
不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。
简单点理解,就是若两个栈帧都是指向同一个对象,那么这两个栈帧就可以共用一个栈帧来实现数据的共享。
我们从JHSDB 工具中也可以验证
示例代码:
JHSDB 中查看栈情况:
# 深入理解堆与栈(堆栈区别总结)
存储内容 栈: ①以栈帧的方式存储方法调用的过程(方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈的过程); ②存储方法调用过程中基本数据类型的变量(byte,short,int,long,float,double,boolean,char)和对象的引用变量。 堆: 堆内存用来存储Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
与线程的关系 栈: 线程私有,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可存。 堆: 线程共享,堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
内存大小 堆是JVM 中最大的一块内存区域,栈的内存要远远小于堆。
# 内存溢出
# 栈溢出
# 参数:-Xxs1m
HotSpot 版本中栈的大小是固定的,是不支持拓展的。
# 异常分析
Java.lang.StackOverflowError
- 当线程所需要的栈的深度(大小)>虚拟机所允许的最大深度(大小)时,JVM 会抛出StackOverflowError异常。
- 一般出现StackOverflowError异常是因为程序里有死循环或递归调用所产生的。
- 虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢 ,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。
OutOfMemoryError
- 当虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
- 不断建立线程,JVM 申请栈内存,机器没有足够的内存时,就会产生OutOfMemoryError 异常(一般演示不出,演示出来机器也死了)。
**值得注意的是,栈区的空间JVM 没有办法去限制的,因为JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。 **
# 堆溢出
# 参数:-Xms30m -Xmx30m
# java.lang.OutOfMemoryError
- 堆溢出一般异常格式:java.lang.OutOfMemoryError: Java heap space
直接溢出:
缓慢溢出:
# 方法区溢出
- 方法区溢出一般异常格式:java.lang.OutOfMemoryError: Metaspace
# 溢出原因
- 运行时常量池溢出
- 方法区中保存的Class 对象没有被及时回收掉或者Class 信息占用的内存超过了我们配置。
注意Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):
1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2、加载该类的ClassLoader 已经被回收。
3、该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
我们可以通过 -Xnoclassgc 禁用类的垃圾回收
方法区溢出示例代码:
cglib 是一个强大的,高性能,高质量的Code 生成类库,它可以在运行期扩展Java 类与实现Java 接口。
CGLIB 包的底层是通过使用一个小而快的字节码处理框架ASM,来转换字节码并生成新的类。除了CGLIB 包,脚本语言例如Groovy 和BeanShell,也是使用ASM 来生成java 的字节码。当然不鼓励直接使用ASM,因为它要求你必须对JVM 内部结构包括class 文件的格式和指令集都很熟悉。
2
运行结果:
# 本机直接内存溢出
- 直接内存溢出一般异常格式:java.lang.OutOfMemoryError: Direct buffer memory
# 参数:MaxDirectMemorySize
直接内存的容量可以通过MaxDirectMemorySize 来设置(默认与堆内存最大值一样)。
# 溢出表现
由直接内存导致的内存溢出,一个比较明显的特征是在HeapDump 文件中不会看见有什么明显的异常情况,如果发生了OOM,同时Dump 文件很小,可
以考虑重点排查下直接内存方面的原因。
直接内存溢出示例代码:
# 常量池
(该图属于个人理解,仅供参考,不一定正确)
# Class常量池(静态常量池)
在class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期间生成的各种 字面量和符号引用。
# 字面量
给基本类型变量赋的值就叫做字面量或者字面值。 比如:String a="b" ,这里"b"就是字符串a的字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。
# 符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,而且引用的目标并不一定加载到内存中。 Java 在编译的时候每一个java 类都会被编译成一个class 文件,但在编译的时期虚拟机并不知道所引用类的实际地址,就用符号引用来代替,而类在解析阶段就是为了把符号引用转化为正真的地址。 假设org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。而在类装载器装载People类时,此时可以通过虚拟机获取Language类的实际内存地址,因此便可以既将符号org.simple.Language 替换为Language 类的实际内存地址。
# 直接引用
直接引用可以是 (1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)。 (2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)。 (3)一个能间接定位到目标的句柄。 直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
# 运行时常量池
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的**常量 **:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩) 运行时常量池是在类加载完成之后,将Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。在JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,只是改变了实现的方式。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区。
# 字符串常量池
字符串常量池这个概念比较有争议性,没有官方的定义(也就是说官方文档找不到这关于这个的定义),可以说它是JVM 开发者们长久工作经验的产物。 以JDK1.8 为例,字符串常量池是存放在堆中,并且与java.lang.String 类有很大关系,String 对象作为Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。 所以要彻底弄懂,我们的重心其实在于深入理解String。
# String
# String 源码分析(JDK1.8)
String 对象的不可变性
由于版本的关于,String的实现方式可能不同(1.6以前是一个版本、1.7/1.8是一个版本、1.9以后又是另一个版本),这里以JDK1.8
为例,首先我们看下String 的部分源码实现。
从源码我们可以看出:
(1)、String类是final类,也即意味着String类不能被继承,并且它的成员方法都默认为final方法。
(在Java中,被final修饰的类是不允许被继承的,并且该类中的成员方法都默认为final方法)。
(2)、String类其实是通过char数组来保存字符串的。
我们再来看部分方法的实现:
我们发现:无论是substring、concat还是replace操作都不是在原有的字符串上进行的,而是重新生成了一个新的字符串对象。也就是说进行这些操作后,最原始的字符串并没有被改变。那么,也就说
String对象一旦被创建就是固定不变,对String对象的任何操作都不影响到原对象,相关的任何change操作都会生成新的对象。Java
实现的这个特性叫作String 对象的不可变性 Java 为什么这么处理String?
(1)、保证了String 对象的安全性。如果String 对象是可变的,那么它就有可能被恶意更改。
(2)、保证hash 属性值不会频繁变更,确保了唯一性,使得类似HashMap 容器才能实现相应的key-value 缓存功能。
(3)、可以实现字符串常量池。
这里对第三点做下相关说明:
我们知道字符串的分配和其他对象分配一样,是需要消耗时间和空间的,而且字符串我们日常代码中是使用的非常多。JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池。每当我们创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的相同字符串的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串。 **
# String 的创建方式以及内存分配方式
# String str="abc"
当代码中使用这种方式创建字符串对象时,JVM
首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str
只是一个引用)
# String str = new String("abc")
首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;其次,在调用new 时,JVM
命令将会调用String
的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个String 对象;最后,str 将引用String
对象。所以,他们三者之间的引用关系是:str-->String 对象-->"abc"。值得注意的是,字符串常量中的"abc"
对象可能并没有被创建!而可能只是指向一个先前已经创建好的对象。
# 对象中赋值成员变量
示例代码:
使用new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。
具体的复制过程是先将常量池中的字符串压入栈中,在使用String 的构造方法是,会拿到栈中的字符串作为构方法的参数。这个构造函数是一个char
数组的赋值过程,而不是new 出来的,所以是引用了常量池中的字符串对象,存在引用关系。
# String str= "a"+ "b"+ "c"
编程过程中,字符串的拼接很常见。由于String 对象是不可变的,如果我们使用String 对象相加,拼接我们想要的字符串,理论上会产生多个对象,例如String str= "a"+ "b"+ "c"。实际上,使用只包含常量的字符串连接符创建的也是常量,编译器自动优化了这行代码,编译期就能确定,所以,它的效果和String str="abc"是一样的。
# 大循环使用+(编译期间不能确定)
示例代码:
String str = "abc";
for(int i=0; i<1000; i++) {
str = str + i;
}
2
3
4
对于这种编译期不能确定的字符串+,Java 在进行字符串拼接时也做了相关优化,具体优化示例代码如下:
String str = "abc";
for (int i = 0; i < 1000; i++) {
str = (new StringBuilder(String.valueOf(str)).append(i).toString());
}
2
3
4
字符串的加号“+” 方法,虽然编译器对其做了优化,使用StringBuilder的append方法进行追加,但是每循环一次都会创建一个StringBuilder对象,且都会调用toString方法转换成字符串,所以开销很大(时间、空间)。(说明: 执行一次字符串“+”,相当于 str = new StringBuilder(str).append("a").toString())。
StringBuilder与StringBuffer作比 首先我们需要知道, (1)、String 是final对象,不会被修改,每次使用 + 进行拼接都会创建新的对象,而不是改变原来的对象,也属于线程安全的; (2)、StringBuffer可变字符串,主要用于字符串的拼接,属于线程安全的;(StringBuffer的append操作用了synchronized) (3)、StringBuilder可变字符串,主要用于字符串的拼接,属于线程不安全的; (4)、StringBuilder性能比StringBuffer要好。(据大佬数据实验显示,在1千万的循环下, StringBuilder大约在500-600毫秒,而StringBuffer大约在700-800毫秒) 结合以上几点,我们可以总结出StringBuilder与StringBuffer在字符串拼接时的使用场景,* 如果要进行的操作是多线程的,那么就要使用StringBuffer,但是在单线程的情况下,还是建议使用速度比较快的StringBuilder*。
字符串拼接效率说明 对于字符串的拼接,通常有这几种方式+, Join,StringBuffer,StringBuilder或String.concat(),下面是它们效率的总结: (1)、用+的方式效率最差,concat由于是内部机制实现,比+的方式好了不少。 (2)、Join和StringBuffer,相差不大,Join方式要快些。 (3)、StringBuilder 的速度最快,但其有线程安全的问题,而且只有JDK1.5及以上的版本支持。 (4)、String对象串联的效率最慢,单线程下字符串的串联用StringBuilder,多线程下字符串的串联用StrngBuffer; (5)、在编译阶段就能够确定的字符串常量,完全没有必要创建String或StringBuffer对象。直接使用字符串常量的"+"连接操作效率最高(如:String str= "a"+ "b"+ "c")。
[^1]: 这里需要说明下,绝大部分基于操作系统的JVM 才会向操作系统申请内存空间,另一种基于硬件本身的JVM (例如:LiquidVM)会自己划分内存空间,这类JVM 本身就是操作系统。