JVM-01-JMM内存模型 | Eloise's Paradise
0%

JVM-01-JMM内存模型

本文为JVM系列文章的第一篇, 主要围绕以下三个半方面展开

  1. JMM (Java内存模型)的组成: 程序计数器,栈,本地方法栈,堆, 方法区 (JDK8之前/后(含)实现是永久代/元空间)及各阶段OOM分析
  2. 常见JVM监控工具:jmap,jhat,Jconsole,jvisualvm等在CPU,I/O,memory飙升问题定位中的简单使用以及对应的优化思路
  3. 垃圾回收,分代模型, 基于根可达和引用计数器模型的内存管理

什么是JVM

定义

Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

好处

一次编写,到处运行
自动内存管理,垃圾回收机制
数组下标越界检查

比较

JVM JRE JDK的区别
JRE-JDK-JVM

JVM内存结构

程序计数器

作用

用于保存JVM中下一条所要执行的指令的地址

特点

  1. 线程私有
    CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
    程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一行指令
  2. 不会存在内存溢出
    程序计数器

虚拟机栈 -Xss256kb

定义

每个线程运行需要的内存空间,称为虚拟机栈
每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
每个线程只能有一个活动栈帧,对应着当前正在执行的方法

演示

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
method1();
}

private static void method1() {
method2(1, 2);
}

private static int method2(int a, int b) {
int c = a + b;
return c;
}
}

图解
栈内存分析

问题辨析

  1. 垃圾回收是否涉及栈内存?
    不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
  2. 栈内存的分配越大越好吗?
    不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
  3. 方法内的局部变量是否是线程安全的?
    如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
    如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题.

实战问题定位

内存溢出

Java.lang.stackOverflowError 栈内存溢出

发生原因:

  1. 虚拟机栈中,栈帧过多(无限递归)
  2. 每个栈帧所占用过大

表现: CPU占用过高

诊断流程:
Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程

  1. top命令,查看是哪个进程占用CPU过高 (假如找到进程pid 22618)
  2. ps H -eo pid, tid(线程id), %cpu | grep 22618 (刚才通过top查到的进程号,通过ps命令进一步查看是哪个线程占用CPU过高)
  3. jstack 进程id 通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的,需要转换

长时间不返回结果

发生原因: 死锁.
表现, CPU标高而且但是一直不返回结果.
用和上一小节同样的方式找到对应进程. 然后执行jstack pid进程id看输出最后一部分:
死锁后台日志输出

本地方法栈

一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法

截至目前, 之前讲解涉及的内存区域(程序计数器, 栈, 本地方法栈) 都是线程私有的.

定义

通过new关键字创建的对象都会被放在堆内存

特点

  1. 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  2. 有垃圾回收机制

堆内存溢出

java.lang.OutofMemoryError :java heap space. 堆内存溢出

堆内存诊断

如果怀疑是堆内存溢出相关的错误, 可以设置对内存小一点, 可以尽早定位. 例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package cn.itcast.jvm.t1.heap;

import java.util.ArrayList;
import java.util.List;

/**
* 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* -Xmx128m
*/
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); // hello, hellohello, hellohellohellohello ...
a = a + a; // hellohellohellohello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(Runtime.getRuntime().maxMemory() / 1024 / 1024 ); // 以MB为单位输出
System.out.println(i);
}
}
}

首先了解idea如何配置-Xmx
Idea配置JVM参数之-Xmx最大堆内存
设置好-Xmx 最大堆内存大小后, 用不同参数运行上面代码观察结果:
Xmx2g时的结果
Xmx128m时的结果
可以看出OOM溢出时, 设置-Xmx越大, 迭代次数越多, 占用对内存越大.

jps

查看当前系统有哪些Java进程

jmap

查看堆内存占用情况. 命令: **`jmap -heap 具体PID`**
![jmap命令查看堆内存占用情况](JVM-ScreenCapture_of_Notes/jmap命令查看堆内存占用情况.png)
⚠️注意: 由于Mac使用的openjdk1.8版本不支持jmap后跟-heap参数, 未生成截图.上图只参考, 具体数据与本例有差异.但是输出结构可以参考. 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Attaching to process ID 29620, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11.0.5+10-LTS

