JVM内存管理机制和垃圾回收机制

从Java平台的逻辑结构上来看,我们可以从下图来了解JVM:

从上图能清晰看到Java平台包含的各个逻辑模块,也能了解到JDK与JRE的区别

对于JVM自身的物理结构,我们可以从下图鸟瞰一下:

对于JVM的学习,在我看来这么几个部分最重要:

  • Java代码编译和执行的整个过程
  • JVM内存管理及垃圾回收机制

下面将这两个部分进行详细学习


Java代码编译是由Java源码编译器来完成,流程图如下所示:

Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:

 

Java代码编译和执行的整个过程包含了以下三个重要的机制:

  • Java源码编译机制
  • 类加载机制
  • 类执行机制

Java源码编译机制

Java 源码编译由以下三个过程组成:

  • 分析和输入到符号表
  • 注解处理
  • 语义分析和生成class文件

流程图如下所示:

最后生成的class文件由以下部分组成:

  • 结构信息。包括class文件格式版本号及各部分的数量与大小的信息
  • 元数据。对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池
  • 方法信息。对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息

类加载机制

JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:

1)Bootstrap ClassLoader

负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类

2)Extension ClassLoader

负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包

3)App ClassLoader

负责记载classpath中指定的jar包及目录中class

4)Custom ClassLoader

属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader

加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。

类执行机制

JVM是基于栈的体系结构来执行class字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。栈的结构如下图所示:



JVM内存组成结构

JVM栈由堆、栈、本地方法栈、方法区等部分组成,结构图如下所示:

1)堆

所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。堆被划分为新生代和旧生代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Space和To Space组成,结构图如下所示:

  • 新生代。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例
  • 旧生代。用于存放新生代中经过多次垃圾回收仍然存活的对象

2)栈

每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、参数和中间结果

3)本地方法栈

用于支持native方法的执行,存储了每个native方法调用的状态

4)方法区

存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(Permanet Generation)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值

垃圾回收机制

JVM分别对新生代和旧生代采用不同的垃圾回收机制

新生代的GC:

新生代通常存活时间较短,因此基于Copying算法来进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和From Space或To Space之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从eden到survivor,最后到旧生代,

用java visualVM来查看,能明显观察到新生代满了后,会把对象转移到旧生代,然后清空继续装载,当旧生代也满了后,就会报outofmemory的异常,如下图所示:

在执行机制上JVM提供了串行GC(Serial GC)、并行回收GC(Parallel Scavenge)和并行GC(ParNew)

1)串行GC

在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定

2)并行回收GC

在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数

3)并行GC

与旧生代的并发GC配合使用

旧生代的GC:

旧生代与新生代不同,对象存活的时间比较长,比较稳定,因此采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并,要么标记出来便于下次进行分配,总之就是要减少内存碎片带来的效率损耗。在执行机制上JVM提供了串行GC(Serial MSC)、并行GC(parallel MSC)和并发GC(CMS),具体算法细节还有待进一步深入研究。

以上各种GC机制是需要组合使用的,指定方式由下表所示:

指定方式

新生代GC方式

旧生代GC方式

-XX:+UseSerialGC

串行GC

串行GC

-XX:+UseParallelGC

并行回收GC

并行GC

-XX:+UseConeMarkSweepGC

并行GC

并发GC

-XX:+UseParNewGC

并行GC

串行GC

-XX:+UseParallelOldGC

并行回收GC

并行GC

-XX:+ UseConeMarkSweepGC

-XX:+UseParNewGC

串行GC

并发GC

不支持的组合

1、-XX:+UseParNewGC -XX:+UseParallelOldGC

2、-XX:+UseParNewGC -XX:+UseSerialGC




我们知道,java所谓的跨平台特性(hardware- and operating system-independence)是依赖于JVM的。也就是,JAVA代码写好了之后会被编译成java的字节,一般存放为class文件,之后由JVM负责解释执行。

The Java Virtual Machine knows nothing of the Java programming language, only of a particular binary format, the class file format. A class file contains Java Virtual Machine instructions (or bytecodes) and a symbol table, as well as other ancillary information. (引用自JVMS)

java代码的执行依赖于JVM,这里我们了解一下JVM的内存管理机制和垃圾回收机制。

JAVA中的内存分区

编程语言会将内存分为不同的区域,具有不同的特性。学C语言的时候,一般是说分成堆和栈两个区域。 
在方法中定义的基本类型,比如int, double这些,是存放在栈上的。方法的调用层次的组织也是用栈实现的。这里的栈和数据结构里的栈类似,因为方法的调用也是后进先出。方法的调用返回通过栈的伸缩可以很容易实现。所以这些栈上的变量会随着方法栈的收缩消亡。因为栈有大小限制,一般不放很大的内容,所以使用malloc函数申请大块内存,这时候这些内存是在堆上的。 
当然,C中的内分区也并不是那么简单。比如还有常量区等等。

JAVA中则分为更多块。根据java® Virtual Machine Specification的说明,有这几个部分:

The pc Register 
Java Virtual Machine Stacks 
Heap 
Method Area 
Run-Time Constant Pool 
Native Method Stacks

以下内容参考Java Virtual Machine Specification

JAVA虚拟机栈

虚拟机栈类似C中的方法栈,用来管理方法的调用,每个被调用的方法在在其中表示为一个栈帧。栈帧中保存了本地变量表,返回地址等等。 
每个线程有自己独立的虚拟机栈。它的生命周期和线程同步,当线程创建的时候被创建,线程销毁的时候被销毁。 
java中的对象需要通过new关键字得到,所有的对象实例都存放在堆上。当然,引用和基本类型是存放在栈中的。

Object o = new Object();
   
   
  • 1

此时o这个引用是存放在栈中的,而真正的Object对象是存放在堆上的。 
栈的特点是访问快,而且栈帧的大小是在编译期就确定了的。

本地方法栈

因为java可以调用其他语言比如C语言的代码,当执行本地方法的时候,栈帧就存在于本地方法栈中而不是虚拟机栈中。此时程序计数区里的数据为undefined

堆是JAVA很重要的区域,因为所有的new出来的instance和array都在堆上。堆又可以分为几个部分。 
堆被所有线程共享。生命周期和JVM同步。

Method Area

It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.

属于堆的一部分。可能会有垃圾回收可能没有。jvm初始化的时候既初始化。 
用于存放已被虚拟机加载的类信息,常量,静态变量,即时编译期编译过后的代码等数据。 
在上面进行的垃圾回收主要是针对常量池的回收和对类型的卸载。

Run-Time Constant Pool

常量区存放一些编译期就确定的常量。 
其实是Method Area的一部分。

程序计数区

标示当前程序运行到的位置。JVM相当于一台抽象的计算机,感觉这个区域类似于真正的CPU中的PC寄存器。 
当程序当前运行到正常的java方法的时候,这里标识的是the address of the Java Virtual Machine instruction currently being executed,当当前运行到本地方法的时候,这里的值是undefined

内存回收机制

和C/C++比起来,java的一个优势是,有自动的垃圾回收机制(garbage collection)。在C/C++中,任何malloc出来的值都要在合适的时候free,任何new出来的值都要在合适的时候delete,不然就容易发生内存泄露。而在JAVA里面则不同,JVM有一个垃圾管理机制,也就是有一个垃圾处理的线程,在某些时候会被执行,把不用的东西从内存中清理掉,避免了内存泄露。一般情况下,java程序员不需要去手动地释放内存,不用的内存不管它就是了。

垃圾收集的几种方法

  • mark & swap
  • 引用计数

  • 分代收集
  • stop & copy

垃圾回收要解决的问题大概可以归为以下三个:

  • 判断什么是垃圾(需要被回收)? 
    一般来说,把没有引用指向的对象当做是可被回收的垃圾。因为没有对象指向它,也就无法对它进行操作,这个对象对于我们来说是没用的了,也就是所谓的垃圾。
  • 什么时候回收? 
    内存不足或者当前空闲的时候。一般来说,gc线程的优先级都不太高。
  • 如何进行回收? 
    有多种实现方案。

mark & sweep

先标记出哪些不是垃圾,回收的时候把没有被标记到的认为是垃圾,进行回收。 
标记的方法是,从一个rootSet出发,能被里面某个引用指向的对象就认为是可达的,也就是不是垃圾。可达性是可以传递的,被认为是可达的对象指向的对象也被认为是可达的。 
一般来说,rootset里的初始的引用可以是栈中的临时变量,static类型的常量,寄存器中内容等等,因为这些内容一定是可达的。

这种方法可能会导致后面内存中有很多碎片。 
CSAPP中讲到这种方法的问题还在于判断某些内存中的数据到底是一个引用还是单纯的数值。java具备能够分辨出引用还是不是引用的能力,所以java的GC叫做准确式GC。

引用计数

针对每个对象对象维护一个表示当前有多少个对象指向这个引用的值。实时更新这个值。也就是另某个引用指向它时引用加一,反之减一。值为0也就表示这个对象不可达。 
这种的弊端是可能会因为循环引用导致某些有应该被回收的对象的引用计数不为0,所以无法被回收。 
比如说A持有B的引用,B也持有A的引用,但是除此之外没有指向A和B的引用,程序根本就无法操作A和B,所以他们应该被回收的,但是并没有。

stop & copy

这种方法和前面不同的主要是清理的方式。 
把堆分成两部分,每个时刻其实只有一个部分起到充当堆内存的作用,进行garbage colloction的时候,把当前作为堆内存部分的不是垃圾的内容copy到另一个部分,然后再把这个部分作为堆来使用就行、

这种方法的缺点是,对内存的利用不够,有一部分是用不到的。而且copy的时候,之前的引用的要进行修正。

java中的实现

