程序计数器

定义

  • JVM中的程序计数器(Program Counter Register)是一块较小的内存空间,它用来保存当前线程下一条要执行的指令的地址。每个线程都有自己独立的程序计数器,它是线程私有的,生命周期与线程相同。程序计数器是JVM中的一种轻量级的内存区域,因为它不会发生内存溢出(OutOfMemoryError)的情况。
  • 程序计数器的作用在于线程切换后能够恢复到正确的执行位置,也就是下一条需要执行的指令地址。
    • 因为在Java虚拟机的多线程环境下,为了支持线程切换后能够恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,否则就会出现线程切换后执行位置混乱的问题。
  • 程序计数器也是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError(内存溢出)情况的区域。因为程序计数器是线程私有的,所以它所占用的内存空间非常小,一般不会导致内存溢出的问题。
  • 程序计数器是JVM中的一种非常重要的内存区域,它是实现Java虚拟机字节码解释器的必要组成部分。
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
  • Java源代码首先编译成二进制字节码,然后交由解释器解释成机器码,最终由CPU执行机器码
    • 程序计数器在其中的作用就是记住下一条JVM指令的执行地址,解释器从程序计数器取到下一条指令地址

小结

  • 程序计数器
    • 作用:保存当前线程下一条要执行的指令的地址
    • 特点:
      • 线程私有
      • 不存在内存溢出

虚拟机栈

定义

  • Java虚拟机栈(Java Virtual Machine Stacks)是Java虚拟机为每个线程分配的一块内存区域,用于存储线程的方法调用和局部变量等信息。
  • 每个线程在运行时都有自己的Java虚拟机栈,线程开始时会创建一个新的栈帧(Stack Frame),用于存储该线程的方法调用信息。当方法调用完成后,该栈帧会被弹出,回到上一次方法调用的位置。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

小结

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

演示

  • 我们编写两个简单的方法,在method1中调用method2,然后断点调试,调试窗口的左边就是虚拟机栈
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
method1();
}

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

private static int method2(int a, int b) {
return a + b;
}
  • 在第二行打断点,然后F7步入,当我们第一次步入的时候,method1会入栈
  • 由于我们在method1中调用了method2,所以再次步入时,method2也会入栈,如下图
  • 当我们点击步出时,method2执行完毕,释放内存,出栈,返回上一次方法调用的位置,即method1
  • 再次步出,method1执行完毕,释放内存,出栈
  • 再次步出,main方法执行完毕

问题辨析

  • Q: 垃圾回收是否涉及栈内存?
  • A: 垃圾回收通常不涉及栈内存。栈内存是在程序运行时自动分配和释放的,因此不需要垃圾回收来处理。相反,垃圾回收主要关注堆内存中的对象,以及这些对象是否还在被引用。垃圾回收器通常会扫描堆内存中的对象,并标记哪些对象仍然被引用,哪些对象可以被清理。
  • Q: 栈内存分配越大越好吗?
  • A: 栈内存的分配大小应该根据实际需要来确定。栈内存的分配是由操作系统负责的
    • Linux/x64(64 位):1024 KB
    • macOS(64 位):1024 KB
    • Oracle Solaris/x64(64 位):1024 KB
    • Windows:默认值取决于虚拟内存
  • 当然我们也可以手动设置线程堆栈大小为1024kb
