首页 JVM内存结构详解
文章
取消

JVM内存结构详解

JVM组成部分

如下图: JVM组成部分

结构简析:

  1. Java程序需要首先编译成字节码,再由JVM的类加载器模块加载到JVM当中
  2. JVM内存结构分为【方法区】、【Heap堆】、【虚拟机栈】、【程序计数器】、【本地方法栈】
  3. 方法区主要存在类的定义,像类字段、方法数据、方法和构造器代码(包括类构造器、实例构造器、接口构造器)
  4. 对象空间在堆区分配,而对象调用方法会创建栈帧,就要使用到虚拟机栈
  5. 而方法的执行(包括JVM指令)就需要程序计数器记住下一条要执行的命令
  6. Java程序无法调用操作系统api,需要借助本地方法(C或C++编写的,如Object类中的wait()、clone()等),而也本地方法的调用于普通方法类似,不过会存在本地方法栈当中

程序计数器

程序计数器有什么作用

它可以记住下一条指令的地址

程序计数器在什么时候发挥作用呢

  1. cpu的调度组件会给每个线程分配时间片,当时间片用完,线程会发生切换,而切换的线程需要知道之前指令执行的位置,这个时候就需要程序计数器器了
  2. 另外在指令的执行过程中,程序计数器也会实时更新,如下图经反编译生成的方法指令 JVM方法指令
  3. 指令前面的编号,我们其实就可以看作指令的内存地址,虽然指令之间间隔不同,那是因为一条指令由操作码和操作数组成,操作码由一个字节存储,操作数就确定了,所以地址间隔会有不同,每执行到当前指令,程序计数器就会记住下一条要执行的指令,而每条JVM指令通过解释器,最终由cpu执行

程序计数器器的特点

  1. 线程私有
  2. JVM结构中唯一一个不会出现内存溢出的错误

程序计数器的物理实现

通过寄存器实现

虚拟机栈(也称为线程栈)

它在JVM中的位置如下图: JVM结构之线程栈

虚拟机栈的概念

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

栈帧的结构

结构图如下: 栈帧

栈帧的具体大小在编译期就能确定,栈帧的结构主要组成部分如下:

  1. 局部变量表
  2. 操作数栈
  3. 类引用,指向方法区中类的定义
  4. 方法引用(或者说方法返回地址),同样时方法区中类中的方法定义
  5. 方法返回值

虚拟机栈的思考

  1. 垃圾回收是否涉及内存
  2. 栈内存分配越大越好吗?
  3. 方法内的局部变量是否线程安全?

  4. 不涉及,垃圾回收的区域主要针对heap堆(也称作GC 堆),即由垃圾回收器管理的堆,而对于虚拟机栈,栈帧随着方法调用创建,随着方法结束弹出,因此它的内存会自动销毁,并不需要GC进行回收
  5. 不是的,栈内存分配的越大,能够创建的线程数量就会越少,显然会影响并发量
  6. 是的,虚拟机栈是线程私有的,不存在多个线程共享的问题,自然不会有线程安全问题

方法中的线程安全问题

看下面的代码:

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 ThreadTest{
    public static void public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(4);
        sb.append(5);
        sb.append(6);
        new Thread(() -> {
            m2(sb);
        }).start();
    }

    public static void m1(){
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }

    public static void m2(StringBuilder sb){
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }

    public static StringBuilder m3(){
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;
    }

}

分析可得:

  1. m1的显然不会存在线程安全问题,问题在于m2和m3
  2. 我们发现,m2,m3要么对象是形参,要么会作为返回值返回,而判断对象是否存在线程安全问题,就在于一个对象会不会存在多个线程访问,显然无论是返回值还是形参,都有可能被其他线程所访问,所以m2和m3是存在线程安全问题的,这个时候就应该使用StringBuffer而不是StringBuilder

栈内存溢出

栈内存溢出的常见场景

  1. 栈帧过多(如无限递归调用、循环依赖)
  2. 栈帧多大,即一个栈帧非常大,直接撑爆了虚拟机栈

栈内存溢出演示代码之无限递归1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StackOverflowErrorTest{
    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();
    }
}

在服务器上运行这个程序,会报下面的错误:

1
2
3
4
5
6
7
8
9
10
11
java.lang.StackOverflowError
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    .....

    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
    at StackOverflowErrorTest.method1(StackOverflowErrorTest.java:15)
21705

这个就是典型的栈溢出错误了java.lang.StackOverflowError在IDEA中可以调整堆内存的大小,如下图:

修改虚拟机栈内存 修改之后可以看到count的数值明显变小了,虚拟机栈的默认大小是1M

1
2
3
4
5
6
7
java.lang.StackOverflowError
    at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
    at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
    at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
    at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
    at com.flameking.stack.StackOverflowErrorTest.method1(StackOverflowErrorTest.java:17)
