一、引论
1.1 什么是JVM
(1)定义
JVM
Java Virtual Machine--Java程序的运行环境
Java二进制字节码运行环境
(2)好处
- Java程序一次编写,到处运行的基石
- 自动内存管理机制,提供垃圾回收功能
- 数组下标越界检查
- 多态
(3)比较
JVM、JRE、JDK比较
- JVM:Java虚拟机,屏蔽Java代码与底层操作系统的差异
- JRE:Java运行时环境,JVM + 基础类库
- JDK:Java开发工具包,加上编译工具(javac、javap、内存检测工具)
1.2 学习JVM有什么用
- 面试
- 理解底层实现原理
- 中高级程序员必备技能
1.3 常见的JVM
JVM是一套规范,很多公司有自己的一套JVM。其中最出名的是Oracle公司的HotSpot
,还有Eclipse的OpenJ9
1.4 学习路线
- 类加载器(源代码编译为二进制字节码后必须经过类加载器才能被加载到JVM中运行)
- JVM内存结构
- 方法区(类)
- Heap堆(实例、对象)
- JVM Stack虚拟机栈(对象调用方法)
- PC Register程序计数器
- Mative Method Stacks本地方法栈
- 执行引擎
- Interpreter解释器(方法执行时,程序中的每行代码是由解释器逐行执行)
- JIT Compiler即时编译器(方法中频繁执行的代码由JIT优化执行)
- GC垃圾回收(对堆中不再被引用的对象进行回收)
学习路线:
- JVM内存结构
- 垃圾回收机制
- 学习类字节码结构,编译前优化
- 类加载器
- 运行期即时编译优化
二、内存结构
2.1 程序计数器(PC Register)
全称Program Counter Register
,PC Register的作用就是在指令的执行过程中,记住下一条指令的地址。物理上是通过寄存器来实现的。
右侧为Java源代码,左边是被编译后的二进制字节码,是一些JVM指令组成,经过解释器后成为机器码,才能被CPU认识并执行。
流程:
拿到一条待执行的指令[0:getstatic]
将下一条指令的地址[3]放入程序计数器
然后将指令给解释器解释成机器码,给CPU进行执行
当要执行下一条指令时解释器会去从程序计数器中取到下一条指令的地址,然后程序计数器更新第三条指令的地址,重复以上步骤
特点:
- 线程私有,属于某一个线程
- 不会存在内存溢出
2.2 虚拟机栈(JVM Stacks)
2.2.1 定义
栈结构:类似于子弹夹,后进的先出
Java虚拟机栈
Java中的虚拟机栈:线程运行时所需的内存空间,如果有多个线程就会有多个虚拟机栈
每个栈内,由栈帧组成,一个栈帧对应一次方法的调用,是每个方法运行时需要的内存(参数、局部变量、返回地址)
每个线程只能有一个活动栈帧,对应着当前正在执行的方法
第一个方法调用时,给第一个方法划分一个栈帧空间,并且将其压入栈内,当方法执行完成后将对应栈帧出栈,释放该方法所占用的内存。
问题辨析
- 垃圾回收是否设计栈内存?
- 栈内存是方法调用时所产生的栈帧内存,在方法结束后栈帧出栈释放内存,不需要垃圾回收。垃圾回收是针对堆内存而言。
- 栈内存分配越大越好么?
- 栈内存可以通过运行时通过虚拟机参数来指定,
-Xss size
,默认1024KB
,Windows则是根据虚拟内存大小来决定。- 并不是,栈内存划分的越大,则线程数越少,因为物理大小的内存是一定的。假如物理内存500M,一个线程1M,理论有500个线程同时运行;一个线程2M,理论有250个线程同时运行;
- 方法内的局部变量是否线程安全?
- 看一个变量是否是线程安全,主要是看多个线程对这个变量是否是共享的。
- 如果方法内的局部变量没有逃离方法作用范围,就是线程安全的。
- 反之,如果局部变量引用了对象,并逃离了方法作用范围,那么存在线程安全的风险。
- 需要注意的是,如果是
static
变量,则不是线程安全的。
扩展:
2.2.2 栈内存溢出
栈内存溢出的情况:
- 栈帧过多导致栈内存溢出
栈的大小是固定的,当栈帧过多时,大小超过了栈内存,导致溢出。
发生情况:递归调用
- 栈帧过大导致栈内存溢出
案例说明
- 栈帧过多
运行报错:
可以看到一共调用了:24864次
重新运行一共调用了: 5222次
- 使用SDK时发生的栈空间溢出
两个类的循环引用问题,导致Json解析出错,发生无限循环。
解决:需要打破这种循环引用
此时再次运行,就不会出错,解析出来的Json中部门包含员工,员工层下就不会再显示部门了。
2.2.3 线程运行诊断
CPU占用过多
- 定位:
top
命令查看哪个进程对CPU的占用过高 - 进一步:
ps H -eo pid,tid,%cpu | grep PID
可以查看进程中的哪一个线程占用过高 jstack PID
指令将该PID的所有线程列举出来,可以显示哪个线程的第几行代码有问题
程序运行很长时间没有结果
有可能多个线程引发死锁,导致程序长时间不能输出结果,同样使用jstack
指令来进行排查。
Found one Java-level deadlock
,提示会告知哪个线程哪一行在等待哪个对象的锁。
2.3 本地方法栈(Native Method Stacks)
本地方法指那些不是由Java编写的代码,Java代码有一定的限制,有的时候不能直接和OS底层打交道,所以需要使用C或C++开发的底层方法与OS打交道。这些本地方法运行时所占用的内存就是本地方法栈。
例如:Object对象的clone
、hashCode
、notify
、wait
方法,该方法的声明就是native
的,该方法是没有方法实现的,实际上实现是使用C或者C++来实现的。Java是通过native
这样的本地方法接口去间接调用本地方法的。
2.4 堆(Heap)
以上所涉及的虚拟机栈、程序计数器、本地方法栈都是线程私有的,而堆、方法区可以看成是线程共享的。
2.4.1 基础概念
定义:
- 通过new关键字,创建对象都会使用堆内存
特点:
- 它是线程共享的,堆中对象都需要考虑线程安全问题
- 有垃圾回收机制
2.4.2 堆内存溢出
如果不断产生对象,且所产生的对象有人使用,这样这些对象就不会被GC回收,产生堆内存溢出的问题。
示例:在死循环中不断给ArrayList中添加字符串项,且项的内容每次循环以2倍长度增长,这样大概在26次左右就会发生堆内存溢出的问题java.lang.OutOfMemoryError: Java Heap Space
。
-Xmx8m
该参数可以在配置程序运行中的VM OPtion
中对堆内存的大小进行配置,默认为8G
2.4.3 堆内存诊断
- jsp工具
- 查看当前系统中有哪些Java进程
- jmap工具
- 查看堆内存占用情况,只能看某一时刻
jmap -heap PID
- jconsole工具
- 图形界面的,多功能的检测工具,可以连续检测
测试代码:
Jmap使用
-
第一次执行
jmap
(在IDEA中terminal中键入) -
第二次执行
jmap
- 第三次执行
jmap
Jconsole使用
2.4.4 案例
垃圾回收后,内存占用仍然很高
工具:Jvisualvm
(可视化虚拟机)
该工具可以使用可视化的方式查看堆的各个组成部分以及内存占用:
点击堆Dump
后,可以查看点击时刻堆内存中的类对象,工具支持查找最大的对象:
通过查看类的示例数,可以发现是这个ArrayList中有大量的Student对象,且一直在使用,导致GC下不去:
因此,查看对应代码可以发现:
2.5 方法区(Method Area)
2.5.2 定义
方法区是所有Java虚拟机线程的共享区域,其中存储和类结构相关的信息:成员变量、方法数据、成员方法与构造器的代码,运行时常量池。
方法区在虚拟机启动时创建,逻辑上是堆的组成部分,不强制方法区的位置,可能不同JVM位置会不同。
方法区也会导致内存溢出
- JVM1.6
- JVM1.8
2.5.3 方法区内存溢出
- 1.8以前会导致永久代内存溢出(虚拟机参数:
-XX:MaxPermSize=8m
) - 1.8会导致元空间内存溢出(使用系统内存,一般很大,所以使用虚拟机参数
-XX:MaxMetaspaceSize=8m
缩小元空间大小)
报错:java.lang.OutOfMemoryError:Metaspace
- 实际开发溢出场景 动态产生Class并加载类的场景:
- Spring(AOP生成代理类)
- Mybatis(使用cglib产生mapper接口)
cglib
:ClassWriter
(运行期间动态生成类的字节码,完成动态类加载,代理技术)、ClassVisitor
2.5.4 运行时常量池
方法区组成中会有运行时常量池,在此之前需要先了解什么是常量池。
举个简单例子:
这一段HelloWorld代码需要运行,首先要将其编译成二进制字节码(类的基本信息、类的常量池、类中的方法定义[包含了虚拟机指令])
为了看懂字节码,需要将编译后的class文件进行反编译:javap -v HelloWorld.class
- 类基本信息
- 常量池
- 类的方法定义
- 虚拟机指令
其中#2
、#3
、#4
后面是javap进行的注释,实际上是需要去常量池的表进行查询。
运行时常量池
- 常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量的信息
- 运行时常量池:常量池是*.class文件中的,当该类被加载时,它的常量池信息就会被放入运行时常量池,并把里面的符号地址变为真实地址。
2.5.5 StringTable
问题
- 例一:
String s1 = "a";
String s1 = "b";
String s1 = "ab";
将该代码进行编译后,反汇编字节码得到:
常量池中的信息,都会被加载到运行时常量池,这是a
、b
、ab
都只是常量池中的符号,还没有变为Java字符串对象,直到主程序运行到ldc #2
这里,就会将a
符号,变为a
字符串对象。并将a
作为key去StringTable中找,看有没有相同的串;如果没有就放入串池。
执行完上面代码后,串池为StringTable[ "a", "b", "ab"]
,串池是一个hashtable
结构,不能扩容。
- 例二:
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
System.out.println( s3 == s4 );
将上述代码编译后反汇编字节码:
实际上所增加代码实现为:new StringBuilder().append("a").append("b").toString()
。
查看StringBuilder的toString方法:
实际上toString方法会创建一个新的字符串对象。因此,s3是串池StringTable中的对象,而s4是在堆中的对象,因此会打印false。
添加s5,反汇编后可以看到:
s5创建是从常量池中直接找“ab”(这是javac在编译期间的优化,结果已经在编译期间确定为“ab”
),与s3创建过程相同,因此二者相等。运行到s3时,串池还没有“ab”,因此向其中进行添加,运行到s5时,串池中找到了“ab”,直接使用。
- 例三:
String s = new String("a") + new String("b");
s.intern();
说明:“a”和“b”是常量,放在串池当中;new出来的“a”和new出来的“b”是对象,存储在堆中;s是创建一个StringBuilder并通过append拼接了“a”和“b”,此时新字符串“ab”的值实际上仅仅存在于堆中,没有存在于串池中,因为是动态拼接得到的。
intern
方法试图将这个字符串对象尝试放入串池,如果串池有,则不会放入,直接返回常量池对象;如果没有则放入串池,最后返回串池的对象返回。(这是JDK1.8的规则,如果是JDK1.6,如果没有则赋值一份,放入串池,放在堆里的和拷贝的是不一样的,返回串池中的)
- 例四:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println( s3 == s4 );
System.out.println( s3 == s5 );
System.out.println( s3 == s6 );
String x2 = new String("c") + new String("d");
String x1 = "cd"
x2.intern();
// 问:如果调换最后两个代码的位置呢?如果是JDK1.6呢?
System.out.println( x1 == x2);
答:
s3
是常量池对象"ab",s4
是通过StringBuilder创建在堆的对象,不相等s5
也是常量池对象,二者相等s6
是先将s4
(new String("ab"))视图放入常量池,但是此时常量池中已有“ab”,因此返回常量池的“ab”,相等x2
是堆中的对象(new String("cd")),intern方法试图将x2放入常量池中,但是由于常量池已经有了“cd”,存放失败,不相等- 调换最后两行代码,
x2
先入常量池,返回常量池对象,然后x1
也是返回常量池对象,相等- JDK1.6,入常量池时创建副本,常量池中的“cd”与堆中的“cd”不一样,因此不相等
总结
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象(创建字符串对象时会去串池找,没有才创建新的)
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译期优化
- 可以使用
intern
方法,主动将串池中还没有的字符串对象放入串池
2.5.6 StringTable位置
如此改变的原因: 永久代的回收效率很低,要老年代空间不足才触发GC,转移到堆中,大大减轻字符串对内存的占用。
2.5.7 StringTable垃圾回收
虚拟机参数:
- -Xmx10m : 设置堆内存最大为10m
- -XX:+PrintStringTableStatistics : 输出串池的统计信息
- -XX:+PrintGCDetails -verbose:gc :打印垃圾回收的次数时间等信息
- 垃圾回收统计信息:
- 串池表信息:
2.5.8 StringTable性能调优
底层是一个哈希表,哈希表的性能是与表大小相关的,如果筒个数少,碰撞几率增高,链的长度增长,查找速度会变慢。
问题一 Stringtable性能调优实际上就是在调整筒的个数。
编译器参数设置:
- -XX:StringTableSize=200000 // 调整筒的个数
- -XX:+PrintStringTableStatistics
结果:
平均每个筒的链表长度为2个单词,用时0.4s
如果不添加参数,结果为:
平均每个筒的链表长度为6个单词,用时0.6s。类似的,如果使用筒最小个数1009,耗时12s。
问题二 性能调优另一个思路就是将常量入池,避免重复出现后消耗内存。
读取开始前,查看内存,字符串占用约为1M。
读取开始之后,可以看到String占用明显上升:
String和char类型加起来占用89%。
修改之后的百分比占用如下图所示,只占用了34%:
2.6 直接内存
并不属于Java虚拟机的内存,而是OS的内存。
- 常见于NIO操作时(ByteBuffer),用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
2.6.1 直接内存的使用
- IO方法(使用传统的阻塞IO来实现文件的读写)
- DirectBuffer方法(使用ByteBuffer来分配读写缓冲区完成文件读写)
结果
800M文件传统IO耗费3s,而ByteBuffer使用不到1s。
为何使用直接内存(ByteBuffer)的大文件读写效率会这么高?
文件的读写过程:
读取时有两个缓冲区,需要将数据缓冲区读写到java缓冲区,造成了不必要的数据赋值,因而效率不高。
DirectBuffer文件读写过程:
当调用ByteBuffer.allocateDirect(_1Mb)
时,创建直接内存,会在OS画出一块缓冲区,这个区域可以被Java代码直接访问。磁盘文件读写到直接内存,java代码直接访问直接内存,少了一次缓冲区的复制操作,所以速度成倍提升。适合做文件的IO操作。
2.6.2 Direct Memory不受JVM回收管理
- 直接内存溢出
结果:
36次(3.6G)的时候直接内存溢出。
- 直接内存释放
既然直接内存不受JVM回收管理,那么如何进行回收?底层如何实现?
注意:这里堆内存进行检测时,不能使用之前Java自带的工具进行检测,因为只能看到java管理的内存,Direct Memory不受java管理,看任务管理器。
运行到分配完毕后,会多出来一个java进行,占用1G内存。继续运行byteBuffer会被会回收,这里提出疑问:不是说GC不会回收直接内存么?那么为何这里的直接内存被回收了?因此需要搞清楚直接内存回收的原理。
- 直接内存释放原理
java中有一个Unsafe
类用于负责直接内存的申请和释放(不建议使用,jdk内部代码会使用)。Unsafe
不能够直接获取成员变量,因此需要通过反射的方法来获取静态成员变量。
拿到Unsafe
对象后,使用allocMemory
方法和setMemory
方法来进行直接内存申请,allocMemory方法返回long
是一个直接地址,通过这个地址和freeMemory
方法来释放地址。因此直接内存是通过Unsafe
来进行回收的,而不是通过GC来回收的。
查看ByteBuffer
的allocateDirect
函数:
继续查看DirectByteBuffer
的构造函数,可以看到在构造函数中使用了Unsafe
对象。
回收内存的时候,是创建了Cleaner
对象,后面的回调任务对象中实现了Runable
:
其中,Cleaner
在java虚拟机中成为虚引用类型,特点是当它所关联的对象(ByteBuffer)被回收时(ByteBuffer还是java对象,会被GC回收),Clearner
会触发虚引用的clean方法,clean方法就会执行任务对象的run
方法,最终调用freeMemory
。
直接内存释放是借助了java中虚引用的机制