1
2
3
-Xss1m
-Xss1024k
-Xss1048576
  • 栈内存划的越大,会让线程数变少,因为物理内存大小是一定的
    • 一个线程用1M,物理内存500M,理论上可以支持500个线程同时运行(但实际还需要一定数量的堆内存和其他系统资源,实际到不了500个线程)
    • 但如果一个线程设置2M,那么只有250个线程
  • 栈内存划分大了,通常只是能够进行更多次的方法递归调用,而不会增强运行效率,反而会使线程数量变少,一般采用系统默认的栈内存就好
  • Q: 方法内的局部变量是否线程安全?
  • A: 方法内的局部变量通常是线程安全的,因为它们只能在方法内部访问。每个线程都有自己的栈帧,栈帧包含方法的参数、局部变量和返回值等信息,因此不同的线程可以在不相互干扰的情况下同时访问相同的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyThread extends Thread {
@Override
public void run() {
method();
}

static void method() {
int x = 0;
for (int i = 0; i < 5000; i++) {
x++;
}
System.out.println(x);
}

public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
MyThread thread3 = new MyThread();

thread1.start();
thread2.start();
thread3.start();
}
}
  • 上述代码中,我是用三个线程执行method方法,最终的值都会是5000
  • 但如果此时的x是静态变量,那么结果就大不相同了,最终结果都会大于5000
    • 因为static变量是多个线程共享的,它如果不加安全保护的话,就会产生线程安全问题
  • 另外,如果方法内局部变量没有逃离方法的作用范围,那么它是线程安全的。如果局部变量引用了对象,并且逃离了方法的作用范围,需要考虑线程安全
  • 例如这里的m2方法就不是线程安全的,因为StringBuilder对象是我们外部传入的,主线程和新线程都在修改StringBuilder对象,此时StringBuilder对象就不再是线程私有的,而是多个线程共享的一个对象
  • 这里的m3方法也不是线程安全的,因为m3方法将StringBuilder对象返回了,其他线程就可以拿到这个StringBuilder对象进行修改
  • 不过这里的m1方法是线程安全的
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 Demo_01 {

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

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

栈内存溢出

  • 栈内存溢出有两种情况
    1. 栈帧过多导致栈内存溢出
      • 死循环递归
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      public class Demo {
      static int count = 0;

      public static void main(String[] args) {
      try {
      method();
      } catch (Throwable e) {
      e.printStackTrace();
      System.out.println(count);
      }
      }

      private static void method() {
      count++;
      method();
      }
      }
      • 最终输出的count为23568,也就是此时递归了23568次就内存溢出了
      • 还记得前面可以修改栈内存吗,现在我们将栈内存设置的小一些,再次执行此方法,只递归了3929
    2. 栈帧过大导致栈内存溢出
      • 下面这个例子中,Emp中引入了Dept,而Dept中又引入了Emp,他们现在在循环引用,导致json解析时会出现StackOverFlow
      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
      import java.util.Arrays;
      import java.util.List;

      import com.fasterxml.jackson.annotation.JsonIgnore;
      import com.fasterxml.jackson.core.JsonProcessingException;
      import com.fasterxml.jackson.databind.ObjectMapper;


      public class Demo_03 {

      public static void main(String[] args) throws JsonProcessingException {
      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));

      // { name: 'Market', emps: [{ name:'zhang', dept:{ name:'', emps: [ {}]} },] }
      ObjectMapper mapper = new ObjectMapper();
      System.out.println(mapper.writeValueAsString(d));
      }
      }

      class Emp {
      private String name;
      // @JsonIgnore
      private Dept dept;

      public String getName() {
      return name;
      }

      public void setName(String name) {
      this.name = name;
      }

      public Dept getDept() {
      return dept;
      }

      public void setDept(Dept dept) {
      this.dept = dept;
      }
      }

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

      public String getName() {
      return name;
      }

      public void setName(String name) {
      this.name = name;
      }

      public List<Emp> getEmps() {
      return emps;
      }

      public void setEmps(List<Emp> emps) {
      this.emps = emps;
      }
      }

      • 需要导的依赖
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-core</artifactId>
      <version>2.14.1</version>
      </dependency>
      <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.14.1</version>
      </dependency>
      <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-annotations</artifactId>
      <version>2.14.1</version>
      </dependency>
      • 此时就需要我们手动打破这种循环关系,解决方案就是使用@JsonIgnore注解来忽略序列化中的循环引用

本地方法栈

  • 本地方法栈,我们先来理解一下什么叫本地方法
    • 本地方法是指由非Java语言编写的代码,如C或C++,并被编译为本地二进制代码。
  • 因为JAVA没法直接和操作系统底层交互,所以需要用到本地方法栈来调用本地的C或C++的方法
  • 例如Object类的源码中就有本地方法,用native关键字修饰本地方法
    • 本地方法只有函数声明,没有函数体,因为函数体是C或C++写的,通常是通过JNI(Java Native Interface)技术来实现的。
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
* Wakes up a single thread that is waiting on this object's
* monitor. If any threads are waiting on this object, one of them
* is chosen to be awakened. The choice is arbitrary and occurs at
* the discretion of the implementation. A thread waits on an object's
* monitor by calling one of the {@code wait} methods.
* <p>
* The awakened thread will not be able to proceed until the current
* thread relinquishes the lock on this object. The awakened thread will
* compete in the usual manner with any other threads that might be
* actively competing to synchronize on this object; for example, the
* awakened thread enjoys no reliable privilege or disadvantage in being
* the next thread to lock this object.
* <p>
* This method should only be called by a thread that is the owner
* of this object's monitor. A thread becomes the owner of the
* object's monitor in one of three ways:
* <ul>
* <li>By executing a synchronized instance method of that object.
* <li>By executing the body of a {@code synchronized} statement
* that synchronizes on the object.
* <li>For objects of type {@code Class,} by executing a
* synchronized static method of that class.
* </ul>
* <p>
* Only one thread at a time can own an object's monitor.
*
* @throws IllegalMonitorStateException if the current thread is not
* the owner of this object's monitor.
* @see java.lang.Object#notifyAll()
* @see java.lang.Object#wait()
*/
public final native void notify();

/**
* Wakes up all threads that are waiting on this object's monitor. A
* thread waits on an object's monitor by calling one of the
* {@code wait} methods.
* <p>
* The awakened threads will not be able to proceed until the current
* thread relinquishes the lock on this object. The awakened threads
* will compete in the usual manner with any other threads that might
* be actively competing to synchronize on this object; for example,
* the awakened threads enjoy no reliable privilege or disadvantage in
* being the next thread to lock this object.
* <p>
* This method should only be called by a thread that is the owner
* of this object's monitor. See the {@code notify} method for a
* description of the ways in which a thread can become the owner of
* a monitor.
*
* @throws IllegalMonitorStateException if the current thread is not
* the owner of this object's monitor.
* @see java.lang.Object#notify()
* @see java.lang.Object#wait()
*/
public final native void notifyAll();

