Java虚拟机内存结构和GC原理

本文详细介绍了Java虚拟机的内存结构,包括私有的程序计数器、虚拟机栈、本地方法栈和共享的Java堆、方法区。其中,程序计数器记录线程执行的位置,虚拟机栈存储方法执行信息,本地方法栈服务于native方法,Java堆存放对象实例,方法区存储类结构信息。此外,文章还讨论了JVM的垃圾回收机制,包括标记清除、复制、标记压缩和分代收集算法,以及Minor GC和Full GC的区别。垃圾回收主要目标是回收不再被引用的对象,防止内存泄漏。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一、Java虚拟机内存结构

私有

程序计数器:

虚拟机栈:

本地方法栈:

共享

Java堆 :

方法区:

二、JVM垃圾回收机制

概述:

2.1哪些内存需要回收?

2.2、垃圾收集算法 标记清除算法

2.3、GC执行过程

2.4、Minor GC和Full GC区别


一、Java虚拟机内存结构

JDK1.7以前String常量池在方法区内,JDK1.8以后String常量池放到了堆heap中;运行时常量池和字符串常量池是两个概念

每个线程有自己的栈stack, 但是没有自己的堆heap

Java6和6之前,常量池是存放在方法区(永久代)中的。

Java7,将常量池是存放到了堆中。

Java8之后,取消了整个永久代区域,取而代之的是元空间。运行时常量池和静态常量池存放在元空间中,而字符串常量池依然存放在堆中。

私有

程序计数器:

是一块较小的内存空间,保存着当前线程执行的虚拟机字节码指令的内存地址。Java多线程的实现,其实是通过线程间的轮流切换并分配处理器执行时间的方式来实现的,在任何时刻,处理器都只会执行一个线程中的指令。在多线程场景下,为了保证线程切换回来后,还能恢复到原先状态,找到原先执行的指令,jvm需要先保存被挂起的线程的上下文环境 ,将线程的执行位置保存到程序计数器中,将调用方法的信息保存在栈中,同时将待执行的线程的程序计数器和栈中的信息写入到处理器中,以此来完成线程的上下文切换,所以每个线程都会设立一个程序计数器,并且各个线程之间不会互相影响。

参考文档:java 计数器_聊聊JVM中的程序计数器_weixin_39706491的博客-优快云博客

虚拟机栈:

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

咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。局部变量表存放了编译期可知的各种基本数据类型(8个基本数据类型)、对象引用(地址指针)、returnAddress类型。

局部变量表所需的内存空间在编译期间完成分配。在运行期间不会改变局部变量表的大小。

这个区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈:

本地方法栈与虚拟机栈所发挥作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。存放着虚拟机使用到的Native方法服务,即调用操作系统提供接口的方法。

共享

Java堆

占据了虚拟机管理内存中最大的一块,唯一目的就是存放对象实例(与引用是两个概念),这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。Java堆是垃圾回收器主要管理的地方,故又称GC堆。从内存回收角度来看java堆可分为:新生代和老生代新生代分为eden区、s0区、s1区,s0和s1也被称为from和to区域;从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。无论怎么划分,都与存放内容无关,无论哪个区域,存储的都是对象实例,进一步的划分都是为了更好的回收内存,或者更快的分配内存。
java堆是被所有线程共享的一块内存区域。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区:

因为总是存放不会轻易改变的内容,故又被称之为“永久代” (注:jdk1.7以前)

方法区(Method Area)是用于存储类结构信息的地方,包括类信息(完整有效名称即全名=包名.类名,类型的修饰符(public, abstract, final的某个子集),直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)等)、常量池、静态变量、构造函数、方法信息等,类信息是由类加载器在类加载时从类文件中提取出来的。

  • 方法区同样存在垃圾收集,因为用户通过自定义加载器加载的一些类同样会成为垃圾,JVM会回收一个未被引用类所占的空间,以使方法区的空间达到最小。
  • 方法区中还存在着常量池,常量池包含着一些常量和符号引用(加载类的连接阶段中的解析过程会将符号引用转换为直接引用)。
  • 方法区是线程共享的
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang.outofMemoryError:PermGen space或者java.lang.OutOfMemoryError: Metaspace
  • 关闭JVM就会释放这个区域的内存。

