深入理解Java虚拟机学习笔记(更新中)

300 阅读15分钟

1、概述

1.1、JAVA体系

主要分为四大产品线

  1. Java Card:支持小程序(Applets)运行在小内存设备的(如智能卡)上的平台

  2. Java ME(Micro Edition):主要是提供对移动端(手机、PDA)的支持,对JavaAPI有所精简,并提供了对移动端的针对性支持

  3. Java SE(Standard Edition):主要针对桌面端的应用,在JDK6以前称为J2SE

  4. Java EE(Enterprise Edition):支持使用多层架构的企业应用的Java平台,提供除了SE的API之外,还提供大量针对性的补充,在JDK6之前称为J2EE,在JDK10之后被捐献,后被称为Jakarta EE

2、虚拟机自动内存管理

2.1、运行时内存区域

2.1.1、总览

运行时数据区域思维导图.png

2.1.2、程序计数器(PC)- 线程私有内存

程序计数器是一块很小的区域,它主要是记录当前线程的锁执行的字节码的位置。Java虚拟机就是通过程序计数器的改变来完成程序分支、循环、跳转、异常处理、线程恢复的操作,字节码解释器时通过改变PC的值来获取下一条指令的执行位置。如果当前线程执行的是Java代码,PC记录的就是此执行的虚拟机字节码指令的地址,如果是Navtive方法,此时PC记录的值就是空(Undefined)。这块区域是虚拟机规范中唯一不会OOM的区域。

由于Java的多线程时通过线程轮换、由CPU分配时间片的方式来实现的,每个处理器同时只能执行一个线程中的一个指令,所以对于程序计数器需要每个线程独立维护(独立存储,各线程互不影响)以满足线程切换之后能找到当前线程下一行字节码执行的位置。所以这块内存区域是线程私有的区域。

2.1.2、Java虚拟机栈(Java Virtual Machine Stack )- 线程私有内存

虚拟机栈(是线程私有的,和线程是同一生命周期)主要是描述Java方法执行的线程内存模型的,它是一个栈的结构,每当当前线程有方法执行的时候,都会创建一个栈帧(Stack Frame:包含 局部变量表、操作数栈、动态链接、方法出口等信息)进行入站,方法的执行就是栈帧在虚拟机栈中入栈出栈的过程。

其中,局部变量表存放在编译器可知的各种Java虚拟机基本类型、引用类型(不是对象本身,可能是对象的起始地址、代表改对象的具柄或者是和此对象有关的地址值)和returnAddress类型(指向一条字节码的地址)。这些数据在局部变量表中是以局部变量槽(Slot)的形式存储的,其中long和double类型占用2个Slot,其余数据都只占用一个Slot。局部变量所占用的大小(指的是Slot的个数)在编译器就已经确定,所以在一个方法执行,生成栈帧入栈是,这个栈帧的局部变量表大小就确认好了。

虚拟机栈可能出现两类异常:

  • StackOverflowError:线程请求的深度大于虚拟机栈定义的最大深度,比如递归很多层,超过了限制。
  • OutOfMemoryError:栈无法申请到足够的内存。

2.1.3、本地方法栈(Native Method Stack)- 线程私有内存

本地方法栈与Java虚拟机栈功能是一致的,只不过本地方法栈是用于执行Native方法,Native方法可以是任何语言实现的,需要看具体虚拟机的实现。

2.1.4、堆区(Java Heap)- 线程共享内存

堆区是虚拟机管理的内存中最大的一块,唯一职责就是用于存放Java的对象实例(由于虚拟机的发展和优化,不一定所有的对象都是存在堆区的,但是堆区都是存放对象的)。经常会对堆区做分代划分,这其实是和具体的GC实现有关,以前的GC大多是采取分带回收做操作,现在有些垃圾回收器可以不分代就进行垃圾回收;所以,无论对堆区做怎样的内存区域划分,堆区的职责就是用于存储对象实例,并且由垃圾回收器管理其内存空间。

对于堆区,在物理层面上可以不连续,但是其逻辑上应该是连续的。多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

