JVM

JVM: 内存结构

张天宇 on 2020-05-16

JVM第一篇,JVM的内存结构。

1. 程序计数器

Program Counter Register

  • 作用:存放下一条指令所在单元的地址的地方,物理上使用寄存器来实现的。
  • 特点:
    • 线程私有。
    • 唯一一个不会存在内存溢出的区域。

2.虚拟机栈

Java Vitural Machine Stacks

  • 虚拟机栈:每个线程运行时所需要的内存。
  • 栈帧:每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存。
  • 每个线程只能有一个活动栈帧,当前栈内最顶部的栈帧,对应着当前正在执行的那个方法。

栈内存不需要进行垃圾回收。

栈内存划的大,方便更多次的方法调用,划的过大,会让线程数变少,因为物理内存是一定的。

方法内局部变量是线程私有的,不需要考虑线程安全,如果是公有的,需要考虑线程安全。

线程安全

判断一个变量是不是线程安全的,不仅要看他是不是方法内的局部变量,还要看他是否逃离了方法的作用范围,如method3。

1
2
3
4
5
6
7
8
9
10
11
12
// 多线程同时执行此方法
static void m1{
int x = 0;
for ( int i = 0; i<5000; i++){
x++;
}
System.out.println(x);
}
/**
* 线程1方法调用该方法,新建一个栈帧,每个线程私有int x作为局部变量,与其他线程不相互影响。
* 修改为static int x,线程1和线程2都要读取x自增后再写回,不加安全保护会产生线程安全问题。
**/
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
public class Demo1_1 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append(4);
sb.append(5);
sb.append(6);
new Thread(() -> {
// 与主线程共享sb
method2(sb);
}).start();
}

// 同上,线程安全
public static void method1() {
StringBuilder sb = new StringBuilder();
sb.append(1);
}

// 非线程安全,因为作为方法参数传递进来,因此可能会有其他线程访问,对其他线程共享,如main。需要使用StringBuffer。
public static int method2(StringBuilder s) {
s.append(1);
s.append(2);
s.append(3);
System.out.println(s.toString());
}

public static StringBuilder m3(){
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
// 其他线程可能拿到这个对象的引用,并发的修改。
return sb;
}
}

栈内存溢出

  • 栈帧过多超过栈内存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Demo_2{
    private static int count;
    public static void main(String[] args){
    try{
    method1();
    } catch (Throwable e){
    e.printStackTrace();
    System.out.println(count);
    }
    }
    private static void method1(){
    count ++;
    method1();
    }
    }
    // java.lang.StackOverflowError
    // 又如,自动转换Json时,部门类套职员类,职员类套部门类,无限嵌套。这时使用@JsonIgnore忽略员工的部门的转换,

    使用-Xss256k设置栈内存大小,使递归调用次数变大。

  • 栈帧过大超过栈内存

即该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,即栈帧过多,会抛出 StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

线程运行诊断

  • Demo 1: CPU占用过多

    1. 定位

      1. 使用linux的top命令定位哪个ID的进程对CPU的占用过高

        1
        top
      2. 使用ps查看进程的哪个线程占用率过高

        1
        ps J -eo pid,tid,%cpu | grep 进程ID
      3. 使用jstack命令查看有问题的线程,展示的线程ID为十六进制,可定位到问题代码的行数。

        1
        jstack 进程id
  • Demo 2: 程序运行很长时间没有结果

    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
    static A a = new A();
    static B b = new B();
    public static void main(String[] args) throws InterruptedException{
    new Thread(()->{
    synchronized(a){
    try{
    Thread.sleep(2000);
    } catch(InterruptedException e){
    e,printStackTrace();
    }
    synchronized(b){
    System.out.println("我获得了a和b");
    }
    }
    }).start();
    Thread.sleep(1000);
    new Thread(()->{
    synchronized(b){
    synchronized(a){{
    System.out.println("我获得了a和b");
    }
    }
    }).start();
    }
    // deadlock 死锁
    // 线程1先锁住a然后休眠2秒,在其休眠这段时间一秒后新线程2锁住了b,当线程2锁线程a时发现已经被锁了需要等待。再过一秒线程1醒过来,想要锁住线程b但是需要等待,于是死锁。

3. 本地方法栈

Native Method Stacks,本地方法运行时候使用的内存。

本地方法:本地方法由其他语言如C或C++编写,编译成与处理器相关的机器代码。

4.堆

通过new关键字,创建对象都会使用堆内存。

  • 线程共享,堆中的对象都需要考虑线程安全的问题。
  • 有垃圾回收机制,不再被引用的对象会被回收。

堆内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 {
e.printStackTrace();
System.out.println(i);
}
}
// java.lang.OutofMemoryError:Java heap space

