JVM之内存结构

1. 程序计数器

定义:

Program Count Register 程序计数器(寄存器)

  • 作用: 记住下一条JVM指令的执行地址
  • 特点:
    • 是线程私有的(每一个线程都有自己的程序计数器)
    • 不会存在内存溢出

作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//二进制字节码       	 //JVM指令			//Java源代码
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
  • 解释器会解释指令为机器码交给 cpu 执行, 程序计数器会记录下一条指令的地址行号, 这样下一次解释器会从程序计数器拿到指令然后进行解释执行。
  • 多线程的环境下, 每一个线程都有自己的程序计数器, 如果两个线程发生了上下文切换, 那么各自程序计数器会记录线程下一行指令的地址行号, 以便于接着往下执行。

2. 虚拟机栈

定义:

Java Virtual Machine Stacks (Java虚拟机栈)

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

看一下代码的实际运行情况:

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

static void method1() {
method2();
}

static void method2() {
System.out.println("end!");
}
}

debug运行, 查看栈内情况

问题

  1. 垃圾回收是否涉及栈内存?

    不会, 栈内存是方法调用产生的, 每次方法调用完毕会自动弹出栈(释放内存)

  2. 栈内存的分配是越大越好么?

    不是, 栈内存分配越大, 所能运行的线程也就越少, 一般使用默认即可

  3. 方法内的局部变量是否线程安全?

    • 如果方法内的局部变量没有逃离方法的作用范围, 它是线程安全的
    • 如果是局部变量引用了对象, 并逃离了方法的作用范围, 那就要考虑线程安全问题

栈内存溢出

-Xss参数用于设置栈内存大小

java.lang.StackOverflowError(栈溢出错误)

  • 栈帧过多导致栈内存溢出(死循环, 死锁等)

  • 栈帧过大导致栈内存溢出

  • 第三方类库操作导致栈内存溢出

线程运行诊断

案例一: 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 进制的, 需要转换

案例二: 程序运行很长时间没有结果

解决方法: 使用jstack命令排查死锁

3. 本地方法栈

定义:

Native Method Stacks(本地方法栈), 是JVM调用一些本地方法时, 给这些本地方法提供的内存空间

由于Java的局限性, 一些与本地操作系统进行交互的方法就要用到操作系统的原生API

4. 堆

定义:

Heap(堆)

  • 通过new关键字创建的对象都会使用堆内存
  • 它是线程共享的, 堆中的对象都需要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出

-Xmx用于设置堆内存大小

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

代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}
}

堆内存诊断

  1. jps 工具
    查看当前系统中有哪些 java 进程

  2. jmap 工具
    查看堆内存占用情况 jmap - heap 进程id

  3. jconsole 工具
    图形界面的, 多功能的监测工具, 可以连续监测

  4. jvisualvm 工具

    同样是一个图形界面的检测工具

5. 方法区

定义:

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域, 或者类似于操作系统进程中的“文本”段。它存储每个类的结构, 例如运行时常量池、字段和方法数据, 以及方法和构造函数的代码, 包括特殊方法, 用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。

尽管方法区域在逻辑上是堆的一部分, 但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小, 或者可以根据计算的需要进行扩展, 并且如果不需要更大的方法区域, 则可以收缩。方法区域的内存不需要是连续的!

组成:

方法区内存溢出

  • 1.8 之前会导致永久代内存溢出
    • 使用 -XX:MaxPermSize=8m 指定永久代内存大小
  • 1.8 之后会导致元空间内存溢出
    • 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小

代码演示内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//先设置元空间内存 -XX:MaxMetaspaceSize=8m
public class Demo4 extends ClassLoader{
public static void main(String[] args) {
int j = 0;
try {
Demo4 test = new Demo4();
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);
}
}
}

运行时常量池

二进制字节码包含(类的基本信息, 常量池, 类方法定义, 包含了虚拟机的指令)
首先看看常量池是什么, 编译如下代码:

1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}

然后使用javap -v HelloWorld.class命令反编译查看结果。

常量池表:

每条指令都会对应常量池表中一个地址, 常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。

类方法定义:

