- 获得更好的阅读体验,可以移步至我的个人博客:https://cyborg2077.github.io/
- JVM导学:https://cyborg2077.github.io/2023/03/26/JvmPart1/
- JVM内存结构:https://cyborg2077.github.io/2023/03/27/JvmPart2/
- JVM垃圾回收:https://cyborg2077.github.io/2023/04/01/JvmPart3/
- JVM类加载与字节码技术:https://cyborg2077.github.io/2023/04/05/JvmPart4/
- JVM内存模型:https://cyborg2077.github.io/2023/04/11/JvmPart5/
- JVM相关面试题:https://cyborg2077.github.io/2023/04/15/JvmPart6/
程序计数器
定义
- JVM中的程序计数器(Program Counter Register)是一块较小的内存空间,它用来保存当前线程下一条要执行的指令的地址。每个线程都有自己独立的程序计数器,它是线程私有的,生命周期与线程相同。程序计数器是JVM中的一种轻量级的内存区域,因为它不会发生内存溢出(OutOfMemoryError)的情况。
- 程序计数器的作用在于线程切换后能够恢复到正确的执行位置,也就是下一条需要执行的指令地址。
- 因为在Java虚拟机的多线程环境下,为了支持线程切换后能够恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,否则就会出现线程切换后执行位置混乱的问题。
- 程序计数器也是唯一一个在Java虚拟机规范中没有规定任何
OutOfMemoryError(内存溢出)
情况的区域。因为程序计数器是线程私有的,所以它所占用的内存空间非常小,一般不会导致内存溢出的问题。 - 程序计数器是JVM中的一种非常重要的内存区域,它是实现Java虚拟机字节码解释器的必要组成部分。
二进制字节码(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虚拟机栈
- 每个线程运行是所需的内存,就成为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
演示
- 我们编写两个简单的方法,在method1中调用method2,然后断点调试,调试窗口的左边就是虚拟机栈
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方法执行完毕
问题辨析
{% note info no-icon %}
Q:
垃圾回收是否涉及栈内存?
{% endnote %}
{% note pink no-icon %}A:
垃圾回收通常不涉及栈内存。栈内存是在程序运行时自动分配和释放的,因此不需要垃圾回收来处理。相反,垃圾回收主要关注堆内存中的对象,以及这些对象是否还在被引用。垃圾回收器通常会扫描堆内存中的对象,并标记哪些对象仍然被引用,哪些对象可以被清理。
{% endnote %}
{% note info no-icon %}Q:
栈内存分配越大越好吗?
{% endnote %}
{% note pink no-icon %}A:
栈内存的分配大小应该根据实际需要来确定。栈内存的分配是由操作系统负责的- Linux/x64(64 位):1024 KB
- macOS(64 位):1024 KB
- Oracle Solaris/x64(64 位):1024 KB
- Windows:默认值取决于虚拟内存
- 当然我们也可以手动设置线程堆栈大小为1024kb
-Xss1m
-Xss1024k
-Xss1048576
- 栈内存划的越大,会让线程数变少,因为物理内存大小是一定的
- 一个线程用1M,物理内存500M,理论上可以支持500个线程同时运行(但实际还需要一定数量的堆内存和其他系统资源,实际到不了500个线程)
- 但如果一个线程设置2M,那么只有250个线程
- 栈内存划分大了,通常只是能够进行更多次的方法递归调用,而不会增强运行效率,反而会使线程数量变少,一般采用系统默认的栈内存就好
{% endnote %}
{% note info no-icon %}
Q:
方法内的局部变量是否线程安全?
{% endnote %}
{% note pink no-icon %}A:
方法内的局部变量通常是线程安全的,因为它们只能在方法内部访问。每个线程都有自己的栈帧,栈帧包含方法的参数、局部变量和返回值等信息,因此不同的线程可以在不相互干扰的情况下同时访问相同的方法。
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方法是线程安全的
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;
}
}
{% endnote %}
栈内存溢出
- 栈内存溢出有两种情况
- 栈帧过多导致栈内存溢出
- 死循环递归
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
次
- 栈帧过大导致栈内存溢出
- 下面这个例子中,Emp中引入了Dept,而Dept中又引入了Emp,他们现在在循环引用,导致json解析时会出现StackOverFlow
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; } }
- 需要导的依赖
<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)技术来实现的。
/**
* 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 (<condition does not hold>)
* 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关键字创建的对象都会使用堆空间
- 特点
- 它是线程共享的,堆空间内的对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出
- 用下面的代码举个例子
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
堆内存诊断
- jps工具
- 查看当前系统中有哪些Java进程
- jmap工具
- 查看堆内存占用情况
jmap -heap 进程id # 进程id就是jps查出来的进程
- jconsole工具
- 图形化界面的多功能监测工具,可以连续监测
- 案例代码如下
/**
* 演示堆内存
*/
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);
}
}
- 对应的诊断指令及结果
- 查询到目标进程id为17756
$ jps 18640 Jps 5592 RemoteMavenServer36 8008 Launcher 17756 Demo_05 2124
- 当控制台输出 "1…"后,查询当前堆内存占用情况
$ 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
- 当控制台输出 "2…"后,再次查看当前堆内存占用情况
$ 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
- 当控制台输出 "3…"后,再次查看当前堆内存占用情况
$ 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无法被回收
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之后会导致源空间内存溢出,测试代码如下
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
,然后运行上面的代码,结果如下
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类
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
- 然后通过命令将编译后的.class文件反汇编成可读的Java代码
$ 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"
- 上面的结果中主要包含三部分
{% tabs 类基本信息、常量池、类方法定义 %}
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[]); // 这就是我们的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;
}
- 其中如下内容就表示虚拟机的指令
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
- 解释器去翻译虚拟机指令的时候,看到的只有这些
getstatic #2
ldc #3
invokevirtual #4
- 解释器在解释的时候,就是拿着#2、#3、#4去查表翻译,查的就是常量池中的内容
- 用#2举例,查表内容如下
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举例,查表内容如下
ldc #3 // 加载参数Hello, World! #3 = String #23 // Hello, World! #23 = Utf8 Hello, World!
- 用#4举例,查表内容如下
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
{% endtabs %}
- 常量池是 *.class 文件中的
Constant pool
中的内容 - 而运行时常量池是当该类被加载时,将常量池信息放入运行时常量池,并把里面的符号地址(#2、#3)变为内存地址
StringTable
- 我们反编译一下这段代码,看看常量池里都有什么
public class Demo_08 { // 常量池中的信息,都会被加载到运行时常量池中,此时a、b、ab都是常量池中的符号,还没有变成java对象 public static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "ab"; } }
- 结果如下(只截取了我们需要的东西)
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呢?
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);
}
}
- 答案我先不说,我们还是先反编译一下这段代码,结果如下
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对象是如何被创建的
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_1
和aload_2
,即a、b
#8
:调用StringBuilder的toString方法
- 总结一下,s4对象的创建方法如下
String s4 = new StringBuilder.append("a").append("b").toString();
- 那么s4对象是new出来的对象,存放在堆空间里,而s3对象是存在于常量池中的,故
s3 == s4
的结果为false
- 再来试试常量拼接的结果如何
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
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来动态的拼接
- 这是javac在编译期的优化,因为s5是由两个常量拼接而成,常量拼接的结果是确定的,那么在编译期间就能确定结果肯定是
字符串延迟加载
- 每遇到一个没见过的字符串对象,才会将其放入常量池,如果池子中已经有了,则不会新增对象
- 使用下面的代码单步调试来验证一下
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特性
- 常量池中的字符串仅是符号,第一次用到时才会变为对象
- 利用串池的机制,避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder(1.8)
- 字符串常量拼接的原理是编译期优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
- 1.8中,将这个字符串对象尝试放入串池
- 如果串池中已有,则不会放入
- 如果串池中没有,则放入串池,并将串池中的结果返回
- 下面是示例代码讲解
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 } }
- 1.8中,将这个字符串对象尝试放入串池
StringTable的位置
- 在
JDK 1.6
中,字符串常量池(也就是 StringTable)是位于永久代
中的。而在JDK 1.8
中,永久代已经被移除,取而代之的是元空间(Metaspace),而字符串常量池也随之移动到了堆
中。这意味着在 JDK 1.8 中,字符串常量池中的字符串也可以被垃圾回收器回收,而在 JDK 1.6 中则不行。
StringTable垃圾回收
- 在 Java 8 及更高版本中,字符串常量池位于堆中,而堆是 JVM 中的一部分,因此字符串常量池中的字符串可以被垃圾回收器回收。具体来说,只有当字符串没有被任何对象引用时,它才能被垃圾回收。当字符串被回收时,它的存储空间将被释放并可以被重新利用。
- 下面我们通过示例代码来验证一下,首先先添加几个VM参数
- -Xmx10m:指定堆内存大小
- -XX:+PrintStringTableStatistics:打印字符串常量池信息
- -XX:+PrintGCDetails:打印垃圾回收详细信息
- -verbose:gc:打印 gc 的次数,耗费时间等信息
public class Demo_11 {
public static void main(String[] args) {
int i = 0;
try {
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
- 打印的日志信息如下
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个字面量。
- 这是字符串常量池的统计信息,包含以下三个方面的信息
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个字符串存入常量池中
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确实是会发生垃圾回收的
[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,实际上设置的是哈希表的大小(即桶的数量)。较小的哈希表意味着更多的哈希冲突。这会增加查找字符串的开销,因为需要在链表中进行顺序搜索才能找到一个字符串。因此,这将会导致字符串查找速度变慢。
- 示例代码如下
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()方法将常量放入常量池中,以节省内存空间并提高性能。
- 实际应用:
{% note info no-icon %}- 根据推特的工程师们所说,推特在存储用户地址信息时采用了字符串常量池的方法。推特上有大量的用户地址信息,而这些信息中有大量的重复内容,如街道名称、城市、州等。通过将这些常见的地址信息存储在字符串常量池中,推特可以节省大量的内存空间。
- 推特使用了Guava库中的Interners工具类来实现字符串常量池。该工具类提供了线程安全的字符串常量池实现,支持不同的策略和配置,例如并发级别、最大容量等。推特选择了使用一个全局的、不限容量的字符串常量池来存储用户地址信息。在存储用户信息时,推特使用了String.intern()方法来将地址信息存储在字符串常量池中,而不是直接使用新的字符串对象。这样,推特可以确保相同的地址信息只会在内存中存在一份拷贝,从而减少内存的占用。
- 通过这种方法,推特成功地实现了在存储大量用户信息时,有效地减少了内存占用。
{% endnote %}
- 那我们现在来复现一个类似的场景,存储大量重复的字符串常量信息,然后使用Java VisualVM监测内存使用情况,示例代码如下
- 每循环一次就有48W个字符串,循环十次就是480W个字符串放到内存中
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
- 那现在我们将字符串入池,再来监测一下内存占用情况
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内存
{% note info no-icon %}
小结:如果我们需要存储大量字符串常量信息,而且这些字符串常量包含大量重复内容,可以使用Java中的字符串常量池机制,通过调用intern()方法将常量放入常量池中,以节省内存空间并提高性能。
{% endnote %}
直接内存
定义
- JVM的直接内存是指JVM中的一个内存区域,也被称为NIO直接缓冲区。和Java堆不同,直接内存并不是由JVM自动管理的,而是由操作系统直接管理的。直接内存的访问速度比Java堆要快,因为它们可以利用操作系统提供的一些优化机制来提高I/O的效率。
- 在Java程序中,可以通过ByteBuffer.allocateDirect()方法来创建直接缓冲区。当调用该方法创建直接缓冲区时,JVM会向操作系统申请一块直接内存,用于存储该缓冲区的数据。这个过程不会像在Java堆中创建对象一样,需要进行垃圾回收和堆内存分配的操作,因此创建直接缓冲区的效率要高于在Java堆中创建对象。
- 需要注意的是,直接内存是不受JVM的内存管理机制控制的,因此如果使用不当,可能会导致内存泄漏等问题。此外,因为直接内存的访问速度快,但申请和释放直接内存的开销较大,因此需要谨慎使用,避免频繁创建和销毁直接缓冲区。
小结
- Direct Memory
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不收JVM内存回收管理
比较示例
- 那这里比较一下传统IO和直接内存对文件拷贝的性能差异(这里建议准备一个比较大的视频文件)
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快了一倍多
io 用时:2248.1023
directBuffer 用时:1097.9701
- 原因是直接内存使用的是操作系统的文件映射机制,而传统IO则需要将文件内容读取到内存中再进行操作。直接内存可以避免将文件数据复制到Java堆内存中的过程,减少了不必要的数据复制,从而提高了效率。
- 传统IO,将文件读取到系统缓冲区中,但是Java代码不能直接读取系统缓冲区,所以需要在堆内存中分配一块Java缓冲区,将数据从系统缓冲区读取到Java缓冲区后,才能进行写操作
- 直接内存的Direct Memory对Java堆内存和系统内存是共享的一块内存区,那么磁盘文件就可以直接读取到Direct Memory,而Java堆内存也可以直接访问Direct Memory
- 传统IO,将文件读取到系统缓冲区中,但是Java代码不能直接读取系统缓冲区,所以需要在堆内存中分配一块Java缓冲区,将数据从系统缓冲区读取到Java缓冲区后,才能进行写操作
直接内存溢出
- 直接内存(Direct Memory)是一种Java NIO中用于高性能I/O操作的内存分配方式,与Java虚拟机中的Java堆不同,它不会受到Java堆大小的限制。直接内存是通过操作系统的内存来分配和释放,因此它不会受到Java堆大小限制的影响,可以更加灵活地使用。
- 然而,如果过度使用直接内存,也可能会导致直接内存溢出。直接内存的使用需要手动进行管理,如果不注意及时释放已经使用的直接内存,或者申请过多的直接内存,就会导致直接内存溢出。
- 当直接内存溢出时,通常会抛出java.lang.OutOfMemoryError异常。为了避免直接内存溢出,建议在使用完直接内存后及时进行释放
- 下面是一段示例代码
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
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()对直接内存无效,那么直接内存是如何分配和回收的呢?
- 来看一下我们的示例代码
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的任务管理器中查看
- 分配内存前的内存使用情况
- 分配内存完毕后的使用情况
- 执行垃圾回收后的使用情况
- 分配内存前的内存使用情况
-
在我们的单步调试中,执行了垃圾回收后,
直接内存被释放了
,这显然与我们之前所说的冲突啊,这是怎么回事呢? -
先别急,我们先来了解一下直接内存应该是怎样被释放的,Java里有一个非常底层的类
Unsafe
,它可以分配直接内存和释放直接内存,但是一般不建议我们直接使用Unsafe类,都是JDK内部自己去使用这个类的。 -
那现在为了演示直接内存的分配释放的流程,我们通过反射来获取一个Unsafe对象,然后来进行操作讲解
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); } } }
- 分配内存前的内存使用情况
- 分配内存完毕后的使用情况
- 执行垃圾回收后的使用情况
- 分配内存前的内存使用情况
-
所以对于直接内存需要使用
Unsafe
对象完成直接内存的分配回收,并且回收需要主动调用freeMemory
方法 -
现在我们来看一下ByteBuffer.allocateDirect()的底层实现是什么
/**
* 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
对象的源码
// 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()这个回调方法中
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
线程通过Cleaner
的clean
方法调用freeMemory
来释放直接内存
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对象对应的那块直接内存,也不会被回收
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()方法来手动释放直接内存了