使用-Xmx8m修改堆空间大小。

堆内存诊断

  1. jps工具

    查看当前系统中有哪些java进程。

  2. jmap工具

    查看某一时刻堆内存占用情况。

    1
    jmap -heap 进程ID
  3. jconsole工具

    图形界面的多功能的检测工具,可以连续监测。

  4. jvisualvm工具

    使用堆Dump堆转储对堆内存信息进行快照。

Demo_1: 多次执行垃圾回收后,内存占用仍然很高。

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo{
public static void main(String[] args) throws InterruptedException{
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++){
students.add(new Student());
}
Thread.sleep(1000000000000);
}
}
class Student{
private byte[] big = new byte[1024*1024];
}

5.方法区

Method Area

方法区,也称非堆(Non-Heap),又是一个被线程共享的内存区域。方法区用于存放class的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等等。另外,方法区包含了一个特殊的区域“运行时常量池”。

方法区内存溢出

Demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1.8放在了元空间,1.8前放在永久代。元空间内存溢出,默认使用物理内存,不限制大小,因此默认不会看到溢出
// -XX:MaxMetaspaceSizer=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 < 20000; i++, j++) {
ClassWriter cw = new ClassWriter(0); //生成类的二进制字节码
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); // 版本号,public,类名,包名,父类,接口
byte[] code = cw.toByteArray(); //返回byte[]
test.defineClass("Class" + i, code, 0, code.length); // 执行类加载
}
} finally {
System.out.println(j);
}
}
}
// 1.8 java.lang.OutOfMemoryError:Metaspace
// 1.6 java.lang.OutOfMemoryError:PermGen space

Java1.8使用 -XX:MaxMetaspaceSizer=8m设置最大元空间大小。

Java1.8前使用 -XX:MaxPermSize=8m设置最大元空间大小。

场景

动态加载类

  • Spring
  • MyBatis

spring aop中都是使用到了cglib这类字节码的技术,动态代理的类越多,就需要越多的方法区来保证动态生成的class可以加载入到内存中去。

运行时常量池

常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

运行时常量池,常量池是*.class文件中的,当该类被加载,他的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。

可以通过javap -v命令反编译.class文件查看。

StringTable

特性

String table又称为String pool,字符串常量池,其存在于堆中(jdk1.7之后改的),hashtable结构,不能扩容。最重要的一点,String table中存储的并不是String类型的对象,存储的而是指向String对象的索引,真实对象还是存储在堆中。

String table还存在一个hash表的特性,里面不存在相同的两个字符串,延迟加载遇到没见过的才加进去。

