JVM 基础、堆内存分析和垃圾回收算法

文章首发我的博客,欢迎访问:https://blog.itzhouq.cn/jvm

首先基本的面试题都是下面的夺命连环问,感受一下。

  • 请你谈谈你对 JVM 的理解。java8 虚拟机和之前有什么变化?
  • 什么是 OOM, 什么是栈溢出 StackOverFlowError? 怎么分析?
  • JVM 的常用调优参数有哪些?
  • 内存快照如何抓取,怎么分析 Dump 文件?你知道吗?
  • 谈谈 JVM 中,你对类加载器的认识?

这篇文章先大体梳理一下相关的知识点,后面再整理一篇基本面试题相关的,先挖个坑。要说明的是,文章中很多地方关于概念是一带而过的,难免有部分内容没有说明白。对于不明白的点,建议自己动手查查相关资料,决不能指望一篇笔记就能把 JVM 搞明白,这显然也是不可能的。

1、JVM 的位置

image

可以看到 JVM 是 JRE 的一部分。主要工作是解释自己的字节码并映射到本地的 CPU 指令集和 OS 的系统调用。Java 语言是跨平台的,不同的操作系统会有不同的 JVM 映射规则,这就使得 Java 语言与操作系统无关。

2、JVM 的体系结构

image
image
image

3、类加载器

作用:加载 Class 文件,比如我们 new Student() 的时候,Student 是类,是抽象的,使用 new 关键词创建对象实例,实例的引用是在栈中,而具体的人是放在堆中。

3.1、类的实例化和双亲委派机制

public class Car {
    public static void main(String[] args) {
        // 类是模板,对象是具体的
        Car car1 = new Car();
        Car car2 = new Car();
        Car car3 = new Car();

        System.out.println(car1.hashCode()); // 460141958
        System.out.println(car2.hashCode()); // 1163157884
        System.out.println(car3.hashCode()); // 1956725890

        Class aClass1 = car1.getClass();
        Class aClass2 = car2.getClass();
        Class aClass3 = car3.getClass();

        System.out.println(aClass1.hashCode()); // 685325104
        System.out.println(aClass2.hashCode()); // 685325104
        System.out.println(aClass3.hashCode()); // 685325104
    }
}
image

3.2、类加载

类加载器:

  1. 虚拟机自带的加载器
  2. 启动类(根)加载器
  3. 扩展类加载器
  4. 应用程序加载器

试验:自己定义一个String类,看是否能执行

package java.lang;

public class String {
    // 双亲委派机制:安全
    // BOOT --> EXT --> APP (最终执行)
    // BOOT
    // EXT
    // APP
    public String toString () {
        return "Hello";
    }

    public static void main(String[] args) {
        String s = new String();
        System.out.println(s.toString());
    }
    
    // 错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
    //   public static void main(String[] args)
    //否则 JavaFX 应用程序类必须扩展javafx.application.Application
    
    /**
     * 类加载的流程
     * 1. 类加载器收到类加载的请求
     * 2. 将这个请求向上委托给父类加载器去完成,一直向上委托,直到启动类加载器
     * 3. 启动节加载器检查是否能够加载当前这个类,能加载就结束,使用当前的加载器,
        否则,抛出异常通知子加载器进行加载
     * 4. 重复步骤3
     */

}

百度:双亲委派机制

4、沙盒安全机制

Java安全的模型的核心就是 Java 沙箱 (sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限制在虚拟机特定的运行环境中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统操作破坏。沙箱主要限制系统资源访问

5、Native

开启一个多线程启动类:

public static void main(String[] args) {
    new Thread(() -> {

    }, "my thread name").start();
}

点进去查看start()方法的源码:

public synchronized void start() {
    /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
    group.add(this);

    boolean started = false;
    try {
        start0(); // 调用start0()方法
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
        }
    }
}

private native void start0();

可以看到源码中使用了特殊的方法 start0(),使用了 native关键词。

native :凡是带了 native 关键词,说明 java 的作用范围达不到了,会调用底层 C 语言的库!
native 的方法会进入本地方法栈,调用本地方法接口 JNI(Java Native Interface 本地方法接口),其他的就是 Java 栈。

JNI的作用:扩展 java 的使用,融合不同的编程语言为 java 所用!最初的时候需要融合 C 和 C++。
        Java 诞生的时候, C 和 C++ 横行,想要立足,必须要有调用 C 和 C++的程序。
        它在内存区域中专门开辟了一块标记区域: Native Method Stack ,登记 native 方法,
        在最终执行的时候,加载本地方法库中的方法通过 JNI。
比如: Java程序驱动打印机,管理系统。这部分掌握即可,在企业级应用中较为少见。