/**
* Causes the current thread to wait until either another thread invokes the
* {@link java.lang.Object#notify()} method or the
* {@link java.lang.Object#notifyAll()} method for this object, or a
* specified amount of time has elapsed.
* <p>
* The current thread must own this object's monitor.
* <p>
* This method causes the current thread (call it <var>T</var>) to
* place itself in the wait set for this object and then to relinquish
* any and all synchronization claims on this object. Thread <var>T</var>
* becomes disabled for thread scheduling purposes and lies dormant
* until one of four things happens:
* <ul>
* <li>Some other thread invokes the {@code notify} method for this
* object and thread <var>T</var> happens to be arbitrarily chosen as
* the thread to be awakened.
* <li>Some other thread invokes the {@code notifyAll} method for this
* object.
* <li>Some other thread {@linkplain Thread#interrupt() interrupts}
* thread <var>T</var>.
* <li>The specified amount of real time has elapsed, more or less. If
* {@code timeout} is zero, however, then real time is not taken into
* consideration and the thread simply waits until notified.
* </ul>
* The thread <var>T</var> is then removed from the wait set for this
* object and re-enabled for thread scheduling. It then competes in the
* usual manner with other threads for the right to synchronize on the
* object; once it has gained control of the object, all its
* synchronization claims on the object are restored to the status quo
* ante - that is, to the situation as of the time that the {@code wait}
* method was invoked. Thread <var>T</var> then returns from the
* invocation of the {@code wait} method. Thus, on return from the
* {@code wait} method, the synchronization state of the object and of
* thread {@code T} is exactly as it was when the {@code wait} method
* was invoked.
* <p>
* A thread can also wake up without being notified, interrupted, or
* timing out, a so-called <i>spurious wakeup</i>. While this will rarely
* occur in practice, applications must guard against it by testing for
* the condition that should have caused the thread to be awakened, and
* continuing to wait if the condition is not satisfied. In other words,
* waits should always occur in loops, like this one:
* <pre>
* synchronized (obj) {
* while (&lt;condition does not hold&gt;)
* obj.wait(timeout);
* ... // Perform action appropriate to condition
* }
* </pre>
* (For more information on this topic, see Section 3.2.3 in Doug Lea's
* "Concurrent Programming in Java (Second Edition)" (Addison-Wesley,
* 2000), or Item 50 in Joshua Bloch's "Effective Java Programming
* Language Guide" (Addison-Wesley, 2001).
*
* <p>If the current thread is {@linkplain java.lang.Thread#interrupt()
* interrupted} by any thread before or while it is waiting, then an
* {@code InterruptedException} is thrown. This exception is not
* thrown until the lock status of this object has been restored as
* described above.
*
* <p>
* Note that the {@code wait} method, as it places the current thread
* into the wait set for this object, unlocks only this object; any
* other objects on which the current thread may be synchronized remain
* locked while the thread waits.
* <p>
* This method should only be called by a thread that is the owner
* of this object's monitor. See the {@code notify} method for a
* description of the ways in which a thread can become the owner of
* a monitor.
*
* @param timeout the maximum time to wait in milliseconds.
* @throws IllegalArgumentException if the value of timeout is
* negative.
* @throws IllegalMonitorStateException if the current thread is not
* the owner of the object's monitor.
* @throws InterruptedException if any thread interrupted the
* current thread before or while the current thread
* was waiting for a notification. The <i>interrupted
* status</i> of the current thread is cleared when
* this exception is thrown.
* @see java.lang.Object#notify()
* @see java.lang.Object#notifyAll()
*/
public final native void wait(long timeout) throws InterruptedException;

定义

  • JVM的堆(Heap)是Java虚拟机(JVM)在内存中用来存放对象的区域,是Java程序中最大的一块内存区域。JVM的堆被所有线程共享,在JVM启动时就已经被创建,并且一直存在于JVM的整个生命周期中。
  • 堆可以被分成两部分:新生代(Young Generation)和老年代(Old Generation)。新生代又被进一步分为Eden空间、幸存区From空间和幸存区To空间。
  • 新生代是用来存放新创建的对象的,其中大部分对象都很快就会被垃圾回收掉。当堆空间不足时,JVM会触发垃圾回收机制(GC),对新生代的对象进行清理。清理过程一般是将存活的对象移到老年代或幸存区,而其余的对象则被回收。
  • 老年代是用来存放生命周期较长的对象的,这些对象一般是从新生代晋升而来,或者是本身就比较大的对象。老年代的对象存活时间较长,因此垃圾回收的频率比新生代低得多。
  • JVM堆的大小可以通过启动JVM时的参数进行调整,如-Xms和-Xmx参数分别控制堆的初始大小和最大大小。如果应用程序需要创建大量的对象,而堆空间不足,则会抛出OutOfMemoryError异常。

小结

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

堆内存溢出

  • 用下面的代码举个例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.ArrayList;