此外String对象调用intern()方法时,会先在String table中查找是否存在于该对象相同的字符串,若存在直接返回String table中字符串的引用,若不存在则在String table中创建一个与该对象相同的字符串。

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
public class Demo1_1 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new String("ab")
// 反编译可得细节new StringBuilder().append("a").append("b").toString() new String("ab")
System.out.println(s3 == s4);
// false
String s5 = "a" + "b";
// 直接在常量池寻找“ab”和s3是一样的
// 这是javac在编译期间的优化,结果在编译期间已经确定,与s4的s1s2的变量不相同
System.out.println(s3 == s5);
// true
String s6 = s4.intern(); // intern用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后 返回引用。
System.out.println(s3 == s6);
// true
String x2 = new String("c") + new String("d"); // 堆中
String x1 = "cd"; // 常量池中
x2.intern(); // 池中已经有了,入池失败。
System.out.println(x1 == x2); // false
// 如果将两行语句调换, 1.8,1.6中副本入池,x2仍然是堆中cd,不等
String x2 = new String("c") + new String("d"); // 堆中
x2.intern(); // 池中没有,入池成功
String x1 = "cd"; // 常量池中有,直接取出来
System.out.println(x1 == x2); // true
}
}
存放位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// JDK 8下设置 -Xmx10m -XX:UseGCOvereadLimit
// JDK 6下设置 -XX:MaxPermSize=10m
public class Demo{
public static void main(String[] args){
List<String> list = new ArrayList<~>();
int i = 0;
try{
for (int j = 0; j< 26000; j++){
list.add(String.valueOf(j).intern());
i++;
}
} catch (Throwsable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
// 1.6 OutOfMemory: PerGen space
// 1.8 OutOfMemory: Heap space

在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代

在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代

在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

垃圾回收

当内存不足时,StringTable中那些没有被引用的字符串仍然会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc*/
public class Solution {
public static void main(String args[]) {
int i = 0;
try {
for (int j = 0; j < 10000; j++) {
String.valueOf(i).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
// [GC (Allocation Failure) [PSYoungGen: 2048K->504K(2560K)] 2048K->664K(9728K), 0.0016202 secs] [Times: user=0.03 sys=0.02, real=0.00 secs]
性能调优
  1. 调整hash桶的个数。如果系统里字符串常量非常多,可以适当调大。

    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
    // -XX:StringTable=20000 -XX:PrintStringTableStatistics
    public class Solution {
    public static void main(String args[]) {
    try (BufferedReader reader =
    new BufferedReader(
    new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    line.intern();
    }
    System.out.println("cost" + (System.nanoTime() - start) / 1000000);
    } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    }
    // 200000 401ms
    // 1009 12000ms
  2. 考虑将字符串对象是否入池。如果应用里有大量的字符串而且可能会重复,则可以考虑让字符串入池减少堆内存个数。

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 Solution {
public static void main(String args[]) throws IOException {
List<String> address = new ArrayList<>();
System.in.read();
for (int i = 0; i < 10; i++) {
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
String line = null;
long start = System.nanoTime();
while (true) {
line = reader.readLine();
if (line == null) {
break;
}
// address.add(line); // 防止垃圾回收
address.add(line.intern()); // 做一个入池动作,将串池内的加入到list,外的被垃圾回收掉
}
System.out.println("cost" + (System.nanoTime() - start) / 1000000);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
System.in.read();
}
}

6.直接内存

Direct Memory,不是JVM的内存,属于操作系统的内存。

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

直接内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Solution {
static int _100Mb = 1024 * 1024 * 1024;
public static void main(String args[]) {
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);
}
}
}
// java.lang.OutOfMemoryError: Direct buffer memory

释放原理

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Solution {
static int _1Gb = 1024 * 1024 * 1024;
public static void main(String args[]) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb); //源码中使用unsafe.allocateMemory分配
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 显示垃圾回收,Full GC
System.in.read();
}
}
// 可以在任务管理器下看到进程增加了1G内存,但并不是gc起到了释放作用。通过unsafe对象的freeMemory管理的。

Java直接内存分配与释放原理

使用了Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法。

ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存。

禁用显式回收堆直接内存的影响

1
-XX:+DisableExplicitGC # 显式GC,使System.gc();无效
1
System.gc();	// 显示垃圾回收,Full GC,回收新生代老年代,造成程序暂停时间较长

上述代码段,不通过代码显式回收ByteBuffer回收,于是ByteBuffer只有等待真正垃圾回收才会被回收掉,内存会长时间占用较大。

所以可以用Unsafe的freeMemory方法来手动管理直接内存。