引言
面试重点考察: 垃圾回收算法,类加载的过程,JVM内存模型
什么是JVM?
定义: Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
JDK包含JRE,JRE包含JVM
面试题: JDK,JRE,JVM三者的区别?
好处:
- 一次编译,到处运行 (jvm屏蔽了字节码和底层操作系统的差异,对外提供了一致的运行环境,jvm可以通过解释的方法,执行二进制字节码,达到代码的平台无关性)
- 自动内存管理,垃圾回收功能
- 数组下标的越界检查,越界会报异常,防止覆盖其他代码的内存
- 多态 提高程序代码的可扩展性,JVM内部使用虚方法表的机制实现多态
内存泄露:被占用的内存无法释放
学习JVM的作用
常见的JVM
参照使用Hotspot虚拟机
学习路线
JVM的组成部分
分为: 类加载,jvm内存结构,执行引擎三大块
Java源代码编译为Java的二进制字节码文件后(.class),必须经过类加载器(ClassLoader),才能被加载到JVM里运行,类都放在方法区(Method Area),类创建的实例/对象都放在堆(Heap)中,堆中的对象调用方法时会用到虚拟机栈,程序计数器,本地方法栈,方法执行时,每行代码是由执行引擎中解释器逐行执行,方法中的热点代码(频繁调用),会被即时编译器优化后执行,堆中不再被引用的对象会被垃圾回收模块回收,Java代码无法实现的功能可以调用本地方法接口调用底层操作系统的提供的功能
JVM的内存结构部分
1.程序计数器
1.1.定义
Program Counter Register 程序计数器 物理上通过(寄存器)来实现
- 作用,是记住下一条jvm指令的执行地址
- 特点
- 是线程私有的,每个线程都有自己的程序计数器,随着线程创建而创建,随着线程销毁而销毁
- 不会存在内存溢出
1.2.作用
Java源代码被编译为二进制字节码中的jvm指令,解释器拿到jvm指令后解释成机器码,就可以交给CPU来执行,执行完一次后解释器会到程序计数器找到下一条指令并执行
2.虚拟机栈
栈和栈帧的关系
就是说一个线程运行,栈会分配一定空间给这个线程,这段空间由由多个栈帧构成,每个栈帧对应一个对象方法的调用,
栈帧: 每个方法运行时需要的内存
2.1.定义
Java Virtual Machine Stacks (Java 虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前栈顶部正在执行的那个方法
问题辨析
- 垃圾回收是否涉及栈内存?
栈内存就是方法每次方法调用产生的栈帧内存,而栈帧内存每次调用方法结束后都会自动弹出栈,也就是会自动被回收掉,不需要垃圾回收管理栈内存,垃圾回收只是回收堆内存的无用对象
2.栈内存分配越大越好吗?
线程分配的栈内存越大,可执行的线程数越少,栈内存越大只能进行更多次的方法递归调用,而不能提高线程运行效率
3.方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的;
- 如局部变量被作为方法返回值逃离了方法的作用范围,那他就是线程不安全的,此时必须对其施加保护
class demo{
public static void main(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
new.Thread(() - > {
m2(sb);
}).start();
}
单纯作为局部变量,线程安全
public static void m1(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
System.out.Println(sb.toString());
}
// 主线程和新线程里同时修改sb对象,线程不安全
public static void m2(StringBuilder sb){
sb.append(1);
sb.append(2);
sb.append(3);
System.out.Println(sb.toString());
}
// 变量返回出去有可能被其他线程访问,线程不安全
public static StringBuilder m3(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
return sb;
}
}
栈是线程私有的,一个线程对应一个栈,每次新的方法调用都会产生一个新的栈帧,线程的栈帧中会分配一个空间给局部变量,不同线程调用同一个方法会产生独立私有的栈帧,因此各线程栈帧中的局部变量也是私有的,互不干扰,所以是线程安全的
若多个线程读取static修饰的变量,没有安全保护的话,则该静态变量就是线程不安全的
2.2 栈内存溢出
- 栈帧过多导致栈内存溢出,无限循环递归时报异常: StackOverFlowError
递归调用没有正确的结束条件
- 栈帧过大导致栈内存溢出
例子:
1.方法递归调用,没有终止条件
2.两个不同类的对象的属性互相应用
2.3 线程运行诊断
案例一:cpu 占用过多
解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程
top
命令,查看是哪个进程占用 CPU 过高ps H -eo pid, tid(线程id)
, %cpu | grep 刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高jstack 进程 id
通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。
案例二:
死锁: 两个资源互相请求且均不释放
public class Demo1_3 {
static A a = new A();
static B b = new B();
public static void main(String[] args) {
new Thread(() ->{
synchronized (a){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b){
System.out.println("我获得了a和b");
}
}
}).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
synchronized (b) {
synchronized (a) {
System.out.println("我获得了a和b");
}
}
}).start();
}
}
class A {
}
class B {
}
线程互斥,请求保持,相互等待,不可剥夺
3. 本地方法栈
不是用Java代码编写的本地方法调用底层系统的api,调用本地方法分配的内存就是本地方法栈
4. 堆
4.1 定义
Heap 堆
- 通过 new 关键字,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制,堆中不再引用的对象会被当成垃圾回收,以释放空闲内存
4.2 堆内存溢出
例子: Java堆空间不足导致堆内存溢出
package com.tiga.jvm;
import java.util.ArrayList;
import java.util.List;
/**
* 堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* @author tiga
* @create 2021-10-16 18:20
*/
public class Demo1_5 {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}
调小默认的堆空间大小可以排查是否有堆内存溢出的问题
4.3 堆内存诊断
例子:
package com.tiga.jvm;
/**
* @author tiga
* @create 2021-10-16 19:58
*/
public class Demo1_4 {
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
Thread.sleep(20000);
//在堆内存中创建一个数组对象
byte[] arr = new byte[1024 * 1024 * 10];//增加10MB内存
System.out.println("2...");
Thread.sleep(20000);
//切断对象的指向,等待垃圾回收
arr = null;
//垃圾回收
System.gc();
System.out.println("3...");
Thread.sleep(1000000L);
}
}
通过执行终端指令查看堆内存的变化
- jps 工具
查看当前系统中有哪些 java 进程
2. jmap 工具
- 查看堆内存占用情况
jmap - heap 进程id
堆内存中还没创建对象时的空间大小
堆中创建对象后的内存大小
垃圾回收后的堆内存
- jconsole 工具
- 图形界面的,多功能的监测工具,可以连续监测
5. 方法区
5.1.定义
方法区概述:
- 方法区是所有Java虚拟机线程共享的区域,类似于堆空间
方法区存储了跟类结构相关的信息:
1. 类的成员变量
2. 类的方法数据
3. 成员方法和构造器方法的代码部分,包括类的构造器
4. 运行时常量池
-
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样都可以是不连续的, 关闭Jvm就会释放这个区域的内存。
-
方法区逻辑上是堆的一个组成部分,但是在不同版本的虚拟机里具体实现上不全是在堆空间中,最典型的就是永久代(PermGen space)和元空间(Metaspace)
永久代: 是Hotspot的jdk1.8以前的对方法区的实现
(注意:方法区是一种规范,而永久代和元空间之前它的一种实现方式)
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区申请内存不足,虚拟机同样会抛出内存溢出错误:
(java.lang.OutOfMemoryError:PermGen space、java.lang.OutOfMemoryError:Metaspace)。
5.2 方法区内存结构的组成
-
JDK1.6的结构实现
方法区只作为概念,他用永久代作为方法区的实现,可以存储类的信息,类加载器,运行时常量池(其中包含串池StringTable
), -
JDK1.8的结构实现
永久代的实现被废弃,取而代之为元空间,和永久代不同的是,他不是JVM来管理它的内存结构,而是移出到本地内存(即操作系统内存),并且StringTable不在方法元空间的运行时常量池中,而被移动到堆内存中
5.3 方法区内存溢出
案例: 方法区内存溢出
/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* 设置虚拟机参数: -XX:MaxMetaspaceSize=8m
*/
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 10000; i++, j++) {
// ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 定义类的版本号, 权限修饰符public, 类名, 包名, 父类, 接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回二进制字节码的 byte[] 数组
byte[] code = cw.toByteArray();
// 执行了类的加载
test.defineClass("Class" + i, code, 0, code.length); // 访问Class 对象
}
} finally {
System.out.println(j);
}
}
}
调整虚拟机参数:-XX:MaxMetaspaceSize=8m
,测试方法区内存溢出
jdk1.8以前方法区内存溢出报错: 永久代空间溢出
jdk1.8以后方法区内存溢出报错: 元空间溢出
方法区加载类的实际使用场景:
- spring
- mybatis
生成动态代理类
5.4 运行时常量池
Java源文件通过编译后得到的二进制字节码.class
文件,其中包含 类的基本信息(类的版本号,成员变量,访问权限,接口)、常量池、类的方法定义(包含了虚拟机指令)。
如何看字节码文件的信息?
- 首先,先编译得到Java二进制字节码文件
- 通过jdk自带的工具可以反编译二进制字节码文件查看
.class
文件的详细信息
例如: 在idea 终端输入:javap -v HelloWorld.class
类的基本信息
常量池(Constant pool)
方法定义信息
虚拟机指令的详细内存存储在常量池中
常量池的作用就是给虚拟机指令提供一些带
#
号的常量符号,通过到Constant pool
查表的方式找到指令要执行的内容
常量池的定义:
就是一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息(字符串,整数类型,布尔类型的值等等)
运行时常量池定义:
常量池是在类的
*.class
文件中的,当该类被加载到虚拟机以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实内存地址
5.5 StringTable
常量池和串池的关系
案例
package com.tiga.jvm;
/**
* @author tiga
* @create 2021-10-17 16:19
*/
public class Demo1_22 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}
把这段的代码进行反编译可以看到方法的执行流程
JVM指令:
ldc
: 表示加载指定地址的常量或对象引用
astore_1
: 表示把加载好的字符串对象存入方法运行时的活动栈帧的局部变量表中的变量
常量池中的信息,在字节码文件运行时都会被加载到运行时常量池中,但这时的 a b ab 都还只是常量池中的符号,还没有成为java字符串对象.
当具体执行到引用到指定符号的代码上,通过jvm指令通过ldc到运行时常量池的指定地址上找到对应保存的符号,并创建一个String对象保存指定符号的值,此时他会准备一块空间叫StringTable
(串池),初始时,串池是空的,然后把创建好的对象作为key到StringTable
中找看是否有取值相同的key,StringTable的底层数据结构是HashTable(哈希表),且长度一开始是固定的,不能扩容,当串池中找没对应的字符串对象,就会将该字符串对象放入串池中,若串池中有对应的对象就直接使用串池的对象,串池中的不同字面量对应的字符串对象只能是唯一的
注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。当串池中已存有符号的对象,则不再重复创建,利用串池的机制,来避免重复创建字符串对象; 此现象被称作字符串延迟加载
StringTable 字符串变量拼接
案例一: 使用拼接字符串变量对象创建字符串的过程
package com.tiga.jvm;
/**
* @author tiga
* @create 2021-10-17 16:19
*/
public class Demo1_22 {
public static void main(String[] args) {
String s1 = "a";//懒惰的执行
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;// StringBuilder().append(“a”).append(“b”).toString()
//因为s3存放的对象是放在StringTable中,s4是在堆中new出来的对象,位置不同,所以地址也不同
System.out.println(s3 == s4);// false
}
}
反编译后的结果:
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing;
27: astore 4
29: return
通过拼接变量的方式来创建字符串对象的过程是:StringBuilder().append(“a”).append(“b”).toString()
最后的toString()方法的返回值根据源码是new出来的对象,因此是在堆中创建的对象
new出来的对象放在堆中,直接给变量赋值字面量的放在StringTable中,虽然JDK1.8后串池转移到了堆中,但是由于对象所在区域的不同,所以s3和s4是不相等的.
案例二: 字符串常量的拼接
原理: 编译期优化
String s5 = "a" + "b";
System.out.println(s4 == s5);// false
System.out.println(s3 == s5);// true
Java代码由于在javac编译期间就知道s5是常量拼接,以后的结果肯定是不变的,所以在编译期就确定结果为ab,所以s5还是直接使用了串池中的对象
在编译期间,变成class文件的时候已经把a和b加在一起了,变成一个ab符号
JDK1.8以后的 intern方法
/**
* @author tiga
* @create 2021-10-18 15:05
*/
public class Demo1_23 {
public static void main(String[] args) {
//传入的a和b字符串字面量参数,分别在串池中创建了两个字符串对象,并且在堆中也创建了a和b两个字符串对象
//拼接之后的变量实际上是对象的拼接,底层是StringBuilder对象的拼接,最后通过new在堆中创建对象
// new String(value, 0, count) value是char型数组
String s = new String("a") + new String("b");
//尝试将s这个字符串对象放入到串池中,若串池中有,则不放入,若没有则放入,并不管串池中有没有都把对象返回
String s2 = s.intern();
System.out.println(s2 == "ab");// true
System.out.println(s == "ab");// true
}
}
public class Demo1_23 {
public static void main(String[] args) {
String x = "ab";
//传入的a和b字符串字面量参数,分别在串池中创建了两个字符串对象,并且在堆中也创建了a和b两个字符串对象
//拼接之后的变量实际上是对象的拼接,底层是StringBuilder对象的拼接,最后通过new在堆中创建对象
// new String(value, 0, count) value是char型数组
String s = new String("a") + new String("b");
//尝试将s这个字符串对象放入到串池中,若串池中有,则不放入,若没有则放入,并不管串池中有没有都把对象返回
String s2 = s.intern();
System.out.println(s2 == "ab");// true
System.out.println(s == "ab");// false
}
}
因为x变量已经把"ab"字符串对象先放入串池中,所以s对象不能再放入到串池中;此时s字符串对象和串池中的"ab"字符串对象不是同一个对象,所以他们地址不等
JDK1.6及以前的 intern方法
字符串对象调用intern方法,当串池中没有对应字符串对象会将该字符串对象尝试复制一份到串池中,实际上原对象还是在堆中
如果串池中有该字符串对象,则放入失败,并且不管存放成功还是失败,intern方法都会返回串池中的字符串对象
5.6 StringTable 位置
- jdk1.6的JVM中StringTable随着常量池存储在永久代当中
- jdk1.7开始把StringTable从永久代转移到了堆中
转移原因:
因为永久代的垃圾回收效率很低,当执行FullGC时才会触发永久代的垃圾回收,但是FullGC要等到老年代的空间不足才会触发,触发时机较晚,间接导致StringTable的回收效率不高,因为Java应用程序中大量的字符串常量对象都会分配到StringTable中,当其回收效率不高就会占用虚拟机大量内存,进而容易导致永久代内存不足
jdk1.7开始转移到堆中,堆中执行垃圾回收只需执行MinorGC就能触发StringTable的垃圾回收,把串池中用不到的字符串常量对象回收,减轻字符串对内存的占用
5.7 StringTable 垃圾回收
jdk1.8后,StringTable当内存紧张时,就会执行新生代的垃圾回收,效率高
5.8 StringTable 性能调优
StringTable中的桶个数代表串池的底层数据结构中数组元素的个数,用来存放链表
StringTable的底层是哈希表,性能是与哈希表的大小相关的,若哈希表桶的个数越多,桶中元素就越分散,哈希碰撞几率就会减少,查找速度也会变快;反之,若桶个数越少,哈希碰撞的几率就增大,每个桶的链表就越长,查找速度就受到影响.
建议:
- 若JVM虚拟机系统中,字符串常量个数非常多,可以适当调大StringTable的桶的个数,优化字符串常量的哈希分布,提高StringTable串池的效率
- 应用中存在大量字符串,并可能出现重复的问题,考虑将字符串对象入池,减少哦字符串个数,减少堆内存的使用
6.直接内存
6.1 定义
不属于JVM虚拟机的内存管理,而属于操作系统的内存
- 属于操作系统,常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
普通数据流文件读写流程
使用直接内存数据流DirectBuffer
直接内存是操作系统和Java代码都可以访问的一块共享内存区域,无需将磁盘中的文件从系统内存复制到Java堆内存,从而提高了效率。
6.2 分配和回收原理
释放原理
直接内存的内存占用不是通过JVM的垃圾回收来释放的,而是通过调用Java中非常底层的类Unsafe的freeMemory方法来释放,一般都是jdk内部自己执行
垃圾回收只能释放JVM虚拟机的内存
根据ByteBuffer的allocateDirect方法源码可知,该方法创建了DirectByteBuffer对象,DirectByteBuffer构造器中调用了unsafe的方法完成对直接内存的分配
从DirectByteBuffer构造器的最后的cleaner对象调用的方法传入了回调任务对象,而直接内存的释放必须手动调用freeMemory方法
Cleaner类在Java类库中是虚引用类型,它的特点是,当它关联的DirectByteBuffer垃圾回收时,他就会触发虚引用对象(cleaner)的clean方法中执行任务对象(Deallocator)的run方法
clean方法不是主线程执行,他是后台专门的ReferenceHandler线程类监测像cleaner这些虚引用对象,一旦虚引用对象关联的实际对象(DirectByteBuffer)被垃圾回收后,ReferenceHandler就会调用虚引用对象的clean方法,接着就会执行任务对象,任务对象内部再执行freeMemory真正释放直接内存
Java对象被回收,触发直接内存回收,直接内存的释放是借助虚引用的机制
6.3.禁用显式垃圾回收
在JVM调优时,一般会配置JVM参数: -XX:DisableExplicitGC
禁用显式代码执行垃圾回收,推荐用unsafe的freeMemory方法手动管理直接内存