JVM Learning

1 JVM运行时数据区

运行时数据区:Java运行时的东西存放区域

jvm运行时数据区.png

2 解析JVM运行时数据区

2.1 方法区(Method Area)

  • 方法区时所有线程共享的区域,它用于存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码数据等。
  • 它有个别名叫 Non-Heap (非堆)。当方法区无法满足内存分配需求时,抛出 OutOfMemoryError 异常。

2.2 Java堆(Java Heap)

  1. java堆是java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
  2. 在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。
  3. Java堆是垃圾收集器管理的主要区域,因此也被成为GC堆
  4. 从内存回收角度来看Java堆可分为:新声代和老生代。
  5. 从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。
  6. 无论怎么划分,都与存放内容无关,无论哪个区域,存储的都是对象实例,进一步的划分是为了更好的回收内容,或者更快的分配内存。
  7. 根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中。当前主流的虚拟机都是可拓展的(通过 -Xmx-Xms 控制)。如果堆中没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

2.3 程序计数器(Program Counter Register)

  1. 程序计数器时一块较小的内存空间,他可以看作是:保存当前线程所正在执行的字节码指令的地址(行号)。
  2. 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。称之为线程私有的内存。程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。

总结:也可以吧它叫做线程计数器。

例子: 在Java中最小的执行单位是线程,线程是要执行指令的,执行的指令最终操作的就是我们的CPU。在CPU上免去运行,有个非常不稳定的因素,叫做调度策略,这个调度策略是基于时间片的,也就是当前的这一纳秒是分配给哪个指令的。

假如: 线程A在看直播

线程A看直播

突然,线程B来了一个视频通话,就会抢夺线程A的时间片,就会打断线程A,线程A将会挂起:

视频通话打断直播

当线程B视频通话结束后,如果没有线程计数器,线程A将不知道该干什么。
线程是最小的执行单位,他不具备记忆功能,他只负责去干,那这个记忆就由:程序计数器来记录

2.4 Java虚拟机栈(Java Virtual Machine Stacks)

  1. Java虚拟机是线程池私有的,它的生命周期和线程相同。
  2. 虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧 Stack Frame用于存储局部变量表、动态链接、方法出口等信息。

解释: 每个虚拟机栈中是有单位的,单位就是栈帧,一个方法一个栈帧。一个栈帧中他又要存储局部变量、操作数栈、动态链接、出口等。

虚拟机栈

解析栈帧:

  1. 局部变量表:用来存储我们临时8哥基本数据类型、对象引用地址、returnAddress类型。returnAddress中保存的是return后要执行的字节码的指令地址。
  2. 操作数栈:操作数栈就是用来操作的,例如代码中有个 i=6*6;,它在一开始的时候就会进行操作,读取代码,进行计算再放入局部变量表中。
  3. 动态链接:假如方法中有个service.add()方法,要链接到别的方法中,这就是动态链接,存储链接的地方。
  4. 出口:出口主要分两类:正常-return、不正常-抛异常。

Q:一个方法调用另一个方法,是否会创建很多栈帧?

A:会。如果一个栈中有动态链接调用别的方法,就回去创建新的栈帧,栈中是有顺序的,一个栈调用另一个栈帧就会排在调用者下面

Q:栈指向堆是什么意思?

A:栈是不会存储成员变量的,只会存储一个应用地址,这个应用地址即为堆中对应成员变量地址。

Q:递归的调用会自己创建很多栈帧吗?

A:递归的话也会创建很多栈帧,就是一直排下去。

2.5 本地方法栈(Native Method Stack)

  1. 本地方法栈很好理解,它跟栈很像,只不过方法上带了 native 关键字。
  2. 它是虚拟机栈为虚拟机执行Java方法(字节码)的服务。
  3. native 关键字的方法是看不到的,必须去Oracle官网去下载,且native关键字修饰的大部分源码都是C、C++实现的。由此可得:本地方法栈中就是C、C++代码。

3 Java内存结构

Java内存结构就是运行时数据区加上其他几个小组件,如图:

3-1.png

3.1 直接内存(Direct Memory)

  1. 直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。受本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。
  2. 在JDK1.4 中新加入了NIO(New Input/OutPut)类,引入了一种基于通道(Channel)缓冲区(Buffer)的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和native(本地)堆中来回复制数据。

直接内存与堆内存的区别:

  • 直接内存申请空间性能损耗高,堆内存申请空间性能损耗较低。
  • 直接内存的IO读写性要优于堆内存,多次读写操作的情况差距显著。

代码示例(报错修改time值):

import java.nio.ByteBuffer;

/**
 * 直接内存 与 堆内存的比较
 *
 * @author qifeng.li01@hand-china.com 2024/6/18 18:34
 */
public class ByteBufferCompare {

    public static void main(String[] args) {
        // 分配比较
        allocateCompare();
        // 读写比较
        operateCompare();
    }

    /**
     * 直接内存 和 堆内存的 分配空间比较
     */
    public static void allocateCompare() {
        // 操作次数
        int time = 10000000;
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            // 非直接内存分配申请
            ByteBuffer buffer = ByteBuffer.allocate(2);
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,堆内存:分配耗时:" + (et - st) + "ms");
        long stHeap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            // 直接内存分配申请
            ByteBuffer buffer = ByteBuffer.allocateDirect(2);
        }
        long etDirect = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,直接内存:分配耗时:" + (etDirect - stHeap) + "ms");
    }

    /**
     * 直接内存 和 堆内存的 读写性能比较
     */
    public static void operateCompare() {
        // 如果报错修改这里,把数字改小一点
        int time = 1000000000;
        ByteBuffer buffer = ByteBuffer.allocate(2 * time);
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,堆内存:读写耗时:" + (et - st) + "ms");
        ByteBuffer bufferD = ByteBuffer.allocateDirect(2 * time);
        long stDirect = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            bufferD.putChar('a');
        }
        bufferD.flip();
        for (int i = 0; i < time; i++) {
            bufferD.getChar();
        }
        long etDirect = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,直接内存:读写耗时:" + (etDirect - stDirect) + "ms");
    }
}

测试结果:

直接内存、堆内存性能比较

3.2 JVM字节码执行引擎

  • 虚拟机核心的组件就是执行引擎,它负责执行虚拟机的字节码,一般先进行编译成机器码后执行。
  • 虚拟机是一个相对于物理机的概念,虚拟机的字节码是不能直接在物理机上运行的,需要JVM字节码执行引擎编译成机器码后才可在物理机上执行。

3.3 垃圾收集系统

  • 程序在运行过程中,会产生大量的内存垃圾, 一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,程序用不了它们了,对程序而言它们已经死亡, 为了确保程序运行时的性能,java虚拟机在程序运行的过程中不断地进行自动的垃圾回收(GC)。
  • 垃圾收集系统是Java的核心,也是不可少的,Java有一套自己进行垃圾清理的机制,开发人员无需手工清理

4 JVM的垃圾回收机制

垃圾回收机制简称GC

GC主要用于Java堆的管理。Java中的堆时JVM所管理的最大的一块内存空间,主要用于存放各种类的实例对象。

4.1 什么是垃圾回收机制

  • 程序在运行过程中,会产生大量的内存垃圾(一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,对于程序而言它们已经死亡),为了确保程序运行时的性能,Java虚拟机在程序的运行过程中不断地进行自动的垃圾回收(GC)。
  • GC时不定时去堆内存中清理不可达对象。不可达对象并不会马上就会被回收,垃圾收集器在一个Java程序中的执行是自动的,不能强制执行清除哪个对象,即使程序员已经能明确判断哪一块内存已经无用了。程序员唯一能做的是通过调用 System.gc();方法来建议JVM执行垃圾回收,但调用后是否执行,什么时候执行都是不可知的。这也是GC的主要缺点。

手动执行GC:

System.gc();