7755

栈内存溢出演示代码之无限递归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
31
32
33
34
35
36
/**
 * 第三方json转换库调用
 */
public class StackOverflowErrorTest2{
    public static void main(String[] args) {
        Dept d = new Dept();
        d.setName("Market");
        Emp e1 = new Emp();
        e1.setName("zhang");
        e1.setDept(d);

        Emp e2 = new Emp();
        e2.setName("li");
        e2.setDept(d);

        d.setEmps(Arrays.asList(e1, e2));

        ObjectMapper mapper = new ObjectMapper();
        System.out.println(mapper.writeValueAsString(d));
    }
}

@Data
class Dept{
  private String name;
  private List<Emp> emps;
}

@Data
class Emp{
  private String name;
  //解决方法
  // @JsonIgnore
  private Dept dept;

}

这段代码存在无限递归:属性之间存在循环依赖,Dept依赖Emp,Emp依赖Dept,结果就导致循环依赖报错如下:

1
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)

线程运行诊断

案例1:CPU占用过多

某个程序CPU占用高达90%以上,影响其他程序的运行

code如下:

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
public class PayloadOfCPUTest {
  public static void main(String[] args) {
    new Thread(null, () -> {
      System.out.println("1....");
      while (true){

      }
    }, "thread1").start();

    new Thread(null, () -> {
      System.out.println("2....");
      try{
        Thread.sleep(1000000L);
      } catch (InterruptedException e){
        e.printStackTrace();
      }
    }, "thread2").start();


    new Thread(null, () -> {
      System.out.println("3....");
      try{
        Thread.sleep(1000000L);
      } catch (InterruptedException e){
        e.printStackTrace();
      }
    }, "thread3").start();
  }
}

以下面的命令开始启动程序,并进行运行诊断

1.首先在服务器输入命令:nohup java PayloadOfCPUTest &,让程序在后台运行

2.输入top实时检测Java代码,如下图所示: top动态监测

我们发现top能监测到某个进程的cpu占用情况,并且可以查看到进程ID,但是无法定位到具体的线程,并且无法定位到代码中具体的位置

3.输入ctrl c结束top命令,然后继续输入命令:ps H -eo pid,tid,%cpu,其中H代表输出所有进程,-eo 表示具体想要显示的信息,这里就会显示进程ID,线程ID,和cpu占用,显示结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
  PID   TID %CPU
    1     1  0.0
    8     8  0.0
......

14799 14809  0.0
14799 14810  0.0
14799 14811 99.9
14799 14812  0.0
14799 14813  0.0

......

我们可以发现,除了我们要定位到的进程和线程信息,还有很多其他的信息,这时候可以用命令ps H -eo pid,tid,%cpu | grep 14799,过滤出我们想要的进程的所有线程信息,结果如下: 过滤线程信息

这样我们就定为到了具体的线程ID了

4.为了查看具体的线程状态我们使用jdk的一个命令:jstack 进程ID,这个命令能打印输出该进程的所有线程状态信息的快照,输入后得到如下结果: jstack打印结果

在这其中我们发现了自己创建的thread1线程,而其他的特殊名字的线程都是JVM自己的线程,从打印信息中看我们的程序处于Runnable状态,并且下面还定位到了代码中可能出现问题的位置,第5行,我们用vim命令打开PayloadOfCPUTest.java文件,发现确实在第5行出现了while死循环,这也就是问题的根源: 第5行while死循环

5.根据ps命令我们定位到了线程ID,而在jstack打印的线程信息中,编号是16进制的,将14811转换成16进制39DB,我们发现就是thread1

6.在去修改代码之前,别忘了结束进程,kill 14799,kill 进程ID,结束进程

到这我们的线程运行诊断就结束了,我们已经定位到了具体的代码行数,接下来就可以自行修改代码,解决问题了

注意在IDEA中有个自带的功能可以是先jstack的效果

栈的快照信息

案例2:程序运行很久没有结果(死锁)

code如下:

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
package com.flameking.stack;

class A { }
class B { }

public class DeadlockTest {
  static A a = new A();
  static B b = new B();

  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      //1.线程先获得a的monitor锁,接下来便进行休眠
      synchronized (a) {
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e){
          e.printStackTrace();
        }
        //4.此时线程先要获取b对象的monitor锁,但是也已经被占用,所以进入阻塞状态
        synchronized (b){
          System.out.println("我获得了a和b");
        }
      }
    }).start();
    //2.main线程休眠
    Thread.sleep(1000);
    
    //3.在其他线程休眠期间,该线程会获取到对象b的monitor锁,进而想要获取a的monitor锁
    //然而目前a被其他线程,占用,该线程进入阻塞状态
    new Thread(() -> {
      synchronized (b){
        synchronized (a){
          System.out.println("我获得了a和b");
        }
      }
    }).start();
  }
}