using thread-local object allocation.
Garbage-First (G1) GC with 4 thread(s)

Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 4253024256 (4056.0MB)
NewSize = 1363144 (1.2999954223632812MB)
MaxNewSize = 2551185408 (2433.0MB)
OldSize = 5452592 (5.1999969482421875MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 1048576 (1.0MB)

Heap Usage:
G1 Heap:
regions = 4056
capacity = 4253024256 (4056.0MB)
used = 10485760 (10.0MB)
free = 4242538496 (4046.0MB)
0.2465483234714004% used
G1 Young Generation:
Eden Space:
regions = 11
capacity = 15728640 (15.0MB)
used = 11534336 (11.0MB)
free = 4194304 (4.0MB)
73.33333333333333% used
Survivor Space:
regions = 0
capacity = 0 (0.0MB)
used = 0 (0.0MB)
free = 0 (0.0MB)
0.0% used
G1 Old Generation:
regions = 0
capacity = 250609664 (239.0MB)
used = 0 (0.0MB)
free = 250609664 (239.0MB)
0.0% used
演示堆内存执行流程:
不同时间点打印命令: `jmap -heap pid`会有不同的输出
(1, 2) 之间打印: Eden Space 的used部分是:6.4Mb
(2, 3)之间打印:   Eden Space 的used部分是:16.4Mb (多出来的10Mb就是new byte[]数组占用的)
(3, 程序结束) 打印: Eden Space 的used部分是:1.2Mb (因为3前一行执行了gc, 垃圾回收释放了没有被继续引用的对象。array = null; 即不再被引用)

jconsole

图形化界面, 多功能监测工具, **可以连续监测**.
还是上面的例子使用Jconsole进行监测的流程.
1. 程序运行时在console输入: **`jconsole`** 会跳出如下界面:
![选择要监控的进程并连接](JVM-ScreenCapture_of_Notes/选择要监控的进程并连接.png)
2. 点击insecure connect. (本地测试) 
![](JVM-ScreenCapture_of_Notes/不安全连接进程.png)
3. 概览界面 (含四个部分)
![jconsole概览界面](JVM-ScreenCapture_of_Notes/jconsole界面概览.png)
4. 如果想要监测对应的部分, 点击对应的tab即可.例如我们此时关注的是对内存, 那么点击memory:
![内存memoryTab页可以选择查看的图表](JVM-ScreenCapture_of_Notes/内存memoryTab页可以选择查看的图表.png)
另外**`jconsole`**还为我么提供了一些其他功能, 比如可以在界面手动触发GC.
![jconsole界面手动触发GC](JVM-ScreenCapture_of_Notes/jconsole界面手动触发GC.png)

jvisualvm

可有有些例子GC之后内存暂用就下来了, 但是有些时候GC并不能解决问题. 可能是因为我们代码逻辑问题导致一些不再被引用的变量不会被回收. 所以内存占用持续在高位.
针对这种情况, 我们演示一个例子.

  1. 代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    package cn.itcast.jvm.t1.heap;

    import java.util.ArrayList;
    import java.util.List;

    /**
    * 演示查看对象个数 堆转储 dump
    */
    public class Demo1_13 {

    public static void main(String[] args) throws InterruptedException {
    List<Student> students = new ArrayList<>();
    for (int i = 0; i < 200; i++) {
    students.add(new Student());
    // Student student = new Student();
    }
    Thread.sleep(1000000000L);
    }
    }
    class Student {
    private byte[] big = new byte[1024*1024]; //1Mb
    }
  2. JPS找到进程
    jps定位进程
  3. jvisualvm监控
    jvisualvm监控页面点击堆dump按钮
    ⚠️注意: 因为Mac导出堆dump文件的方式是使用:jmap -dump: live,format=b,file=dump20220711-92360.hprof 92360 (Mac没有jvisualvm工具.)
    file后面的文件名可以是绝对路径也可以是相对路径. 此处选择了先对路径, 执行完发现项目路径多了多一个文件.
    Mac导出的堆dump文件的生成路径
  4. 在结果中查看占用内存最大的前20个对象.
    Windows系统
    windows系统堆dump文件占用内存最大的对象-查找动作
    windows系统堆dump文件占用内存最大的对象-查找结果


5. 查看最大对象的具体组成:
最大对象的具体组成查看
返回去看Demo13中Student代码可以得到验证, 每个Student确实是1M:

1
private byte[] big = new byte[1024*1024]; //1Mb

方法区

⚠️注意: 方法区是规范, 永久代或者元空间都是对方法区的实现.

定义

JVM中对方法区的高度概括:

1
2
3
4
5
6
7
1. The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. 
2. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the "text" segment in an operating system process.
3. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.
4. The method area is created on virtual machine start-up.
5. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it.
6. This specification does not mandate the location of the method area or the policies used to manage compiled code.
7. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous.

对上述定义的翻译:

  1. 方法去线程共享.
  2. 方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。
  3. 方法区存储的是class结构信息. 包括: 运行时常量池, 变量和方法数据, 以及实例或接口初始化时的构造或特殊方法.
  4. JVM启动就会创建方法区
  5. 方法区逻辑上属于堆空间. (简单的JVM实现可以不处理方法区的GC或者压缩.)
  6. 本规范对方法区的位置或用于管理已编译代码的策略没有强制要求.
  7. 方法区大小可以固定,或者根据需求弹性伸缩. 而且不必占用连续内存.

JVM规范对与方法区相关的OOME异常:

1
2
3
The following exceptional condition is associated with the method area:
• If memory in the method area cannot be made available to satisfy an allocation
request, the Java Virtual Machine throws an OutOfMemoryError.

如果方法区的内存不能满足新的变量内存内存分配的请求, 那么JVM会抛OOME.

方法区内存溢出

1.8之前永久代内存溢出, 1.8(含)之后元空间溢出.

JDK1.8演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package cn.itcast.jvm.t1.metaspace;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* JDK1.8(含)后的方法区实现是元空间, 其默认使用的是系统内存且没有设置上限。所以为了便于观察设置降低参数:
* -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);
}
}
}

