JVM内存管理

2023-11-14 09:29:18 浏览数 (1)

JVM内存管理

Java只支持直接使用基本数据类型和对象类型,并且是JVM进行控制管理内存

这样出现内存问题,无法像C/C 那样对所管理的内存进行合理地处理

内存区域划分

在虚拟机运行时,内存区域如下划分:

内存区域一共分为5个区域:

  1. 方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁
  2. 虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程独享期中一个区域区域,线程启动时会自动创建,结束之后会自动销毁
程序计数器

JVM虚拟机目的就是实现物理机那样的程序执行,JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就指的是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。

当某个cpu线程切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,恢复上下文数据进行继续执行。

虚拟机栈

虚拟机栈是一个栈结构,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。

局部变量表、操作数栈、动态链接、方法出口:

  1. 局部变量表就是我们方法中的局部变量,实际上局部变量表在class文件中就已经定义好了
  2. 操作数栈就是字节码执行时使用到的栈结构
  3. 每个栈帧还保存了一个可以指向当前方法所在类的运行时常量池,当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法,这就是动态链接
  4. 方法出口就是方法该如何结束,是抛出异常还是正常返回
本地方法栈
  1. 类似于虚拟机栈,区别是虚拟机栈为执行 Java 方法服务,而本地方法栈则为Native 方法服务
  2. 允许被实现成固定或者是可动态扩展的内存大小
  3. Native Method就是一个java调用非java代码的接口

堆是整个Java应用程序共享的区域,此区域的职责就是存放和管理对象和数组,垃圾回收机制也是主要作用于这一部分内存区域。

方法区

方法区是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。方法区也是我们要重点介绍的部分。

各个内存区域的用途:

  • 程序计数器:保存当前程序的执行位置
  • 虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行
  • 本地方法栈:同上,作用与本地方法
  • 堆:所有的对象和数组都在这里保存
  • 方法区:类信息、即时编译器的代码缓存、运行时常量池

垃圾回收机制

JVM提供了一套全自动的内存管理机制,当一个Java对象不再用到时,JVM会自动将其进行回收并释放内存。

对象存活判定算法
引用计数法

原理:

  1. 每个对象都包含一个 引用计数器,用于存放引用计数(其实就是存放被引用的次数)
  2. 每当有一个地方引用此对象时,引用计数 1;当引用失效(比如离开了局部变量的作用域或是引用被设定为null)时,引用计数-1
  3. 当引用计数为0时,表示此对象不可能再被使用,已经没有任何方法可以得到此对象的引用了
  4. 存在循环引用的问题,无法得到对象的引用但是引用计数依旧不为0无法释放
代码语言:javascript复制
public class Main {
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.another = b;
        b.another = a;
        a = b = null;
    }
    private static class Test{
        Test another;
    }
}
可达性分析算法

目前比较主流的编程语言(包括Java),一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。每个对象的引用都有机会成为树的根节点(GC Roots)。

可以被选定作为根节点条件如下:

  1. 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(局部变量),也包括本地方法栈中JNI引用的对象
  2. 类的静态成员变量引用的对象
  3. 方法区中,常量池里面引用的对象
  4. 被添加了锁的对象(比如synchronized关键字)
  5. 虚拟机内部需要用到的对象

一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。

由于其没有任何根节点引用,所以此对象即可被判定为不再使用,也就是说如果某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的。

循环引用的情况:

最终判定

经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,依然可以在最终判定阶段对其进行挽留。

如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的

代码语言:javascript复制
public class Main {
    private static Test a;
    public static void main(String[] args) throws InterruptedException {
        a = new Test();
        a  = null;
        //手动申请执行垃圾回收操作
        System.gc();
        //等垃圾回收一下()
        Thread.sleep(1000);
        System.out.println(a);
    }
    private static class Test{
        @Override
        protected void finalize() throws Throwable {
            System.out.println(this " 开始了它的救赎之路!");
            a = this;
        }
    }
}

finalize()方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级的Finalizer线程(优先级比较低)进行处理

同一个对象的finalize()方法只会有一次调用机会,如果连续两次这样操作,那么第二次,对象必定被回收

finalize()方法也并不是专门防止对象被回收的,主要用它来释放一些程序使用中的资源等。

垃圾回收算法

对象存活判定算法可以准确地知道堆中的哪些对象可以被回收了

垃圾回收算法是用来确定该如何回收可回收对象

分代收集机制

Java虚拟机将堆内存划分为新生代老年代永久代