现在调用第三方语言接口的方式很多,比如:Socket、WebService、HTTP等。

6、PC 寄存器

程序计数器:Program Counter Register

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向一条指令的地址,也即将要执行的指令代码),在执行引擎下读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。

7、方法区

Method Area 方法区

方法区是被所有线程共享的,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间

**==静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关== ** 。

static、final、Class、常量池。

8、栈

栈是一种数据结构,可以形象地理解为一个水桶或水杯,其特点是先进后出。 比如,依次将乒乓球放入杯子中,先放进去的球,最后才能拿出来。

栈中存放 8 大基本数据类型 + 对象引用 + 实例的方法。

栈内存主管程序的运行,生命周期和线程同步。Java 中执行方法的过程就是调用栈的过程。为什么 main() 方法,最先执行,最后结束呢?因为main() 方法是程序的入口,执行时 main() 会最先被压到栈底, 在 main() 方法中调用其他方法时,依次将其他方法压入栈中。

线程结束,栈内存就释放了,对于栈来说,不存在垃圾回收问题。

栈的运行原理:

栈的运行原理

栈 + 堆 + 方法区的交互关系

栈堆方法区的交互关系

画一个对象实例化的过程在内存中:百度、看视频。

JVM的内存区域划分,对象实例化分析

JVM系列分析- 内存模型

9、三种 JVM

  • Sun 公司 HotSpotJava HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
  • BEA:JRockit
  • IBM:J9VM

我们学习的都是HotSpot

10、堆

Heap,一个 JVM 只有一个堆内存,堆内存的大小是可以调节的。

类加载器读取类文件后,一般会把什么东西放入堆中?类、方法、常量、变量,保存所有引用类型的真实对象。

堆内存中还要细分为三个区域:

  • 新生区(伊甸园区 Eden):Young/New
  • 养老区:old
  • 永久区:Perm
堆内存的细分

新生区中没有被垃圾收集器干掉的对象会进入幸存区0区幸存区0区中没有被干掉的对象会进入幸存区1区幸存区0区幸存区1区会不停的交换位置。经过一定次数后的垃圾回收后还没有被干掉的对象会进入养老区,这个区域的对象一般不会被干掉,但不是绝对的。假设养老区满了,对象会进入永久存储区

针对新生区的垃圾回收称为轻量级的垃圾收集,也称轻 GC。针对养老区的垃圾回收称为重量级的垃圾收集,也称重 GC。

GC 垃圾回收,主要是在伊甸园区和养老区。

假设内存满了,会报错 OOM,OutOfMemeroy,对内存不够!

public static void main(String[] args) {
    String str = "hello world!";

    while (true) {
        str += str + new Random().nextInt(88888888) + new Random().nextInt(99999999);
    }
    //        Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    //        at java.util.Arrays.copyOf(Arrays.java:3332)
    //        at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    //        at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
    //        at java.lang.StringBuilder.append(StringBuilder.java:208)
    //        at Hello.main(Hello.java:8)
}

在 JDK8 以后,永久存储区改名为元空间。

11、新生区、老年区

新生区:

  • 类:诞生和成长的地方,甚至死亡;
  • 伊甸园,所有对象都是在伊甸园区 new 出来的;
  • 幸存者区:0 和 1 区。
  • 经过研究,99%的对象都是临时对象,所以进入老年区的对象很少。

12、永久区

这个区域常驻内存的。用来存放 JDK 自身携带的 Class 对象。Interface 元数据,存储的是 Java 运行时的一些环境或类信息,这个区域不存在垃圾回收!关闭 VM 虚拟机就会释放这个区域的内存。

一个启动类,加载了大量的第三方 jar 包。Tomcat 部署了太多的应用,大量动态生成的反射类。不断的被加载,知道内存满,就会出现 OOM。

  • JDK1.6:永久代,常量池存放在方法区;
  • JDK1.7:永久代,但是慢慢的退化了,常量池在堆中;
  • JDK1.8:无永久代,常量池在元空间。
堆空间内存模型

13、堆内存调优

public static void main(String[] args) {
    // 返回 JVM 试图使用的最大内存
    long max = Runtime.getRuntime().maxMemory(); // 字节

    // 返回 JVM 的初始化总内存
    long total = Runtime.getRuntime().totalMemory();

    System.out.println("max=" + max + "字节\t" + (max / (double)1024/1024) + "MB");
    // max=2831679488字节 2700.5MB
    System.out.println("total=" + max + "字节\t" + (total / (double)1024/1024) + "MB");
    // total=2831679488字节   182.5MB

    // 默认情况下:分配的从内存是电脑内存的 1/4, 而初始化内存是电脑内存的 1/64。
}