方法区存储类型详解文章方法区---JVM(九)_Eliza白的博客-优快云博客

常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在加载后进入方法区的运行时常量池中存放。

JDK1.8中JVM把String常量池移入了堆中,同时取消了“永久代”,改用元空间代替(Metaspace)
JDK1.7中JVM把String常量区从方法区中移除了即JDK1.7以前String常量池在方法区内

对于通过new产生一个字符串(假设为”hello”)时,会先去常量池中查找是否已经有了”hello”对象,如果没有则在常量池中创建一个此字符串对象,然后堆中再创建一个常量池中此”hello”对象的拷贝对象。

引出:
String s = new String(“xyz”);产生几个对象?一个或两个,如果常量池中原来没有”xyz”,就是两个。

二、JVM垃圾回收机制

概述:

新生代分为eden区、s0区、s1区,s0和s1也被称为from和to区域,他们是两块大小相等并且可以互相角色的空间。
绝大多数情况下,对象首先分配在eden区,在新生代回收后,如果对象还存活,则进入s0或s1区,之后每经过一次新生代回收,如果对象存活则它的年龄就加1,对象达到一定的年龄后,则进入老年代

2.1、哪些内存需要回收

哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无非就是那些不可能再被任何途径使用的对象。那么如何找到这些对象?
引用计数法

 这个算法的实现是,给对象中添加一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。任何时刻计数值为0的对象就是不可能再被使用的。这种算法使用场景很多,但是,Java中却没有使用这种算法,因为这种算法很难解决对象之间相互引用的情况。

上代码

public class ReferenceCountingGC
{
   private Object instance = null;
   private static final int _1MB = 1024 * 1024;
   
   /** 这个成员属性唯一的作用就是占用一点内存 */
   private byte[] bigSize = new byte[2 * _1MB];
   
   public static void main(String[] args)
   {
       ReferenceCountingGC objectA = new ReferenceCountingGC();
       ReferenceCountingGC objectB = new ReferenceCountingGC();
       objectA.instance = objectB;
       objectB.instance = objectA;
       objectA = null;
       objectB = null;
       
       System.gc();
   }
}
结果:
[GC 4417K->288K(61440K), 0.0013498 secs]
[Full GC 288K->194K(61440K), 0.0094790 secs]

看到,两个对象相互引用着,但是虚拟机还是把这两个对象回收掉了,这也说明虚拟机并不是通过引用计数法来判定对象是否存活的。


根搜索算法

   这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

那么问题又来了,如何选取GCRoots对象呢?在Java语言中,可以作为GCRoots的对象包括下面几种:

  •     虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
  •     方法区中的类静态属性引用的对象。
  •     方法区中常量引用的对象。
  •     本地方法栈中JNI(Native方法)引用的对象。

2.2、垃圾收集算法
 标记清除算法

该算法有两个阶段。

        标记阶段:找到所有可访问的对象,做个标记
        清除阶段:遍历堆,把未被标记的对象回收

应用场景:

该算法一般应用于老年代,因为老年代的对象生命周期比较长。
关于回收后碎片化的理解:

该算法的优缺点:

优点

    是可以解决循环引用的问题
    必要时才回收(内存不足时)

缺点

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

标记压缩算法

    标记清除算法和标记压缩算法非常相同,但是标记压缩算法在标记清除算法之上解决内存碎片化.让所有存活对象都向一端移动,然后直接清理掉边界以外的内存

    任意顺序 : 即不考虑原先对象的排列顺序,也不考虑对象之间的引用关系,随意移动对象;
    线性顺序 : 考虑对象的引用关系,例如a对象引用了b对象,则尽可能将a和b移动到一块;
    滑动顺序 : 按照对象原来在堆中的顺序滑动到堆的一端。