/**
* 演示堆内存溢出:java.lang.OutOfMemoryError: Java heap space
*/
public class Demo_04 {
public static void main(String[] args) {
int i = 0;
try {
ArrayList<String> list = new ArrayList<>(); //Hello, HelloHello, HelloHelloHelloHello ···
String a = "Hello";
while (true) {
list.add(a);
a = a + a; // HelloHelloHelloHello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}
  • list对象的作用域是在try块中,list对象是通过new出来的,所以占用的是堆空间。
  • 由于a的字符串长度是指数增长的,所以堆空间很快就会不足,此时会触发垃圾回收机制,尝试清理新生代对象,但由于list对象一直处于存活状态,无法释放,最终导致堆内存溢出,最终我这里输出的i为27
  • 但是由于堆空间很大,所以有些堆内存溢出的情况可能不是很容易能诊断出来,所以我们可以通过添加JVM参数,将堆空间修改的小一些来进行测试,此时最终输出的i为17

堆内存诊断

  1. jps工具
    • 查看当前系统中有哪些Java进程
  2. jmap工具
    • 查看堆内存占用情况
    1
    jmap -heap 进程id ## 进程id就是jps查出来的进程
  3. jconsole工具
    • 图形化界面的多功能监测工具,可以连续监测
  • 案例代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 演示堆内存
*/
public class Demo_05 {
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
// 1. 在休眠期间查询当前新生代内存占用情况
Thread.sleep(30000);
byte[] array = new byte[1024 * 1024 * 10]; // 10 Mb
System.out.println("2...");
// 2. 由于这里new了一个10M大小的数组,所以新生代内存占用情况应该比上一次多10M左右
Thread.sleep(20000);
// 3. 这里将数组置空,并进行垃圾回收,此时数组占用的10M就会呗回收掉,内存占用应该比2少
array = null;
System.gc();
System.out.println("3...");
Thread.sleep(1000000L);
}
}
  • 对应的诊断指令及结果
    1. 查询到目标进程id为17756
    1
    2
    3
    4
    5
    6
    $ jps
    18640 Jps
    5592 RemoteMavenServer36
    8008 Launcher
    17756 Demo_05
    2124
    1. 当控制台输出 "1…"后,查询当前堆内存占用情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ jmap -heap 18188

    Heap Usage:
    PS Young Generation
    Eden Space:
    capacity = 100663296 (96.0MB)
    used = 8053144 (7.680076599121094MB) ## 主要看这个,这个是新生代内存占用,当前为7.6M
    free = 92610152 (88.3199234008789MB)
    8.000079790751139% used
    1. 当控制台输出 "2…"后,再次查看当前堆内存占用情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ jmap -heap 18188

    Heap Usage:
    PS Young Generation
    Eden Space:
    capacity = 100663296 (96.0MB)
    used = 18538920 (17.680091857910156MB) ## new了一个10M大小的数组,当前内存占用17.6M,符合我们的预期
    free = 82124376 (78.31990814208984MB)
    18.416762351989746% used
    1. 当控制台输出 "3…"后,再次查看当前堆内存占用情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ jmap -heap 18188

    Heap Usage:
    PS Young Generation
    Eden Space:
    capacity = 100663296 (96.0MB)
    used = 2013288 (1.9200210571289062MB) ## 进行垃圾回收,占用内存比上一步少
    free = 98650008 (94.0799789428711MB)
    2.0000219345092773% used
  • 下面使用jconsole进行测试

案例

  • 垃圾回收后,内存占用仍然很高
    • 先根据前面新生代老年代的定义来推测一下可能是什么原因
    • 垃圾回收主要回收的是新生代对象,同时将存活的新生代对象移到老年代的空间
    • 那么原因可能就是新生代对象一直存活,导致垃圾回收的时候回收不了多少内存,同时这些存活的新生代转为老年代
  • 下面使用jvisualvm进行诊断
  • 可以看到有一个集合占了200M左右,那我们继续查找最大的对象
  • 诊断结果中是有200个student对象在占用内存
  • 再来看看代码是否真的跟我们想的一样,students集合要等main方法执行完毕后才能释放,而下面休眠了1000000秒,就导致students无法被回收
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.ArrayList;
import java.util.List;

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

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);
}
}
  • 真实场景中的业务逻辑会比这个复杂,但是诊断方式都是相通的

方法区

定义

  • 在JVM中,方法区是一块用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据的内存区域,它是Java虚拟机规范中的一个概念。Java SE 7及之前版本中,方法区被称为永久代,但在Java SE 8之后的版本中,永久代被废弃了,被元空间所替代。
  • 元空间是JVM在Java SE 8之后引入的一个新的概念,它与永久代类似,都是用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据的内存区域,但元空间的实现方式与永久代有所不同。
  • 与永久代不同的是,元空间使用的是本地内存(Native Memory),而不是虚拟机内存(堆内存),这样就避免了OutOfMemoryError错误,因为在使用本地内存时,可以动态地调整大小,而且可以使用操作系统的虚拟内存机制,使得Java应用程序不会被限制在固定的内存大小中。
  • 此外,元空间还引入了一些新的概念和机制,例如MetaspaceSize、MaxMetaspaceSize、CompressedClassSpaceSize等,这些概念和机制都是为了更好地管理元空间的内存使用和性能。

组成