元空间在逻辑上存在,物理上不存在。

OOM 解决方案:

  1. 尝试扩大堆内存看结果;
  2. 分析内存,看一下哪个地方出现了问题(专业工具)。
堆内存调优
image

14、使用 JProfier 工具分析 OOM 原因

在一个项目中,突然出现了 OOM 故障,那么该如何排除,研究为什么出错?

  • 能够看到代码第几行出错:内存快照分析工具,MAT(Eclipse)、JProfiler
  • Debugger,一行行分析代码。

MAT 、JProfiler的作用:

  • 分析 Dump 内存文件,快读定位内存泄漏;
  • 获得堆中的数据;
  • 获得大的对象;
  • 。。。。。。

JProfiler 插件和Windows客户端安装百度。

配置JProfiler

编写一个 OOM 的程序测试

public class Demo03 {
    byte[] array = new byte[1 * 1024 * 1024]; // 1MB

    public static void main(String[] args) {
        ArrayList list = new ArrayList<>();
        int count = 0;

        try {
            while (true) {
                list.add(new Demo03());
                count ++;
            }
        } catch (Exception e) { // 错误写法
            e.printStackTrace();
        }
    }
//    Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
//    at Demo03.(Demo03.java:4)
//    at Demo03.main(Demo03.java:12)
}

这个程序出现了 OOM,但是从报错信息无法看出哪里的问题。

此时需要添加一些配置,打印一些信息。

配置Dump参数
Dump文件

通过 JProfiler 工具打开文件:

线程Dump
大对象
总结:
    // -Xms 设置初始化内存分配的大小  默认1/64
    // -Xmx 设置最大分配内存    默认 1/4
    // -XX:+PrintDCDetails  // 打印GC垃圾回收信息
    // -Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError    OOM  Dump(转储文件)

15、GC : 常用算法

GC 的作用区域:

GC的作用区域

JVM 在进行 GC 时,并不是对这三个区域统一回收,大部分时候,回收都是新生区。

  • 新生区
  • 幸存区:from to
  • 老年区

GC 的分类:

轻 GC(普通的 GC):主要针对新生区,偶尔对幸存区进行 GC。

重 GC(全局 GC):把上面所有的区域都进行 GC,也就是释放内存。

堆内存中的幸存区是可以交换位置的。

堆内存中幸存区

GC 的题目:

  • JVM 的内存模型和分区,详细到每个区放什么;
  • 堆里面的分区有哪些? Eden、from、to、Old,说说他们的特点;
  • GC 的算法有哪些?标记清除法、标记压缩、复制算法、引用计数器,怎么用的?
  • 轻 GC 和重 GC 分别在什么时候发生?

引用计数器:(用得少)

引用计数法

复制算法:幸存区的复制

GC复制算法
GC复制算法2

GC 复制算法:

好处:没有内存碎片;

坏处:浪费了空间内存,多了一半空间(to)永远都是空的。极端情况下,比如对象 100% 存活,这个缺点就很明显。

复制算法最佳使用场景:对象存活度较低,比如新生区。

标记清除算法

优点:不需要额外的空间!

缺点:两次扫描,严重浪费时间,会产生内存碎片。

标记清除算法

标记压缩

对标清除进行再优化。

标记压缩算法

标记清除压缩

再次优化上述算法,可以多次进行标记清除,进行一次标记压缩。

16、总结:

  • 内存效率:复制算法 > 标记清除算法 > 标记压缩算法(时间复杂度)
  • 内存整齐度:复制算法 == 标记压缩算法 > 标记清除算法
  • 内存利用率:标记压缩算法 == 标记清除算法 > 复制算法

思考一个问题:难道没有一个最优的算法吗?

答案:没有,没有最好的算法,只有最合适的算法---> GC:分代收集算法

年轻代:

  • 存活率低
  • 复制算法

老年代:

  • 区域大,存活率低
  • 标记清除(内存碎片不是太多)+ 标记压缩混合实现。

参考书籍:《深入理解 JVM》

17、JMM(高频) 和 快速学习方法

  1. 什么是 JMM?

JMM:Java Memory Model 的缩写。

  1. 它是干嘛的?

作用:缓存一致性协议,用于定义数据读写的规则。

JMM 定义了线程工作内存和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(Local Memory)。

解决共享对象可见性这个问题: volilate

  1. 它该如何学习?

官方、其他人的博客、对应的视频。。。

Java内存模型(JMM)总结

关于内存图:可以去思维导图 : processon 网站搜索 JVM 可以看到别人画的相关思维导图。


参考文章

你可能感兴趣的:(JVM 基础、堆内存分析和垃圾回收算法)