优点:解决内存碎片问题,缺点压缩阶段,由于移动了可用对象,需要去更新引用。

复制算法

    如果JVM使用了复制算法,一开始就会将可用内存分为两块,from域和to域, 每次只是使用from域,to域则空闲着。当from域内存不够了,开始执行GC操作,这个时候,会把from域存活的对象拷贝到to域,然后直接把from域进行内存清理。

应用场景

复制算法一般是使用在新生代中,jvm将Heap 内存划分为新生代与老年代,又将新生代划分为Eden(伊甸园) 与2块Survivor Space(幸存者区) ,然后在Eden –>Survivor Space 以及From Survivor Space 与To Survivor Space 之间实行Copying 算法。

不过jvm在应用复制算法时,并不是把内存按照1:1来划分的,这样太浪费内存空间了。一般的jvm都是8:1。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)

2.3、GC执行过程

    当Eden区满的时候,会触发第一次young gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发young gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。

    当后续Eden又发生young gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。

    可见部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代
    注意: 万一存活对象数量比较多,那么To域的内存可能不够存放,这个时候会借助老年代的空间。

优缺点

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

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

    这种算法,根据对象的存活周期的不同将内存划分成几块,新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。可以用抓重点的思路来理解这个算法。
    新生代对象朝生夕死,对象数量多,只要重点扫描这个区域,那么就可以大大提高垃圾收集的效率。另外老年代对象存储久,无需经常扫描老年代,避免扫描导致的开销。

    新生代
    在新生代,每次垃圾收集器都发现有大批对象死去,只有少量存活,采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集.
    老年代
    而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须“标记-清除-压缩”算法进行回收。

2.4、Minor GC和Full GC区别

    新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
    老年代 GC(Major GC / Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 ParallelScavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程) 。MajorGC 的速度一般会比 Minor GC 慢 10倍以上。

    Minor GC触发机制:
        当年轻代满时就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC
    Full GC触发机制:
        当年老代满时会引发Full GC,Full GC将会同时回收年轻代、年老代,当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。

JVM的永久代中会发生垃圾回收么?

会,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。
(注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)
总结

参考文档:

https://www.cnblogs.com/tiancai/p/9321338.htm

关于JVM强引用对象回收的问题

《深入理解Java虚拟机》第二版P65中关于强引用有这么一段描述:

强引用关联的对象。指的是程序代码中普遍存在的,类似Object obj = new Object() 的代码,这类的引用只要强引用还存在,垃圾收集器永远不会回收调被引用的对象(回收heap中的对象);

怎么理解:

在实际的应用中 Object obj = new Object() 这样的代码非常常见,这些也都是强引用,在使用完obj对象的时候也不会去 obj = null,也就是说obj这个强引用会一直存在,那jvm就不去回收他啦?要是都不回收的话JVM虚拟机还不分分钟内存溢出,不知道该如何正确理解这段关于强引用的陈述

原因:

简单讲,从GC roots开始找,只要找不到obj,就代表这个obj可以被回收了。

Object obj = new Object();

这种写法,通常obj在local variables(JVM局部变量表)里面,而local variables(JVM局部变量表)是属于调用这段代码的方法的,随着方法的开始而创建,退出而销毁,那么obj就无法从GC roots上面找到了(假设没有设置为field或其他)。

local variables----JVM局部变量表

local variable-----本地变量

四中引用说明文档:

 JVM之强引用、软引用、弱引用、虚引用_娃哈哈、的博客-优快云博客

强引用Reference

当内存不足,JVM开始垃圾回收,对于强引用的对象,就算是出现了00M也不会对该对象进行回收,死都不收。

强引用是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。在Java中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java内存泄漏的主要原因之一

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,一般认为就是可以被垃圾收集的了〈当然具体回收时机还是要看垃圾收集策略)。

 软引用SoftReference

软引用是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference类来实现,可以让对象豁免一些

对于只有软引用的对象来说,当系统内存充足时它不会被回收,当系统内存不足时它会被回收