常量池:
就是一张表, 虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
运行时常量池:
常量池是 *.class 文件中的, 当该类被加载以后, 它的常量池信息就会放入运行时常量池, 并把里面的符号地址变为真实地址

StringTable(字符串池)

先看下面几段代码, 你能知道运行结果么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
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);

特点:

  • 常量池中的字符串仅是符号, 只有在被用到时才会转化为对象
  • 利用串池的机制, 来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder
  • 字符串常量拼接的原理是编译器优化
  • 可以使用intern方法, 主动将串池中还没有的字符串对象放入串池中

intern方法(JDK1.8)

调用字符串对象的 intern 方法, 会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象, 则放入成功
  • 如果有该字符串对象, 则放入失败
  • 无论放入是否成功, 都会返回串池中的字符串对象

注意:

JDK1.8中, 此时如果调用 intern 方法成功, 堆内存与串池中的字符串对象是同一个对象; 如果失败, 则不是同一个对象

而在JDK1.6中, 将这个字符串对象尝试放入串池, 如果有则并不会放入, 如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回

例1:

1
2
3
4
5
6
7
8
9
// "a" "b" 被放入串池中,str 则存在于堆内存之中
String str = new String("a") + new String("b");
// 调用 str 的 intern 方法,这时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
String st2 = str.intern();
// 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
String str3 = "ab";
// 因为堆内存与串池中的 "ab" 是同一个对象,所以以下两条语句打印的都为 true
System.out.println(str == st2);
System.out.println(str == str3);

例2:

1
2
3
4
5
6
7
8
9
10
11
12
 // 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
String str3 = "ab";
// "a" "b" 被放入串池中,str 则存在于堆内存之中
String str = new String("a") + new String("b");
// 此时因为在创建 str3 时,"ab" 已存在与串池中,所以放入失败,但是会返回串池中的 "ab"
String str2 = str.intern();
// false
System.out.println(str == str2);
// false
System.out.println(str == str3);
// true
System.out.println(str2 == str3);

知道了这些再来看看上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; //底层使用StringBulider的append方法进行拼接, 存入堆内存
String s5 = "a" + "b"; //编译时会对常量字符串进行优化, "a" + "b" 直接被优化为 "ab"
String s6 = s4.intern(); //取到串池中的"ab"
// 问
System.out.println(s3 == s4); //false
System.out.println(s3 == s5); //true
System.out.println(s3 == s6); //true

//-----------------------------------------

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern(); //尝试入池失败
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);
//如果先入池, x2入池成功, x1直接从常量池中取, 所以结果为true
//如果是JDK1.6, 入池intern不会将对象直接入池, 而是复制一份, 所以结果为false

位置

在JDK1.6, StringTable是在永久代中的, 由于在永久代中对于GC不太方便, 在JDK1.7开始就把StringTable放在了堆中, 方便进行内存回收

垃圾回收

先看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 演示 StringTable 垃圾回收
* 虚拟机参数设置如下
* -Xmx10m 设置堆内存大小
* -XX:+PrintStringTableStatistics 打印字符串常量池信息
* -XX:+PrintGCDetails -verbose:gc 打印GC的次数, 耗时等信息
*/
public class Demo {
public static void main(String[] args) {
int i = 0;
try {
for(int j = 0; j < 10000; j++) { // j = 100, j = 10000
String.valueOf(j).intern();
i++;
}
}catch (Exception e) {
e.printStackTrace();
}finally {
System.out.println(i);
}
}
}
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
//垃圾回收信息
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->624K(9728K), 0.0036565 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
10000
Heap
PSYoungGen total 2560K, used 569K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 4% used [0x00000000ffd00000,0x00000000ffd147c0,0x00000000fff00000)
from space 512K, 95% used [0x00000000fff00000,0x00000000fff7a020,0x00000000fff80000)
to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen total 7168K, used 136K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 1% used [0x00000000ff600000,0x00000000ff622000,0x00000000ffd00000)
Metaspace used 3216K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 348K, capacity 388K, committed 512K, reserved 1048576K
//符号表
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13269 = 318456 bytes, avg 24.000
Number of literals : 13269 = 567376 bytes, avg 42.760
Total footprint : = 1045920 bytes
Average bucket size : 0.663
Variance of bucket size : 0.663
Std. dev. of bucket size: 0.814
Maximum bucket size : 6
//StringTable底层实现是HashTable
StringTable statistics:
//桶的个数
Number of buckets : 60013 = 480104 bytes, avg 8.000
//字符串个数
Number of entries : 2559 = 61416 bytes, avg 24.000
Number of literals : 2559 = 196144 bytes, avg 76.649
//总占用空间
Total footprint : = 737664 bytes
Average bucket size : 0.043
Variance of bucket size : 0.043
Std. dev. of bucket size: 0.207
Maximum bucket size : 3