堆区的大小通常是可以通过 -Xmx和-Xmn来设定其可拓展的,也有虚拟机将其设置成固定大小的。如果堆内没有足够的内存完成对象内存分配,就会抛出OutOfMemoryError异常。

2.1.5、方法区(Method Area)- 线程共享内存

方法区和堆区一样,是线程共享的内存空间,主要是用于存储类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虚拟机规范把它当成堆区的一个逻辑部分,但是它有个别名叫“非堆(No-Heap)”,目的是与堆分开。

方法区是一个逻辑的概念,由具体的虚拟机来实现。HotSpot在JDK1.8以前的版本使用了永久带的概念来实现方法区,即使用和堆区同样的分代回收的逻辑堆方法区进行回收,这样就不需要堆方法区做单独的内存管理实现,但是这种方法其实让程序更容易有内存溢出的问题。后续到了1.8之后使用元空间(MetaSpace)来替代永久带的实现,元空间使用的是本地内存来实现方法区。所以方法区是一个逻辑的概念,永久带和元空间都是具体虚拟机堆方法区的具体实现。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选 择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对来说,这里的数据是极少做垃圾回收的,一般是堆常量池进行回收或者对类进行卸载时才会进行垃圾回收。

如果方法区没有足够的内存分配数据,就会抛出OutOfMemoryError异常。

2.1.6、运行时常量池(Runtime Constant Pool)- 线程共享内存

运行时常量池是属于方法区的一部分,存在于Class类型信息中,用于存放编译后的各种字面量和符号引用。

运行时常量池时动态的,不是在编译器就固定了,后续可以通过程序动态将数据放入常量池中,如String的intern() 方法。

属于方法区,所以受到方法区内存限制,如果方法区没有足够的内存分配数据,就会抛出OutOfMemoryError异常。

2.1.7、直接内存(Direct Memory)- 线程共享内存

直接内存,直接内存不属于Java虚拟机规范的定义部分,但是在Nio中通过内存地址映射,直接通过Api管理系统的本地内存,也可以对其进行内存管理,避免物理内存到虚拟机的堆内存的拷贝,实现零拷贝。

其也可以指定大小限制,使用中要注意设定其内存大小+堆内存大小等不能超过物理机限制。

其虽然不受到JVM内存大小的限制,但是受限的物理内存大小限制,当没有足够内存分配时,还是会触发OutOfMemoryError异常。

2.2、对象的创建过程

不同的虚拟机实现方式不同,针对HotShop虚拟机时,在使用new关键字创建对象的时候(使用其他方式创建对象可能不会加入invokespecial指令),虚拟机在后台会执行两条指令,new指令和invokespecial指令

  1. new指令主要是为对象划分内存空间,并且为对象赋予初始的值;

  2. 首先根据new的参数到常量池中定位到具体的类的符号引用,如果找到了,表示类已经加载过了,否则会进行类的装载操作;

  3. 接下来就是为这个对象分配内存大小,因为对象的实例的内存大小在编译的时候就能完全确认下来,所以这时候的目标就是在堆空间中找到一块足够大小的连续空间,分配给这个对象。那么,如何找到这样一块内存呢?这时候需要根据垃圾回收器的算法来确定。如果说使用的垃圾回收器的算法使用的是整理压缩算法(Compact)的算法,那么堆区的内存是非常规整的,那么只需要维护一个指针指向已使用内存的结尾,在创建对象分配内存的时候只需要根据所需的内存大小向后移动指针即可(这种方式叫做指针碰撞);如果垃圾回收器算法是不带整理压缩算法(Compact)的话(如CMS算法),那么在GC之后内存中会有很多的碎片,已经使用的内存和空闲的内存列表交织在一起,这时候需要维护列表,记录空闲内存区域,当需要分配内存时,从列表中找到足够大的空闲区域,划分给新建的对象,并且更新这个列表的记录。这里还需要考虑一个问题,那就是对象的创建时非常频繁的,上述分配内存的操作可能会遇到并发问题,即同时有多个对象需要创建,这时候会采用CAS+重试来解决;还有一种方式是按照线程隔离的方式(TLAB Thread Local Allocation Buffer),为每个线程分配一小块区域作为缓冲,如果该线程需要创建对象,就在其自己的缓冲上先进行分配,如果空间不够了需要申请新的缓冲空间的时候才会进行同步操作,这个配置可以采用 XX:+/-UseTLAB 参数来设定。

  4. 为对象(不包含对象头)赋予零值,这个操作是为了让数据在没有初始化的时候在程序中也能安全的使用。如果开启了TLAB,这个零值的初始化操作也可能提前到TLAB分配的时候进行。

  5. 最后是对这个对象的对象头进行初始化。包含这个对象的类元信息、类对象的寻址方式、对象哈希码以及GC分代年龄等信息;

  6. invokespecial指令主要是制定init函数对数据进行初始化;