方法区内存溢出

  • 1.8之前会导致永久代内存溢出
  • 1.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
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=50m
*/
public class Demo_07 extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo_07 test = new Demo_07();
for (int i = 0; i < 100000; 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=50m,然后运行上面的代码,结果如下
1
2
3
4
5
6
70801
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
at java.lang.ClassLoader.defineClass(ClassLoader.java:635)
at com.demo.Demo_07.main(Demo_07.java:23)

运行时常量池

  • 常量池就是一行表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 我们先来编写一个简单的HelloWorld类
1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
  • 然后通过命令将编译后的.class文件反汇编成可读的Java代码
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 D:/Workspace/JVM/demo/target/classes/com/demo/HelloWorld.class
Classfile /D:/Workspace/JVM/demo/target/classes/com/demo/HelloWorld.class
Last modified 2023-3-30; size 553 bytes
MD5 checksum a920c142d5bb891e2b9fc1ff43b55128
Compiled from "HelloWorld.java"
public class com.demo.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 // com/demo/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 Lcom/demo/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 com/demo/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 com.demo.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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/demo/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 5: 0
line 6: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"
  • 上面的结果中主要包含三部分
1
2
3
4
5
6
7
8
Classfile /D:/Workspace/JVM/demo/target/classes/com/demo/HelloWorld.class
Last modified 2023-3-30; size 553 bytes
MD5 checksum a920c142d5bb891e2b9fc1ff43b55128
Compiled from "HelloWorld.java"
public class com.demo.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
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
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 // com/demo/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 Lcom/demo/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 com/demo/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
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
{
public com.demo.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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/demo/HelloWorld;

public static void main(java.lang.String[]); // 这就是我们的main方法了
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;
}
  • 其中如下内容就表示虚拟机的指令
1
2
3
4
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
  • 解释器去翻译虚拟机指令的时候,看到的只有这些
1
2
3
getstatic     #2
ldc #3
invokevirtual #4
  • 解释器在解释的时候,就是拿着#2、#3、#4去查表翻译,查的就是常量池中的内容
    • 用#2举例,查表内容如下
    1
    2
    3
    4
    5
    6
    7
    getstatic     #2                        // 获取静态变量System.out
    #2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
    #21 = Class #28 // java/lang/System
    #28 = Utf8 java/lang/System
    #22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
    #29 = Utf8 out
    #30 = Utf8 Ljava/io/PrintStream;
    • 用#3举例,查表内容如下
    1
    2
    3
    ldc           #3                        // 加载参数Hello, World!
    #3 = String #23 // Hello, World!
    #23 = Utf8 Hello, World!
    • 用#4举例,查表内容如下
    1
    2
    3
    4
    5
    6
    7
    invokevirtual #4                        // 执行虚方法调用,调用println,输出Hello, World!
    #4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
    #24 = Class #31 // java/io/PrintStream
    #31 = Utf8 java/io/PrintStream
    #25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
    #32 = Utf8 println
    #33 = Utf8 (Ljava/lang/String;)V
  • 常量池是 *.class 文件中的Constant pool中的内容
  • 而运行时常量池是当该类被加载时,将常量池信息放入运行时常量池,并把里面的符号地址(#2、#3)变为内存地址

StringTable

  • 我们反编译一下这段代码,看看常量池里都有什么
    1
    2
    3
    4
    5
    6
    7
    8
    public class Demo_08 {
    // 常量池中的信息,都会被加载到运行时常量池中,此时a、b、ab都是常量池中的符号,还没有变成java对象
    public static void main(String[] args) {
    String s1 = "a";
    String s2 = "b";
    String s3 = "ab";
    }
    }
  • 结果如下(只截取了我们需要的东西)
    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
    Constant pool:
    #1 = Methodref #6.#24 // java/lang/Object."<init>":()V
    #2 = String #25 // a
    #3 = String #26 // b
    #4 = String #27 // ab
    #5 = Class #28 // com/demo/Demo_08

    public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
    stack=1, locals=4, args_size=1
    0: ldc #2 // String a
    2: astore_1
    3: ldc #3 // String b
    5: astore_2
    6: ldc #4 // String ab
    8: astore_3
    9: return
    LineNumberTable:
    line 5: 0
    line 6: 3
    line 7: 6
    line 8: 9
    LocalVariableTable:
    Start Length Slot Name Signature
    0 10 0 args [Ljava/lang/String;
    3 7 1 s1 Ljava/lang/String;
    6 4 2 s2 Ljava/lang/String;
    9 1 3 s3 Ljava/lang/String;
    • ldc #2 会把a符号变成"a"字符串对象
    • ldc #3 会把a符号变成"b"字符串对象
    • ldc #4 会把a符号变成"ab"字符串对象
  • 下面添加两行代码,变成一道经典面试题,输出结果是true还是false呢?
1
2
3
4
5
6
7
8
9
public class Demo_08 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
}
}
  • 答案我先不说,我们还是先反编译一下这段代码,结果如下
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 void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
32: aload_3
33: aload 4
  • 通过反编译的结果,我们来分析一下s4对象是如何被创建的
    1
    2
    3
    4
    5
    6
    7
    8
    9: new           #5                  // class java/lang/StringBuilder
    12: dup
    13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
    16: aload_1
    17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    20: aload_2
    21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
    • #5、#6:调用StringBuilder的无参构造方法,创建对象
    • #7:调用StringBuilder的append方法,加载参数为aload_1aload_2,即a、b
    • #8:调用StringBuilder的toString方法
  • 总结一下,s4对象的创建方法如下
1
String s4 = new StringBuilder.append("a").append("b").toString();
  • 那么s4对象是new出来的对象,存放在堆空间里,而s3对象是存在于常量池中的,故s3 == s4的结果为false
  • 再来试试常量拼接的结果如何
1
2
3
4
5
6
7
8
9
10
11
public class Demo_08 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);
String s5 = "a" + "b";
System.out.println(s3 == s5);
}
}
  • 反编译结果如下,可以看到s5对象的创建,就是去常量池中直接获取ab,而不会创建新的字符串对象,故s3 == s5的结果是true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=6, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: ldc #4 // String ab
31: astore 5
  • 那s5对象为什么是直接从常量池中获取的呢?
    • 这是javac在编译期的优化,因为s5是由两个常量拼接而成,常量拼接的结果是确定的,那么在编译期间就能确定结果肯定是"ab"
    • 而s4是由s1、s2两个变量拼接而成的,变量在运行的时候,引用的值可能被修改,那么结果就不能确定,所以只能在运行期间,使用StringBuilder来动态的拼接

字符串延迟加载

  • 每遇到一个没见过的字符串对象,才会将其放入常量池,如果池子中已经有了,则不会新增对象
  • 使用下面的代码单步调试来验证一下
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
public class Demo_09 {
public static void main(String[] args) {
System.out.println(); // 字符串个数:2224
System.out.println("0"); // 每步入到下一行,字符串个数 +1
System.out.println("1");
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println(); // 字符串个数:2235
System.out.println("0");
System.out.println("1"); // 字符串个数保持2235不在变化
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
}
}

