3

1. 什么是 JVM?

1.1 定义

JVM,即Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

1.2 好处

  1. 一次编写,到处运行的基石
  2. 自动内存管理,垃圾回收功能
  3. 数据下标越界检查
  4. 多态

1.3 JVM、JRE、JDK 比较

image

1.4 常见的 JVM

JVM 只是套规范,基于这套规范的实现常见如下几种:

image

2. 内存结构

2.1 整体架构

JVM 内存结构主要包含:程序计数器虚拟机栈本地方法栈方法区,其中前三者是线程私有的,后两者是线程共享的。整体架构如下图:

image

2.2 程序计数器

2.2.1 作用

用于保存 JVM 中下一条要执行的指令的地址

2.2.2 特点

  • 线程私有

    • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程的代码。程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一条指令
  • 不会存在内存溢出

    • 因为永远只存储一个指令地址,JVM规范中唯一一个不存在OutOfMemoryError的区域

2.3 虚拟机栈

2.3.1 定义

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

使用 IDE 调试演示,从控制台可以看到方法进入虚拟机栈后的样子,如下:
image

2.3.2 特点

  • 线程私有的
  • 存在栈内存溢出

2.3.3 问题辨析

  • 垃圾回收是否涉及栈内存?

    • 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈,内存会自动释放。所以无需通过垃圾回收机制去回收栈内存
  • 栈内存的分配越大越好吗?

    • 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少
  • 方法内的局部变量是否是线程安全的?

    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题
    • 代码示例:
          //是线程安全的,因为局部变量sb只在m1()方法内使用
          public static void m1() {
              StringBuilder sb = new StringBuilder();
              sb.append(1);
              sb.append(2);
              sb.append(3);
              System.out.println(sb.toString());
          }
        
          //不是线程安全的,因为局部变量sb是作为参数传过来的,m2()执行的同时可能会被其他线程修改
          public static void m2(StringBuilder sb) {
              sb.append(1);
              sb.append(2);
              sb.append(3);
              System.out.println(sb.toString());
          }
        
          //不是线程安全的,因为局部变量sb是作为返回值,可能会被多个其他线程拿到并同时修改
          public static StringBuilder m3() {
              StringBuilder sb = new StringBuilder();
              sb.append(1);
              sb.append(2);
              sb.append(3);
              return sb;
          }

2.3.4 栈内存溢出

Java.lang.stackOverflowError 栈内存溢出

发生原因

  • 虚拟机栈中,栈帧过多(比如无限递归)
  • 每个栈帧所占用内存过大

