JVM内存结构

  • 获得更好的阅读体验,可以移步至我的个人博客:https://cyborg2077.github.io/
    1. JVM导学:https://cyborg2077.github.io/2023/03/26/JvmPart1/
    2. JVM内存结构:https://cyborg2077.github.io/2023/03/27/JvmPart2/
    3. JVM垃圾回收:https://cyborg2077.github.io/2023/04/01/JvmPart3/
    4. JVM类加载与字节码技术:https://cyborg2077.github.io/2023/04/05/JvmPart4/
    5. JVM内存模型:https://cyborg2077.github.io/2023/04/11/JvmPart5/
    6. 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虚拟机栈
    1. 每个线程运行是所需的内存,就成为虚拟机栈
    2. 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时占用的内存
    3. 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

演示

  • 我们编写两个简单的方法,在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 %}

栈内存溢出

  • 栈内存溢出有两种情况
    1. 栈帧过多导致栈内存溢出
      • 死循环递归
      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
      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 (&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关键字创建的对象都会使用堆空间
  • 特点
    • 它是线程共享的,堆空间内的对象都需要考虑线程安全的问题
    • 有垃圾回收机制

堆内存溢出

  • 用下面的代码举个例子
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工具
    • 查看堆内存占用情况
    jmap -heap 进程id # 进程id就是jps查出来的进程
    
  3. 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);
    }
}
  • 对应的诊断指令及结果
    1. 查询到目标进程id为17756
    $ jps
    18640 Jps
    5592 RemoteMavenServer36
    8008 Launcher
    17756 Demo_05           
    2124
    
    1. 当控制台输出 "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
    
    1. 当控制台输出 "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
    
    1. 当控制台输出 "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_1aload_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来动态的拼接

字符串延迟加载

  • 每遇到一个没见过的字符串对象,才会将其放入常量池,如果池子中已经有了,则不会新增对象
  • 使用下面的代码单步调试来验证一下
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中,将这个字符串对象尝试放入串池
      • 如果串池中已有,则不会放入
      • 如果串池中没有,则放入串池,并将串池中的结果返回
    • 下面是示例代码讲解
    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 的次数,耗费时间等信息
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
    1. 常见于NIO操作时,用于数据缓冲区
    2. 分配回收成本较高,但读写性能高
    3. 不收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

直接内存溢出

  • 直接内存(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的任务管理器中查看
      1. 分配内存前的内存使用情况
      2. 分配内存完毕后的使用情况
      3. 执行垃圾回收后的使用情况
  • 在我们的单步调试中,执行了垃圾回收后,直接内存被释放了,这显然与我们之前所说的冲突啊,这是怎么回事呢?

  • 先别急,我们先来了解一下直接内存应该是怎样被释放的,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);
            }
        }
    }
    
    1. 分配内存前的内存使用情况
    2. 分配内存完毕后的使用情况
    3. 执行垃圾回收后的使用情况
  • 所以对于直接内存需要使用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线程通过Cleanerclean方法调用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()方法来手动释放直接内存了
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值