因为大部分对象的生命周期并不长久,而少部分的对象又可以活得很久,所以可以把堆分为不同的区域,分别存放存活时间短的和存活时间长的对象,分别执行不同的策略。 
把堆分为年轻代,老年代。年轻代上存放经历garbage collection次数较少的对象,老年代上存放经历过较多次garbage collection的对象。 
年轻代分为三个部分,分别是eden,toSurvival,fromSurvival。每个时刻eden加上toSurvival,fromSurvival中的一个区域作为用作分配的区域,另一个Survival区域则是用于发生gc的时候使用。

当发生垃圾回收的时候,根据上面的说法,年轻代中只有少数对象是还存活着的。那么这时候采用stop-copy算法就比较高效,因为只需要比较少的复制操作。年轻代上的garbage collectio叫做minor gc。

当需要在老年代上进行gc操作的时候,采取的是标记算法。老年代上的gc叫做major gc。一般比新生代上的gc费时得多。

rootset包括虚拟机栈中引用的对象,方法区中类静态属性引用的对象,本地区中常量引用的对象,本地方法栈中JNI引用的对象 
java使用了一组叫做OopMap的数据结构来存储rootser,但是执行每条指令都更新这个数据,那就太麻烦了。不在每次执行指令的时候更新,为了避免在枚举可达性的过程中可达性不同步,在某个特定的点更新,然后gc只会在等待所有线程都进行到这个点的时候执行。

finalize()

当gc决定要回收一个对象的内存的时候,就会去调用这个对象的finalize()函数(如果这个类重写了finalize函数并且没有被执行过),然后在下一次gc过程中再去真正回收这个对象。 
finalize函数被用来做一些清理的工作,java几乎所有对象都是通过new方法在堆上分配的,这些内存可以正确地被jvm回收,但是如果调用了本地方法,比如说用到了C里面的malloc,那么就需要在finilize函数中正确地释放这部分内存了。 
考虑下面的代码(摘自《深入理解java虚拟机》)

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive(){
        System.out.println("yes,i am still alive!");
    }

    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead!");
        }
        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead!");
        }
    }
}
   
   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

这段代码验证了finalize函数只会被执行一次

各种引用

  • 强引用 
    我们平时使用的类似Object o = new Object(),这里o就是一个强引用。前面提到的判断是否应该被回收的问题,被强引用引用的对象就被认为是可达的,就算发生OutOfMemonyError也不会回收它。
  • 软引用 
    如果有一些对象我们可能会用到,但是又不想因为它们发生OutOfMemonyError,那么就把指向它们的引用设为软引用。当内存不足要进行gc的时候,只被软引用引用的对象是可以被回收的。 
    比如SoftReference<Object> weakRef = SoftReference<Object>(new Object()) 
    在设计缓存的时候,会经常会有用到软应用的需求。
  • 弱引用 
    类似软引用,但是只被弱引用指向的对象更有可能被回收。 
    只被弱引用引用对象一旦被垃圾处理器线程发现就会被回收。

JVM内存组成结构

JVM栈由堆、栈、本地方法栈、方法区等部分组成,结构图如下所示:

1)堆

所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx和-Xms来控制。堆被划分为新生代和旧生代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Space和To Space组成,结构图如下所示:

  • 新生代。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例
  • 旧生代。用于存放新生代中经过多次垃圾回收仍然存活的对象

2)栈

每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、参数和中间结果

3)本地方法栈

用于支持native方法的执行,存储了每个native方法调用的状态

4)方法区

存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(Permanet Generation)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值

垃圾回收机制

JVM分别对新生代和旧生代采用不同的垃圾回收机制

新生代的GC:

新生代通常存活时间较短,因此基于Copying算法来进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和From Space或To Space之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从eden到survivor,最后到旧生代,

用java visualVM来查看,能明显观察到新生代满了后,会把对象转移到旧生代,然后清空继续装载,当旧生代也满了后,就会报outofmemory的异常,如下图所示:

在执行机制上JVM提供了串行GC(Serial GC)、并行回收GC(Parallel Scavenge)和并行GC(ParNew)

1)串行GC

在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定

2)并行回收GC

在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数

3)并行GC

与旧生代的并发GC配合使用

旧生代的GC:

旧生代与新生代不同,对象存活的时间比较长,比较稳定,因此采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并,要么标记出来便于下次进行分配,总之就是要减少内存碎片带来的效率损耗。在执行机制上JVM提供了串行GC(Serial MSC)、并行GC(parallel MSC)和并发GC(CMS),具体算法细节还有待进一步深入研究。

以上各种GC机制是需要组合使用的,指定方式由下表所示:


指定方式

新生代GC方式

旧生代GC方式

-XX:+UseSerialGC

串行GC

串行GC

-XX:+UseParallelGC

并行回收GC

并行GC

-XX:+UseConeMarkSweepGC

并行GC

并发GC

-XX:+UseParNewGC

并行GC

串行GC

-XX:+UseParallelOldGC

并行回收GC

并行GC

-XX:+ UseConeMarkSweepGC

-XX:+UseParNewGC

串行GC

并发GC

不支持的组合

1、-XX:+UseParNewGC -XX:+UseParallelOldGC

2、-XX:+UseParNewGC -XX:+UseSerialGC




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值