JVM知识点(一)---内存管理
一、JVM概念
什么是JVM?
定义:
Java Virtual Machine - java程序的运行环境(java二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界越界检查
- 多态
比较jvm jre jdk区别
学习路线
二、内存结构
1.程序计数器
1.1定义
Program Counter Register程序计数器(寄存器)
作用:是记住下一条jvm指令的执行地址
特点:
- 是线程私有的
- 不会存在内存溢出
线程私有指的是:每个线程有一个自己的计数器。在线程即将切换时,计数器记住下次要执行指令的地址,等线程再次切换回来时,会根据计数器记住的指令地址,继续执行上次未执行完的流程。
1.2作用
java源代码对应一份二进制字节码,这些二进制字节码代表jvm指令。这些指令通过解释器变成机器码,然后cpu识别机器码开始运行。
程序计数器的作用是:在解释器解释jvm指令后,记住下一条jvm指令在内存中的地址。等cpu执行完后,解释器通过计数器保存的地址找到将要执行的jvm指令。
2.虚拟机栈
2.1定义
Java Virtual Machine Stacks(Java虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
栈和栈桢怎么联系起来的?
当有方法被调用时,栈桢被压入栈,方法调用结束后,栈桢弹出栈,栈桢占用内存被释放。
问题辨析
1.垃圾回收是否涉及栈内存?
不涉及,垃圾回收的是堆内存中无用的对象。栈内存是一次方法调用产生的栈桢内存,方法调用结束后内存会被自动回收。
2.栈内存分配越大越好吗?
不是越大越好。
系统默认栈内存为1M,因为内存大小是固定的,栈内存分配越大,可分配的线程数就越少。
3.方法内的局部变量是否线程安全?
要判断变量是否安全,要看这个变量是私有变量还是共享变量。而方法内的局部变量是线程内的私有变量,在多线程情况下,(如果此变量没有作为返回结果返回)不会受到其他线程访问影响,所以是线程安全的。
static修饰的变量不加线程保护是共享变量,每个线程都可以修改它的值,存在线程安全问题。
总结:
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
下图三个方法的局部变量sb,只有m1是线程安全的。
2.2栈内存溢出
- 栈帧过多导致栈内存溢出
- 栈帧过大导致栈内存溢出
一般出现在方法无线递归,和调用第三方框架方法无限递归造成。
可以在idea中VM options添加参数 -Xss256k 修改栈内存大小
2.3线程运行诊断
案例1:cpu占用过多
定位:可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号
用top定位哪个进程对cpu的占用过高
nohup命令让程序在后台运行
ps H -eo pid,tid,%cpu | grep 进程id(用ps命令进一步定位是哪个线程引起的cpu占用过高)
jstack 进程id
jstack输出的线程是十六进制的,需要将十进制32655换算成十六进制7f99查看
案例2:程序运行很长时间没有结果
通过jstack排查死锁问题
3.本地方法栈
- 本地方法表示不是由Java代码编写的方法
- java代码不能直接和操作系统底层api打交道,需要通过C/C++编写的本地方法间接调用底层功能
- java虚拟机调用本地方法时,给本地方法提供内存空间。
4.堆
4.1定义
Heap 堆
通过new关键字,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
4.2堆内存溢出
通过 -Xmx 修改堆内存大小
4.3堆内存诊断
1.jps工具
查看当前系统中有哪些java进程
2.jmap工具
查看堆内存占用情况,jmap -heap 进程id
jdk9以后:jhsdb jmap --heap --pid 进程id
3.jconsole工具
图形界面的,多功能的监测工具,可以连续监测
4.jvisualvm工具
案例:垃圾回收后,内存占用仍然很高
首先用jconsole执行垃圾回收,发现内存占用依然很高
打开jvisualvm,执行堆Dump,抓取堆的当前快照,对里面详细内容分析
点击查找就可查看当前堆内存较大的对象信息
5.方法区
5.1定义
Java虚拟机(JVM)有一个方法区,该区域在所有Java虚拟机线程之间共享。
它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化以及接口初始化的特殊方法.
方法区在虚拟机启动时创建。方法区在逻辑上是堆的一部分(可以不在堆内存实现,比如下图的元空间)
如果方法区中的内存无法满足分配请求,Java虚拟机将抛出OutOfMemoryError
5.2组成
5.3方法区内存溢出
1.8以前会导致永久代内存溢出
1.8之后会导致元空间内存溢出
1.8版本之后通过 -XX:MaxMetaspaceSize 设置方法区内存大小
1.8版本之前通过 -XX:MaxPermSize 设置方法区内存大小
5.4运行时常量池
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
常量池作用:为jvm指令提供常量符号,以便在常量池找到对应信息
通过 javap -v HelloWorld.class 反编译获取二进制字节码文件内容
二进制字节码主要包括:类基本信息,常量池,类方法定义,包含了虚拟机指令
PS D:\JavaCode\netty_demo\reactor-demo\target\classes> javap -v HelloWorld.class
Classfile /D:/JavaCode/netty_demo/reactor-demo/target/classes/HelloWorld.class
Last modified 2025年4月26日; size 534 bytes
SHA-256 checksum ff57c608d401e6857e5eaf152482d23ad567492c290624935e0a43f26533bf94
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // HelloWorld
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World!
#14 = Utf8 Hello World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // HelloWorld
#22 = Utf8 HelloWorld
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 LocalVariableTable
#26 = Utf8 this
#27 = Utf8 LHelloWorld;
#28 = Utf8 main
#29 = Utf8 ([Ljava/lang/String;)V
#30 = Utf8 args
#31 = Utf8 [Ljava/lang/String;
#32 = Utf8 SourceFile
#33 = Utf8 HelloWorld.java
{
public HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LHelloWorld;public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"
5.5 StringTable
StringTable(字符串常量池)是 JVM 在启动时创建的。
StringTable 的管理
- 哈希表结构:StringTable 是一个哈希表,用于快速查找和存储字符串。
- 垃圾回收:在 Java 7 之前,StringTable 位于永久代(PermGen),不参与垃圾回收。从 Java 7 开始,StringTable 被移到堆内存(Heap),并参与垃圾回收。
- 大小调整:可以通过 JVM 参数调整 StringTable 的大小,例如:-XX:StringTableSize=<size>:设置 StringTable 的桶数量(默认值因 JVM 版本而异)。
字符串常量池的作用
- 字符串常量池是 JVM 中用于存储字符串字面量(如 "a"、"b"、"ab")的一个特殊区域。
- 它的目的是避免重复创建相同的字符串对象,节省内存。
- 只有通过字面量赋值(如 String s = "abc")或显式调用 intern() 方法的字符串才会被放入字符串常量池。
编译下方代码得到字节码文件
public class Demo1_3 {public Demo1_3() {}public static void main(String[] args) {String s1 = "a";String s2 = "b";String s3 = "ab";}
}
ldc指令:将从常量池 #7 位置加载一个字符串对象
astore_1:将这个对象存放局部变量表 LocalVariableTable 的 Slot 的 1 号位置
常量池与串池的关系
程序运行时,常量池中的信息,都会被加戴到运行时常量池中,这时a、b、ab都是常量池中的符号,还没有变为 java 字符串对象(懒惰的)。
当执行ldc指令,要加载字符串对象a时,JVM 首先在字符串常量池中查找 "a":
- 如果找到,则直接返回常量池中的引用。
- 如果未找到,则创建 "a" 对象并放入常量池,然后返回引用。
StringTable[ ]:hashtable结构,不能扩容
字符串变量拼接
public class Demo1_4 {public static void main(String[] args) {String s1 = "a";String s2 = "b";String s3 = "ab";String s4 = s1 + s2;}
}
jdk8之前:字符串拼接底层通过创建StringBuilder对象,最后toString()生成新的字符串对象。
显式使用 StringBuilder 会带来以下内存开销:
- 对象创建开销:每次拼接字符串时,都需要创建一个新的 StringBuilder 对象。
- 缓冲区扩容开销:StringBuilder 内部使用字符数组存储数据,当字符数组容量不足时,需要扩容并复制数据,这会增加额外的内存分配和复制操作。
- 临时对象开销:StringBuilder 最终会调用 toString() 方法生成一个新的 String 对象,这也会增加内存占用。
通过 invokedynamic 和 makeConcatWithConstants,Java 可以:
- 避免创建 StringBuilder 对象:直接在运行时生成拼接逻辑,无需中间对象。
- 优化内存分配:动态生成的拼接逻辑可以更高效地分配内存,减少不必要的扩容和复制操作。
- 减少临时对象:直接生成最终的 String 对象,避免中间步骤的临时对象。
jdk9之后:底层在字符串拼接时,invokedynamic 会调用 makeConcatWithConstants 方法,动态生成拼接逻辑。
直接生成的 s4 不会自动放入字符串常量池,而是分配在堆内存中。
如果需要将 s4 放入字符串常量池,可以显式调用 intern() 方法。
String s4 = (s1 + s2).intern();
System.out.println(s3 == s4); // true
编译期优化
“a” + "b" 是两个确定的常量,不会改变,拼接结果确定为“ab”。而s1+s2是两个在运行期间可变的变量。
字符串变量拼接的原理是StringBuilder(1.8)
字符串常量拼接的原理是编译期优化
intern
1.8版本的intern()会将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回.
1.6将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回 (s拷贝一份放入串池,也就是s没有放入串池,与串池的引用不相同)
5.6 StringTable位置
在 Java 7 之前,StringTable 位于永久代,存在大小限制、垃圾回收效率低、内存泄漏风险和性能问题。
从 Java 7 开始,StringTable 被移到堆内存,解决了永久代的不足,提供了更好的内存管理和性能表现。
在 Java 7 之前,StringTable(字符串常量池)位于永久代(PermGen),这种设计存在一些明显的不足。以下是主要问题及其影响:
永久代的垃圾回收是Full GC时触发,而Full GC要等到老年代的空间不足才会触发,触发时机较晚,导致回收效率不高。
1. 永久代的大小限制
固定大小:永久代的大小是固定的,默认值较小(例如 64MB),无法动态扩展。
容易溢出:如果应用程序中使用了大量字符串(尤其是通过 intern() 方法显式添加的字符串),永久代可能会快速耗尽,导致 java.lang.OutOfMemoryError: PermGen space 错误。
2. 垃圾回收问题
不支持高效回收:永久代的垃圾回收机制不够高效,尤其是对于字符串常量池中的对象。
内存泄漏风险:如果字符串常量池中的对象不再被使用,但由于永久代的垃圾回收机制不完善,这些对象可能无法被及时回收,导致内存泄漏。
3. 性能问题
Full GC 影响:永久代的垃圾回收通常与 Full GC 相关联,而 Full GC 会暂停整个应用程序(Stop-The-World),影响性能。
扩展性差:由于永久代的大小固定且无法动态调整,对于需要处理大量字符串的应用程序(如 Web 应用),性能问题尤为突出。
4. 从永久代移到堆内存的改进
从 Java 7 开始,StringTable 被移到了堆内存(Heap),这一改进解决了上述问题:
动态扩展:堆内存的大小可以根据需要动态调整,避免了永久代大小固定的限制。
高效垃圾回收:堆内存的垃圾回收机制更加高效,尤其是年轻代的 Minor GC 可以快速回收不再使用的字符串对象。
减少内存泄漏:由于堆内存的垃圾回收机制更完善,字符串常量池中的无用对象可以及时回收,减少了内存泄漏的风险。
性能提升:堆内存的垃圾回收(尤其是年轻代的 Minor GC)对应用程序的影响较小,提升了整体性能。
5.7StringTable垃圾回收
-Xmx10m 设置堆内存大小
-XX:+PrintstringTablestatistics 打印字符串表的统计信息
-XX:+PrintGCDetails -verbose:go 打印发生垃圾回收的详细信息
发生了垃圾回收
5.8StringTable性能调优
- 调整-XX:String TableSize-=桶个数
- 考虑将字符串对象是否入池
底层HashTable,桶的个数越多,越分散,hash碰撞概率越低,链表越短,查询速度就越快。
测试代码
设置桶个数为200000时,耗时0.4s
使用默认桶个数时,耗时0.6s
设置桶个数最小时,耗时12s
6.直接内存
- 常见于IO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
public class DirectByteBuffer {static final String FROM = "D:\\upload\\2.mp4";static final String To = "D:\\upload\\test.mp4";static final int _1Mb = 1024 * 1024;public static void main(String[] args) {io();directBuffer();}private static void directBuffer() {long start = System.nanoTime();try (FileChannel from = new FileInputStream(FROM).getChannel();FileChannel to = new FileOutputStream(To).getChannel();) {ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);while (true) {int len = from.read(bb);if (len == -1) {break;}bb.flip();to.write(bb);bb.clear();}} catch (IOException e) {e.printStackTrace();}long end = System.nanoTime();System.out.println("directBuffer用时:" + (end - start) / 1000_000.0);}private static void io() {long start = System.nanoTime();try (FileInputStream from = new FileInputStream(FROM);FileOutputStream to = new FileOutputStream(To);) {byte[] buf = new byte[_1Mb];while (true) {int len = from.read(buf);if (len == -1) {break;}to.write(buf, 0, len);}} catch (IOException e) {e.printStackTrace();}long end = System.nanoTime();System.out.println("io用时:" + (end - start) / 1000_000.0);}
}
使用ByteBuffer效率比io高
6.1基本使用
java本身不具备磁盘读写能力,需要调用本地方法(操作系统提供的函数),此时CPU运行状态由java的用户态切换到系统的内核态
内存读取磁盘内容到系统内存的系统缓存区,处在系统缓存区的数据java是不能运行的,java会在堆内存中划分一块java缓存区,将系统缓存区数据读入到java缓存区。
我们可以发现一份数据占了两个内存,造成不必要的复制,导致效率不高
上述方法调用后会在操作系统内存划分一块直接内存,这块内存,系统和java代码都可用,比上图的流程少了一步复制数据的操作,速度得到了提升。
6.2直接内存溢出
6.3直接内存释放原理
使用了Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBuffer对象被垃
圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存
在任务管理器查看直接内存占用。
直接内存释放需要调用unsafe.freeMemory()方法
当directByteBuffer被回收后会调用(虚引用)Cleaner的clean()方法执行任务对象的run()方法
他们是关联的
6.4禁用显式的垃圾回收
在JVM调优时会使用
-XX:+DisableExplicitGC 显式的
作用:使代码中的System.gc()无效
显式的垃圾回收,触发的是Full GC 既回收新生代,也回收老年代,造成系统暂停时间较长。
影响:直接内存的回收受到影响,没有执行内存垃圾回收,btyeBuffer = null时,虽然没人引用它,由于内存充足,还会继续存活,继而导致直接内存也没回收。
解决方法:手动调用unsafe.freeMemory()方法