4.2 finalize方法作用

  • finalize()方法是在每次执行GC操作之前都会调用的方法,可以用它做必要的清理工作。
  • 它是在Object类中定义的,因此所有的类都继承了它。子类覆盖finalize()方法以整理系统资源或执行其他清理工作。finalize()方法是在垃圾收集器删除对象之前对这个对象的调用。
public class FinalizeTest {
	public static void main(String[] args) {
		Test test = new Test();
		test = null;
		// 手动回收垃圾
		System.gc();
	}

	@Override
	protected void finalize() throws Throwable {
		// gc回收垃圾之前调用
		System.out.println("finalize ---> 整理系统资源");
	}
}

4.3 新生代、老年代、永久代(方法区)的区别

  • Java中的堆是JVM所管理的最大的一块内存空间,主要用于存放各种类的实例对象。
  • 在Java中,堆被划分成两个不同的区域:新生代(Young)、老年代(Old)。
  • 老年代就一个区域。新生代则被划分为三个区域:EdenFrom SurvivorTo Survivor
  • 这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。
  • 默认的,新生代与老年代的比例的值为1:2该值可以通过参数-XX:NewRatio 来指定。
  • 其中,新生代被细分为Eden两个Survivor区,这两个Survivor区域分别被命名为From SurvivorTo Survivor
  • 默认的:Eden:From Survivor:To Survivor = 8:1:1。可以通过参数-XX:SurvivorRatio来设定。
  • JVM每次只会使用Eden和其中一块Survivor区域来为对象服务,所以无论什么时候,总是有一块Survivor区域是空的。因此,新生代实际可用空间为90%的新生代空间。
  • 永久代就是JVM的方法区。在这里都是放着一些被虚拟机加载的类信息、静态变量、常量等数据。这个区中的东西比老年代和新生代更不容易回收。

4.3.1 为什么要这样分代:

主要原因是可以根据各个年代的特点进行对象分区存储,更便于回收,采用最适当的收集算法。

  • 新生代中,每次垃圾收集时都会发现大批无引用对象,只有少量对象可用,便采用复制算法,只需付出少量存活对象的复制成本就可以完成收集。
  • 而老年代中,因为都是在一次次新生代GC下存活下来的对象,对象存活率较高、无额外空间对它们进行分配担保,所以采用标记-清理或者标记-整理算法。

上面提到:新生代分为Eden和Survivor (From与To,这里简称一个区)两个区。加上老年代就这三个区。数据会首先分配到Eden区当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)。当Eden没有足够空间的时候就会触发JVM发起一次Minor GC,。如果对象经过一次Minor-GC还存活,并且又能被Survivor空间接受,那么将被移动到Survivor空间当中。并将其年龄设为1,对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代中了,当然晋升老年代的年龄是可以设置的。>

4.3.2 Minor GC、Major GC、Full GC区别及触发条件

  • Minor GC是新生代GC,指的是发生在新生代的GC。由于Java对象大都是引用、销毁频繁,所以Minor GC也非常频繁,一般回收速度也比较快。
  • Major GC是老年代GC,指的是发生在老年代的GC。通常执行Major GC会连着Minor GC一起执行,同时Major GC的速度也要比Minor GC慢得多。
  • Full GC是清理整个堆空间,包括新生代和老年代。

Minor GC 触发条件:

  1. Eden 区满时。
  2. 动态对象年龄判断,当 Survivor 区中同年龄对象大小总和超过 Survivor 区本身大小的一半时,年龄大于或等于该年龄的对象会被晋升到老年代。
  3. 永久代(或者元空间)满时。
  4. 显式调用 System.gc(),应当避免依赖这种方式触发垃圾收集,因为它可能会对性能产生负面影响。

Major GC和Full GC 触发条件: (Major GC通常跟Full GC是等价的)

  1. 每次晋升到老年代的对象平均大小>老年代剩余空间
  2. Minor GC后存活的对象超过了老年代剩余空间
  3. 永久代空间不足
  4. 执行System.gc();
  5. CMS GC异常
  6. 堆内存分配很大的对象

4.4 如何判断对象是否存活

