JVM内存模型与垃圾回收机制

JVM的定义和基本原理

Java语言是通过JVM来实现平台无关性的,编译器只需生成.class的字节码文件,JVM就可以把它们解释成具体平台的机器指令执行。也就是说,JVM屏蔽了具体平台相关的信息,它能把字节码文件映射到本地CPU的指令集或操作系统的调用,对于不同的操作系统有着不同的映射规则。这就是Java语言能够“一处编译,到处运行”的原因。

首先来说一下JDK、JRE、JVM的区别:
JDK = JRE + Java开发工具包(编译器、调试器)
JRE = Java类库(由class文件打包成的jar文件,在lib目录下) + JVM

JVM的定义:
是一个虚构出来的计算机,可以仿真模拟各种计算机功能,它有完善的硬件架构,如处理器、堆栈、寄存器、指令系统。

JVM执行程序的过程 :
1.加载.class文件
2.管理并分配内存
3.执行垃圾收集

JVM是Java程序运行的容器,但是他同时也是操作系统的一个进程,因此他也有他自己的运行的生命周期,也有自己的代码和数据空间。
JVM在整个jdk中处于最底层,负责与操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境。操作系统装入JVM是通过jdk中Java.exe来完成,通过下面4步来完成JVM环境:
1.创建JVM装载环境和配置
2.装载JVM.dll
3.初始化JVM.dll并挂接到JNIENV(JNI调用接口)实例
4.调用JNIEnv实例装载并处理class类。

JVM的体系结构图

上图是JVM的体系结构图。class文件只要符合结构就可以被ClassLoader加载到内存,能否运行取决于执行引擎(也叫作解释器),它会将class文件解释成机器码,为提高效率,会采用即时编译的方式(JIT)。内存中有专门的区域处理标记为native的代码(本地方法栈),解释器在执行时就会调本地方法接口。


JVM内存模型

这里写图片描述

堆和方法区是线程间共享的,栈是线程私有的,每个线程创建的同时都会创建JVM栈,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址;PC寄存器存放每个线程下一步要执行的java方法;Java中所有通过new创建的对象的内存几乎都在堆上分配,且需要等待GC进行回收;方法区在JDK 1.8后又叫做元数据区,大小可变,直到耗尽内存为止。

public class Person {

    private int mAge = 0; //Java堆
    private static final int AGE_MAX = 100; //方法区
    private String mName; //引用在Java堆

    public void setAge(int a) {
        if (a > AGE_MAX) {
            return;
        }
        mAge = a;
    }

    public void setName(String name) {
        mName = name;
    }

    public void print() {
        System.out.println(mName + "," + mAge);
    }

    public static void main(String[] args) {
        int t = 10; //Java虚拟机栈-局部变量表
        String name = “XiaoMing”; //引用在局部变量表,指向方法区常量
        Person xiaoMing = new Person(); //引用在局部变量表,实例在JAVA堆
        xiaoMing.setAge(t);
        xiaoMing.setName(name);
        xiaoMing.print();
    }
}


对象的访问:

Java程序通过栈上的reference数据来访问和操作具体的堆上对象,对象访问方式取决于JVM。主流的方式有两种:

1.句柄
Java堆中会划分出一块内存作为句柄池,reference中存储的是句柄池的地址。句柄中包含了实例数据和类型数据各自的地址。
这里写图片描述

2.指针
reference中存储的直接就是实例数据的地址,并且实例数据在堆中的内存里还存储着类型数据的地址。
这里写图片描述


垃圾回收机制

垃圾回收的区域

每一个栈帧中分配多少内存基本上都在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内都不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆则不一样,只有在程序运行时才能知道会创建哪些对象,这部分内存的分配和回收是动态的,垃圾收集器所关注的是这部分内存。对于方法区,JDK 1.7以后可以回收部分内容,条件较严格。

垃圾查找方法

1.引用计数法:

当有指针指向某对象时,这个对象的计数+1,每当有指针置空,计数-1。任何时刻计数为0的对象都可以被回收。
缺点:无法处理循环引用。

public class MyObject {
    public Object ref = null;
    public static void main(String[] args) {
        MyObject myObject1 = new MyObject();
        MyObject myObject2 = new MyObject();
        myObject1.ref = myObject2;
        myObject2.ref = myObject1;
    }
}

上述代码中,myObject1和myObject2的计数都为1,所以都无法回收。
如果myObject1.ref = 一个别的对象otherObject,otherObject的计数是1,myObject1的计数为0,因此可以回收myObject1,接着otherObject计数变为0,也就能回收了。

2.根搜索算法:

通过一系列称为GC Roots对象的节点开始向下搜索,搜索走过的路径称为引用链。从如果GC Roots到一个对象没有任何引用链相连,则该对象可回收。Java堆回收采用这种算法。

GC Roots包含:
虚拟机栈本地变量表引用的对象
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈JNI引用的对象

public class SingleInstance {

    private volatile static SingleInstance singleton;

    public static SingleInstance getInstance() {

        if (singleton == null) {
            synchronized (SingleInstance.class) {
                if (singleton == null) {
                    singleton = new SingleInstance();
                }
            }
        }
        return singleton;
    }