Process finished with exit code 0

性能调优

  • 因为StringTable是由HashTable实现的, 所以可以适当增加HashTable桶的个数, 来减少字符串放入串池所需要的时间

    -XX:StringTableSize=桶个数(最少设置为1009 )

  • 考虑是否需要将字符串对象入池
    可以通过 intern 方法减少重复入池

6. 直接内存

定义:

Direct Meory(直接内存)

  • 常见于NIO操作时, 用于数据缓冲区
  • 分配回收成本较高, 但读写性能高
  • 不受JVM内存回收管理

好处:

读写文件对比案例:

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
static final String FROM = "D:\\video\\moive.mp4";
static final String TO = "D:\\a.mp4";
static final int _1Mb = 1024 * 1024;

public static void main(String[] args) {
io(); // io 用时: 17.5304 14.2835 10.1162
directBuffer(); // directBuffer 用时: 9.9542 7.9202 6.5641
}

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);
}

发现使用directBuffer的总是要快一点, 为什么呢, 看一下读取过程

文件读写过程(使用普通IO):

因为 Java 不能直接操作文件管理, 需要切换到内核态, 使用本地方法进行操作, 然后读取磁盘文件, 会在系统内存中创建一个缓冲区, 将数据读到系统缓冲区, 然后在将系统缓冲区数据, 复制到 Java 堆内存中。缺点是数据存储了两份, 在系统内存中有一份, Java 堆中有一份, 造成了不必要的复制。

使用DirectBuffer的读写过程:

直接内存是操作系统和 Java 代码都可以访问的一块区域, 无需将代码从系统内存复制到 Java 堆内存, 从而提高了效率。

直接内存释放原理

1
2
3
4
5
6
7
8
9
10
11
private static int _1GB = 1024 * 1024 * 1024;

public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 手动 gc
System.in.read();
}

由于直接内存不由JVM进行管理, 所以要通过任务管理器查看

  1. 分配完毕

  2. 开始释放

但其实直接内存不是由JVM的垃圾回收释放的, 而是通过unsafe.freeMemory来手动释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static int _1GB = 1024 * 1024 * 1024;

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, IOException {
Unsafe unsafe = getUnsafe();
//分配内存
long base = unsafe.allocateMemory(_1GB);
unsafe.setMemory(base,_1GB, (byte)0);
System.in.read();
//释放内存
unsafe.freeMemory(base);
System.in.read();
}

private static Unsafe getUnsafe() throws IllegalAccessException, NoSuchFieldException {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(Unsafe.class);
}
  1. 分配内存:

  2. 释放内存

ByteBuffer底层源码

allocateDirect方法:

1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

可以看到, 这里是给我们返回了一个DirectByteBuffer, 我们接着往下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
DirectByteBuffer(int cap) {                   // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

明显看到底层就是使用unsafe来分配内存的, 而且里面还调用了一个Cleaner.create方法, 使用了 Cleaner(虚引用)来监测 ByteBuffer 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}

public void clean() {
if (remove(this)) {
try {
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}

System.exit(1);
return null;
}
});
}

}
}

一旦 ByteBuffer 对象被垃圾回收, 那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用unsafe.freeMemory来释放直接内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}

public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}

注意:

一般用 jvm 调优时, 会加上下面的参数:

-XX:+DisableExplicitGC // 静止显示的 GC

意思就是禁止我们手动的 GC, 比如手动 System.gc() 无效, 它是一种 full gc, 会回收新生代、老年代, 会造成程序执行的时间比较长。所以我们就通过 unsafe 对象调用 freeMemory 的方式释放内存。