执行完new指令后所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。这时候会执行析构函数,即Class文件中的()方法

2.3、对象的内存布局

整体上,对象由三部分组成,分别是 对象头、对象实例数据和数据填充

  • 对象头:主要包含运行时数据和类指针信息两部分

    • 运行时数据:官方称为markWorld,它记录对象运行时的数据,如哈希码、对象锁信息等。
    • 类指针信息:指向该类型的元数据信息。通过这个指针虚拟机能知道对象是哪个类的实例。如果对象是数组类型,还会记录数组的长度。
  • 对象实例数据:存储的是真正的对象数据信息,即类定义中的字段数据信息,这里包含从父类继承下来的数据字段,父类字段在子类字段之前,这些字段的顺序虚拟机会将相同宽度的字段类型放在一起。此时:-XX:FieldsAllocationStyle 可以配置字段的顺序。+XX:CompactFields=true(默认为true)可以配置是否允许子类的字段能插入父类分配字段的空隙之中,以节约一些空间。

  • 数据填充部分:因为HotShop虚拟机对内存管理的策略是对象的起始地址都是8字节的整数倍,所以需要限定所有的对象的大小都是8字节的整数倍;所以如果有对象的数据不满足这个条件,会使用数据填充来占位,填满这个空间。

2.4、对象访问定位

创建好对象之后,如何访问这个对象呢?程序会通过方法栈上的Reference数据来操作对象。而Reference在虚拟机规范之中只是一种定义,定义访问对象的方式,而实际对对象访问的地位分为如下两种方式,直接指针和句柄:

  • 直接指针:Reference直接存储堆中对象的地址,这时候对象的内存布局中就需要考虑如何存储这个对象的类型信息。直接指向对象的话,不像句柄需要两次的指针定位,只需要一次定位就可以找对需要使用的对象信息,因为对象的访问非常的频繁,所以积少成多,其实也节约了很大的运行成本。

直接指针访问.png

  • 句柄:在堆区中维护一个句柄池,此时Reference直接指向句柄池中的稳定的句柄的地址,而句柄池维护指向对象的指针和指向对象类型的指针。

句柄访问.png

2.5、OOM实战

2.5.1、堆溢出


    public static Boolean RUN = true;

    /**

    * 堆溢出 VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

    * -Xms20m 最小堆大小 20M

    * -Xmx20m 最大堆大小 20M

    * -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照

    */

    void testHeapOOM() {

    List<AnyObject> list = new ArrayList<>();

    while (RUN) {

        list.add(new AnyObject());

    }

    System.out.println(list);

}

// 结果:

// java.lang.OutOfMemoryError: Java heap space

// Dumping heap to java_pid6396.hprof ...

// Heap dump file created [31688478 bytes in 0.104 secs]

堆溢出排查方案:

首先确认是内存泄露还是内存溢出。

  • 如果是内存泄露,应该根据dump文件查看这些对象到GCRoot的引用,分析出其用路径找出其创建的位置,继而分析其具体泄露的代码的原因。

  • 如果是内存溢出,即确实是需要这么大的内存,但是当前机器或者堆大小设置得不合理。或者是存储结构或者存在时间过长等原因,再优化代码。

