目录
整体架构图
程序计数器(Program Counter Register)
作用
特点
Java 虚拟机栈(JVM Stacks)
定义
问题辨析
栈内存溢出
线程运行诊断
本地方法栈
定义
堆(Heap)
定义
特点
堆内存溢出
堆内存诊断
方法区
定义
组成
方法区内存溢出
运行时常量池
StringTable
StringTable 特性
StringTable 位置
StringTable 垃圾回收
StringTable 调优
直接内存
定义
好处
直接内存溢出
分配释放原理
整体架构图
程序计数器(Program Counter Register)
作用
记住下一条 JVM 指令的执行地址(对应下面的数字)
当 JVM 在执行0时,就会将地址3放入到程序计数器
程序计数器使用操作系统的寄存器实现的
CPU 是不能直接执行这些二进制字节码的,需要先将这些二进制字节码通过解释器转换成机器码后才能被 CPU 执行
0: getstatic #20 // PrintStream out = System.out; 3: astore_1 // -- 4: aload_1 // out.println(1); 5: iconst_1 // -- 6: invokevirtual #26 // -- 9: aload_1 // out.println(2); 10: iconst_2 // -- 11: invokevirtual #26 // -- 14: aload_1 // out.println(3); 15: iconst_3 // -- 16: invokevirtual #26 // -- 19: aload_1 // out.println(4); 20: iconst_4 // -- 21: invokevirtual #26 // -- 24: aload_1 // out.println(5); 25: iconst_5 // -- 26: invokevirtual #26 // -- 29: return
特点
-
线程私有
-
不会存在内存溢出
线程私有
不会存在内存溢出
Java 虚拟机栈(JVM Stacks)
定义
-
每个线程运行时所需要的内存,称为虚拟机栈
-
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
-
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问题辨析
-
垃圾回收是否涉及栈内存?
不涉及,虚拟机栈不存在垃圾回收。
-
栈内存分配越大越好吗?
不是,一般使用默认的就好了。栈内存分配越大,只能增加这个方法的递归调用次数(即增加这个栈的栈帧数量),并不能提高方法的执行效率,反而会减少多线程的数量。
-
方法内的局部变量是否线程安全?
如果方法内的局部变量没有逃离方法的作用范围,它就是线程安全的;
如果局部变量引用了对象,并逃离方法的作用范围,就需要考虑线程安全。
栈内存溢出
- 栈帧过多导致栈内存溢出(递归、使用第三方类库)
public class Demo01 {
private static int count;
public static void main(String[] args) {
method01();
}
private static void method01() {
count++;
method01();
}
}
-
每个线程运行时所需要的内存,称为虚拟机栈
-
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
-
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问题辨析
-
垃圾回收是否涉及栈内存?
不涉及,虚拟机栈不存在垃圾回收。
-
栈内存分配越大越好吗?
不是,一般使用默认的就好了。栈内存分配越大,只能增加这个方法的递归调用次数(即增加这个栈的栈帧数量),并不能提高方法的执行效率,反而会减少多线程的数量。
-
方法内的局部变量是否线程安全?
如果方法内的局部变量没有逃离方法的作用范围,它就是线程安全的;
如果局部变量引用了对象,并逃离方法的作用范围,就需要考虑线程安全。
栈内存溢出
- 栈帧过多导致栈内存溢出(递归、使用第三方类库)
public class Demo01 {
private static int count;
public static void main(String[] args) {
method01();
}
private static void method01() {
count++;
method01();
}
}
垃圾回收是否涉及栈内存?
不涉及,虚拟机栈不存在垃圾回收。
栈内存分配越大越好吗?
不是,一般使用默认的就好了。栈内存分配越大,只能增加这个方法的递归调用次数(即增加这个栈的栈帧数量),并不能提高方法的执行效率,反而会减少多线程的数量。
方法内的局部变量是否线程安全?
如果方法内的局部变量没有逃离方法的作用范围,它就是线程安全的;
如果局部变量引用了对象,并逃离方法的作用范围,就需要考虑线程安全。
- 栈帧过多导致栈内存溢出(递归、使用第三方类库)
public class Demo01 {
private static int count;
public static void main(String[] args) {
method01();
}
private static void method01() {
count++;
method01();
}
}
结果:
2. 栈帧过大导致栈内存溢出
线程运行诊断
案例1:CPU 占用过多
定位
-
top 命令,查看是哪个进程占用 CPU 过高
-
ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高
-
jstack 进程 id 通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。
本地方法栈 定义
一些带有 native 关键字的方法就是需要 Java 去调用本地的 C 或者 C++ 方法,因为 Java 有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法。
堆(Heap) 定义
通过 new 关键字,创建对象都会使用堆内存
特点-
线程共享,堆中对象都需要考虑线程安全的问题
-
有垃圾回收机制
堆内存溢出
可以使用 -Xmx8m 来指定堆内存的大小
可以使用 -Xmx8m 来指定堆内存的大小
举例:
public class Demo01 {
public static void main(String[] args) {
int i = 0;
List list = new ArrayList<>();
String str = "hello";
while (true) {
list.add(str);
str = str + str;
}
}
}
结果:
堆内存诊断
jps 工具
查看当前系统中有哪些 java 进程
jmap 工具
查看堆内存占用情况 jmap - heap 进程id
jconsole 工具
图形界面的,多功能的监测工具,可以连续监测
jvisualvm 工具
方法区 定义
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
组成
方法区内存溢出
程序在运行过程中加载的类过多时就有可能会导致方法区内存溢出
-
1.8 之前会导致永久代内存溢出(java.lang.OutOfMemoryError: PermGen space)
-
使用 -XX:MaxPermSize=8m 指定永久代内存大小
-
1.8 之后会导致元空间内存溢出(java.lang.OutOfMemoryError: metaspace)
-
使用 -XX:MaxmetaspaceSize=8m 指定元空间大小
运行时常量池
程序在运行过程中加载的类过多时就有可能会导致方法区内存溢出
-
1.8 之前会导致永久代内存溢出(java.lang.OutOfMemoryError: PermGen space)
-
使用 -XX:MaxPermSize=8m 指定永久代内存大小
-
-
1.8 之后会导致元空间内存溢出(java.lang.OutOfMemoryError: metaspace)
-
使用 -XX:MaxmetaspaceSize=8m 指定元空间大小
-
运行时常量池
二进制字节码包含:类的基本信息,常量池,类方法定义,虚拟机的指令
在学习运行时常量池之前,下面先通过例子来认识常量池
public class Demo01 {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
上面程序在编译完成后,在控制台使用 javap -v Demo01.class 命令反编译查看字节码文件:
public class jvm.metaspace.Demo01 minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#20 // java/lang/Object."":()V #2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #23 // Hello World! #4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #26 // jvm/metaspace/Demo01 #6 = Class #27 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Ljvm/metaspace/Demo01; #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 args #17 = Utf8 [Ljava/lang/String; #18 = Utf8 SourceFile #19 = Utf8 Demo01.java #20 = NameAndType #7:#8 // " ":()V #21 = Class #28 // java/lang/System #22 = NameAndType #29:#30 // out:Ljava/io/PrintStream; #23 = Utf8 Hello World! #24 = Class #31 // java/io/PrintStream #25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V #26 = Utf8 jvm/metaspace/Demo01 #27 = Utf8 java/lang/Object #28 = Utf8 java/lang/System #29 = Utf8 out #30 = Utf8 Ljava/io/PrintStream; #31 = Utf8 java/io/PrintStream #32 = Utf8 println #33 = Utf8 (Ljava/lang/String;)V { public jvm.metaspace.Demo01(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object." ":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Ljvm/metaspace/Demo01; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello World! 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 5: 0 line 6: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; } SourceFile: "Demo01.java"
常量池: 就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
运行时常量池: 常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
StringTable
程序代码
public class Demo01 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "c";
}
}
编译后二进制字节码文件的部分内容
常量池
字节码
局部变量表
分析
常量池中的信息,都会被加载到运行时常量池中,这时 a b c 都是常量池中的符号,还没有变为 java 字符串对象
ldc #2 // 从常量池中2号位置的值"a"加载进来,此时符号a变为字符串对象"a"(放入串池,串池有就不放,串池的字符串对象都是唯一的)
astore_1 // 将"a"放入到局部变量表下标为1的位置
ps:采用懒加载的方式,即只有运行到这行字节码的时候才会创建这个字符串对象
题目1
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String str = s1 + s2;
System.out.println(s3 == str);
// String s = "a" + "b" // 编译期优化 <=> "ab"
}
代码分析
- 字符串s3是存在串池当中的。
- (s1 + s2)<=>(new StringBuilder().apend("a").apend("b").toString(),这个toString()<=>new String("ab")),所以这个str是存在堆当中的。
- 正常来说new String()是会先在堆当中创建一个字符串对象,然后再去串池当中看看有没有内容相同的字符串,有就不用创建,没有则在串池中也存储一份。但是StringBuilder的toString()方法有点特殊,只会在堆中创建,不会在串池中创建。
结论:答案为:false,因为一个在串池,一个在堆,肯定不一样。
字节码分析
StringTable 特性
-
常量池中的字符串仅是符号,第一次用到时才变为对象
-
利用串池的机制,来避免重复创建字符串对象
-
字符串变量拼接的原理是 StringBuilder(1.8)
-
字符串常量拼接的原理是编译期优化
-
可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
public class Demo01 {
public static void main(String[] args) {
String s1 = new String("a") + new String("b");
String s2 = s1.intern();
System.out.println(s1 == s2); // JDK 1.7:true JDK 1.6:false
}
}
分析:
new String("a") + new String("b") 一共创建了6个对象,步骤如下:
(1) 在堆中创建一个 StringBuilder 对象
(2) 在堆中创建字符串对象"a"
(3) 在串池中创建字符串对象"a"
(4) 调用 append 方法将 "a" 拼接到 StringBuilder 中
(5) 在堆中创建字符串对象"b"
(6) 在串池中创建字符串对象"b"
(7) 调用 append 方法将 "a" 拼接到 StringBuilder 中
(8) 调用 toString 方法将拼接后的 "ab" 在堆中创建(new String("ab"))
此时s1指向的是堆中"ab"的地址(串池中没有"ab"字符串对象),s1.intern()就是将堆中的"ab"在串池中也创建一份,跟上面一样,存在就返回引用地址,不存在则创建一份,然后再返回引用地址。
需要注意的是,在JDK 1.6时,串池还在永久代的常量池中,所 s1.intern() 是直接在串池中创建对象s1,而在JDK 1.7之后,串池放在了堆中,所以 s1.intern() 是在串池中存储堆中的s1的引用地址而不需要再创建对象。
StringTable 位置
常量池中的字符串仅是符号,第一次用到时才变为对象
利用串池的机制,来避免重复创建字符串对象
字符串变量拼接的原理是 StringBuilder(1.8)
字符串常量拼接的原理是编译期优化
可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
在JDK 1.6时,串池在永久代的常量池中,而在JDK 1.7之后,串池放在了堆中。
原因:永久代的回收效率很低,在 FULLGC 的才会触发,而 StringTable 使用是很频繁的,如果回收效率低就很容易导致永久代内存不足。
StringTable 垃圾回收
-Xmx10m 指定堆内存大小
-XX:+PrintStringTableStatistics 打印字符串常量池信息
-XX:+PrintGCDetails
-verbose:gc 打印 gc 的次数,耗费时间等信息
StringTable 调优
-
调整-XX:StringTableSize=桶个数
-Xmx10m 指定堆内存大小
-XX:+PrintStringTableStatistics 打印字符串常量池信息
-XX:+PrintGCDetails
-verbose:gc 打印 gc 的次数,耗费时间等信息
-
调整-XX:StringTableSize=桶个数
如果程序中需要存储大量的字符串,可以根据实际情况对桶(串池采用 hash 表存储)个数进行修改,增大桶个数可以减少 hash 冲突,提高字符串存储效率
-
考虑将字符串对象入池
如果程序中需要存储大量的字符串,而这些字符串又有很多是重复的,可以将这些字符串入池再存储,减少内存的占用
直接内存 定义
Direct Memory
-
常见于 NIO 操作时,用于数据缓冲区
-
分配回收成本较高,但读写性能高
-
不受 JVM 内存回收管理
好处
未使用直接内存前如下:
分析:
程序在读取磁盘文件信息时,需要在系统内存中划分一块系统缓存区来存放,而 Java 程序是不能直接读取这块系统缓存区的,所以就需要在 Java 堆内存中划分一块 Java 缓冲区(byte[]),将系统缓存区的信息读取过来。此时 Java 程序才能读取到磁盘文件信息。
这样就需要划分两块内存区(系统缓存区、Java 缓冲区),造成不必要的内存复制,导致效率低下
使用直接内存后如下:
分析:
直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。
直接内存溢出
public class Demo01 {
public static int _100Mb = 1024 * 1024 * 100;
public static void main(String[] args) {
List list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}
}
运行结果:
分配释放原理
直接内存的分配与释放是根据 unsafe 对象来完成的,而不是由垃圾回收完成
public class Demo01 {
public static int _1GB = 1024 * 1024 * 1024;
public static void main(String[] args) throws Exception {
Unsafe unsafe = getUnsafe();
// 分配内存
long base = unsafe.allocateMemory(_1GB);
unsafe.setMemory(base, _1GB, (byte) 0);
System.in.read();
// 释放内存
unsafe.freeMemory(base);
System.in.read();
}
public static Unsafe getUnsafe() throws Exception {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
}
}
总结
-
使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用 freeMemory 方法
-
ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦 ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存
-XX:+DisableExplicitGC // 禁止显示的GC,即System.gc()无效
System.gc() 执行的是 full gc,回收时间很长,所以可以通过 unsafe 对象调用 freeMemory 的方式释放直接内存