StringTable特性

  1. 常量池中的字符串仅是符号,第一次用到时才会变为对象
  2. 利用串池的机制,避免重复创建字符串对象
  3. 字符串变量拼接的原理是StringBuilder(1.8)
  4. 字符串常量拼接的原理是编译期优化
  5. 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
    • 1.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
    public class Demo_10 {

    public static void main(String[] args) {
    String s1 = "a"; // 常量池:["a"]
    String s2 = "b"; // 常量池:["a", "b"]
    String s3 = "a" + "b"; // 常量池:["a", "b", "ab"]
    String s4 = s1 + s2; // 堆:new String("ab")
    String s5 = "ab"; // s5引用常量池中已有的对象
    String s6 = s4.intern(); // 常量池中已有"ab",将常量池中的"ab"的引用返回,s6引用常量池中已有的对象

    System.out.println(s3 == s4); // s3在常量池,s4在堆,false
    System.out.println(s3 == s5); // s3在常量池,s5在常量池,true
    System.out.println(s3 == s6); // s3在常量池,s6在常量池,true

    String str1 = "cd"; // 常量池:["cd"]
    String str2 = new String("c") + new String("d"); // 堆:new String("cd")
    str2.intern(); // 常量池中已有"cd",放入失败
    System.out.println(str1 == str2); // str1在常量池,str2在堆,false

    String str4 = new String("e") + new String("f"); // 堆:new String("ef")
    str4.intern(); // 常量池中没有"ef",放入成功,并返回常量池"ef"的引用
    String str3 = "ef"; // 常量池:["ef"]
    System.out.println(str3 == str4); // str4是常量池的引用,str3也是常量池的引用,true
    }
    }

StringTable的位置

  • JDK 1.6 中,字符串常量池(也就是 StringTable)是位于永久代中的。而在 JDK 1.8 中,永久代已经被移除,取而代之的是元空间(Metaspace),而字符串常量池也随之移动到了中。这意味着在 JDK 1.8 中,字符串常量池中的字符串也可以被垃圾回收器回收,而在 JDK 1.6 中则不行。

StringTable垃圾回收

  • 在 Java 8 及更高版本中,字符串常量池位于堆中,而堆是 JVM 中的一部分,因此字符串常量池中的字符串可以被垃圾回收器回收。具体来说,只有当字符串没有被任何对象引用时,它才能被垃圾回收。当字符串被回收时,它的存储空间将被释放并可以被重新利用。
  • 下面我们通过示例代码来验证一下,首先先添加几个VM参数
    1. -Xmx10m:指定堆内存大小
    2. -XX:+PrintStringTableStatistics:打印字符串常量池信息
    3. -XX:+PrintGCDetails:打印垃圾回收详细信息
    4. -verbose:gc:打印 gc 的次数,耗费时间等信息