VM参数设置: -XX:MaxMetaspaceSize=8m
运行结果:
JDK8演示元空间内存溢出

JDK1.6演示

同样的, 上述测试做两次改动验证JDK1.6永久代溢出

  1. cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, “Class” + i, null, “java/lang/Object”, null); 第一个参数改成V_6
  2. VM参数改成 -XX:MaxPermSize=8m

当然也别忘了修改IDEA使用的SDK.
运行结果:
JDK6演示永久代内存溢出

既然方法区(不管是永久代还是元空间)只是存储Class类结构, 那实际开发中怎么还会出现方法区溢出的情况呢?
其实这种情况还是挺常见的, 比如: 我们在使用Spring等框架时, 底层会通过反向代理帮我们在程序运行过程中动态生成很多代理对象.

组成

运行时常量池

进入class文件目录: cd out/production/jvm/cn/itcast/jvm/t5
执行反编译命令: javap -v HelloWorld (其中-v参数表示查看详细信息) 得到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
╰─ javap -v HelloWorld                                                                                                                                                            ─╯
Warning: Binary file HelloWorld contains cn.itcast.jvm.t5.HelloWorld
Classfile /Users/JoshuaBrooks/Downloads/baiduNetdisk/尚硅谷mysql/高级/资料-解密JVM/资料 解密JVM/代码/jvm/out/production/jvm/cn/itcast/jvm/t5/HelloWorld.class
Last modified 11-Jul-2022; size 567 bytes
MD5 checksum 8efebdac91aa496515fa1c161184e354
Compiled from "HelloWorld.java"
public class cn.itcast.jvm.t5.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()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 // cn/itcast/jvm/t5/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcn/itcast/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()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 cn/itcast/jvm/t5/HelloWorld
#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 cn.itcast.jvm.t5.HelloWorld();
descriptor: ()V
flags: 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 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t5/HelloWorld;

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 6: 0
line 7: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}