其中永久代是HotSpot虚拟机特有的概念,在JDK8之前方法区实际上就是采用的永久代作为实现,而在JDK8之后,方法区由元空间实现,并且使用的是本地内存,容量大小取决于物理机实际大小

在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1,老年代的GC评率相对较低,永久代一般存放类信息等

运行原理:

新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代)

在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象

一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区的From区,当From区满了,将From中存活的对象移动到To区,此后每次 gc 存活的对象交换区域,每次交换都会进行年龄判定,当对象经历了15轮则会进入老年代

GC类型:

  1. Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
    • 触发条件:新生代的Eden区容量已满时。
  2. Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
  3. Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。
    1. 触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间
    2. 触发条件2:Minor GC后存活的对象超过了老年代剩余空间
    3. 触发条件3:永久代内存不足(JDK8之前)
    4. 触发条件4:手动调用System.gc()方法
空间分配担保

在一次GC后,新生代Eden区仍然存在大量的对象,超出Survivor区的容量,分配担保机制把Survivor区无法容纳的对象直接送到老年代

对于进入老年代的数据,会判断一下之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间,如果小于,那么说明也许可以放得下,否则会先来一次Full GC,进行一次大规模垃圾回收,来尝试腾出空间,再次判断老年代是否有空间存放,要是还是装不下,直接抛出OOM错误

标记-清除算法

堆内存实际上是以分代收集机制为主,对于如何具体的收集过程有几种算法思想

标记-清除算法:

首先标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。

缺点:回收内存顺序的不一致,导致内存空间出现内存碎片化,空间利用率降低

标记-复制算法

适用于新生代,新生代Survivor区其实就是这个思路

标记-复制算法:

将容量分为同样大小的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。

优点:解决对象大面积回收后空间碎片化严重的问题

缺点:内存空间减半,消耗时间进行复制操作

标记-整理算法

进入到老年代,一般长期都不会回收,标记-复制算法就不太适合

在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢。

这样保证内存空间充分使用,但是效率低,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿。

垃圾收集器实现
CMS收集器

CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

它主要采用标记清除算法

它的垃圾回收分为4个阶段:

  • 初始标记(需要暂停用户线程):这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象,速度比较快。
  • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(需要暂停用户线程):重新标记阶段是为了修正并发标记期间,因为用户线程执行而导致标记产生变动(错标、漏标)哪一部分对象。
  • 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。

缺点:

  1. 标记清除算法会产生大量的内存碎片,导致可用连续空间逐渐变少,会有更高的概率触发Full GC
  2. 与用户线程并发执行也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢
Garbage First (G1) 收集器

Garbage First (G1) 收集器是一款主要面向于服务端的垃圾收集器,并且在JDK9时,取代了JDK8默认的 Parallel Scavenge Parallel Old 的回收方案

G1收集器将整个Java堆划分成2048个大小相同的独立Region块,每个Region块的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region大小相同,且在JVM的整个生命周期内不会发生改变。

每一个Region都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。

G1收集器还存在一个Humongous区域,它专门用于存放大对象(超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。

回收过程与CMS大体类似:

  • 初始标记(暂停用户线程):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
元空间

在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。

类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。

其他引用类型

在Java中,如果变量是一个对象类型的,那么它实际上存放的是对象的引用,但是如果是一个基本类型,那么存放的就是基本类型的值。这样的的引用类型称为强引用

当JVM内存空间不足时,JVM宁愿抛出OutOfMemoryError使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。

软引用

软引用不像强引用那样不可回收,当 JVM 认为内存不足时,会去试图回收软引用指向的对象,即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。

弱引用

引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存。

代码语言:javascript复制
public class Main {
    public static void main(String[] args) {
        SoftReference<Object> softReference = new SoftReference<>(new Object());
        WeakReference<Object> weakReference = new WeakReference<>(new Object());
        System.gc();
        System.out.println("软引用对象:" softReference.get());
        System.out.println("弱引用对象:" weakReference.get());
    }
}
虚引用(鬼引用)

虚引用相当于没有引用,随时都有可能会被回收。

代码语言:javascript复制
Object());
 WeakReference weakReference = new WeakReference<>(new Object());
 System.gc();
 System.out.println(“软引用对象:” softReference.get());
 System.out.println(“弱引用对象:” weakReference.get());
 }
 }

代码语言:javascript复制
#### 虚引用(鬼引用)

虚引用相当于没有引用,随时都有可能会被回收。

虚引用本身就不算是个引用,相当于这个对象不存在任何引用,并且只能使用带队列的构造方法,以便对象被回收时接到通知。

0 人点赞