软引用通常用在对内存敏感的程序中,比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收!

public class Reference {

    public static void main (String[] args){

        Object  ob = new Object();
        SoftReference  sr = new SoftReference<>(ob);
        System.out.println(ob);
        System.out.println(sr.get());
        ob=null;
        System.gc();//GC 回收 , 因为sr 引用ob 对象是软引用, 因此当内存充足时 对象实例ob不会被回收
        System.out.println(ob);
        System.out.println(sr.get());//因为sr 引用ob 对象是软引用, 因此当内存充足时 对象实例ob不会被回收
        }
}

输出结果:

java.lang.Object@e2144e4
java.lang.Object@e2144e4
null
java.lang.Object@e2144e4

Process finished with exit code 0

弱引用WeakReference

  1. 弱引用需要用Java.lang.ref.WeakReference类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否是够,都会回收该对象占用的内存。

public class WeakReferenceDemo {

    public static void main(String[] args) {

        Object o1 = new Object();

        WeakReference<Object> weakReference = new WeakReference<>(o1);

        System.out.println(o1);

        System.out.println(weakReference.get());

        o1 = null;

        System.gc();

        System.out.println("==============");

        System.out.println(o1);

        System.out.println(weakReference.get());

    }

}

在这里插入图片描述

软引用和弱引用的适用场景

假如有一个应用需要读取大量的本地图片:

如果每次读取图片都从硬盘读取则会严重影响性能,
如果一次性全部加载到内存中又可能造成内存溢出。
此时使用软引用可以解决这个问题。

设计思路是:用一个HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了00M的问题。
Map<String,SoftReference>imageCache=new HashMap<String,SoftReference>();

虚引用PhantomReference

虚引用需要java.lang.ref.PhantomReference类来实现。

顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问
对象,虚引用必须和引用队列(ReferenceQueue)联合使用。

虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被finalize以后,做某些事情的机制。PhantomReference的get方法总是返回null,因此无法访问对应的引用对象。其意义在于说明一个对象己经进入俑finalization阶段,可以被gc回收,用来实现比finalization机制更灵活的回收操作。

换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理。Java技术允许使用finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。

import java.lang.ref.PhantomReference;

import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {

    public static void main(String[] args) throws InterruptedException {

        Object o1 = new Object();

        ReferenceQueue<Object> referenceQueue = new ReferenceQueue();

        PhantomReference<Object> phantomReference = new PhantomReference<>(o1,referenceQueue);

        System.out.println(o1);

        System.out.println(phantomReference.get());

        System.out.println(referenceQueue.poll());

        System.out.println("=====================");

        o1 = null;

        System.gc();

        Thread.sleep(500);

        System.out.println(o1);

        System.out.println(phantomReference.get());

        System.out.println(referenceQueue.poll());

    }

}

在这里插入图片描述

ReferenceQueue引用队列介绍

java提供了4种引用类型,在垃圾回收的时候,都有自己各自的特点。
ReferenceQueue是用来配合引用工作的,没有Referencequeue一样可以运行
创建引用的时候可以指定关联的队列,当GC释放对象内存的时候,会将引用加入到引用队列,如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动,这相当于一种通知机制。
4.当关联的引用队列中有数据的时候,意味着引用指向的堆内存中的对象被回收。通过这种方式,JVM运行我们在对象被销毁后,做一些我们自己想做的事情。

public class ReferenceQueueDemo {

    public static void main(String[] args) throws InterruptedException {

        Object o1 = new Object();

        ReferenceQueue<Object> referenceQueue = new ReferenceQueue();

        WeakReference<Object> weakReference = new WeakReference<>(o1,referenceQueue);

        System.out.println(o1);

        System.out.println(weakReference.get());

        System.out.println(referenceQueue.poll());

        System.out.println("=====================");

        o1 = null;

        System.gc();

        Thread.sleep(500);

        System.out.println(o1);

        System.out.println(weakReference.get());

        System.out.println(referenceQueue.poll());

    }

}

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值