反编译的信息大概就是上面提到的:

  1. 类基本信息
    类基本信息
  2. 常量池表
    常量池表
  3. 指令加载
    指令加载
    常量池就是一张表, 虚拟机指令根据这张表找到要执行的类名, 方法名和参数类型, 字面量等信息.
    运行时常量池, 常量池是放在.class中的, 当该类被加载时, 这个常量池信息就会被放入运行时常量池, 其中的符号地址也会被替换为真实地址.

    StringTable (运行时常量池的重要组成之一)

    测试demo:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    package cn.itcast.jvm.t1.stringtable;

    /**
    * 重点: 1, 对象创建都是懒惰的; 2。都会被加到StringTable, StringTable长度固定,不可扩容。
    */
    // StringTable [ "a", "b" ,"ab" ] hashtable 结构,不能扩容
    public class Demo1_22 {
    // 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
    // ldc #2 会把 a 符号变为 "a" 字符串对象
    // ldc #3 会把 b 符号变为 "b" 字符串对象
    // ldc #4 会把 ab 符号变为 "ab" 字符串对象

    public static void main(String[] args) {
    String s1 = "a"; // 懒惰的
    String s2 = "b";
    String s3 = "ab";
    String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
    System.out.println(s3 == s4); // false
    String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab
    System.out.println(s3 == s5);
    }
    }
    串池Demo-1-只有前三个变量时反编译字节码查看
    串池Demo-1-加了第4个变量时反编译字节码查看
    串池Demo-1-加了第4个变量后反编译字节码分析底层做了什么
    根据上面的分析, 如果对s3和s4进行比较, 返回结果为false, 虽然字面量值一样, 但是s3是在方法区的常量池中的StringTable里, 但是s4是new 出来的, 在堆内存中.
    为了验证结果, 重新运行:

StringTable的延迟加载特性

下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 演示字符串字面量也是【延迟】成为对象的
*/
public class TestString {
public static void main(String[] args) {
int x = args.length;
System.out.println("x =" + x); // 字符串个数 2275
System.out.print("1");
System.out.print("2");
System.out.print("3");
System.out.print("4");
System.out.print("5");
System.out.print("6");
System.out.print("7");
System.out.print("8");
System.out.print("9");
System.out.print("0");
System.out.print("1"); // 字符串个数 2285
System.out.print("2");
System.out.print("3");
System.out.print("4");
System.out.print("5");
System.out.print("6");
System.out.print("7");
System.out.print("8");
System.out.print("9");
System.out.print("0");
System.out.println("x =" + x); // 字符串个数
}
}

当debug运行执行到第一个 System.out.print("1"); (还未执行)时, 字符串个数时2275,
当debug运行执行到第二个 System.out.print("1"); (还未执行)时, 字符串个数时2285,
逐行debug观察可以知道, 前面每执行一行, 字符串个数多一个, 但是第二轮的1-0 10个没有继续往里加, 因为已经存在StringTable里了.
演示字符串字面量是延迟成为对象的
第一次和第二次出现的sout1只有第一次会往串池方第二次直接用

String的intern方法底层原理

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo1_23 {
public static void main(String[] args) {
// 执行完这行
// StringTable中有: ["a", "b"]
// 堆中: new String("a") new String("b") new String("ab"), 其中最后一个new String("ab")是StringBuilder调用toString方法的结果
String s = new String("a") + new String("b");
// intern方法将这个字符串对象尝试放入StringTable串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
String s2 = s.intern();
// 执行完上面一行, StringTable: ["a","b","ab"], 而且s2是StringTable返回, 所以s2 == "ab" 为 true
System.out.println( s2 == "ab");
}
}

intern底层原理-1
intern底层原理-2
2⃣️在1⃣️的基础上main方法加了第一行和最后一行比较, 因为这种情况下, 执行到intern方法调用行的时候, StringTable里已经有”ab” 常量了, 所以不会往里放, 但是返回的还是StringTable里的”ab”, 所以System.out.println( s2 == "ab");的返回为true
intern底层原理-3
3⃣️在2⃣️的基础上加了最后一行, 因为x是StringTable里的字符串常量, 而s是StringBuilder返回的 new出来的, 所以在堆中.所以结果为false.

intern方法底层原理-JDK6版本

StringTable特性