    private SingleInstance() {

    }

}

由于单例模式new出的对象会被静态属性所引用,所以“不能回收”。单例对象生命周期跟进程保持一致。


即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于”缓刑“阶段,要真正宣告一个对象的死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过,虚拟机将这两种情况都视为”没有必要执行了“。

如果这个对象被判定为有必要执行finalize方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它(即触发这个方法)。

稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize中成功救赎自己——只要重新与引用链上的任何一个对象建立关联即可,那么第二次标记它将会被移除出”即将回收的“的集合,如果这个时候对象还没有逃脱,那基本上它就真的被回收了。

finalize方法尽量不要在平时的编码中去调用。

3.方法区回收:

方法区的垃圾回收主要回收两部分内容:废弃常量和无用的类。其中废弃常量与Java堆类似,也采用可达性;无用的类回收条件较苛刻,需满足下述条件:
(1)该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
(2)加载该类的ClassLoader已经被回收。
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过它访问该类的方法。

垃圾回收算法

1.标记-清除算法

根搜索算法扫描出不可达的对象并标记,然后再集中将其清除,内存回收。
缺点:效率低;会产生内存碎片。
这里写图片描述

2.复制算法

将内存分成等大的两部分,一部分存放对象,一部分空闲。存放空间满时,所有根搜索可达对象(存活对象)会被复制到空闲空间,会严格按照之前的内存地址依次排列,并更新栈中的内存引用地址;然后之前的存放空间会被清空,即存放空间和空闲空间发生了互换。
优点:效率高,适合新生代。
缺点:内存使用率低;如果对象存活率高,复制成本高。

GC前:
这里写图片描述

GC后:
这里写图片描述

3.标记-整理算法

标记压缩算法在标记清除算法的基础上做了些改进,它在标记完所有的有效对象后,将有效的内存压缩到一起,然后再清除其它区域。
回收少,适用于老年代,效率也不高。

GC前:
这里写图片描述

GC后:
这里写图片描述

4.分代收集算法

新生代:朝生夕死,复制算法
老年代:持有时间长,标记清理或标记整理

堆中各代的分布图:

这里写图片描述

新生代:一块Eden空间+两块Survivor空间 8:1:1,浪费10%空间,主要存放新生对象

老年代:用于新生代复制算法的内存担保,主要存放应用程序中生命周期长的内存对象

Permanent区:是指内存的永久保存区域,主要存放Class和Meta的信息,Class在被load的时候被放入Permanent区域。 它和堆区域不同,GC时不会在主程序运行期对Permanent区域进行清理,所以如果程序会load很多Class的话,很可能出现Permanent区错误。

内存分配策略:

1.对象优先在Eden分配:
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发生一次GC。

2.大对象直接进入老年代:
大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串或数组。

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

4.动态对象年龄判定:
虚拟机并不是永远要求对象的年龄必须达到了设置的阈值才能晋升老生代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老生代,无须等待阈值要求的年龄。

5.空间分配担保:
新生代采用复制算法,使用一块Eden空间+两块Survivor空间的方式,内存浪费从50%降到了10%,老年代的内存就成了担保者,而老年代的内存使用标记-整理,内存利用率高,但也有可能发生内存耗尽的情况。每次Minor GC后,JVM都会检测之前每次晋升到老年代的对象的平均大小是否大于老年代的剩余内存空间,如果大于,则触发Full GC,以保证老年代有足够的空间支持空间分配担保成功。其实在每次GC发生的时候,我们也不知道到底会有多少对象被回收,又有多少对象能存活。故而只好取之前每次回收晋升到老年代的对象的平均值作为经验值来判断,但是如果某次GC后存活对象激增,很可能导致担保失败,那么只能重新进行Full GC了。大部分情况,允许担保失败。

注:
GC (或Minor GC):只收集新生代区域。
Full GC (或Major GC):对整个堆进行垃圾收集。
当显示调用System.gc()时会触发Full GC。


Java的4种引用方式与GC的关系

任何被强引用指向的对象都不能被回收;
弱引用在GC时一定会被回收;
软引用只有在内存不足时才会被回收;
虚引用在任何时刻都可能会被回收,程序中可通过判断引用队列中是否已经加入虚引用来判断对象是否将要被GC。

来看下面三组例子:

public class ReferenceTest {
    WeakReference<String> w;

    public void test() {
        w = new WeakReference<String>(new String("aaa"));
        System.gc();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(w.get());
    }
}

答案是null,因为”aaa”这个对象只有w这一个弱引用指向它。

String a = new String("aaa");
w = new WeakReference<String>(a);
System.gc();
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println(w.get());

答案是”aaa”,因为”aaa”被一个强引用a指向,所以GC时不会被回收,因此w仍到得到这个对象。

String a = new String("aaa");
w = new WeakReference<String>(a);
a = null;
System.gc();
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println(w.get());

答案是null,因为”aaa”起初被一个强引用a指向,但后来这个强引用没了,所以GC时”aaa”就被回收了。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值