JVM知多少

144 阅读10分钟

JVM是什么?

我们先来看看JVM都包含了些什么:程序计数器,虚拟机栈,本地方法栈,堆,方法区。

程序计数器(寄存器):

用来存储程序执行下一条指令的地方属于线程私有的并且内存不会溢出,它是基于寄存器实现的。 程序执行的时候解释器通过程序技术器获得执行指令并转转成机器码交给cpu来执行

虚拟机栈:

线程运行需要的内存空间,称为虚拟机栈,而虚拟机栈是由栈帧所组成, 每个线程只能有一个活动栈帧,对应着当前正在执行的方法,也就是说栈帧指的就是当前执行的方法。同时也是线程私有的哈

问题来了:

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

不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。

2.栈内存分配越大越好吗?

不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

3.方法内的局部变量是否线程安全

如果方法内部的变量没有逃离方法的作用访问,它是线程安全的

如果是局部变量引用了对象,并逃离了方法的访问,那就要考虑线程安全问题。

虚拟机栈内存溢出(StackOverFlow):当前栈帧过大或者栈帧过大则会出现虚拟机栈溢出,一般是很难出现的-Xss,如果设置了动态扩容机制那么就可能会出现OOM

线程运行诊断

cpu 占用过多

解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程 top 命令,查看是哪个进程占用 CPU 过高 ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高。 jstack 进程 id 通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。注意jstack是java独有的哈! 若是docker环境则需要通过docke exec -it 容器ID bash进入容器内部进行监测。

本地方法栈

一些带有 native 关键字的方法就是需要 JAVA 去调用本地的C或者C++方法,因为 JAVA 有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法。 如果设置了本地方法栈的动态扩容机制那么有可能造成本地方法栈溢出也就是oom了

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

特点:

  • 它是线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出

java.lang.OutofMemoryError :java heap space. 堆内存溢出 可以使用 -Xmx8m 来指定堆内存大小。

堆内存诊断

  • jps 工具
  • 查看当前系统中有哪些 java 进程
  • jmap 工具
  • 查看堆内存占用情况 jmap - heap 进程id
  • jconsole 工具
  • 图形界面的,多功能的监测工具,可以连续监测
  • jvisualvm 工具

方法区

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!

image.png 由上图可以看出1.6里面利用永久代实现的方法区是存在jvm中并且常量池,类加载都在方法区中,1.8之后方法区由元空间实现已经不是由jvm来管理内存结构了放到了本地内存中但是常量池中的StringTable放到了堆中。

方法区内存溢出

  • 1.8之前会导致永久代内存溢出

    • 使用 -XX:MaxPermSize=9m 指定永久代内存大小
  • 1.8 之后会导致元空间内存溢出

    • 使用 -XX:MaxMetaspaceSize=9m 指定元空间大小

常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息

运行时常量池:常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址,说白了就是存储正在执行的常量池信息

StringTable

常量池中的字符串仅是符号,只有在被用到时才会转化为对象 利用串池的机制,来避免重复创建字符串对象 字符串变量拼接的原理是StringBuilder 字符串常量拼接的原理是编译器优化 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中 intern方法 1.8 调用字符串对象的 intern 方法,会将该字符串对象尝试放入到串池中

如果串池中没有该字符串对象,则放入成功 如果有该字符串对象,则放入失败 无论放入是否成功,都会返回串池中的字符串对象 注意:此时如果调用 intern 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

例1:

public class Main {
	public static void main(String[] args) {
		// "a" "b" 被放入串池中,str 则存在于堆内存之中
		String str = new String("a") + new String("b");
		// 调用 str 的 intern 方法,这时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
		String st2 = str.intern();
		// 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
		String str3 = "ab";
		// 因为堆内存与串池中的 "ab" 是同一个对象,所以以下两条语句打印的都为 true
		System.out.println(str == st2);
		System.out.println(str == str3);
	}
}

例2:

public class Main {
	public static void main(String[] args) {
        // 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
		String str3 = "ab";
        // "a" "b" 被放入串池中,str 则存在于堆内存之中
		String str = new String("a") + new String("b");
        // 此时因为在创建 str3 时,"ab" 已存在与串池中,所以放入失败,但是会返回串池中的 "ab" 
		String str2 = str.intern();
        // false
		System.out.println(str == str2);
        // false
		System.out.println(str == str3);
        // true
		System.out.println(str2 == str3);
	}
}

StringTable 的位置

jdk1.6 StringTable 位置是在永久代中,1.8 StringTable 位置是在堆中。

StringTable 垃圾回收

-Xmx10m 指定堆内存大小

-XX:+PrintStringTableStatistics 打印字符串常量池信息

-XX:+PrintGCDetails

-verbose:gc 打印 gc 的次数,耗费时间等信息

代码演示

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

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

}

StringTable 性能调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间

  • -XX:StringTableSize=桶个数(最少设置为 1009 以上)

  • 考虑是否需要将字符串对象入池

  • 可以通过 intern 方法减少重复入池

直接内存

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

使用直接内存的好处

文件读写流程:

image.png

因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java 堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。

使用了 DirectBuffer 文件读取流程

image.png

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。

直接内存回收原理

public class Code_06_DirectMemoryTest {

    public static int _1GB = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
//        method();
        method1();
    }

    // 演示 直接内存 是被 unsafe 创建与回收
    private static void method1() throws IOException, NoSuchFieldException, IllegalAccessException {

        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe)field.get(Unsafe.class);

        long base = unsafe.allocateMemory(_1GB);
        unsafe.setMemory(base,_1GB, (byte)0);
        System.in.read();

        unsafe.freeMemory(base);
        System.in.read();
    }

    // 演示 直接内存被 释放
    private static void method() throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
        System.out.println("分配完毕");
        System.in.read();
        System.out.println("开始释放");
        byteBuffer = null;
        System.gc(); // 手动 gc
        System.in.read();
    }

}

直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过unsafe.freeMemory 来手动释放。 第一步:allocateDirect 的实现


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

底层是创建了一个 DirectByteBuffer 对象。 第二步:DirectByteBuffer 类


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)); // 通过虚引用,来实现直接内存的释放,this为虚引用的实际对象, 第二个参数是一个回调,实现了 runnable 接口,run 方法中通过 unsafe 释放内存。
    att = null;
}

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

 public void clean() {
        if (remove(this)) {
            try {
            // 都用函数的 run 方法, 释放内存
                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.run(),thunk 是 Runnable 对象。run 方法就是回调 Deallocator 中的 run 方法,

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

直接内存的回收机制总结

使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法 ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存 注意:

   /**
     * -XX:+DisableExplicitGC 显示的
     */
    private static void method() throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
        System.out.println("分配完毕");
        System.in.read();
        System.out.println("开始释放");
        byteBuffer = null;
        System.gc(); // 手动 gc 失效
        System.in.read();
    }

一般用 jvm 调优时,会加上下面的参数:

-XX:+DisableExplicitGC // 静止显示的 GC

意思就是禁止我们手动的 GC,比如手动 System.gc() 无效,它是一种 full gc,会回收新生代、老年代,会造成程序执行的时间比较长。所以我们就通过 unsafe 对象调用 freeMemory 的方式释放内存。

总结一下吧,程序计数器,虚拟机栈,本地方法栈为线程私有的,其中程序计数器不会造成内存溢出,虚拟机栈会由于栈帧过大或者过多导致虚拟机栈溢出。 堆,方法区,直接内存为线程共享的需要考虑线程安全问题,他们都有可能造成OOM,如果本地方法栈在设置了动态扩容机制后也会造成OOM哦!