最后, 再总结下StringTable的特性
StringTable的特性

面试题讲解:
String相关面试题讲解

JDK8 对 StringTable做的优化

JDK8对StringTable的存储位置做了优化
为了验证上面的优化:

1
2


JDK6中StringTable存于永久代的验证
为了方便测试, 记得设置永久代JVM参数, maxPermSize否则.

JDK8中StringTable存于堆空间的验证-UseGCOverheadLimit默认开启时
JVM参数UseGCOverheadLimit的官方解释
该参数默认开启
Idea设置堆空间大小-Xmx8m 并且关闭UseGCOverheadLimit
dea设置多JVM参数Xmx和UseGCOverheadLimit
重新测试:
JDK8中StringTable存于堆空间的验证

通过统计信息查看验证StringTable也会垃圾回收

开启JVM参数: -Xmx16m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

StringTable性能调优

a. 修改-XX:StringTableSize参数从1009到400000, 效率提升十几倍(耗时 1939ms -> 143 ms).
因为这个参数时空值buckets大小的, buckets越大, hash越分散, 查找重复率的效率越高.

StringTableSize设置为1009时耗时-优化前
StringTableSize设置为400000时耗时-优化后
Idea设置StringTableSize为400000
b. 如果发现有业务场景是需要对大量重复的字符串进行使用, 那么可以考虑通过intern入池来降低内存占用.
比如: twitter想要统计数亿计的用户的地址信息, 因为地址信息大部分都是重复的, 符合这个条件, 所以可以考虑通过这种方式提升效率.

该调优方式的demo验证.
代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn.itcast.jvm.t1.stringtable;

import java.util.ArrayList;
import java.util.List;

/**
* 演示 StringTable 垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
*/
public class Demo1_7 {
public static void main(String[] args) throws InterruptedException {
int i = 0;
try {
for (int j = 0; j < 100_000; j++) { // j=100, j=10000
String.valueOf(j).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}

}
}

Round One
一开始没调intern有826entries
Round Two
调百次entries变为926
Round Three
调十万次intern触发了GC

直接内存

定义

Java的NIO库允许Java程序使用直接内存。直接内存是在java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

特点

不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的直接区域。
直接内存是在Java堆外的、直接向系统申请的内存区间。
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。
通常,访问直接内存的速度会优于Java堆,即读写性能高。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
Java的NIO允许Java程序直接使用内存,用于数据缓冲区。
也可能导致OutOfMemoryError异常
优于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的综合依然受限与操作系统能给出的最大内存。
缺点
分配回收成本较高
不受JVM内存回收管理

直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致

内存溢出

java.lang.OutOfMemoryError: Direct buffer memory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package cn.itcast.jvm.t1.direct;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;


/**
* 演示直接内存溢出
*/
public class Demo1_10 {
static int _100Mb = 1024 * 1024 * 100;

public static void main(String[] args) throws InterruptedException {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}
}

直接内存溢出演示

垃圾回收

对直接内存分配的占用空间使用System.gc()进行显示垃圾回收.
一开始占用20.7G
直接内存-垃圾回收测试-最开始分配前
分配后,回收前占用21.7

显示回收后 内存占用20.2:

分配回收底层原理

看上去System.gc() 使得内存被回收了, 但是实际上, 直接内存的分配和回收都是由底层Unsafe类的allocateMemory或者setMemory方法分配, 并由freeMemory方法回收的.
具体测试可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package cn.itcast.jvm.t1.direct;

import sun.misc.Unsafe;

import java.io.IOException;
import java.lang.reflect.Field;

/**
* 直接内存分配的底层原理:Unsafe
*/
public class Demo1_27 {
static int _1Gb = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
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() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}

在分配/回收直接内存代码行前后断点查看系统内存占用情况可以看出效果. 与上一个例子类似, 此处不再贴图.

在了解了直接内存的分配和回收原理后, 即可以使用unsafe的方法来进行直接内管理. 因为System.gc()是fullGC, 会STW造成比较大的影响, 所以一般为了以避免开发人员在代码中使用,会在JVM参数里设置:
-XX:+DisableExplicitGC 来禁用显式调用gc.

垃圾回收

如何判断是否可以回收

引用计数器方式

定义

当一个对象被另一个对象引用时, 引用计数器就+1, 反之, 不再被其引用时, 引用计数器就-1, 直到最后, 引用计数器为0 时, 则表示该对象占用的堆内存即为垃圾, 可以被回收.

缺点

虽然该练简单, 可操作. 但是致命点在于其无法解决循环引用的垃圾对象. 比如a->b->c->a的三个对象(-> 表示引用), a,b,c相互都被他们前一个对象引用, 但是如果a,b,c都不被除了他们以外的任意对象引用, 那么他们三个都可以被看作垃圾, 但是却因为引用计数器不为0所以不会被回收. 造成一堆垃圾.

根可达性算法

定义

在进行垃圾回收前, JVM的垃圾回收器会将堆内存中的所有对象进行标志, 如果是根可达(被跟对象直接或者间接引用)的是有用的, 否则则可以标记为垃圾.
那么, 哪些对象是根呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
* 演示GC Roots
*/
public class Demo2_2 {

public static void main(String[] args) throws InterruptedException, IOException {
List<Object> list1 = new ArrayList<>();
list1.add("a");
list1.add("b");
System.out.println(1);
System.in.read();

list1 = null;
System.out.println(2);
System.in.read();
System.out.println("end...");
}
}

可以先使用:jmap -dump: live,format=b,file=dump20220711-92360.hprof 92360 导出内存快照转储文件.
然后使用eclipse插件Memory Analyzer打开, 并找到GC root菜单, 即可看见哪些根对象类型.
如何使用eclipse的MemoryAnalyzer进行GCRoots分析
可以作为GCRoots对象的类的主要类型

对上面类型的分析:

  1. System Class: 系统类, 由启动类加载器BootstrapClassLoader加载的类.
    GCRoots-typeof-SystemClass
  2. Native Stack: jvm在实现某些方法时会不可避免的调用某些本地方法/(即操作系统的方法native method), 而这些方法执行时引用的对象, 即为Native Stack类型的跟对象.
    GCRoots-typeof-NativeStack
  3. Thread : 正在运行的线程, 其栈帧内的对象所引用的对象 也可以是GC Roots.(上面代码中list1是栈帧内的局部变量, 其引用的对象是堆中的new ArrayList(), 这个被引用的对象就是GC Roots). 可以在list1 = null前后进行转储文件的导出查看GC Roots. 发现引用置空后Thread类型里main主线程里的ArrayList 行Root没有了.
    GCRoots-typeof-Thread
  4. Busy Monitor: synchronized修饰的代码块, 会被一个monitor对象所关联, 被枷锁的对象不能被回收, 否则会”死锁”. 所以这类对象也能是GC Roots.

优缺点

四种引用

强: 被GC Roots直接或者间接引用.

软:

终结器引用

垃圾回收算法

分代回收垃圾机制

垃圾回收器

垃圾回收调优

常见JVM设置:

堆设置
-Xms:初始堆大小
-Xmx:最大堆大小
-Xmn:新生代大小
-XX:NewRatio:设置新生代和老年代的比值。如:为3,表示年轻代与老年代比值为1:3
-XX:SurvivorRatio:新生代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:为3,表示Eden:Survivor=3:2,一个Survivor区占整个新生代的1/5
-XX:MaxTenuringThreshold:设置转入老年代的存活次数。如果是0,则直接跳过新生代进入老年代
-XX:PermSize、-XX:MaxPermSize:分别设置永久代最小大小与最大大小(Java8以前)
-XX:MetaspaceSize、-XX:MaxMetaspaceSize:分别设置元空间最小大小与最大大小(Java8以后)
收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行老年代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器
垃圾回收统计信息
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename
并行收集器设置
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
并发收集器设置
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器新生代收集方式为并行收集时,使用的CPU数。并行收集线程数。

-XX:+PrintStringTableStatics
-XX:+PrintGCDetails
-verbose:gc

javap
jps
jhat
jmap
jinfo
jstat
jstatd
jstack
jvisualvm

-------------本文结束感谢您的阅读-------------