这个程序最终两个线程会因为对方互相占用了锁并且也不释放,导致死锁,下面演示排查过程,如下图: 排查死锁

  1. 执行命令:nohup java DeadlockTest &
  2. 执行命令:jstack 进程ID

我们可以看到jstack打印信息的最后面的部分,可以看到下面的信息:

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
Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007fab28003778 (object 0x00000000ecc5d220, a A),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007fab280062c8 (object 0x00000000ecc5de68, a B),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
    at DeadlockTest.lambda$main$1(DeadlockTest.java:27)
    - waiting to lock <0x00000000ecc5d220> (a A)
    - locked <0x00000000ecc5de68> (a B)
    at DeadlockTest$$Lambda$2/303563356.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:750)
"Thread-0":
    at DeadlockTest.lambda$main$0(DeadlockTest.java:17)
    - waiting to lock <0x00000000ecc5de68> (a B)
    - locked <0x00000000ecc5d220> (a A)
    at DeadlockTest$$Lambda$1/471910020.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:750)

Found 1 deadlock.

从第一行我们就能明显看出,程序出现了死锁问题,从370行后面能够看到死锁产生的具体原因,到这我们的死锁诊断就完成了

总结

在线程诊断中,我们用到了下面这些命令:

  1. nohup java 程序名 &,后台运行Java程序
  2. top,实时监控进程cpu占用情况
  3. ps H -eo pid,tid,%cpu | grep 进程ID,过滤出指定进程的所有线程占用cpu情况
  4. jstack 进程ID,能打印出线程更加详细的状态信息快照

本地方法栈

也是线程私有的,它给本地方法的运行提供内存,本地方法(native method):不是由Java语言编写的(Java不能直接与操作系统api交互,这时候就需要使用本地方法,它们大多是使用C、C++编写的)

GC堆

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

特点

  1. 它是线程共享的,堆中对象都需要考虑线程安全的问题
  2. 有垃圾回收机制,具体也是针对GC堆

GC堆在堆内存中的位置如下: 堆内存结构

从图中来简单看看堆内存的组成,后面会详细介绍:

  1. heap堆(GC堆)
    • 年轻代
      • 新生代
      • 存活区:S0、S1
    • 老年代
  2. 非堆(Non-Heap)
    • 方法区

堆内存溢出

试想一个问题:

为什么堆内存有垃圾回收机制自动回收垃圾,还会出现堆内存溢出现象呢?

因为堆内存会将那些空闲的对象,比如在代码中将对象引用指向null后,对象就会自动有垃圾回收器回收
但也有这样的情况,程序不断的生成对象,而且每个对象都有在使用,这样慢慢积累对象,最后就会出现堆内存溢出

堆内存溢出演示代码

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

代码大致逻辑:不断拼接字符串,然后将每次拼接得到的字符串存在list当中,因为list一致不断在使用,同时字符串也存在list中,因此都有在使用,所以不会有垃圾回收

运行一段时间后,报错:

1
2
3
4
5
6
7
java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.flameking.HeapSpaceTest.main(HeapSpaceTest.java:14)
26

修改堆内存的大小

通过-Xmx指令修改堆内存大小: 修改堆内存大小

修改后能明显发现i的值变小,测试后i为17

注意:如果我们要排查堆内存溢出问题,应该把堆内存设置的小一些,这样能够尽早的排除出问题

堆内存诊断工具

  1. jps工具,查看当前系统中有哪些Java进程
  2. jmap工具,拿到进程ID后,查看堆内存使用情况,只能查询某个时刻堆内存情况
  3. jconsole功能,图形界面,多功能检测进程,线程,cpu等,并能连续检测

诊断演示代码

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HeapToolTest {
  public static void main(String[] args) throws InterruptedException {
    System.out.println("1.....");
    Thread.sleep(30000);
    byte[] array = new byte[1024*1024*10];  //10Mb
    System.out.println("2.....");
    Thread.sleep(20000);
    array = null;
    System.gc();
    System.out.println("3.....");
    Thread.sleep(1000000L);
  }
}

程序大致逻辑:程序运行时会有三个不同点状态,第一个是起始运行状态,第二个是创建了一个10Mb字节数组的状态,第三个是回收了字节数组的状态

使用诊断工具检测堆内存状态

注意这些工具请在Linux环境下运行,jmap在Windows环境下不太好用

1.用jsp命令查询所有的进程,输入:jps

2.执行命令:jmap -heap 进程ID,这样我们能在其中看到GC堆的各个结构的大小,如年轻代,新生代,老年代,最大堆内存大小等等,打印结果如下:

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
Attaching to process ID 14120, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.341-b10