2.5.2、虚拟机栈溢出


    public static Boolean RUN = true;

    private static int STACK_NUMBER = 0;

    /**

    * 虚拟机栈溢出测试 VM Args:-Xss180k

    * -Xss180k : stack oom :1438

    * -Xss360k : stack oom :3487

    * 注意:1、 -Xss指定的是栈的大小,如果xss越大,那么可以容纳的栈帧也就越多,出现 java.lang.StackOverflowError 时候的深度值也越大

    * 2、 栈的深度也取决于栈帧的大小,局部变量的方法 相同的Xss配置下,深度更小,说明其栈帧大小更大

    */

@Test
void testStackOverFlow() {

    try {

        stackLeak();

    } catch (Throwable throwable) {

        // stack oom :6001

        System.out.println("stack oom :" + STACK_NUMBER);

        System.out.println(ExceptionUtils.getMessage(throwable));

    }

    clearNumber();

    try {

        stackLeakWithArgs();

    } catch (Throwable throwable) {

        // stack with args oom :3216

        System.out.println("stack with args oom :" + STACK_NUMBER);

        System.out.println(ExceptionUtils.getMessage(throwable));

    }

}

    /**

    * 递归当前方法模拟无限入栈,测试虚拟机栈OOM

    */

    private void stackLeak() {

        // 栈深度计数 + 1

        RuntimeDataAreasTest.STACK_NUMBER ++;

        while (RUN) {

            stackLeak();

        }

    }

    /**

    * 递归当前方法模拟无限入栈,测试虚拟机栈OOM

    */

    private void stackLeakWithArgs() {

        String innerArg;

        // 栈深度计数 + 1

        RuntimeDataAreasTest.STACK_NUMBER ++;

        while (RUN) {

            stackLeakWithArgs();

        }

}

    /**

    * STACK_NUMBER 置为 0

    */

    private void clearNumber() {

        RuntimeDataAreasTest.STACK_NUMBER = 0;

    }

StackOverflowError异常解决方案:

出现StackOverflowError异常时,根据堆栈输出可以很方便找到出现问题的代码。一般虚拟机默认的参数,栈深度到达1000-2000左右,正常是不会有问题的。

2.5.3、方法区和常量池溢出

在JDK8以前方法区是采用一种永久代的形式,而在JDK8之后采用元空间(本地内存)代替,所以很难再出现方法区的溢出了。

不过其提供了几个参数来做元空间大小的防御措施:

  • -XX:MaxMetaspaceSize:设置元空间的最大的大小,默认为-1.不限制其大小,只受限于本地内存的大小。

  • -XX:MetaspaceSize:指定元空间的初始大小,达到该值会触发类的卸载。在类卸载释放元空间的内存时,虚拟机会也会动态调整这个值(MetaspaceSize):如果释放的内存很大,会适当提高;如果释放的内存很小,会适当缩小这个值。

  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。

  • -XX:MaxMetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

2.5.4、直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不 去指定,则默认与Java堆最大值(由-Xmx指定)一致

3、垃圾回收器与回收策略

垃圾回收主要需要完成三件事情:1、哪些内存需要回收? 2、什么时候回收? 3、如何回收?

对于java虚拟机来说:

  • 线程似有的内存空间(PC、虚拟机栈、本地方法栈)其实本身的大小是固定的,其内存的分配和回收都随着线程创建和销毁而分配和回收。

  • 线程共享的内存空间(堆区、方法区)确存在很多的不确定性。因为只有运行过程中才知道有哪些对象,需要多大的空间。所以有一般就行垃圾回收的设计主要是针对这部分区域的内存来说的。

  • 总的来说,就是虚拟机将那些不再需要使用的内存进行回收。

3.1、判断对象已经不再使用

3.1.1、引用计数法

为每个对象增加一个引用计数器,没当有一个对象引用这个对象时,就对计数器加一,每当一个引用失效时,引用就减一。这样如果一个对象的计数器为零的话就说明其已经不再使用了。

这个算法的弊端是无法解决循环引用的回收。 如下:


    public static class ReferenceObject {

        /**

        * 引用的对象

        */

        private Object instance;

    }

    @Test

    void countReference() {

        ReferenceObject object1 = new ReferenceObject();

        ReferenceObject object2 = new ReferenceObject();

        object1.instance = object2;

        object2.instance = object1;

        object1 = null;

        object2 = null;

        System.gc();

    }