2.3.5 线程运行诊断

  • CPU 占用过高。Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程

    1. top 命令,查看哪个进程占用CPU过高
    2. 拿到占用CPU过高的进程ID后,再通过 ps 查看哪个线程占用 CPU 过高。" ps H -eo pid, tid, %cpu | grep 查到的占用CPU过高的进程ID"
    3. 通过 "jstack 进程id" 命令查看进程中线程ID(nid),与上一步查出的线程ID(tid)来对比定位有问题的线程以及源码行号。注意jstack查找出的线程ID(nid)是16进制的需要转换。
  • 程序运行很长时间没有结果 。一般可能是发生了线程死锁,这时需要找到死锁的线程

    • 找到当前运行的java程序的进程ID(也可以使用 jps 命令)
    • 通过 "jstack 进程id" 命令查看进程中是否有死锁线程
    • 演示

      class A{}
      class B{}
      public class Demo004 {
      
          static A a = new A();
          static B b = new B();
      
          public static void main(String[] args) throws InterruptedException {
              new Thread(()->{
                  synchronized (a) {
                      try {
                          Thread.sleep(2000);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      synchronized (b) {
                          System.out.println("我获得了 a 和 b");
                      }
                  }
              }).start();
              
              Thread.sleep(1000);
              
              new Thread(()->{
                  synchronized (b) {
                      synchronized (a) {
                          System.out.println("我获得了 a 和 b");
                      }
                  }
              }).start();
          }
      
      }

image

2.4 本地方法栈

一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法。

本地方法栈也是 线程私有的

2.5 堆

2.5.1 定义

通过new关键字创建的对象都会被放在堆内存

2.5.2 特点

  • 所有线程共享。堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制
  • 存在堆内存溢出

2.5.3 堆内存溢出

java.lang.OutofMemoryError :java heap space 堆内存溢出

2.5.4 堆内存诊断工具

  • jps工具:查看当前系统中有哪些java进程
  • jmap工具:java内存映像工具。如查看堆内存占用情况 ,jmap -heap pid
  • jconsole工具:图形界面的,Java监视与管理控制台
  • jvisualvm工具:图形界面的,多合一故障处理工具

2.6 方法区

2.6.1 定义

方法区用于存储每个类的结构,比如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括在类和实例初始化以及接口初始化中使用的特殊方法。方法区域可以是固定大小,也可以根据计算的需要进行扩展,如果不需要更大的方法区域,则可以收缩。

2.6.2 组成

方法区是一个抽象概念,JDK1.8以前方法区由永久代实现,JDK1.8以后方法区由元空间实现

image

2.6.3 方法区内存溢出

  • JDK1.8之前会导致永久代内存溢出

    • 设置永久代最大内存参数:-XX:MaxPerSize=<size>
    • 异常信息:java.lang.OutOfMemoryError: PerGen space
  • JDK1.8之后会导致元空间内存溢出

    • 设置元空间最大内存参数:-XX:MaxMetaspaceSize=<size>
    • 异常信息:java.lang.OutOfMemoryError: Metaspace
    • 演示

      /**
       * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
 */
public class Demo006 extends ClassLoader { // 继承ClassLoader,可以用来加载类的二进制字节码
    
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo006 test = new Demo006();
            for (int i = 0; i < 10000; 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);
        }
    }
}
```

![image](/img/bVbZOEr)
虽然我们自己编写的程序没有大量使用动态加载类,但如果我们在使用外部一些框架时,可能大量动态加载类,就可能会导致元空间内存溢出。如 spring、mybatis 的动态代理

2.6.4 常量池

  • 常量池:Class文件中除了类的版本、字段、方法、接口等描述信息,还有常量池,用于存放编译期生成的各种字面量和符号引用。就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。
  • 运行时常量池:常量池是class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址翻译为真实地址
较于Class文件常量池,运行时常量池具有动态性,在运行期间也可以将新的常量放入常量池中,而不是一定要在编译时确定的常量才能放入。最主要的运用便是String类的intern()方法

常量池与JVM字节码演示

public class Demo007 {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

通过 "javap -v <class文件>" 反编译,查看二进制字节码,其中包含了类基本信息常量池类方法定义(包含虚拟机指令)
进入Demo007.class 所在目录执行,javap -v Demo007.class,输出结果如下,

Classfile /E:/codes/java_demos/out/production/jvm/indi/taicw/jvm/structure/methodarea/constantpool/Demo007.class
  Last modified 2020-10-7; size 622 bytes
  MD5 checksum 1356d96bb349e8a95e8c5df1cb36040c
  Compiled from "Demo007.java"
public class indi.taicw.jvm.structure.methodarea.constantpool.Demo007
  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            // indi/taicw/jvm/structure/methodarea/constantpool/Demo007
   #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               Lindi/taicw/jvm/structure/methodarea/constantpool/Demo007;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Demo007.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               indi/taicw/jvm/structure/methodarea/constantpool/Demo007
  #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 indi.taicw.jvm.structure.methodarea.constantpool.Demo007();
    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 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lindi/taicw/jvm/structure/methodarea/constantpool/Demo007;

  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 8: 0
        line 9: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "Demo007.java"

2.6.5 字符串常量池 StringTable

字符串常量池 是常量池中专门用来存放常量字符串的一块区域,使用 StringTable 进行存储,底层数据结构是一个哈希表。

StringTable 位置

  • JDK1.6版本中,字符串常量池是在永久代
  • JDK1.7版本及以后版本,JVM 已经将字符串常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放字符串常量池

StringTable 特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象并放入字符串常量池
  • 利用字符串常量池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 String#intern() 方法,主动将当前字符串加入到字符串常量池中,并返回常量池中该字符串对象的引用

    • JDK1.7及以后版本中调用intern()方法将这个字符串对象尝试放入串池,如果串池有则不会放入,如果没有则放入串池,并把串池中的对象引用返回
    • JDK1.6 版本中调用intern()方法将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把对象复制一份放入串池,并把串池中的对象引用返回,而之前那个字符串对象还是存在的

字符串拼接与字符串常量池相关问题

public class Demo008 {

    public static void main(String[] args) {
        String s1 = "a";    //字符串 "a" 放入串池,StringTable ["a"]
        String s2 = "b";    //字符串 "b" 放入串池,StringTable ["a", "b"]
        String s3 = "ab";   //字符串 "ab" 放入串池,StringTable ["a", "b", "ab"]

        //因为s1、s2是变量,只有运行时期才能确定具体值,所以底层实现是 new StringBuilder().append("a").append("b").toString(),返回一个新的"ab"字符串对象
        String s4 = s1 + s2;
        // javac 在编译期间的优化,结果已经在编译期确定为 "ab",所以等价于 s5 = "ab",此时常量池已存在不用再创建新的"ab"对象
        String s5 = "a" + "b";
        // s4调用intern()方法尝试把"ab"放入串池中,此时串池已存在"ab", 并返回串池中的"ab"对象引用赋值给s6
        String s6 = s4.intern();

        //false,原因:s3 是串池中"ab"字符串对象的引用,s4是堆中"ab"对象的引用
        System.out.println(s3 == s4);
        //true,原因:s3、s5 都是串池中"ab"字符串对象的引用
        System.out.println(s3 == s5);
        //true,原因:s3、s6 都是串池中"ab"字符串对象的引用
        System.out.println(s3 == s6);


        String x2 = new String("c") + new String("d");
        x2.intern(); //把字符串"cd"放入串池,StringTable ["a", "b", "ab", "cd"]
        String x1 = "cd"; //直接把串池中的"cd"对象引用赋值给 x1
        
        //JDK1.7及之后版本,结果为true, 原因:x2调用intern()方法尝试把"cd"放入串池,此时串池不存在"cd"放入成功,此时串池中的"cd"对象与堆中的是同一个
        //JDK1.6版本,结果为false, 原因:x2调用intern()方法尝试把"cd"放入串池,此时串池不存在"cd"字符串,会复制一个新的"cd"对象放入串池中,此时串池中的"cd"对象与堆中的不是同一个
        System.out.println(x1 == x2);

        String x4 = new String("e") + new String("f");
        String x3 = "ef"; //把字符串"cd"放入串池,StringTable ["a", "b", "ab", "cd", "ef"]
        x4.intern();
        //结果为false, 原因:x4调用intern()方法尝试把"ef"放入串池,此时串池已存在"cd"放入失败,此时x3是串池中"ef"对象的引用,而x4是堆中的"ef"对象引用
        System.out.println(x3 == x4);
    }
}

2.6.6 StringTable 垃圾回收

因为在jdk1.7版本以后,字符串常量池是放在堆中,如果堆空间不足,字符串常量池也会进行垃圾回收。如下演示代码,设置最大堆内存为10m 并打印 gc 信息

/**
 * 演示 StringTable 垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class Demo009 {

    public static void main(String[] args) {
        int i = 0;
        try {
            for (int j = 0; j < 100000; j++) { // j=100, j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}

2.6.7 StringTable 性能调优

  • StringTable 的数据结构是哈希表,所以可以适当增加哈希表桶的大小,来减少字符长放入StringTable所需的时间

    • 设置StringTable桶大小参数:-XX:StringTableSize=<桶个数>
  • 对于有些重复的字符串可以考虑放入常量池,可以减少内存占用

2.7 直接内存

2.7.1 定义

直接内存并不是JVM内存区域的一部分,不受JVM内存回收管理常见于NIO操作用于数据缓存区分配回收成本高但读写性能高

java程序资源文件读取过程中,未使用直接内存和使用直接内存区别如下:

  • 未使用直接内存

    • 需要从用户态向内核态申请资源,内核态会创建系统缓冲区,用户态会创建一个java 缓冲区byte[],然后数据从系统缓存区复制到java缓存区
    • image
  • 使用直接内存

    • 需要从用户态向内核态申请资源,即内核态会创建一块直接内存direct memory,这块direct memory内存可以在用户态、内核态使用。直接内存是操作系统和Java代码都可以访问的一块区域,无需将数据从系统内存复制到Java堆内存,从而提高了效率
    • image

使用普通内存方式与使用直接内存方式读取大文件效率比较,演示代码如下:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * 演示 普通内存与直接内存读取文件效率
 */
public class Demo011 {
    static final String FROM = "C:\\Users\\taichangwei\\Downloads\\CentOS-6.10-x86_64-bin-DVD2.iso"; //一个2.03G的文件
    static final String TO = "E:\\a.iso";
    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 用时:4944.8368
directBuffer 用时:1927.7912

io 用时:4989.7547
directBuffer 用时:2287.7028

io 用时:5022.2618
directBuffer 用时:2453.5307
 */

2.7.2 直接内存溢出

直接内存虽然不受JVM内存管理,但是物理内存是有限的,所以直接内存还是会存在内存溢出,java.lang.OutOfMemoryError: Direct buffer memory

演示直接内存溢出,代码如下:

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示 直接内存溢出
 */
public class Demo012 {
    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);
        }
    }
}

循环了 36 次,使用了3.6G的内存导致内存溢出了

image

2.7.3 直接内存的分配及回收原理

  • 底层使用了UnSafe对象完成直接内存的分配及回收,并且回收需要主动调用freeMemory()方法

    • 直接内存分配及回收的底层原理演示,代码如下:

      import sun.misc.Unsafe;
      import java.io.IOException;
      import java.lang.reflect.Field;
      
      public class Demo014 {
      
          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);
              //暂定等待。此时查看任务管理器(windows系统),会发现有一个占用1G内存的java程序
              System.in.read();
      
              // 释放内存
              unsafe.freeMemory(base);
              //暂定等待。此时查看任务管理器(windows系统),会发现刚才占用1G内存的java程序内存释放了
              System.in.read();
          }
      
          public static Unsafe getUnsafe() {
              try {
                  //Unsafe 构造方法是私有化的,只能通过反射获得
                  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);
              }
          }
      }
  • 通常情况是我们是使用 "ByteBuffer.allocateDirect(int capacity)" 来申请直接内存就可以了。ByteBuffer的实现类内部使用了Cleaner(虚引用)来检测ByteBuffer一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler线程来调用Cleaner的clean()方法调用freeMemory()来释放内存

    • allocateDirect(int capacity) 方法内部实现:

          public static ByteBuffer allocateDirect(int capacity) {
              return new DirectByteBuffer(capacity);
          }

      DirectByteBuffer(int cap) 方法内部实现

          DirectByteBuffer(int cap) {            
      
              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;
          }

      这里调用了一个Cleaner的create()方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里指的是DirectByteBuffer对象)被回收以后,就会调用Cleaner的clean()方法,来清除直接内存中占用的内存

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

      其中 "this.thunk" 是上一步传入的 Deallocator 对象,它的run方法内部实现如下:

          public void run() {
              if (address == 0) {
                  // Paranoia
                  return;
              }
              unsafe.freeMemory(address);
              address = 0;
              Bits.unreserveMemory(size, capacity);
          }

小飞侠
388 声望80 粉丝

一切都在进行着,唯一可能停止的只有自己!!!