1
2
3
4
5
6
7
8
9
10
11
12
public class Demo_11 {
public static void main(String[] args) {
int i = 0;
try {

} catch (Throwable 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
Heap
PSYoungGen total 2560K, used 2007K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 98% used [0x00000000ffd00000,0x00000000ffef5d50,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
Metaspace used 3256K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 356K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13446 = 322704 bytes, avg 24.000
Number of literals : 13446 = 574288 bytes, avg 42.711
Total footprint : = 1057080 bytes
Average bucket size : 0.672
Variance of bucket size : 0.677
Std. dev. of bucket size: 0.823
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1760 = 42240 bytes, avg 24.000
Number of literals : 1760 = 157872 bytes, avg 89.700
Total footprint : = 680216 bytes
Average bucket size : 0.029
Variance of bucket size : 0.030
Std. dev. of bucket size: 0.172
Maximum bucket size : 3
  • 由于在上面的代码中,我们没有创建字符串常量,所以没有触发垃圾回收机制,我们重点只关注StringTable statistics中的内容
    • 这是字符串常量池的统计信息,包含以下三个方面的信息
      • Number of buckets: 字符串常量池中的桶(bucket)数量。在这个例子中,共有60013个桶。
      • Number of entries: 字符串常量池中的实际条目(entry)数量。在这个例子中,共有1760个条目。
      • Number of literals: 字符串常量池中存储的字面量(literal)数量。在这个例子中,共有1760个字面量。
1
2
3
4
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1760 = 42240 bytes, avg 24.000
Number of literals : 1760 = 157872 bytes, avg 89.700
  • 那现在我们什么都没做,字符串常量池中有1760个字符串常量,那我现在尝试将5W个字符串存入常量池中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo_11 {
public static void main(String[] args) {
int i = 0;
try {
for (int j = 0; j < 50000; j++) {
String.valueOf(j).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
  • 运行,观察日志,可以看到,触发了垃圾回收机制,且串池中的数量也远小于5W个,所以StringTable确实是会发生垃圾回收的
1
2
3
4
5
6
[GC (Allocation Failure) [PSYoungGen: 2536K->488K(2560K)] 2833K->865K(9728K), 0.0011680 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 13299 = 319176 bytes, avg 24.000
Number of literals : 13299 = 804392 bytes, avg 60.485

StringTable性能调优1

  • 在JVM内部,字符串常量池就是通过哈希表实现的。
    • 添加VM参数-XX:StringTableSize=1024,实际上设置的是哈希表的大小(即桶的数量)。较小的哈希表意味着更多的哈希冲突。这会增加查找字符串的开销,因为需要在链表中进行顺序搜索才能找到一个字符串。因此,这将会导致字符串查找速度变慢。
    • 示例代码如下
    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
    import java.io.BufferedReader;
    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStreamReader;

    /**
    * 演示串池大小对性能的影响,读取文件,将内容存入字符串常量池,文件中约有48W个不同的字符串
    * -XX:StringTableSize=50000 耗时0.318s
    * -XX:StringTableSize=10000 耗时1.098s
    */
    public class Demo_12 {
    public static void main(String[] args) throws IOException {
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\Workspace\\JVM\\demo\\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);
    }
    }
    }
    • 添加VM参数-XX:StringTableSize=50000,耗时0.318s
    • 添加VM参数-XX:StringTableSize=10000,耗时1.098s

StringTable性能调优2

  • 如果应用需要存储大量字符串常量信息,而且这些字符串常量包含大量重复内容,可以使用Java中的字符串常量池机制,通过调用intern()方法将常量放入常量池中,以节省内存空间并提高性能。
  • 实际应用:
    • 根据推特的工程师们所说,推特在存储用户地址信息时采用了字符串常量池的方法。推特上有大量的用户地址信息,而这些信息中有大量的重复内容,如街道名称、城市、州等。通过将这些常见的地址信息存储在字符串常量池中,推特可以节省大量的内存空间。
    • 推特使用了Guava库中的Interners工具类来实现字符串常量池。该工具类提供了线程安全的字符串常量池实现,支持不同的策略和配置,例如并发级别、最大容量等。推特选择了使用一个全局的、不限容量的字符串常量池来存储用户地址信息。在存储用户信息时,推特使用了String.intern()方法来将地址信息存储在字符串常量池中,而不是直接使用新的字符串对象。这样,推特可以确保相同的地址信息只会在内存中存在一份拷贝,从而减少内存的占用。
    • 通过这种方法,推特成功地实现了在存储大量用户信息时,有效地减少了内存占用。
  • 那我们现在来复现一个类似的场景,存储大量重复的字符串常量信息,然后使用Java VisualVM监测内存使用情况,示例代码如下
    • 每循环一次就有48W个字符串,循环十次就是480W个字符串放到内存中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Demo_13 {
    public static void main(String[] args) throws IOException {
    ArrayList<String> list = new ArrayList<>();
    System.in.read();
    for (int i = 0; i < 10; i++) {
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\Workspace\\JVM\\demo\\linux.words"), "utf-8"))) {
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    // 没有入池
    list.add(line);
    }
    System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
    }
    }
    }
    }
    • 监测内存String和char[]加起来大概占用了250M
    • 那现在我们将字符串入池,再来监测一下内存占用情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Demo_13 {
    public static void main(String[] args) throws IOException {
    ArrayList<String> list = new ArrayList<>();
    System.in.read();
    for (int i = 0; i < 10; i++) {
    try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\Workspace\\JVM\\demo\\linux.words"), "utf-8"))) {
    String line = null;
    long start = System.nanoTime();
    while (true) {
    line = reader.readLine();
    if (line == null) {
    break;
    }
    // 入池
    list.add(line.intern());
    }
    System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
    }
    }
    }
    }
    • 内存占用情况如下,这下仅仅才占用50M内存

小结:如果我们需要存储大量字符串常量信息,而且这些字符串常量包含大量重复内容,可以使用Java中的字符串常量池机制,通过调用intern()方法将常量放入常量池中,以节省内存空间并提高性能。

直接内存

定义

  • JVM的直接内存是指JVM中的一个内存区域,也被称为NIO直接缓冲区。和Java堆不同,直接内存并不是由JVM自动管理的,而是由操作系统直接管理的。直接内存的访问速度比Java堆要快,因为它们可以利用操作系统提供的一些优化机制来提高I/O的效率。
  • 在Java程序中,可以通过ByteBuffer.allocateDirect()方法来创建直接缓冲区。当调用该方法创建直接缓冲区时,JVM会向操作系统申请一块直接内存,用于存储该缓冲区的数据。这个过程不会像在Java堆中创建对象一样,需要进行垃圾回收和堆内存分配的操作,因此创建直接缓冲区的效率要高于在Java堆中创建对象。
  • 需要注意的是,直接内存是不受JVM的内存管理机制控制的,因此如果使用不当,可能会导致内存泄漏等问题。此外,因为直接内存的访问速度快,但申请和释放直接内存的开销较大,因此需要谨慎使用,避免频繁创建和销毁直接缓冲区。

小结

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

比较示例

  • 那这里比较一下传统IO和直接内存对文件拷贝的性能差异(这里建议准备一个比较大的视频文件)
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
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
* 演示 ByteBuffer 作用
*/
public class Demo_14 {
static final String FROM = "D:\\BaiduNetdiskDownload\\星际牛仔.mp4";
static final String TO = "D:\\星际牛仔.mp4";
static final int _1Mb = 1024 * 1024;

public static void main(String[] args) {
io();
directBuffer();
}

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);
}
}
  • 最终结果如下,可以发现,使用直接内存比传统IO快了一倍多
1
2
io 用时:2248.1023
directBuffer 用时:1097.9701
  • 原因是直接内存使用的是操作系统的文件映射机制,而传统IO则需要将文件内容读取到内存中再进行操作。直接内存可以避免将文件数据复制到Java堆内存中的过程,减少了不必要的数据复制,从而提高了效率。
    • 传统IO,将文件读取到系统缓冲区中,但是Java代码不能直接读取系统缓冲区,所以需要在堆内存中分配一块Java缓冲区,将数据从系统缓冲区读取到Java缓冲区后,才能进行写操作
    • 直接内存的Direct Memory对Java堆内存和系统内存是共享的一块内存区,那么磁盘文件就可以直接读取到Direct Memory,而Java堆内存也可以直接访问Direct Memory

直接内存溢出

  • 直接内存(Direct Memory)是一种Java NIO中用于高性能I/O操作的内存分配方式,与Java虚拟机中的Java堆不同,它不会受到Java堆大小的限制。直接内存是通过操作系统的内存来分配和释放,因此它不会受到Java堆大小限制的影响,可以更加灵活地使用。
  • 然而,如果过度使用直接内存,也可能会导致直接内存溢出。直接内存的使用需要手动进行管理,如果不注意及时释放已经使用的直接内存,或者申请过多的直接内存,就会导致直接内存溢出。
  • 当直接内存溢出时,通常会抛出java.lang.OutOfMemoryError异常。为了避免直接内存溢出,建议在使用完直接内存后及时进行释放
  • 下面是一段示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

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

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);
}
}
}
  • 循环了54次就内存溢出了,差不多是5.4G