4.4.1 引用计数法

引用计数法:每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,计数器为0就代表该对象死亡。

  • 引用计数法就是如果一个对象没有任何被引用指向,则可视之为垃圾。这种方法的缺点是不能检测到还的存在。
  • 首先需要声明,至少主流的Java虚拟机里面都没有选引用计数法来管理内存。

引用计数法的优点: 引用计数法实现简单,判定效率高,在大部分情况下它都是一个不错的GC算法。

引用计数法的缺点: 它很难解决对象之间循环引用的问题。

eg:

public class Test {
	public Object object = null;
	public static void main(String[] args) {
		Test a = new Test();
		Test b = new Test();
		/**
		 * 循环引用,此时引用计数器法失效
		 */
		a.object = b;
		b.object = a;

		a = null;
		b = null;
	}
}

4.4.2 可达性分析法

这种方法是从GC Roots开始向下搜做,搜做所走过的路径为引用链。当一个对象到GC Roots没用任何引用链时,则证明对象是不可用的,表示可以回收。

可达性分析法样图

  • 上图中Object1、Object2、Object3、Object4、Object5到GC Roots是可达的,表示它们是有引用对象的,也就是算法不回收部分。
  • Object6、Object7、Object8虽然是互相关联的,但是它们到GC Roots是不可达的,所以它们是可以进行回收的对象。

哪些对象可以作为GC Roots 的对象:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  2. 方法区中类静态属于引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中JNI(Native方法)引用的对象

可达性算法的优点: 解决循环引用问题。

可达性算法的应用场景: 目前主流的虚拟机都是采用的这种GC算法。

4.5 垃圾回收机制策略(也称为GC的算法)

4.5.1 引用计数算法(Reference counting)

引用计数法:每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,计数器为0就代表该对象死亡。

引用计数法的优点: 引用计数法实现简单,判定效率高,在大部分情况下它都是一个不错的GC算法。

引用计数法的缺点: 它很难解决对象之间循环引用的问题。

eg:

public class Test {
	public Object object = null;
	public static void main(String[] args) {
		Test a = new Test();
		Test b = new Test();
		/**
		 * 循环引用,此时引用计数器法失效
		 */
		a.object = b;
		b.object = a;

		a = null;
		b = null;
	}
}

4.5.2 标记–清除算法(Mark-Sweep)

为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。

分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。

标记-清除算法的优点:

  • 解决了循环依赖问题
  • 必要时才执行(内存不足时)

标记-清除算法的缺点:

  • 回收时,应用需要挂起,也就是stop the world
  • 标记和清除的效率不高,尤其是要扫描的对象比较多的时候
  • 会造成内存碎片(会导致明明有内存空间,但是由于不连续,申请稍微大一些的对象无法做到)

标记-清除算法的应用场景: 一般应用于老年代,因为老年代的对象生命周期比较长。

4.5.3 标记–整理算法

标记清除算法和标记压缩算法非常相同,但是标记压缩算法在标记清除算法之上解决内存碎片化

标记-整理法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。

标记–整理算法的优点: 解决标记清除算法出现的内存碎片问题。

标记–整理算法的缺点: 压缩阶段,由于移动了可用对象,需要去更新引用。

4.5.4 复制算法

复制算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。

这个算法与标记-整理算法的区别在于,该算法不是在同一个区域复制,而是将所有存活的对象复制到另一个区域内。

复制算法的优点: 在存活对象不多的情况下,性能高,能解决内存碎片和java垃圾回收算法之-标记清除 中导致的引用更新问题。

复制算法的缺点: 会造成一部分的内存浪费。不过可以根据实际情况,将内存块大小比例适当调整;如果存活对象的数量比较大,复制算法的性能会变得很差。

复制算法的应用场景:

  • 复制算法一般是使用在新生代中,因为新生代中的对象一般都是朝生夕死的,存活对象的数量并不多,这样使用复制算法进行拷贝时效率比较高。
  • 在Eden –>Survivor Space 与To Survivor之间实行复制算法。