using thread-local object allocation.
Parallel GC with 2 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 968884224 (924.0MB)
   NewSize                  = 19922944 (19.0MB)
   MaxNewSize               = 322961408 (308.0MB)
   OldSize                  = 40894464 (39.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 15728640 (15.0MB)
   used     = 629224 (0.6000747680664062MB)
   free     = 15099416 (14.399925231933594MB)
   4.000498453776042% used
From Space:
   capacity = 2097152 (2.0MB)
   used     = 0 (0.0MB)
   free     = 2097152 (2.0MB)
   0.0% used
To Space:
   capacity = 2097152 (2.0MB)
   used     = 0 (0.0MB)
   free     = 2097152 (2.0MB)
   0.0% used
PS Old Generation
   capacity = 40894464 (39.0MB)
   used     = 0 (0.0MB)
   free     = 40894464 (39.0MB)
   0.0% used

710 interned Strings occupying 47392 bytes.

以上两个工具介绍完成,开始介绍jconsole工具的使用,在命令行输入:jconsole命令,会弹出一个窗口: jconsole连接窗口

点击要检测的程序,进行连接,连接后的界面如下: 连接后的界面 其中的Threads功能界面就可以发挥jstack的作用

堆内存工具诊断案例:垃圾回收后内存占用仍然很高

code如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.ArrayList;
import java.util.List;

public class GCTest {
  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(1000000000L);
  }
}
class Student{
  private byte[] big = new byte[1024*1024];
}

我们先使用jps和jconsole命令诊断,然后在jconsole中进行手动GC回收,如下: jconsole手动GC回收

虽然使用了上诉两个命令,但是仍然没能排查出问题,所以下面我们使用一个工具,叫做jvisualvm,输入命令:jvisualvm,得到如下界面: jvisualvm界面

点击进入到我们要排查的程序,这里是GCTest,进去之后能看到下面的界面: 进入到jvisualvm功能界面

而我们实际要进入的界面是下面的这个: 观察具体的内存占用

前20最大的对象信息如下: 前20最大的对象信息

第一个ArrayList对象的内部信息如下: ArrayList内部信息

总结

我们总共介绍了四款工具,分别是:

  1. jps,查看所有的Java进程
  2. jmap,查询Java进程的堆内存使用情况
  3. jconsole,实时查看堆内存情况,功能也更多
  4. jvisualvm,它基本包含了1,2,3的功能

方法区

方法区存在的位置就是非堆当中,但它其实是一个规范,具体要看各个jvm的实现

方法区的特性

  1. 运行时常量池,类定义信息:类字段,方法数据,方法和构造器代码(类构造器、实例构造器
  2. 方法区在虚拟机启动时创建,并且在逻辑上是堆的一部分(在不同的jvm实现上会有不同,虽然规范这样要求,但也不是每个jvm实现都遵守了的)
  3. 方法区会导致JVM内存溢出

下面是hotspot在1.6和1.8方法区的不同实现: 方法区在hotspot不同版本中的实现

在1.6中,方法区的实现被称作永久代,里面包含常量池信息,类定义信息,类加载器信息,并且放在堆内存中

而在1.8中被称作元空间,这个时候StringTable从常量池抽取出来放在了Heap堆中,而元空间整个移进了本地内存中(也就是操作系统内存)

方法区内存溢出

演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

public class MethodspaceTest extends ClassLoader{
  public static void main(String[] args) {
    int j = 0;
    try {
      MethodspaceTest test = new MethodspaceTest();
      for (int i = 0; i < 10000; i++, j++) {
        //ClassWriter 作用是生成类的二进制字节码
        ClassWriter cw = new ClassWriter(0);
        //版本号(1.8),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);
      }
    }finally {
      System.out.println(j);
    }
  }
}

之前我们在jmap中输出的信息看到,元空间的最大空间可以很大,因此这里运行并不会导致内存溢出,我们可以通过指令-XX:MaxMetaspaceSize=8m,将元空间大小改位8m,再次执行,结果如下:

1
2
3
4
5
6
5411
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
    at com.flameking.methodspace.MethodspaceTest.main(MethodspaceTest.java:19)

如果是1.6版本的话,这里报的错就是java.lang.OutOfMemoryError: PermGen space

实际应用中可能会出现元空间溢出的场景

Spring、MyBatis使用cglib采用的字节码动态生成技术生成代理类,它们其实就非常容易导致OOM错误的,我们上面的演示代码用的就是跟Spring、MyBatis同样的字节码动态生成技术

运行时常量池

首先看看常量池,我们使用javap -v .class文件,会输出类的信息(类基本信息,如类的访问修饰符,类使用的Java版本,类的名称,类的父类,类的接口信息等等),常量池信息,类方法的定义(包含虚拟机指令)

常量池和运行时常量池

  1. 常量池就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  2. 常量池是.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号变为真实地址
本文由作者按照 CC BY 4.0 进行授权