1
2
3
4
5
6
54
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:695)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at com.demo.Demo_15.main(Demo_15.java:18)

分配和回收原理

  • 前面说直接内存不受JVM的管理,所以垃圾回收gc()对直接内存无效,那么直接内存是如何分配和回收的呢?

    • 来看一下我们的示例代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import java.nio.ByteBuffer;

    public class Demo_16 {
    static int _1GB = 1024 * 1024 * 1024;

    public static void main(String[] args) {
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
    System.out.println("分配完毕");
    byteBuffer = null;
    System.gc();
    System.out.println("释放完毕");
    }
    }
    • 因为是直接内存,所以我们想查看它的使用情况,可以单步调试,直接在Windows的任务管理器中查看
      1. 分配内存前的内存使用情况
      2. 分配内存完毕后的使用情况
      3. 执行垃圾回收后的使用情况
  • 在我们的单步调试中,执行了垃圾回收后,直接内存被释放了,这显然与我们之前所说的冲突啊,这是怎么回事呢?

  • 先别急,我们先来了解一下直接内存应该是怎样被释放的,Java里有一个非常底层的类Unsafe,它可以分配直接内存和释放直接内存,但是一般不建议我们直接使用Unsafe类,都是JDK内部自己去使用这个类的。

  • 那现在为了演示直接内存的分配释放的流程,我们通过反射来获取一个Unsafe对象,然后来进行操作讲解

    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
    import sun.misc.Unsafe;

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

    /**
    * 直接内存分配的底层原理:Unsafe
    */
    public class Demo_17 {
    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.out.println("分配内存");
    unsafe.freeMemory(base);
    System.out.println("释放内存");
    }

    /**
    * 反射获取Unsafe对象
    * @return Unsafe对象
    */
    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);
    }
    }
    }
    1. 分配内存前的内存使用情况
    2. 分配内存完毕后的使用情况
    3. 执行垃圾回收后的使用情况
  • 所以对于直接内存需要使用Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法

  • 现在我们来看一下ByteBuffer.allocateDirect()的底层实现是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Allocates a new direct byte buffer.
*
* <p> The new buffer's position will be zero, its limit will be its
* capacity, its mark will be undefined, and each of its elements will be
* initialized to zero. Whether or not it has a
* {@link #hasArray backing array} is unspecified.
*
* @param capacity
* The new buffer's capacity, in bytes
*
* @return The new byte buffer
*
* @throws IllegalArgumentException
* If the <tt>capacity</tt> is a negative integer
*/
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
25
26
// Primary constructor
//
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对象对直接内存的分配,但是却没有看到回收方法freeMemory
    • 其实释放的方法是在Deallocator()这个回调方法中
    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
    private static class Deallocator
    implements Runnable
    {

    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

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

    }
    • 而它是由Cleaner调用的, Cleaner(虚引用类型)是用来监测ByteBuffer对象的,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleanerclean方法调用freeMemory来释放直接内存
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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;
    }
    });
    }
    }
    }

禁用垃圾回收对直接内存的影响

  • 由于垃圾回收是一个相对昂贵的操作,需要消耗CPU时间和系统资源。频繁调用System.gc()可能会导致性能下降,并且在某些情况下可能会造成应用程序的不稳定性。
  • 所以为了避免有些程序员老是手动调用垃圾回收,我们一般会进制显式手动垃圾回收,添加VM参数-XX:+DisableExplicitGC禁用显式的垃圾回收
  • 那么加上这个参数以后,可能就会影响到我们的直接内存的回收机制,例如下面的代码中,执行完System.gc()后(被禁用,相当于没执行),由于内存很充裕,所以ByteBuffer对象并不会被回收,那么ByteBuffer对象对应的那块直接内存,也不会被回收
1
2
3
4
5
6
7
8
9
10
11
public class Demo_16 {
static int _1GB = 1024 * 1024 * 1024;

public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
byteBuffer = null;
System.gc();
System.out.println("释放完毕");
}
}
  • 单步调试,观察直接内存占用情况,执行垃圾回收后,直接内存没有被释放,那么此时我们就只能通过Unsafe的freeMemory()方法来手动释放直接内存了