3.1.2、可达性算法

可达性算法通过一组名为GC ROOT的对象作为起点,根据引用关系一路向下寻找,对于搜索过的对象组成引用链,最后如果某个对象没有与任何一条引用链相连,那么这个对象对于GC ROOT对象就是不可达的,即对于对象也是不会被使用到的,可以被回收。如图所示:

利用可达性算法判断对象是否存活.png

对于java技术体系中,如下的对象会固定作为GC ROOT:

  • 虚拟机栈(栈帧)中的本地变量表所指向的对象:包括方法参数、局部变量、临时变量等

  • 方法区中类静态属性引用的对象,即引用类型的静态常量

  • 方法区中常量所引用的对象,比如字符串常量池中的引用

  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象

  • 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等

除了以上固定的对象回作为GC ROOT对象之外,根据垃圾回收器和垃圾回收设计的不同,还有有一些临时的对象加入进来。比如垃圾回收器在做局部回收时,这块区域可能与其他区域中的对象存在关联,这时候需要将关联的对象也加入进来才能保证GC ROOT的完整。

3.1.3、引用类型

JDK1.2之后,对于Reference(引用)的概念做了区分分为Strongly Reference(强引用)、Soft Reference(软引用)、Weak Reference(弱引用)、Phantom Reference(虚引用);

  1. 强引用:指的是传统的引用复制,如:Object o = new Object(),只要引用关系存在,垃圾回收器就不会回收这个对象。

  2. 软引用:指的是那些有用但是非必需的对象(如缓存对象等),软引用通过SoftReference aSoftRef=new SoftReference(new Object()) 来创建,这些对象在发生垃圾回收之后内存不足即将发生内存溢出时会对这些对象进行二次回收。

  3. 弱引用:指的是那些非必需的对象,通过WeakReference<People>reference=new WeakReference<People>(new Object())创建。当发生垃圾回收时,无论当前内存是否足够,都会回收弱引用对象。

  4. 虚引用:又称幽灵引用,虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用需要与引用队列结合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。


ReferenceQueue<String> queue = new ReferenceQueue<String>();

PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);

3.1.4、对象finalize()方法

一个对象如果被标记为死亡的对象,会先是否需要执行finalize方法(如果没有覆盖finalize方法或者已经执行过了就不会执行),如果需要执行,就会把这个对象放入一个叫F-Queue的队列中,之后会有一条由虚拟机创建的Finalizer线程去依次执行这些对象的finalize()方法。如果在finalize()方法中将当前对象重新挂在GC ROOT的引用链上,那么这个对象就不会被回收。

注意点:

  1. 每个对象只能被'自救'一次,因为finalize方法只会被执行一次,当下次垃圾回收时,这个对象已经执行过finalize就不会再执行了.

  2. Finalizer线程去执行F-Queue队列中的方法只能保证会调用这个对象的finalize方法,但不保证会等待它执行结束,这是因为如果在对象的finalize执行时间过长或者出现死循环的问题时导致整个垃圾回收子系统的崩溃。

  3. 官方已经不推荐使用finalize方法了,因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

3.1.5、对于方法区的回收

针对方法区的回收一般是性价比比较低的,方法区主要是存放类信息和常量池,而对于他们而言,达到回收的条件是比较难的。

  • 常量池:针对常量池的回收其实和回收堆中的对象差不多,比如一个字符串常量在当前系统中没有任何一个对象拥有对其的引用,如果发生垃圾回收且垃圾回收器判断其有必要回收的话就会将其回收。

  • 类:判断一个类是否需要回收则要复杂的多,主要包括如下:

  1. 该类的所有实例对象都被回收

  2. 加载该类的垃圾回收器已经被回收(这个一般出现在自定义的垃圾回收器加载的类,如:jsp)

  3. 该类对应的java.lang.Class被回收,这样是保证无法通过反射获取到该类的实例

当对象满足如上的三个条件时,也只是被允许回收,而不是和对象一样不存在引用就会在被回收。关于是否要对类型进行回收。虚拟机提供了参数来打印类加载和卸载的信息。